feat: add global auth settings and pending users

This commit is contained in:
2026-07-02 06:27:32 -07:00
parent f6b7924d0c
commit 4bb4312b08
24 changed files with 2831 additions and 23 deletions

View File

@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { PendingAssignment } from "@/modules/auth/components/PendingAssignment";
import { getBootstrapState, getCurrentAuthContext } from "@/modules/core/server/auth";
import { AppShell } from "@/modules/shared/components/AppShell";
@@ -20,6 +21,10 @@ export default async function AppLayout({ children }: AppLayoutProps): Promise<R
redirect("/login");
}
if (!context.organization && context.permissions.length === 0) {
return <PendingAssignment account={context.account} />;
}
return (
<AppShell account={context.account} permissions={context.permissions}>
{children}

View File

@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { getSystemSettings } from "@/modules/core/server/system-settings";
import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient";
export default async function GlobalSettingsPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.permissions, "platform:manage")) {
redirect("/app/dashboard");
}
const settings = await getSystemSettings();
return (
<div className="flex flex-col gap-5">
<section>
<Badge variant="success">Global</Badge>
<h2 className="mt-3 text-xl font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">OIDC </p>
</section>
<GlobalSettingsClient settings={settings} />
</div>
);
}

View File

@@ -10,6 +10,7 @@ import type { RoleId } from "@/modules/core/types";
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient";
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
type UsersPageProps = {
searchParams?: Promise<SearchParams>;
@@ -105,8 +106,13 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
{visibleAccounts.map((account) => (
<tr className="table-row-enter hover:bg-secondary/45" key={account.id}>
<td className="px-4 py-3">
<p className="font-medium">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
<div className="flex items-center gap-3">
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium">{account.name}</p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">{formatRoleLabel(account.role)}</td>
<td className="px-4 py-3 text-muted-foreground">{account.organization ?? "-"}</td>

View File

@@ -41,9 +41,9 @@ export async function POST(request: Request): Promise<Response> {
if (created.session) {
await setSessionCookie(created.session.id);
}
return jsonSuccess(organizationId ? "申请已提交,待机构管理员审批" : "账号已创建", {
return jsonSuccess(organizationId && !invitationToken ? "账号已创建,机构加入申请已提交" : "账号已创建", {
account: created.account,
pendingApproval: !created.session,
pendingApproval: false,
});
} catch (error) {
const reason = error instanceof Error ? error.message : "账号创建失败";

View File

@@ -61,6 +61,7 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
const oidcClientSecret = readString(body, "oidcClientSecret");
const oidcScopes = readString(body, "oidcScopes");
const oidcRedirectUri = readString(body, "oidcRedirectUri");
const oidcAvatarClaim = readString(body, "oidcAvatarClaim");
const status = "status" in body ? readStatus(body.status) : null;
if ("status" in body && !status) {
return jsonFailure("机构状态无效");
@@ -77,6 +78,9 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
if ("oidcAutoProvision" in body && oidcAutoProvision === null) {
return jsonFailure("OIDC自动开户配置无效");
}
if (oidcEnabled === true && (!oidcIssuerUrl || !oidcClientId)) {
return jsonFailure("启用 OIDC 前请填写 Issuer URL 和 Client ID");
}
if (
!name &&
@@ -89,6 +93,7 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
!oidcClientSecret &&
!oidcScopes &&
!oidcRedirectUri &&
!oidcAvatarClaim &&
oidcAutoProvision === null
) {
return jsonFailure("请至少填写一个要更新的字段");
@@ -105,6 +110,7 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
oidcClientSecret?: string;
oidcScopes?: string;
oidcRedirectUri?: string;
oidcAvatarClaim?: string;
oidcAutoProvision?: boolean;
updatedAt: Date;
} = {
@@ -141,6 +147,9 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
if (oidcRedirectUri) {
updateValues.oidcRedirectUri = oidcRedirectUri;
}
if (oidcAvatarClaim) {
updateValues.oidcAvatarClaim = oidcAvatarClaim;
}
if (oidcAutoProvision !== null) {
updateValues.oidcAutoProvision = oidcAutoProvision;
}

View File

@@ -6,6 +6,7 @@ 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";
import { getSystemSettings } from "@/modules/core/server/system-settings";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -28,11 +29,14 @@ function createSlug(name: string): string {
export async function GET(): Promise<Response> {
const database = getDatabase();
const rows = await database.select().from(organizations).where(eq(organizations.status, "active"));
const [settings, rows] = await Promise.all([
getSystemSettings(),
database.select().from(organizations).where(eq(organizations.status, "active")),
]);
return jsonSuccess("机构列表已加载", {
organizations: rows
.filter((organization) => organization.registrationEnabled)
.filter((organization) => settings.registrationEnabled && organization.registrationEnabled)
.map((organization) => ({
id: organization.id,
name: organization.name,
@@ -45,6 +49,7 @@ export async function GET(): Promise<Response> {
oidcHasClientSecret: organization.oidcClientSecret.length > 0,
oidcScopes: organization.oidcScopes,
oidcRedirectUri: organization.oidcRedirectUri,
oidcAvatarClaim: organization.oidcAvatarClaim,
oidcAutoProvision: organization.oidcAutoProvision,
createdAt: organization.createdAt.toISOString(),
updatedAt: organization.updatedAt.toISOString(),

View File

@@ -46,6 +46,7 @@ function toOrganization(row: typeof organizations.$inferSelect): Organization {
oidcHasClientSecret: row.oidcClientSecret.length > 0,
oidcScopes: row.oidcScopes,
oidcRedirectUri: row.oidcRedirectUri,
oidcAvatarClaim: row.oidcAvatarClaim,
oidcAutoProvision: row.oidcAutoProvision,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -70,6 +71,8 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
}
const name = readString(body, "name");
const avatarUrl = readString(body, "avatarUrl");
const hasAvatarUrl = "avatarUrl" in body;
const status = "status" in body ? readStatus(body.status) : null;
const roleId = readString(body, "roleId");
const requestedOrganizationId = readString(body, "organizationId");
@@ -79,7 +82,7 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
return jsonFailure("账号状态无效");
}
if (!name && !status && !roleId) {
if (!name && !hasAvatarUrl && !status && !roleId) {
return jsonFailure("请至少填写一个要更新的字段");
}
@@ -113,9 +116,10 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
try {
await database.transaction(async (transaction) => {
if (name || status) {
if (name || hasAvatarUrl || status) {
const updateValues: {
name?: string;
avatarUrl?: string;
status?: AccountStatus;
updatedAt: Date;
} = {
@@ -124,6 +128,9 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
if (name) {
updateValues.name = name;
}
if (hasAvatarUrl) {
updateValues.avatarUrl = avatarUrl;
}
if (status) {
updateValues.status = status;
}

View File

@@ -76,6 +76,7 @@ export async function POST(request: Request): Promise<Response> {
const name = readString(body, "name");
const email = readString(body, "email");
const avatarUrl = readString(body, "avatarUrl");
const password = readString(body, "password");
const roleId = readString(body, "roleId");
const organizationId = readString(body, "organizationId") || auth.context.organization?.id;
@@ -119,6 +120,7 @@ export async function POST(request: Request): Promise<Response> {
.values({
name,
email: normalizedEmail,
avatarUrl,
passwordHash: passwordHash.hash,
passwordSalt: passwordHash.salt,
status: "active",
@@ -162,6 +164,7 @@ export async function POST(request: Request): Promise<Response> {
oidcHasClientSecret: created.organization.oidcClientSecret.length > 0,
oidcScopes: created.organization.oidcScopes,
oidcRedirectUri: created.organization.oidcRedirectUri,
oidcAvatarClaim: created.organization.oidcAvatarClaim,
oidcAutoProvision: created.organization.oidcAutoProvision,
createdAt: created.organization.createdAt.toISOString(),
updatedAt: created.organization.updatedAt.toISOString(),

View File

@@ -0,0 +1,92 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { getSystemSettings, updateSystemSettings } from "@/modules/core/server/system-settings";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function readBoolean(source: Record<string, unknown>, key: string): boolean | null {
const value = source[key];
return typeof value === "boolean" ? value : null;
}
export async function GET(): Promise<Response> {
const auth = await requirePermission("platform:manage", {
action: "systemSettings.read",
targetType: "systemSettings",
targetId: "global",
});
if (!auth.success) {
return auth.response;
}
const settings = await getSystemSettings();
return jsonSuccess("系统配置已加载", { settings });
}
export async function PATCH(request: Request): Promise<Response> {
const auth = await requirePermission("platform:manage", {
action: "systemSettings.update",
targetType: "systemSettings",
targetId: "global",
});
if (!auth.success) {
return auth.response;
}
const body = await readJsonBody(request);
if (!isRecord(body)) {
return jsonFailure("请求数据格式无效");
}
const registrationEnabled = "registrationEnabled" in body ? readBoolean(body, "registrationEnabled") : null;
const oidcEnabled = "oidcEnabled" in body ? readBoolean(body, "oidcEnabled") : null;
const oidcAutoProvision = "oidcAutoProvision" in body ? readBoolean(body, "oidcAutoProvision") : null;
if ("registrationEnabled" in body && registrationEnabled === null) {
return jsonFailure("注册开关无效");
}
if ("oidcEnabled" in body && oidcEnabled === null) {
return jsonFailure("OIDC开关无效");
}
if ("oidcAutoProvision" in body && oidcAutoProvision === null) {
return jsonFailure("OIDC自动开户配置无效");
}
const oidcIssuerUrl = readString(body, "oidcIssuerUrl");
const oidcClientId = readString(body, "oidcClientId");
if (oidcEnabled === true && (!oidcIssuerUrl || !oidcClientId)) {
return jsonFailure("启用 OIDC 前请填写 Issuer URL 和 Client ID");
}
const settings = await updateSystemSettings({
registrationEnabled: registrationEnabled ?? undefined,
oidcEnabled: oidcEnabled ?? undefined,
oidcIssuerUrl,
oidcClientId,
oidcClientSecret: readString(body, "oidcClientSecret"),
oidcScopes: readString(body, "oidcScopes") || "openid profile email",
oidcRedirectUri: readString(body, "oidcRedirectUri"),
oidcAvatarClaim: readString(body, "oidcAvatarClaim") || "picture",
oidcAutoProvision: oidcAutoProvision ?? undefined,
});
await recordAuditLog({
actor: auth.context.account,
action: "systemSettings.update",
targetType: "systemSettings",
targetId: settings.id,
result: "success",
reason: "更新全局系统配置",
});
return jsonSuccess("系统配置已保存", { settings });
}

View File

@@ -0,0 +1,17 @@
CREATE TABLE "system_settings" (
"id" text PRIMARY KEY DEFAULT 'global' NOT NULL,
"registration_enabled" boolean DEFAULT true NOT NULL,
"oidc_enabled" boolean DEFAULT false NOT NULL,
"oidc_issuer_url" text DEFAULT '' NOT NULL,
"oidc_client_id" text DEFAULT '' NOT NULL,
"oidc_client_secret" text DEFAULT '' NOT NULL,
"oidc_scopes" text DEFAULT 'openid profile email' NOT NULL,
"oidc_redirect_uri" text DEFAULT '' NOT NULL,
"oidc_avatar_claim" text DEFAULT 'picture' NOT NULL,
"oidc_auto_provision" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounts" ADD COLUMN "avatar_url" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "organizations" ADD COLUMN "oidc_avatar_claim" text DEFAULT 'picture' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1782994149630,
"tag": "0001_tired_queen_noir",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782998189463,
"tag": "0002_equal_bishop",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,63 @@
import Link from "next/link";
import { Clock3, MailCheck, ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SignOutButton } from "@/modules/auth/components/SignOutButton";
import type { PublicAccount } from "@/modules/core/types";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
type PendingAssignmentProps = {
account: PublicAccount;
};
export function PendingAssignment({ account }: PendingAssignmentProps): React.ReactElement {
return (
<main className="flex min-h-svh items-center justify-center bg-background px-4 py-10">
<section className="page-enter w-full max-w-2xl">
<div className="mb-6 flex items-center justify-between">
<div className="flex min-w-0 items-center gap-3">
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="lg" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{account.name}</p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div>
</div>
<SignOutButton />
</div>
<Card className="overflow-hidden">
<CardHeader className="border-b bg-secondary/35">
<Badge className="w-fit" variant="warning">
</Badge>
<CardTitle className="mt-3 text-2xl leading-tight"></CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-5">
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border bg-background p-4">
<Clock3 className="size-5 text-amber-600" aria-hidden="true" />
<p className="mt-3 text-sm font-medium"></p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
<div className="rounded-md border bg-background p-4">
<MailCheck className="size-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-sm font-medium"></p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
<div className="rounded-md border bg-background p-4">
<ShieldCheck className="size-5 text-emerald-600" aria-hidden="true" />
<p className="mt-3 text-sm font-medium"></p>
<p className="mt-1 text-xs text-muted-foreground">使</p>
</div>
</div>
<Button asChild className="w-fit" variant="outline">
<Link href="/app/dashboard"></Link>
</Button>
</CardContent>
</Card>
</section>
</main>
);
}

View File

@@ -16,6 +16,7 @@ import {
roles,
sessions,
} from "@/modules/core/server/schema";
import { getSystemSettings } from "@/modules/core/server/system-settings";
import type { Account, AuthContext, Membership, Organization, Permission, PublicAccount, Session } from "@/modules/core/types";
const SESSION_COOKIE = "teatea_session";
@@ -48,6 +49,7 @@ function toOrganization(row: OrganizationRow): Organization {
oidcHasClientSecret: row.oidcClientSecret.length > 0,
oidcScopes: row.oidcScopes,
oidcRedirectUri: row.oidcRedirectUri,
oidcAvatarClaim: row.oidcAvatarClaim,
oidcAutoProvision: row.oidcAutoProvision,
createdAt: toIsoString(row.createdAt),
updatedAt: toIsoString(row.updatedAt),
@@ -88,6 +90,7 @@ export function toPublicAccount(account: AccountRow | Account, roleKey?: string,
platformRoleId: "platformRoleId" in account ? account.platformRoleId ?? undefined : undefined,
name: account.name,
email: account.email,
avatarUrl: "avatarUrl" in account ? account.avatarUrl : "",
role: roleKey ?? fallbackRole,
organization: organization?.name ?? fallbackOrganization,
organizationId: organization?.id ?? fallbackOrganizationId,
@@ -272,6 +275,7 @@ export async function createRegistration(input: {
const database = getDatabase();
const password = hashPassword(input.password);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000);
const settings = await getSystemSettings();
const created = await database.transaction(async (transaction) => {
const normalizedEmail = normalizeEmail(input.email);
@@ -296,6 +300,9 @@ export async function createRegistration(input: {
if (invitation && (invitation.invitation.status !== "active" || invitation.invitation.expiresAt < new Date())) {
throw new Error("邀请链接已失效");
}
if (!invitation && !settings.registrationEnabled) {
throw new Error("系统暂未开放注册");
}
const targetOrganizationId = invitation?.invitation.organizationId ?? input.organizationId;
if (targetOrganizationId && !invitation) {
@@ -320,7 +327,7 @@ export async function createRegistration(input: {
email: normalizedEmail,
passwordHash: password.hash,
passwordSalt: password.salt,
status: targetOrganizationId && !invitation ? "pending" : "active",
status: "active",
})
.returning();
const account = accountRows[0];
@@ -346,9 +353,7 @@ export async function createRegistration(input: {
.where(eq(organizationInvitations.id, invitation.invitation.id));
}
const sessionRows = targetOrganizationId && !invitation
? []
: await transaction
const sessionRows = await transaction
.insert(sessions)
.values({
accountId: account.id,

View File

@@ -2,6 +2,7 @@ import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { permissions, rolePermissions, roles } from "@/modules/core/server/schema";
import { ensureSystemSettings } from "@/modules/core/server/system-settings";
import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types";
import { PERMISSIONS, ROLE_LABELS } from "@/modules/core/types";
@@ -132,6 +133,7 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
export async function ensureSystemDefaults(): Promise<void> {
const database = getDatabase();
await ensureSystemSettings();
await database
.insert(permissions)

View File

@@ -24,6 +24,21 @@ export const incidentStatusEnum = pgEnum("incident_status", ["open", "acknowledg
export const incidentSeverityEnum = pgEnum("incident_severity", ["info", "warning", "critical"]);
export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "approved", "rejected"]);
export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"),
registrationEnabled: boolean("registration_enabled").notNull().default(true),
oidcEnabled: boolean("oidc_enabled").notNull().default(false),
oidcIssuerUrl: text("oidc_issuer_url").notNull().default(""),
oidcClientId: text("oidc_client_id").notNull().default(""),
oidcClientSecret: text("oidc_client_secret").notNull().default(""),
oidcScopes: text("oidc_scopes").notNull().default("openid profile email"),
oidcRedirectUri: text("oidc_redirect_uri").notNull().default(""),
oidcAvatarClaim: text("oidc_avatar_claim").notNull().default("picture"),
oidcAutoProvision: boolean("oidc_auto_provision").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const organizations = pgTable("organizations", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
@@ -36,6 +51,7 @@ export const organizations = pgTable("organizations", {
oidcClientSecret: text("oidc_client_secret").notNull().default(""),
oidcScopes: text("oidc_scopes").notNull().default("openid profile email"),
oidcRedirectUri: text("oidc_redirect_uri").notNull().default(""),
oidcAvatarClaim: text("oidc_avatar_claim").notNull().default("picture"),
oidcAutoProvision: boolean("oidc_auto_provision").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@@ -79,6 +95,7 @@ export const accounts = pgTable("accounts", {
platformRoleId: uuid("platform_role_id").references(() => roles.id),
name: text("name").notNull(),
email: text("email").notNull(),
avatarUrl: text("avatar_url").notNull().default(""),
passwordHash: text("password_hash").notNull(),
passwordSalt: text("password_salt").notNull(),
status: accountStatusEnum("status").notNull().default("active"),

View File

@@ -14,6 +14,7 @@ import {
roles,
rooms,
sessions,
systemSettings,
systemIncidents,
} from "@/modules/core/server/schema";
import type {
@@ -50,6 +51,7 @@ export async function readData(): Promise<AppData> {
admissionRows,
auditLogRows,
incidentRows,
systemSettingsRows,
] = await Promise.all([
database.select().from(organizations).orderBy(desc(organizations.createdAt)),
database.select().from(accounts).orderBy(desc(accounts.createdAt)),
@@ -107,6 +109,7 @@ export async function readData(): Promise<AppData> {
.orderBy(desc(admissions.admittedAt)),
listAuditLogs({ limit: 100 }),
database.select().from(systemIncidents).orderBy(desc(systemIncidents.createdAt)),
database.select().from(systemSettings).limit(1),
]);
const roleById = new Map(membershipRows.map((row) => [row.membership.id, row.role]));
@@ -132,6 +135,7 @@ export async function readData(): Promise<AppData> {
oidcHasClientSecret: row.oidcClientSecret.length > 0,
oidcScopes: row.oidcScopes,
oidcRedirectUri: row.oidcRedirectUri,
oidcAvatarClaim: row.oidcAvatarClaim,
oidcAutoProvision: row.oidcAutoProvision,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
@@ -142,6 +146,7 @@ export async function readData(): Promise<AppData> {
platformRoleId: row.platformRoleId ?? undefined,
name: row.name,
email: row.email,
avatarUrl: row.avatarUrl,
role: row.platformRoleId ?? "viewer",
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
@@ -276,7 +281,37 @@ export async function readData(): Promise<AppData> {
updatedAt: iso(row.updatedAt),
}));
const systemSettingsRow = systemSettingsRows[0];
const systemSettingsData = systemSettingsRow
? {
id: systemSettingsRow.id,
registrationEnabled: systemSettingsRow.registrationEnabled,
oidcEnabled: systemSettingsRow.oidcEnabled,
oidcIssuerUrl: systemSettingsRow.oidcIssuerUrl,
oidcClientId: systemSettingsRow.oidcClientId,
oidcHasClientSecret: systemSettingsRow.oidcClientSecret.length > 0,
oidcScopes: systemSettingsRow.oidcScopes,
oidcRedirectUri: systemSettingsRow.oidcRedirectUri,
oidcAvatarClaim: systemSettingsRow.oidcAvatarClaim,
oidcAutoProvision: systemSettingsRow.oidcAutoProvision,
updatedAt: iso(systemSettingsRow.updatedAt),
}
: {
id: "global",
registrationEnabled: true,
oidcEnabled: false,
oidcIssuerUrl: "",
oidcClientId: "",
oidcHasClientSecret: false,
oidcScopes: "openid profile email",
oidcRedirectUri: "",
oidcAvatarClaim: "picture",
oidcAutoProvision: false,
updatedAt: new Date(0).toISOString(),
};
return {
systemSettings: systemSettingsData,
organizations: organizationsData,
accounts: accountsData,
memberships: membershipsData,

View File

@@ -0,0 +1,129 @@
import { eq } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { systemSettings } from "@/modules/core/server/schema";
import type { SystemSettings } from "@/modules/core/types";
const GLOBAL_SETTINGS_ID = "global";
type SystemSettingsRow = typeof systemSettings.$inferSelect;
export type SystemSettingsUpdate = {
registrationEnabled?: boolean;
oidcEnabled?: boolean;
oidcIssuerUrl?: string;
oidcClientId?: string;
oidcClientSecret?: string;
oidcScopes?: string;
oidcRedirectUri?: string;
oidcAvatarClaim?: string;
oidcAutoProvision?: boolean;
};
function toSystemSettings(row: SystemSettingsRow): SystemSettings {
return {
id: row.id,
registrationEnabled: row.registrationEnabled,
oidcEnabled: row.oidcEnabled,
oidcIssuerUrl: row.oidcIssuerUrl,
oidcClientId: row.oidcClientId,
oidcHasClientSecret: row.oidcClientSecret.length > 0,
oidcScopes: row.oidcScopes,
oidcRedirectUri: row.oidcRedirectUri,
oidcAvatarClaim: row.oidcAvatarClaim,
oidcAutoProvision: row.oidcAutoProvision,
updatedAt: row.updatedAt.toISOString(),
};
}
async function ensureSystemSettingsRow(): Promise<SystemSettingsRow> {
const database = getDatabase();
const existingRows = await database
.select()
.from(systemSettings)
.where(eq(systemSettings.id, GLOBAL_SETTINGS_ID))
.limit(1);
const existing = existingRows[0];
if (existing) {
return existing;
}
const createdRows = await database
.insert(systemSettings)
.values({ id: GLOBAL_SETTINGS_ID })
.onConflictDoNothing()
.returning();
const created = createdRows[0];
if (created) {
return created;
}
const fallbackRows = await database
.select()
.from(systemSettings)
.where(eq(systemSettings.id, GLOBAL_SETTINGS_ID))
.limit(1);
const fallback = fallbackRows[0];
if (!fallback) {
throw new Error("系统配置初始化失败");
}
return fallback;
}
export async function ensureSystemSettings(): Promise<void> {
await ensureSystemSettingsRow();
}
export async function getSystemSettings(): Promise<SystemSettings> {
const row = await ensureSystemSettingsRow();
return toSystemSettings(row);
}
export async function updateSystemSettings(input: SystemSettingsUpdate): Promise<SystemSettings> {
const database = getDatabase();
await ensureSystemSettingsRow();
const updateValues: SystemSettingsUpdate & { updatedAt: Date } = {
updatedAt: new Date(),
};
if (input.registrationEnabled !== undefined) {
updateValues.registrationEnabled = input.registrationEnabled;
}
if (input.oidcEnabled !== undefined) {
updateValues.oidcEnabled = input.oidcEnabled;
}
if (input.oidcIssuerUrl !== undefined) {
updateValues.oidcIssuerUrl = input.oidcIssuerUrl;
}
if (input.oidcClientId !== undefined) {
updateValues.oidcClientId = input.oidcClientId;
}
if (input.oidcClientSecret !== undefined && input.oidcClientSecret.length > 0) {
updateValues.oidcClientSecret = input.oidcClientSecret;
}
if (input.oidcScopes !== undefined) {
updateValues.oidcScopes = input.oidcScopes;
}
if (input.oidcRedirectUri !== undefined) {
updateValues.oidcRedirectUri = input.oidcRedirectUri;
}
if (input.oidcAvatarClaim !== undefined) {
updateValues.oidcAvatarClaim = input.oidcAvatarClaim;
}
if (input.oidcAutoProvision !== undefined) {
updateValues.oidcAutoProvision = input.oidcAutoProvision;
}
const rows = await database
.update(systemSettings)
.set(updateValues)
.where(eq(systemSettings.id, GLOBAL_SETTINGS_ID))
.returning();
const row = rows[0];
if (!row) {
throw new Error("系统配置不存在");
}
return toSystemSettings(row);
}

View File

@@ -75,11 +75,26 @@ export type Organization = {
oidcHasClientSecret: boolean;
oidcScopes: string;
oidcRedirectUri: string;
oidcAvatarClaim: string;
oidcAutoProvision: boolean;
createdAt: string;
updatedAt: string;
};
export type SystemSettings = {
id: string;
registrationEnabled: boolean;
oidcEnabled: boolean;
oidcIssuerUrl: string;
oidcClientId: string;
oidcHasClientSecret: boolean;
oidcScopes: string;
oidcRedirectUri: string;
oidcAvatarClaim: string;
oidcAutoProvision: boolean;
updatedAt: string;
};
export type MembershipStatus = "active" | "disabled" | "pending";
export type Membership = {
@@ -134,6 +149,7 @@ export type Account = {
platformRoleId?: string;
name: string;
email: string;
avatarUrl: string;
role: RoleId | string;
organization?: string;
organizationId?: string;
@@ -229,6 +245,7 @@ export type SystemIncident = {
};
export type AppData = {
systemSettings: SystemSettings;
organizations: Organization[];
accounts: Account[];
memberships: Membership[];

View File

@@ -0,0 +1,157 @@
"use client";
import { FormEvent, useState } from "react";
import { Globe2, Save, ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import type { ApiResult } from "@/modules/core/server/api";
import type { SystemSettings } from "@/modules/core/types";
type GlobalSettingsClientProps = {
settings: SystemSettings;
};
export function GlobalSettingsClient({ settings }: GlobalSettingsClientProps): React.ReactElement {
const router = useRouter();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setMessage("");
setIsPending(true);
const formData = new FormData(event.currentTarget);
const response = await fetch("/api/settings/global", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
registrationEnabled: formData.get("registrationEnabled") === "on",
oidcEnabled: formData.get("oidcEnabled") === "on",
oidcIssuerUrl: String(formData.get("oidcIssuerUrl") ?? ""),
oidcClientId: String(formData.get("oidcClientId") ?? ""),
oidcClientSecret: String(formData.get("oidcClientSecret") ?? ""),
oidcScopes: String(formData.get("oidcScopes") ?? ""),
oidcRedirectUri: String(formData.get("oidcRedirectUri") ?? ""),
oidcAvatarClaim: String(formData.get("oidcAvatarClaim") ?? ""),
oidcAutoProvision: formData.get("oidcAutoProvision") === "on",
}),
});
const result = (await response.json()) as ApiResult<{ settings: SystemSettings }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setMessage(result.reason);
router.refresh();
}
return (
<form className="grid gap-5 xl:grid-cols-[0.9fr_1.1fr]" onSubmit={handleSubmit}>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe2 className="size-5 text-primary" aria-hidden="true" />
<CardTitle></CardTitle>
</div>
<CardDescription> OIDC </CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium"></span>
<span className="block text-xs text-muted-foreground">使</span>
</span>
<input defaultChecked={settings.registrationEnabled} name="registrationEnabled" type="checkbox" />
</label>
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium"> OIDC </span>
<span className="block text-xs text-muted-foreground"> OIDC </span>
</span>
<input defaultChecked={settings.oidcEnabled} name="oidcEnabled" type="checkbox" />
</label>
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium">OIDC </span>
<span className="block text-xs text-muted-foreground"> OIDC </span>
</span>
<input defaultChecked={settings.oidcAutoProvision} name="oidcAutoProvision" type="checkbox" />
</label>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<ShieldCheck className="size-5 text-primary" aria-hidden="true" />
<CardTitle>OIDC </CardTitle>
</div>
<CardDescription> OIDC issuerclientscope claim</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium">Issuer URL</span>
<Input defaultValue={settings.oidcIssuerUrl} name="oidcIssuerUrl" placeholder="https://idp.example.com" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium">Client ID</span>
<Input defaultValue={settings.oidcClientId} name="oidcClientId" />
</label>
</div>
<label className="grid gap-2 text-sm">
<span className="font-medium">Client Secret</span>
<Input
name="oidcClientSecret"
placeholder={settings.oidcHasClientSecret ? "已配置,留空则不修改" : "未配置"}
type="password"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium">Scopes</span>
<Input defaultValue={settings.oidcScopes} name="oidcScopes" placeholder="openid profile email" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium">Redirect URI</span>
<Input defaultValue={settings.oidcRedirectUri} name="oidcRedirectUri" placeholder="/api/auth/oidc/callback" />
</label>
</div>
<label className="grid gap-2 text-sm">
<span className="font-medium">Avatar Claim</span>
<Input defaultValue={settings.oidcAvatarClaim} name="oidcAvatarClaim" placeholder="picture" />
</label>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<div className="flex items-center justify-between gap-3 border-t pt-4">
<Badge variant={settings.oidcEnabled ? "success" : "secondary"}>
{settings.oidcEnabled ? "OIDC 已启用" : "OIDC 未启用"}
</Badge>
<Button disabled={isPending} type="submit">
<Save className="size-4" aria-hidden="true" />
</Button>
</div>
</CardContent>
</Card>
</form>
);
}

View File

@@ -44,6 +44,7 @@ export function OrganizationDetailClient({
oidcClientSecret: String(formData.get("oidcClientSecret") ?? ""),
oidcScopes: String(formData.get("oidcScopes") ?? ""),
oidcRedirectUri: String(formData.get("oidcRedirectUri") ?? ""),
oidcAvatarClaim: String(formData.get("oidcAvatarClaim") ?? ""),
oidcAutoProvision: formData.get("oidcAutoProvision") === "on",
}),
});
@@ -115,6 +116,11 @@ export function OrganizationDetailClient({
</label>
</div>
<label className="grid gap-2 text-sm">
<span className="font-medium">Avatar Claim</span>
<Input defaultValue={organization.oidcAvatarClaim} name="oidcAvatarClaim" placeholder="picture" />
</label>
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium">OIDC </span>

View File

@@ -77,6 +77,7 @@ export function UserManagementClient({
roleId: String(formData.get("roleId") ?? ""),
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
avatarUrl: String(formData.get("avatarUrl") ?? ""),
password: String(formData.get("password") ?? ""),
}),
});
@@ -270,6 +271,10 @@ export function UserManagementClient({
<span className="font-medium"></span>
<Input name="email" required type="email" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"> URL</span>
<Input name="avatarUrl" placeholder="https://example.com/avatar.png" type="url" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input minLength={6} name="password" required type="password" />
@@ -366,6 +371,7 @@ export function UserAccountActions({
const formData = new FormData(event.currentTarget);
await updateAccount({
name: String(formData.get("name") ?? ""),
avatarUrl: String(formData.get("avatarUrl") ?? ""),
status: String(formData.get("status") ?? ""),
organizationId: String(formData.get("organizationId") ?? ""),
roleId: String(formData.get("roleId") ?? ""),
@@ -412,6 +418,10 @@ export function UserAccountActions({
<span className="font-medium"></span>
<Input defaultValue={account.name} name="name" required />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"> URL</span>
<Input defaultValue={account.avatarUrl} name="avatarUrl" placeholder="https://example.com/avatar.png" type="url" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<select

View File

@@ -4,6 +4,7 @@ import {
BedDouble,
Building2,
ClipboardCheck,
Globe2,
HeartPulse,
Home,
LayoutDashboard,
@@ -21,11 +22,13 @@ import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Permission, PublicAccount } from "@/modules/core/types";
import { SignOutButton } from "@/modules/auth/components/SignOutButton";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
type NavItem = {
href: string;
label: string;
icon: LucideIcon;
permission?: Permission;
};
type NavGroup = {
@@ -97,30 +100,41 @@ const navGroups: NavGroup[] = [
{
label: "系统设置",
items: [
{
href: "/app/settings/global",
label: "全局配置",
icon: Globe2,
permission: "platform:manage",
},
{
href: "/app/settings/organizations",
label: "机构管理",
icon: Building2,
permission: "organization:read",
},
{
href: "/app/settings/users",
label: "用户管理",
icon: Users,
permission: "account:read",
},
{
href: "/app/settings/roles",
label: "角色权限",
icon: Settings,
permission: "role:read",
},
{
href: "/app/settings/audit",
label: "审计日志",
icon: ListChecks,
permission: "audit:read",
},
{
href: "/app/settings/status",
label: "运行状态",
icon: Wrench,
permission: "incident:read",
},
],
},
@@ -136,6 +150,12 @@ type AppShellProps = {
export function AppShell({ children, account, permissions }: AppShellProps): React.ReactElement {
const canCreateElder = permissions.includes("elder:create");
const visibleNavGroups = navGroups
.map((group) => ({
...group,
items: group.items.filter((item) => !item.permission || permissions.includes(item.permission)),
}))
.filter((group) => group.items.length > 0);
return (
<div className="flex min-h-svh items-stretch bg-background text-foreground">
@@ -152,7 +172,7 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-4" aria-label="主导航">
<div className="space-y-5">
{navGroups.map((group) => (
{visibleNavGroups.map((group) => (
<section key={group.label}>
<p className="px-3 pb-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
<ul className="space-y-1">
@@ -189,10 +209,13 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden text-right sm:block">
<div className="hidden items-center gap-2 sm:flex">
<div className="text-right">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
</div>
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
</div>
<Button size="sm" disabled={!canCreateElder}>
<Home className="size-4" aria-hidden="true" />

View File

@@ -0,0 +1,63 @@
import { cn } from "@/lib/utils";
type UserAvatarProps = {
name: string;
avatarUrl?: string;
size?: "sm" | "md" | "lg";
className?: string;
};
const sizeClasses: Record<NonNullable<UserAvatarProps["size"]>, string> = {
sm: "size-9 text-xs",
md: "size-10 text-sm",
lg: "size-14 text-lg",
};
function getInitial(name: string): string {
const trimmedName = name.trim();
if (!trimmedName) {
return "用";
}
return trimmedName.slice(0, 1).toUpperCase();
}
function isSupportedAvatarUrl(avatarUrl: string | undefined): avatarUrl is string {
if (!avatarUrl) {
return false;
}
return avatarUrl.startsWith("https://") || avatarUrl.startsWith("http://") || avatarUrl.startsWith("data:image/");
}
export function UserAvatar({
name,
avatarUrl,
size = "md",
className,
}: UserAvatarProps): React.ReactElement {
const hasAvatar = isSupportedAvatarUrl(avatarUrl);
return (
<span
aria-label={`${name || "用户"}头像`}
className={cn(
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md border bg-secondary font-semibold text-primary",
sizeClasses[size],
className,
)}
role="img"
style={
hasAvatar
? {
backgroundImage: `url(${avatarUrl})`,
backgroundPosition: "center",
backgroundSize: "cover",
}
: undefined
}
>
{hasAvatar ? <span className="sr-only">{name}</span> : getInitial(name)}
</span>
);
}