fix: unify staging admin access
Deploy staging to test server / deploy (push) Successful in 3m16s

This commit is contained in:
linmeng
2026-07-29 11:28:15 +08:00
parent 5b5f72d403
commit 248e1b8a07
42 changed files with 223 additions and 776 deletions
+11 -1
View File
@@ -1,11 +1,21 @@
import "@refinedev/antd/dist/reset.css";
import "antd/dist/reset.css";
import type { ReactNode } from "react";
import { redirect } from "next/navigation";
import { AdminApp } from "@/components/admin/admin-app";
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
export const dynamic = "force-dynamic";
export default function AdminLayout({ children }: { children: ReactNode }) {
export default async function AdminLayout({ children }: { children: ReactNode }) {
try {
await requireAdminSession("read");
} catch (error) {
if (error instanceof AdminAuthorizationError) {
redirect(error.status === 401 ? "/login" : "/");
}
throw error;
}
return <AdminApp>{children}</AdminApp>;
}
+18 -5
View File
@@ -1,6 +1,19 @@
export function GET() {
return new Response(null, {
status: 307,
headers: { location: "/admin/codes" },
});
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
export async function GET() {
try {
await requireAdminSession("read");
return new Response(null, {
status: 307,
headers: { location: "/admin/codes" },
});
} catch (error) {
if (error instanceof AdminAuthorizationError) {
return new Response(null, {
status: 307,
headers: { location: error.status === 401 ? "/login" : "/" },
});
}
throw error;
}
}
+1 -7
View File
@@ -13,7 +13,6 @@ import {
isSupabaseConfigurationError,
} from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { readIdentityConfig } from "@/modules/identity/config";
export const runtime = "nodejs";
@@ -111,12 +110,7 @@ export async function GET() {
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
);
const isAdmin = await isAdminUser(user);
const identityConfig = readIdentityConfig(process.env);
const adminUrl = isAdmin
? identityConfig.provider === "self-hosted"
? new URL("/admin/codes", identityConfig.adminOrigin).toString()
: "/admin/codes"
: null;
const adminUrl = isAdmin ? "/admin/codes" : null;
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
+7 -13
View File
@@ -1,19 +1,13 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { adminErrorResponse } from "@/lib/admin/http";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
const schema = z.object({ name: z.string().trim().min(1).max(80), description: z.string().trim().max(500), priceCents: z.number().int().positive().max(100_000_000), credits: z.number().int().positive().max(10_000_000), sortOrder: z.number().int().min(-100_000).max(100_000), enabled: z.boolean() });
async function requireAdmin() {
const client = await createServerSupabaseClient();
const { data: { user } } = await client.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
return user;
}
function output(row: Record<string, unknown>) { return { id: row.id, name: row.name, description: row.description, priceCents: row.price_cents, credits: row.credits, sortOrder: row.sort_order, enabled: row.enabled, createdAt: row.created_at, updatedAt: row.updated_at }; }
export async function GET() { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); }
export async function POST(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); }
export async function PATCH(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); }
export async function DELETE(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); }
export async function GET() { try { await requireAdminSession("read"); const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); } catch (error) { return adminErrorResponse(error); } }
export async function POST(request: Request) { try { const auth = await requireAdminSession("write"); const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.user.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); } catch (error) { return adminErrorResponse(error); } }
export async function PATCH(request: Request) { try { await requireAdminSession("write"); const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); } catch (error) { return adminErrorResponse(error); } }
export async function DELETE(request: Request) { try { await requireAdminSession("write"); const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); } catch (error) { return adminErrorResponse(error); } }
+6 -12
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import { requireAdminSession } from "@/lib/admin/auth";
import { adminErrorResponse } from "@/lib/admin/http";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
@@ -14,18 +15,9 @@ const querySchema = z.object({
offset: z.coerce.number().int().min(0).default(0),
});
async function requireAdmin() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
return user;
}
export async function GET(request: Request) {
try {
const auth = await requireAdmin();
if (auth instanceof NextResponse) return auth;
await requireAdminSession("read");
const url = new URL(request.url);
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams));
@@ -83,6 +75,8 @@ export async function GET(request: Request) {
return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } });
} catch (error) {
if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
const authError = adminErrorResponse(error);
if (authError.status === 401 || authError.status === 403) return authError;
return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 });
}
}
@@ -24,7 +24,6 @@ async function dispatch(
const services = getIdentityAuthServices();
const handlers = createHostIsolatedAuthHandlers(config, {
user: toNextJsHandler(services.user),
admin: toNextJsHandler(services.admin),
});
return handlers[method](request);
}
+3 -24
View File
@@ -1,35 +1,14 @@
import { headers } from "next/headers";
import { EmailOtpLogin } from "@/components/email-otp-login";
import {
isSelfHostedIdentityEnabled,
readIdentityConfig,
readSelfHostedIdentityConfig,
} from "@/modules/identity/config";
import { resolveIdentitySurface } from "@/modules/identity/host";
import { readIdentityConfig } from "@/modules/identity/config";
export const dynamic = "force-dynamic";
export default async function LoginPage() {
const config = readIdentityConfig(process.env);
let provider = config.provider;
let passwordEnabled = false;
let passwordOnly = false;
if (isSelfHostedIdentityEnabled(process.env)) {
const selfHosted = readSelfHostedIdentityConfig(process.env);
const surface = resolveIdentitySurface(
(await headers()).get("host"),
selfHosted,
);
if (surface === "admin") provider = "self-hosted";
passwordEnabled = provider === "self-hosted";
passwordOnly = surface === "admin";
}
return (
<EmailOtpLogin
provider={provider}
passwordEnabled={passwordEnabled}
passwordOnly={passwordOnly}
provider={config.provider}
passwordEnabled={config.provider === "self-hosted"}
/>
);
}
@@ -38,7 +38,7 @@ const statusColors: Record<CodeRecord["status"], string> = {
};
export default function CodesPage() {
const { data: role } = usePermissions<"admin" | "viewer">({});
const { data: role } = usePermissions<"admin">({});
const { data: identity } = useGetIdentity<AdminIdentity>();
const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>();
const { mutate: updateCode, mutation: updateMutation } = useUpdate<CodeRecord>();
@@ -137,7 +137,7 @@ export default function CodesPage() {
{ label: "已兑换", value: "redeemed" },
{ label: "已撤销", value: "revoked" },
]}
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}></Button> : <Tag>viewer </Tag>}
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}></Button> : null}
/>
<Modal title="批量生成兑换码" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden>
+2 -2
View File
@@ -132,7 +132,7 @@ export function EmailOtpLogin({
});
if (otpError) throw otpError;
}
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
window.location.assign("/");
} catch (caught) {
if (!(caught instanceof Error)) throw caught;
setError(authMessage(caught));
@@ -149,7 +149,7 @@ export function EmailOtpLogin({
setNotice("");
try {
await selfHostedAuthActions.signInWithPassword(email, password);
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
window.location.assign("/");
} catch (caught) {
if (!(caught instanceof Error)) throw caught;
setError(authMessage(caught));
+3 -8
View File
@@ -1,6 +1,6 @@
import type { IdentityUser } from "@/modules/identity/contracts";
export type AdminRole = "admin" | "viewer";
export type AdminRole = "admin";
export type AdminAccessResult =
| { allowed: true; role: AdminRole }
@@ -11,13 +11,8 @@ export function authorizeAdminAccess(
access: "read" | "write",
): AdminAccessResult {
if (!user) return { allowed: false, status: 401 };
const role: AdminRole | null = user.role.includes("admin")
? "admin"
: user.role.includes("viewer")
? "viewer"
: null;
if (!role || (access === "write" && role !== "admin")) {
if (!user.role.includes("admin")) {
return { allowed: false, status: 403 };
}
return { allowed: true, role };
return { allowed: true, role: "admin" };
}
+1 -1
View File
@@ -34,7 +34,7 @@ export async function requireAdminSession(
try {
const user = await requireIdentityUser(
getIdentityAuthServices().admin.api,
getIdentityAuthServices().user.api,
new Headers(await headers()),
);
const authorization = authorizeAdminAccess(user, access);
+2 -2
View File
@@ -18,7 +18,7 @@ export type AdminIdentity = {
id: string;
email: string;
name: string;
role: "admin" | "viewer";
role: "admin";
};
const apiBase = "/api/admin";
@@ -165,7 +165,7 @@ export const adminAccessControlProvider: AccessControlProvider = {
}
return role === "admin"
? { can: true }
: { can: false, reason: "viewer 仅可查看" };
: { can: false, reason: "无管理员权限" };
},
options: {
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
+1 -1
View File
@@ -19,7 +19,7 @@ export async function isAdminUser(user: { id?: string; email?: string | null })
return rows[0]?.role
.split(",")
.map((role) => role.trim())
.some((role) => role === "admin" || role === "viewer") ?? false;
.some((role) => role === "admin") ?? false;
} catch {
return false;
}
+3 -8
View File
@@ -7,20 +7,15 @@ import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
import { readDatabaseUrl } from "@/lib/db/config";
import { getIdentityAuthServices } from "@/modules/identity/auth";
import { readIdentitySession } from "@/modules/identity/session";
import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
import { resolveIdentitySurface } from "@/modules/identity/host";
import { getSupabasePublicConfig } from "./config";
export async function createServerSupabaseClient() {
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const requestHeaders = new Headers(await headers());
const services = getIdentityAuthServices();
const surface = resolveIdentitySurface(
requestHeaders.get("host"),
readSelfHostedIdentityConfig(process.env),
const session = await readIdentitySession(
getIdentityAuthServices().user.api,
requestHeaders,
);
const auth = surface === "admin" ? services.admin : services.user;
const session = await readIdentitySession(auth.api, requestHeaders);
return createLocalPostgresDataClient(
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
session ? { id: session.user.id, email: session.user.email } : null,
+7 -38
View File
@@ -1,20 +1,18 @@
import { createHmac } from "node:crypto";
import type { Pool } from "pg";
import { APIError, type BetterAuthOptions } from "better-auth";
import type { BetterAuthOptions } from "better-auth";
import { admin, emailOTP, type EmailOTPOptions } from "better-auth/plugins";
import type { SelfHostedIdentityConfig } from "./config.ts";
import type { EmailOtpSender, IdentitySurface } from "./contracts.ts";
import type { EmailOtpSender } from "./contracts.ts";
import { identityModelMapping } from "./model.ts";
export type AdminUserAuthorizer = (userId: string) => Promise<boolean>;
interface BuildAuthOptionsInput {
surface: IdentitySurface;
config: SelfHostedIdentityConfig;
database: Pool;
emailSender: EmailOtpSender;
authorizeAdminUser?: AdminUserAuthorizer;
}
function otpIdempotencyKey(
@@ -58,26 +56,17 @@ export function createEmailOtpOptions(
}
export function buildAuthOptions({
surface,
config,
database,
emailSender,
authorizeAdminUser,
}: BuildAuthOptionsInput): BetterAuthOptions {
if (surface === "admin" && !authorizeAdminUser) {
throw new Error("admin user authorizer is required");
}
const origin = surface === "user" ? config.userOrigin : config.adminOrigin;
const secret = surface === "user" ? config.userSecret : config.adminSecret;
return {
appName: "Jyotisha",
baseURL: origin,
baseURL: config.userOrigin,
basePath: "/api/auth",
secret,
secret: config.userSecret,
database,
trustedOrigins: [origin],
trustedOrigins: [config.userOrigin],
telemetry: { enabled: false },
user: identityModelMapping.user,
session: identityModelMapping.session,
@@ -91,8 +80,7 @@ export function buildAuthOptions({
},
advanced: {
database: { generateId: "uuid" },
cookiePrefix:
surface === "user" ? "jyotisha-user" : "jyotisha-admin",
cookiePrefix: "jyotisha-user",
defaultCookieAttributes: {
secure: true,
httpOnly: true,
@@ -108,31 +96,12 @@ export function buildAuthOptions({
revokeSessionsOnPasswordReset: true,
},
plugins: [
...(surface === "user"
? [emailOTP(createEmailOtpOptions(emailSender, secret, false))]
: []),
emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false)),
admin({
defaultRole: "user",
adminRoles: ["admin"],
schema: identityModelMapping.admin,
}),
],
...(surface === "admin"
? {
databaseHooks: {
session: {
create: {
async before(session: { userId: string }) {
if (!(await authorizeAdminUser!(session.userId))) {
throw new APIError("FORBIDDEN", {
message: "Administrator access required",
});
}
},
},
},
},
}
: {}),
};
}
+2 -17
View File
@@ -16,7 +16,7 @@ interface AdminRoleRow {
ban_expires: Date | null;
}
export type IdentityAdminSurfaceRole = "admin" | "viewer";
export type IdentityAdminSurfaceRole = "admin";
export function createIdentityPool(databaseUrl: string): Pool {
return new Pool({
@@ -68,19 +68,17 @@ export function createDatabaseAdminAuthorizer(
export function createDatabaseAdminSurfaceAuthorizer(
pool: Pool,
): AdminUserAuthorizer {
return createDatabaseRoleAuthorizer(pool, new Set(["admin", "viewer"]));
return createDatabaseAdminAuthorizer(pool);
}
export interface IdentityAuthServices {
pool: Pool;
user: ReturnType<typeof betterAuth>;
admin: ReturnType<typeof betterAuth>;
}
interface IdentityAuthDependencies {
pool?: Pool;
emailSender?: EmailOtpSender;
authorizeAdminUser?: AdminUserAuthorizer;
}
export function createIdentityAuthServices(
@@ -94,28 +92,15 @@ export function createIdentityAuthServices(
apiKey: config.resendApiKey,
from: config.resendFrom,
});
const authorizeAdminUser =
dependencies.authorizeAdminUser ?? createDatabaseAdminSurfaceAuthorizer(pool);
return {
pool,
user: betterAuth(
buildAuthOptions({
surface: "user",
config,
database: pool,
emailSender,
}),
),
admin: betterAuth(
buildAuthOptions({
surface: "admin",
config,
database: pool,
emailSender,
authorizeAdminUser,
}),
),
};
}
-13
View File
@@ -8,9 +8,7 @@ export interface SelfHostedIdentityConfig {
provider: "self-hosted";
databaseUrl: string;
userOrigin: string;
adminOrigin: string;
userSecret: string;
adminSecret: string;
resendApiKey: string;
resendFrom: string;
}
@@ -95,24 +93,13 @@ export function readSelfHostedIdentityConfig(
env: IdentityEnvironment,
): SelfHostedIdentityConfig {
const userOrigin = readOrigin(env, "AUTH_USER_ORIGIN");
const adminOrigin = readOrigin(env, "AUTH_ADMIN_ORIGIN");
if (userOrigin === adminOrigin) {
throw new Error("user and admin origins must be different");
}
const userSecret = readSecret(env, "BETTER_AUTH_USER_SECRET");
const adminSecret = readSecret(env, "BETTER_AUTH_ADMIN_SECRET");
if (userSecret === adminSecret) {
throw new Error("user and admin secrets must be different");
}
return {
provider: "self-hosted",
databaseUrl: readPostgresUrl(env),
userOrigin,
adminOrigin,
userSecret,
adminSecret,
resendApiKey: required(env, "RESEND_API_KEY"),
resendFrom: readSender(env),
};
@@ -1,5 +1,3 @@
export type IdentitySurface = "user" | "admin";
export type EmailOtpType =
| "sign-in"
| "email-verification"
+5 -10
View File
@@ -1,5 +1,4 @@
import type { SelfHostedIdentityConfig } from "./config.ts";
import type { IdentitySurface } from "./contracts.ts";
export type IdentityRequestHandler = (
request: Request,
@@ -33,15 +32,11 @@ function normalizeHost(value: string | null): string | null {
export function resolveIdentitySurface(
hostHeader: string | null,
config: SelfHostedIdentityConfig,
): IdentitySurface | null {
): "user" | null {
const host = normalizeHost(hostHeader);
if (!host) return null;
const userHost = new URL(config.userOrigin).host.toLowerCase();
const adminHost = new URL(config.adminOrigin).host.toLowerCase();
if (host === userHost) return "user";
if (host === adminHost) return "admin";
return null;
return host === new URL(config.userOrigin).host.toLowerCase() ? "user" : null;
}
function isAdminEndpoint(request: Request): boolean {
@@ -55,7 +50,7 @@ function isAdminEndpoint(request: Request): boolean {
export function createHostIsolatedAuthHandlers(
config: SelfHostedIdentityConfig,
handlers: Record<IdentitySurface, IdentityAuthHandlers>,
handlers: { user: IdentityAuthHandlers },
): IdentityAuthHandlers {
const dispatch =
(method: keyof IdentityAuthHandlers): IdentityRequestHandler =>
@@ -64,10 +59,10 @@ export function createHostIsolatedAuthHandlers(
if (!surface) {
return new Response("Unrecognized identity host", { status: 421 });
}
if (surface === "user" && isAdminEndpoint(request)) {
if (isAdminEndpoint(request)) {
return new Response("Not found", { status: 404 });
}
return handlers[surface][method](request);
return handlers.user[method](request);
};
return { GET: dispatch("GET"), POST: dispatch("POST") };