management: add root-only custom UUID user creation with groups

This commit is contained in:
Haitao Pan 2026-02-05 13:25:11 +08:00
parent d922407836
commit cd5db28a25
3 changed files with 250 additions and 4 deletions

View File

@ -0,0 +1,109 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
type ErrorPayload = {
error: string
}
type CreateUserBody = {
email?: unknown
uuid?: unknown
groups?: unknown
}
function normalizeString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function normalizeGroups(value: unknown): string[] | null {
if (!Array.isArray(value)) {
return null
}
const result: string[] = []
for (const item of value) {
const normalized = normalizeString(item)
if (!normalized) {
continue
}
result.push(normalized)
}
if (result.length === 0) {
return null
}
return Array.from(new Set(result))
}
function isRootUser(username?: string): boolean {
return username?.trim().toLowerCase() === 'root'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isRootUser(user.username)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
}
const body = (await request.json().catch(() => null)) as CreateUserBody | null
if (!body) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_body' }, { status: 400 })
}
const email = normalizeString(body.email)
const uuid = normalizeString(body.uuid)
const groups = normalizeGroups(body.groups)
if (!email || !uuid || !groups) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_body' }, { status: 400 })
}
if (!UUID_PATTERN.test(uuid)) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_uuid' }, { status: 400 })
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
uuid,
groups,
}),
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { type FormEvent, useMemo, useState } from 'react'
import Card from '../../components/Card'
export type ManagedUser = {
@ -12,11 +12,18 @@ export type ManagedUser = {
created_at?: string
}
export type CreateManagedUserInput = {
email: string
uuid: string
groups: string[]
}
type UserGroupManagementProps = {
users?: ManagedUser[]
isLoading?: boolean
pendingUserIds?: Set<string>
canEditRoles: boolean
canCreateCustomUser?: boolean
onRoleChange?: (userId: string, role: string) => void
onInvite?: () => void
onImport?: () => void
@ -25,6 +32,7 @@ type UserGroupManagementProps = {
onDeleteUser?: (userId: string) => void
onRenewUuid?: (userId: string) => void
onManageBlacklist?: () => void
onCreateCustomUser?: (input: CreateManagedUserInput) => Promise<void> | void
}
const ROLE_OPTIONS = [
@ -33,11 +41,22 @@ const ROLE_OPTIONS = [
{ value: 'user', label: '用户' },
]
function parseGroupList(input: string): string[] {
const values = input
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
return Array.from(new Set(values))
}
export function UserGroupManagement({
users,
isLoading = false,
pendingUserIds,
canEditRoles,
canCreateCustomUser = false,
onRoleChange,
onInvite,
onImport,
@ -46,10 +65,56 @@ export function UserGroupManagement({
onDeleteUser,
onRenewUuid,
onManageBlacklist,
onCreateCustomUser,
}: UserGroupManagementProps) {
const data = useMemo(() => users ?? [], [users])
const pendingSet = pendingUserIds ?? new Set<string>()
const [customEmail, setCustomEmail] = useState('')
const [customUuid, setCustomUuid] = useState('')
const [customGroups, setCustomGroups] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [createMessage, setCreateMessage] = useState<string | undefined>()
const [createError, setCreateError] = useState<string | undefined>()
const parsedCustomGroups = useMemo(() => parseGroupList(customGroups), [customGroups])
const handleCreateCustomUser = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!onCreateCustomUser) {
return
}
const email = customEmail.trim()
const uuid = customUuid.trim()
if (!email || !uuid) {
setCreateError('请填写邮箱与 UUID')
return
}
const groups = parsedCustomGroups
if (groups.length === 0) {
setCreateError('请至少填写一个用户组')
return
}
setIsCreating(true)
setCreateError(undefined)
setCreateMessage(undefined)
try {
await onCreateCustomUser({ email, uuid, groups })
setCreateMessage('用户创建成功')
setCustomEmail('')
setCustomUuid('')
setCustomGroups('')
} catch (error) {
setCreateError(error instanceof Error ? error.message : '创建失败')
} finally {
setIsCreating(false)
}
}
return (
<Card>
<div className="flex flex-col gap-4">
@ -83,6 +148,56 @@ export function UserGroupManagement({
</div>
</header>
{canCreateCustomUser ? (
<form onSubmit={handleCreateCustomUser} className="rounded-xl border border-purple-100 bg-purple-50/60 p-4">
<h3 className="text-sm font-semibold text-purple-800">Root UUID </h3>
<p className="mt-1 text-xs text-purple-700"></p>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-xs text-gray-600">
<input
type="email"
value={customEmail}
onChange={(event) => setCustomEmail(event.target.value)}
placeholder="user@example.com"
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-gray-600">
UUID
<input
type="text"
value={customUuid}
onChange={(event) => setCustomUuid(event.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-gray-600 md:col-span-2">
<textarea
value={customGroups}
onChange={(event) => setCustomGroups(event.target.value)}
placeholder="group-a,group-b"
rows={3}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={isCreating}
className="inline-flex items-center rounded-full bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreating ? '创建中…' : '创建用户'}
</button>
{createMessage ? <span className="text-xs text-green-700">{createMessage}</span> : null}
{createError ? <span className="text-xs text-red-600">{createError}</span> : null}
</div>
</form>
) : null}
<div className="overflow-x-auto" aria-busy={isLoading} aria-live="polite">
<table className="min-w-full divide-y divide-gray-200 text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500">
@ -139,8 +254,7 @@ export function UserGroupManagement({
<td className="px-4 py-3 text-gray-600">{user.groups?.join('、') || '—'}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${user.active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${user.active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
>
{user.active ? '活跃' : '已暂停'}
</span>

View File

@ -9,7 +9,10 @@ import OverviewCards, { type MetricsOverview } from '../management/components/Ov
import PermissionMatrixEditor, {
type PermissionMatrix,
} from '../management/components/PermissionMatrixEditor'
import UserGroupManagement, { type ManagedUser } from '../management/components/UserGroupManagement'
import UserGroupManagement, {
type ManagedUser,
type CreateManagedUserInput,
} from '../management/components/UserGroupManagement'
import { EmailBlacklist } from '../management/components/EmailBlacklist'
import { resolveAccess } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
@ -64,6 +67,7 @@ export default function UserCenterManagementRoute() {
const canAccess = accessDecision.allowed
const canEditPermissions = Boolean(user?.isAdmin)
const canEditRoles = Boolean(user?.isAdmin)
const canCreateCustomUser = Boolean(user?.isAdmin && user?.username?.trim().toLowerCase() === 'root')
const [matrixDraft, setMatrixDraft] = useState<PermissionMatrix>({})
const [matrixVersion, setMatrixVersion] = useState<number>(0)
@ -288,6 +292,23 @@ export default function UserCenterManagementRoute() {
}
}, [usersSWR])
const handleCreateCustomUser = useCallback(async (input: CreateManagedUserInput) => {
if (!canCreateCustomUser) {
throw new Error('仅 root 管理员可创建自定义 UUID 用户')
}
await jsonFetcher('/api/admin/users', {
method: 'POST',
body: JSON.stringify({
email: input.email,
uuid: input.uuid,
groups: input.groups,
}),
})
await usersSWR.mutate()
}, [canCreateCustomUser, usersSWR])
const matrixPending = matrixSaving || isUserLoading
const metricsLoading = metricsSWR.isLoading
const settingsLoading = settingsSWR.isLoading
@ -323,11 +344,13 @@ export default function UserCenterManagementRoute() {
isLoading={usersLoading}
onRoleChange={handleRoleChange}
canEditRoles={canEditRoles}
canCreateCustomUser={canCreateCustomUser}
pendingUserIds={pendingRoleUpdates}
onPauseUser={handlePauseUser}
onResumeUser={handleResumeUser}
onDeleteUser={handleDeleteUser}
onRenewUuid={handleRenewUuid}
onCreateCustomUser={handleCreateCustomUser}
onManageBlacklist={() => setIsBlacklistOpen(true)}
/>
<EmailBlacklist isOpen={isBlacklistOpen} onClose={() => setIsBlacklistOpen(false)} />