195 lines
11 KiB
TypeScript
195 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import Select from '@/components/ui/Select'
|
|
|
|
import { Fragment, useCallback, useEffect, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { ChevronRight, Search, X } from 'lucide-react'
|
|
import { useSession } from 'next-auth/react'
|
|
import DepartmentShell from './DepartmentShell'
|
|
|
|
type GroupRef = { slug: string; name: string }
|
|
|
|
type DepartmentGroup = GroupRef & {
|
|
attachedPath: string
|
|
source: 'own' | 'inherit' | string
|
|
}
|
|
|
|
type Person = {
|
|
userId: string
|
|
username: string
|
|
displayName: string
|
|
jobNumber: string | null
|
|
departmentPath: string | null
|
|
departmentName: string | null
|
|
departmentGroups: DepartmentGroup[]
|
|
personalGroups: GroupRef[]
|
|
}
|
|
|
|
export default function UserPermissionsDashboard({ embedded = false }: { embedded?: boolean }) {
|
|
const { data: session, status } = useSession()
|
|
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
|
|
|
|
const [keyword, setKeyword] = useState('')
|
|
const [people, setPeople] = useState<Person[]>([])
|
|
const [groups, setGroups] = useState<GroupRef[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [notice, setNotice] = useState<string | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const search = useCallback(async (needle: string) => {
|
|
if (!needle.trim()) {
|
|
setPeople([])
|
|
return
|
|
}
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const response = await fetch(
|
|
`/api/organization/user-permissions?q=${encodeURIComponent(needle.trim())}`,
|
|
{ cache: 'no-store' },
|
|
)
|
|
const body = await response.json().catch(() => null)
|
|
if (!response.ok) throw new Error(body?.error || '搜索失败')
|
|
setPeople(body.people || [])
|
|
setGroups(body.groups || [])
|
|
} catch (searchError) {
|
|
setError(searchError instanceof Error ? searchError.message : '搜索失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (status !== 'authenticated' || !isSuperAdmin) return
|
|
const handle = window.setTimeout(() => {
|
|
void search(keyword)
|
|
}, 250)
|
|
return () => window.clearTimeout(handle)
|
|
}, [keyword, status, isSuperAdmin, search])
|
|
|
|
const mutate = async (body: Record<string, unknown>, success: string) => {
|
|
setSaving(true)
|
|
setError(null)
|
|
setNotice(null)
|
|
try {
|
|
const response = await fetch('/api/organization/user-permissions', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const payload = await response.json().catch(() => null)
|
|
if (!response.ok) throw new Error(payload?.error || '保存失败')
|
|
setNotice(success)
|
|
await search(keyword)
|
|
} catch (saveError) {
|
|
setError(saveError instanceof Error ? saveError.message : '保存失败')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const Frame = embedded ? Fragment : DepartmentShell
|
|
const Content = embedded ? 'div' : 'main'
|
|
return (
|
|
<Frame>
|
|
<Content className={embedded ? 'flex min-h-[320px] flex-col lg:h-full lg:min-h-0' : 'flex h-[calc(100dvh-72px)] flex-col overflow-hidden px-4 pt-4 sm:px-6 lg:px-8'}>
|
|
<header className="shrink-0 pb-4">
|
|
{!embedded && <div className="flex flex-wrap items-center justify-between gap-3">
|
|
<h1 className="text-2xl font-bold text-[#101828]">按人看权限</h1>
|
|
<Link href="/workspaces/governance/permissions" className="inline-flex min-h-11 items-center gap-1 rounded-lg px-2 text-sm font-medium text-[#4f6077] hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
|
|
权限组<ChevronRight className="h-4 w-4" />
|
|
</Link>
|
|
</div>}
|
|
<label className="sr-only" htmlFor="user-permission-search">搜索人员</label>
|
|
<div className={`relative max-w-xl ${embedded ? '' : 'mt-3'}`}>
|
|
<Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" />
|
|
<input id="user-permission-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜花名 / 工号 / 账号" className="h-11 w-full rounded-lg border border-[#cfd8e3] bg-white pl-9 pr-3 text-sm text-[#101828] placeholder:text-[#66758b] focus:outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20" />
|
|
</div>
|
|
</header>
|
|
|
|
{status === 'loading' ? (
|
|
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">加载中…</div>
|
|
) : !isSuperAdmin ? (
|
|
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
|
|
只有超级管理员可以改人员权限。
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-auto pb-6">
|
|
{error && (
|
|
<div role="alert" className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
|
|
)}
|
|
{notice && (
|
|
<div role="status" className="mb-3 text-sm text-[#087052]">{notice}</div>
|
|
)}
|
|
{loading && <p className="mb-3 text-sm text-[#66758b]">搜索中…</p>}
|
|
{!keyword.trim() ? (
|
|
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
|
|
搜索人员查看权限组
|
|
</div>
|
|
) : people.length === 0 && !loading ? (
|
|
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
|
|
未找到已绑定人员
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
|
|
<div aria-hidden="true" className="hidden grid-cols-[minmax(150px,1fr)_minmax(140px,1fr)_minmax(180px,1.3fr)_160px] gap-4 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-3 text-xs font-semibold text-[#4f6077] xl:grid">
|
|
<span>人员</span><span>部门组</span><span>个人组</span><span>加组</span>
|
|
</div>
|
|
<ul aria-label="人员权限" className="divide-y divide-[#e2e8f0]">
|
|
{people.map((person) => {
|
|
const personalSlugs = new Set(person.personalGroups.map((row) => row.slug))
|
|
const extraGroups = groups.filter((group) => !personalSlugs.has(group.slug))
|
|
return (
|
|
<li key={person.userId} className="grid min-w-0 grid-cols-[minmax(0,1fr)_120px] gap-x-4 gap-y-2 px-4 py-3 sm:grid-cols-2 xl:grid-cols-[minmax(150px,1fr)_minmax(140px,1fr)_minmax(180px,1.3fr)_160px] xl:items-center">
|
|
<div className="min-w-0">
|
|
<h2 className="break-words text-sm font-semibold text-[#101828]">{person.displayName}</h2>
|
|
<p title={person.departmentPath || undefined} className="mt-1 break-words text-xs text-[#66758b]">
|
|
@{person.username}{person.jobNumber ? ` · ${person.jobNumber}` : ''}{person.departmentName ? ` · ${person.departmentName}` : ''}
|
|
</p>
|
|
{person.departmentPath && <span className="sr-only">{person.departmentPath}</span>}
|
|
</div>
|
|
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
|
|
<span className="shrink-0 pt-1 text-xs text-[#66758b] xl:sr-only">部门组</span>
|
|
{person.departmentGroups.length === 0 ? <p className="text-sm leading-6 text-[#66758b]">未关联权限组</p> :
|
|
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{person.departmentGroups.map((group) => (
|
|
<li key={`${person.userId}-${group.slug}`} className="break-words text-sm leading-6 text-[#101828]">
|
|
{group.name}<span title={group.attachedPath} className="ml-2 text-xs text-[#66758b]">{group.source === 'own' ? '本部门' : `随 ${group.attachedPath.split('/').pop()}`}</span>
|
|
</li>
|
|
))}</ul>}
|
|
</div>
|
|
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
|
|
<span className="shrink-0 pt-3.5 text-xs text-[#66758b] xl:sr-only">个人组</span>
|
|
{person.personalGroups.length === 0 ? <p className="py-3 text-sm text-[#66758b]">—<span className="sr-only">未单独加组</span></p> :
|
|
<ul className="flex min-w-0 flex-wrap gap-2">{person.personalGroups.map((group) => (
|
|
<li key={group.slug} className="inline-flex min-w-0 items-center rounded-lg bg-[#f8fafc] pl-3 text-sm text-[#101828]">
|
|
<span className="min-w-0 break-words">{group.name}</span>
|
|
<button type="button" disabled={saving} aria-label={`将${person.displayName}移出${group.name}组`} title={`移出${group.name}组`} onClick={() => void mutate({ action: 'remove-member', userId: person.userId, slug: group.slug }, `已将 ${person.displayName} 移出「${group.name}」`)} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] hover:bg-[#fff5f4] hover:text-[#b42318] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"><X className="h-4 w-4" /></button>
|
|
</li>
|
|
))}</ul>}
|
|
</div>
|
|
<div className="col-start-2 row-start-1 min-w-0 sm:col-start-auto sm:row-start-auto">
|
|
{extraGroups.length > 0 && <Select aria-label={`为${person.displayName}加组`} disabled={saving} defaultValue="" onChange={(event) => {
|
|
const slug = event.target.value
|
|
if (!slug) return
|
|
const group = extraGroups.find((item) => item.slug === slug)
|
|
void mutate({ action: 'add-member', userId: person.userId, slug }, `已将 ${person.displayName} 加入「${group?.name || slug}」`)
|
|
event.target.value = ''
|
|
}} className="min-h-11 w-full max-w-[160px] rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm text-[#4f6077] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
|
|
<option value="">加组…</option>{extraGroups.map((group) => <option key={group.slug} value={group.slug}>{group.name}</option>)}
|
|
</Select>}
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Content>
|
|
</Frame>
|
|
)
|
|
}
|