feat: add global auth settings and pending users
This commit is contained in:
63
modules/auth/components/PendingAssignment.tsx
Normal file
63
modules/auth/components/PendingAssignment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,16 +353,14 @@ export async function createRegistration(input: {
|
||||
.where(eq(organizationInvitations.id, invitation.invitation.id));
|
||||
}
|
||||
|
||||
const sessionRows = targetOrganizationId && !invitation
|
||||
? []
|
||||
: await transaction
|
||||
.insert(sessions)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
activeOrganizationId: invitation?.invitation.organizationId,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
const sessionRows = await transaction
|
||||
.insert(sessions)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
activeOrganizationId: invitation?.invitation.organizationId,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
const session = sessionRows[0];
|
||||
|
||||
if (targetOrganizationId && !invitation) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
129
modules/core/server/system-settings.ts
Normal file
129
modules/core/server/system-settings.ts
Normal 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);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
157
modules/settings/components/GlobalSettingsClient.tsx
Normal file
157
modules/settings/components/GlobalSettingsClient.tsx
Normal 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 issuer、client、scope 和头像 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +209,12 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-sm font-medium">{account.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.email}</p>
|
||||
<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" />
|
||||
|
||||
63
modules/shared/components/UserAvatar.tsx
Normal file
63
modules/shared/components/UserAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user