feat: add global auth settings and pending users
This commit is contained in:
@@ -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}
|
||||
|
||||
32
app/(app)/app/settings/global/page.tsx
Normal file
32
app/(app)/app/settings/global/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 : "账号创建失败";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
92
app/api/settings/global/route.ts
Normal file
92
app/api/settings/global/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user