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

@@ -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) {

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[];