251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import Select from '@/components/ui/Select'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import Link from 'next/link'
|
||
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() {
|
||
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)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<DepartmentShell>
|
||
<main className="flex h-[calc(100dvh-72px)] flex-col overflow-hidden px-4 pt-4 sm:px-6 lg:px-8">
|
||
<header className="shrink-0 pb-3">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[#214ba7]">治理与设置</p>
|
||
<div className="mt-1 flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-[-0.03em] text-[#101828]">按人看权限</h1>
|
||
<p className="mt-1 max-w-3xl text-sm leading-6 text-[#66758b]">
|
||
搜九千七这种人,看他部门带了哪组,自己又加了哪组。不要再改账号上的部门名。
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/workspaces/governance/roles-permissions"
|
||
className="text-sm font-semibold text-[#1677ff] hover:underline"
|
||
>
|
||
回到权限组
|
||
</Link>
|
||
</div>
|
||
<label className="sr-only" htmlFor="user-permission-search">搜索人员</label>
|
||
<input
|
||
id="user-permission-search"
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜花名 / 工号 / 账号,例如九千七"
|
||
className="mt-4 h-11 w-full max-w-xl rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#5a9ce6]/40"
|
||
/>
|
||
</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 className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
|
||
)}
|
||
{notice && (
|
||
<div className="mb-4 rounded-lg border border-[#b7ebc6] bg-[#f0fff4] px-4 py-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>
|
||
) : (
|
||
<ul className="space-y-3">
|
||
{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="rounded-xl border border-[#e2e8f0] bg-white px-5 py-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-lg font-bold text-[#101828]">{person.displayName}</h2>
|
||
<p className="mt-1 text-sm text-[#66758b]">
|
||
@{person.username}
|
||
{person.jobNumber ? ` · ${person.jobNumber}` : ''}
|
||
{person.departmentName ? ` · ${person.departmentName}` : ''}
|
||
</p>
|
||
{person.departmentPath && (
|
||
<p className="mt-1 text-xs text-[#66758b]">{person.departmentPath}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<p className="text-xs font-semibold text-[#4f6077]">部门带来的组</p>
|
||
{person.departmentGroups.length === 0 ? (
|
||
<p className="mt-2 text-sm text-[#66758b]">这个部门还没进组,暂时走旧的部门权限。</p>
|
||
) : (
|
||
<ul className="mt-2 space-y-1.5">
|
||
{person.departmentGroups.map((group) => (
|
||
<li key={`${person.userId}-${group.slug}`} className="text-sm text-[#101828]">
|
||
{group.name}
|
||
<span className="ml-2 text-xs text-[#66758b]">
|
||
{group.source === 'own'
|
||
? '本部门在组里'
|
||
: `随 ${group.attachedPath.split('/').pop()}`}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-[#4f6077]">个人加的组</p>
|
||
{person.personalGroups.length === 0 ? (
|
||
<p className="mt-2 text-sm text-[#66758b]">还没有单独加组</p>
|
||
) : (
|
||
<ul className="mt-2 space-y-1.5">
|
||
{person.personalGroups.map((group) => (
|
||
<li key={group.slug} className="flex items-center justify-between gap-3 text-sm">
|
||
<span className="text-[#101828]">{group.name}</span>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() =>
|
||
void mutate(
|
||
{ action: 'remove-member', userId: person.userId, slug: group.slug },
|
||
`已把 ${person.displayName} 从「${group.name}」拿掉`,
|
||
)
|
||
}
|
||
className="text-xs font-semibold text-[#b42318] hover:underline"
|
||
>
|
||
去掉
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{extraGroups.length > 0 && (
|
||
<label className="mt-3 block">
|
||
<span className="sr-only">再加一组</span>
|
||
<Select
|
||
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="mt-1 h-9 w-full max-w-xs rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-2 text-sm"
|
||
>
|
||
<option value="">再加一组…</option>
|
||
{extraGroups.map((group) => (
|
||
<option key={group.slug} value={group.slug}>
|
||
{group.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
</DepartmentShell>
|
||
)
|
||
}
|