From 99bcfb1038f4b8599fa86e8286ed46a3f0600e64 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 2 Jul 2026 01:18:52 -0700 Subject: [PATCH] feat: complete user management workflow --- app/(app)/app/settings/page.tsx | 1 + app/api/auth/register/route.ts | 9 +- app/api/organizations/route.ts | 91 +++++++ app/api/settings/accounts/route.ts | 80 +++++-- app/api/settings/join-requests/[id]/route.ts | 146 ++++++++++++ app/api/settings/join-requests/route.ts | 23 ++ modules/auth/components/AuthPanel.tsx | 36 ++- modules/core/server/auth.ts | 26 +- modules/core/server/store.ts | 41 +++- modules/core/types.ts | 17 ++ .../settings/components/SettingsOverview.tsx | 15 +- .../components/UserManagementClient.tsx | 224 ++++++++++++++++++ 12 files changed, 663 insertions(+), 46 deletions(-) create mode 100644 app/api/organizations/route.ts create mode 100644 app/api/settings/join-requests/[id]/route.ts create mode 100644 app/api/settings/join-requests/route.ts create mode 100644 modules/settings/components/UserManagementClient.tsx diff --git a/app/(app)/app/settings/page.tsx b/app/(app)/app/settings/page.tsx index 40b01ae..468cf40 100644 --- a/app/(app)/app/settings/page.tsx +++ b/app/(app)/app/settings/page.tsx @@ -24,6 +24,7 @@ export default async function SettingsPage(): Promise { auditLogs={data.auditLogs.slice(0, 100)} organizations={data.organizations} memberships={data.memberships} + joinRequests={data.joinRequests} incidents={data.incidents} /> ); diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 46ef8ac..aacf617 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -36,8 +36,13 @@ export async function POST(request: Request): Promise { password, organizationId: organizationId || undefined, }); - await setSessionCookie(created.session.id); - return jsonSuccess("账号已创建", { account: created.account }); + if (created.session) { + await setSessionCookie(created.session.id); + } + return jsonSuccess(organizationId ? "申请已提交,待机构管理员审批" : "账号已创建", { + account: created.account, + pendingApproval: !created.session, + }); } catch (error) { const reason = error instanceof Error ? error.message : "账号创建失败"; const status = reason === "账号已存在" ? 409 : 500; diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts new file mode 100644 index 0000000..7d85784 --- /dev/null +++ b/app/api/organizations/route.ts @@ -0,0 +1,91 @@ +import { eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { seedOrganizationRoles } from "@/modules/core/server/permissions"; +import { organizations } from "@/modules/core/server/schema"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function createSlug(name: string): string { + const base = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + return base.length > 0 ? base : `org-${Date.now()}`; +} + +export async function GET(): Promise { + const database = getDatabase(); + const rows = await database.select().from(organizations).where(eq(organizations.status, "active")); + + return jsonSuccess("机构列表已加载", { + organizations: rows.map((organization) => ({ + id: organization.id, + name: organization.name, + slug: organization.slug, + status: organization.status, + createdAt: organization.createdAt.toISOString(), + updatedAt: organization.updatedAt.toISOString(), + })), + }); +} + +export async function POST(request: Request): Promise { + const auth = await requirePermission("organization:manage", { + action: "organization.create", + targetType: "organization", + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const name = readString(body, "name"); + if (!name) { + return jsonFailure("机构名称不能为空"); + } + + const database = getDatabase(); + const rows = await database + .insert(organizations) + .values({ + name, + slug: readString(body, "slug") || createSlug(name), + status: "active", + }) + .returning(); + const organization = rows[0]; + if (!organization) { + return jsonFailure("机构创建失败", 500); + } + + await seedOrganizationRoles(organization.id); + await recordAuditLog({ + actor: auth.context.account, + organizationId: organization.id, + action: "organization.create", + targetType: "organization", + targetId: organization.id, + result: "success", + reason: `创建机构:${organization.name}`, + }); + + return jsonSuccess("机构已创建", { organization }, 201); +} diff --git a/app/api/settings/accounts/route.ts b/app/api/settings/accounts/route.ts index 3add8bc..f4b6160 100644 --- a/app/api/settings/accounts/route.ts +++ b/app/api/settings/accounts/route.ts @@ -1,10 +1,10 @@ import { and, eq } from "drizzle-orm"; -import { jsonSuccess } from "@/modules/core/server/api"; +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; import { hashPassword, normalizeEmail, requirePermission, toPublicAccount } from "@/modules/core/server/auth"; -import { jsonFailure, readJsonBody } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; import { getDatabase } from "@/modules/core/server/db"; +import { hasPermission } from "@/modules/core/server/permissions"; import { accounts, memberships, organizations, roles } from "@/modules/core/server/schema"; import { readData } from "@/modules/core/server/store"; @@ -28,8 +28,21 @@ export async function GET(): Promise { } const data = await readData(); + const canReadAllAccounts = hasPermission(auth.context.permissions, "platform:manage"); + const organizationId = auth.context.organization?.id; + const scopedAccountIds = + organizationId && !canReadAllAccounts + ? new Set( + data.memberships + .filter((membership) => membership.organizationId === organizationId) + .map((membership) => membership.accountId), + ) + : null; + return jsonSuccess("账号列表已加载", { - accounts: data.accounts.map((account) => toPublicAccount(account)), + accounts: data.accounts + .filter((account) => !scopedAccountIds || scopedAccountIds.has(account.id)) + .map((account) => toPublicAccount(account)), }); } @@ -60,7 +73,18 @@ export async function POST(request: Request): Promise { const database = getDatabase(); const passwordHash = hashPassword(password); - const created = await database.transaction(async (transaction) => { + try { + const created = await database.transaction(async (transaction) => { + const normalizedEmail = normalizeEmail(email); + const existingAccountRows = await transaction + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.email, normalizedEmail)) + .limit(1); + if (existingAccountRows.length > 0) { + throw new Error("账号已存在"); + } + const organizationRows = await transaction.select().from(organizations).where(eq(organizations.id, organizationId)).limit(1); const organization = organizationRows[0]; if (!organization) { @@ -81,7 +105,7 @@ export async function POST(request: Request): Promise { .insert(accounts) .values({ name, - email: normalizeEmail(email), + email: normalizedEmail, passwordHash: passwordHash.hash, passwordSalt: passwordHash.salt, status: "active", @@ -99,27 +123,31 @@ export async function POST(request: Request): Promise { status: "active", }); - return { account, organization, role }; - }); + return { account, organization, role }; + }); - await recordAuditLog({ - actor: auth.context.account, - organizationId, - action: "account.create", - targetType: "account", - targetId: created.account.id, - result: "success", - reason: `创建机构用户:${created.account.email}`, - }); + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "account.create", + targetType: "account", + targetId: created.account.id, + result: "success", + reason: `创建机构用户:${created.account.email}`, + }); - return jsonSuccess("账号已创建", { - account: toPublicAccount(created.account, created.role.key, { - id: created.organization.id, - name: created.organization.name, - slug: created.organization.slug, - status: created.organization.status, - createdAt: created.organization.createdAt.toISOString(), - updatedAt: created.organization.updatedAt.toISOString(), - }), - }, 201); + return jsonSuccess("账号已创建", { + account: toPublicAccount(created.account, created.role.key, { + id: created.organization.id, + name: created.organization.name, + slug: created.organization.slug, + status: created.organization.status, + createdAt: created.organization.createdAt.toISOString(), + updatedAt: created.organization.updatedAt.toISOString(), + }), + }, 201); + } catch (error) { + const reason = error instanceof Error ? error.message : "账号创建失败"; + return jsonFailure(reason, reason === "账号已存在" ? 409 : 400); + } } diff --git a/app/api/settings/join-requests/[id]/route.ts b/app/api/settings/join-requests/[id]/route.ts new file mode 100644 index 0000000..2ffece9 --- /dev/null +++ b/app/api/settings/join-requests/[id]/route.ts @@ -0,0 +1,146 @@ +import { and, eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { accounts, joinRequests, memberships, roles } from "@/modules/core/server/schema"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("account:manage", { + action: "joinRequest.review", + targetType: "joinRequest", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const decision = readString(body, "decision"); + if (decision !== "approved" && decision !== "rejected") { + return jsonFailure("审批结果无效"); + } + + const database = getDatabase(); + const reviewed = await database.transaction(async (transaction) => { + const requestRows = await transaction + .select() + .from(joinRequests) + .where(eq(joinRequests.id, id)) + .limit(1); + const joinRequest = requestRows[0]; + if (!joinRequest) { + return null; + } + + const activeOrganizationId = auth.context.organization?.id; + if (activeOrganizationId && joinRequest.organizationId !== activeOrganizationId) { + throw new Error("不能审批其他机构的申请"); + } + + if (joinRequest.status !== "pending") { + throw new Error("申请已处理"); + } + + let roleId = readString(body, "roleId"); + if (decision === "approved" && !roleId) { + const defaultRoleRows = await transaction + .select() + .from(roles) + .where(and(eq(roles.organizationId, joinRequest.organizationId), eq(roles.key, "caregiver"))) + .limit(1); + const defaultRole = defaultRoleRows[0]; + if (!defaultRole) { + throw new Error("默认角色不存在"); + } + roleId = defaultRole.id; + } + + if (decision === "approved") { + const roleRows = await transaction + .select() + .from(roles) + .where(and(eq(roles.id, roleId), eq(roles.organizationId, joinRequest.organizationId))) + .limit(1); + const role = roleRows[0]; + if (!role) { + throw new Error("审批角色不存在"); + } + + await transaction + .insert(memberships) + .values({ + accountId: joinRequest.accountId, + organizationId: joinRequest.organizationId, + roleId: role.id, + status: "active", + }) + .onConflictDoUpdate({ + target: [memberships.accountId, memberships.organizationId], + set: { + roleId: role.id, + status: "active", + updatedAt: new Date(), + }, + }); + await transaction + .update(accounts) + .set({ status: "active", updatedAt: new Date() }) + .where(eq(accounts.id, joinRequest.accountId)); + } + + const updatedRows = await transaction + .update(joinRequests) + .set({ + status: decision, + reviewedByAccountId: auth.context.account.id, + reviewedAt: new Date(), + }) + .where(eq(joinRequests.id, joinRequest.id)) + .returning(); + const updated = updatedRows[0]; + if (!updated) { + throw new Error("申请审批失败"); + } + + return updated; + }); + + if (!reviewed) { + return jsonFailure("加入申请不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId: reviewed.organizationId, + action: "joinRequest.review", + targetType: "joinRequest", + targetId: reviewed.id, + result: "success", + reason: decision === "approved" ? "通过加入申请" : "拒绝加入申请", + }); + + return jsonSuccess("加入申请已处理", { joinRequest: reviewed }); +} diff --git a/app/api/settings/join-requests/route.ts b/app/api/settings/join-requests/route.ts new file mode 100644 index 0000000..3d295fc --- /dev/null +++ b/app/api/settings/join-requests/route.ts @@ -0,0 +1,23 @@ +import { jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission } from "@/modules/core/server/auth"; +import { readData } from "@/modules/core/server/store"; + +export async function GET(): Promise { + const auth = await requirePermission("account:read", { + action: "joinRequest.list", + targetType: "joinRequest", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + const organizationId = auth.context.organization?.id; + + return jsonSuccess("加入申请已加载", { + joinRequests: organizationId + ? data.joinRequests.filter((request) => request.organizationId === organizationId) + : data.joinRequests, + }); +} diff --git a/modules/auth/components/AuthPanel.tsx b/modules/auth/components/AuthPanel.tsx index 230ac86..bc3471b 100644 --- a/modules/auth/components/AuthPanel.tsx +++ b/modules/auth/components/AuthPanel.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import type { ApiResult } from "@/modules/core/server/api"; +import type { Organization } from "@/modules/core/types"; type AuthMode = "login" | "register" | "setup"; @@ -30,6 +31,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { const [message, setMessage] = useState(""); const [isPending, setIsPending] = useState(false); const [hasAccounts, setHasAccounts] = useState(null); + const [organizations, setOrganizations] = useState([]); const copy = modeCopy[mode]; const isSetup = mode === "setup"; @@ -39,17 +41,23 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { let isMounted = true; async function loadState(): Promise { - const [bootstrapResponse, sessionResponse] = await Promise.all([ + const [bootstrapResponse, sessionResponse, organizationsResponse] = await Promise.all([ fetch("/api/auth/bootstrap"), fetch("/api/auth/session"), + fetch("/api/organizations"), ]); const bootstrap = (await bootstrapResponse.json()) as ApiResult<{ setupRequired: boolean }>; const session = (await sessionResponse.json()) as ApiResult<{ account: unknown | null }>; + const organizationsResult = (await organizationsResponse.json()) as ApiResult<{ organizations: Organization[] }>; if (!isMounted || !bootstrap.success || !session.success) { return; } + if (organizationsResult.success) { + setOrganizations(organizationsResult.organizations); + } + const initialized = !bootstrap.setupRequired; setHasAccounts(initialized); @@ -97,6 +105,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { const email = String(formData.get("email") ?? ""); const password = String(formData.get("password") ?? ""); const organization = String(formData.get("organization") ?? ""); + const organizationId = String(formData.get("organizationId") ?? ""); const endpoint = isLogin ? "/api/auth/login" : isSetup ? "/api/auth/setup" : "/api/auth/register"; const response = await fetch(endpoint, { @@ -107,9 +116,10 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { email, password, organization, + organizationId, }), }); - const result = (await response.json()) as ApiResult<{ account: unknown }>; + const result = (await response.json()) as ApiResult<{ account: unknown; pendingApproval?: boolean }>; setIsPending(false); if (!result.success) { @@ -117,6 +127,11 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { return; } + if (result.pendingApproval) { + setMessage(result.reason); + return; + } + router.refresh(); router.replace(isLogin ? redirectTo : "/app/dashboard"); } @@ -180,6 +195,23 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { ) : null} + {mode === "register" && organizations.length > 0 ? ( + + ) : null} + {!isLogin ? (