management: add root-only custom UUID user creation with groups
This commit is contained in:
parent
d922407836
commit
cd5db28a25
109
src/app/api/admin/users/route.ts
Normal file
109
src/app/api/admin/users/route.ts
Normal 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 })
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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)} />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user