feat(admin): add audited Refine staging console

This commit is contained in:
Jesse
2026-07-27 19:55:38 +08:00
parent 43581ac0f7
commit 02ec5db361
35 changed files with 4545 additions and 272 deletions
+23
View File
@@ -0,0 +1,23 @@
import type { IdentityUser } from "@/modules/identity/contracts";
export type AdminRole = "admin" | "viewer";
export type AdminAccessResult =
| { allowed: true; role: AdminRole }
| { allowed: false; status: 401 | 403 };
export function authorizeAdminAccess(
user: IdentityUser | null,
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")) {
return { allowed: false, status: 403 };
}
return { allowed: true, role };
}
+55
View File
@@ -0,0 +1,55 @@
import "server-only";
import { headers } from "next/headers";
import { getIdentityAuthServices } from "@/modules/identity/auth";
import {
IdentityAuthorizationError,
requireIdentityUser,
} from "@/modules/identity/session";
import type { IdentityUser } from "@/modules/identity/contracts";
import { authorizeAdminAccess, type AdminRole } from "./auth-policy";
export type { AdminRole } from "./auth-policy";
export class AdminAuthorizationError extends Error {
constructor(
message: string,
readonly status: 401 | 403,
) {
super(message);
this.name = "AdminAuthorizationError";
}
}
export async function requireAdminSession(
access: "read" | "write" = "read",
): Promise<{ user: IdentityUser; role: AdminRole }> {
if (
process.env.AUTH_PROVIDER?.trim() !== "self-hosted"
|| process.env.APP_ENV?.trim() === "production"
) {
throw new AdminAuthorizationError("后台身份服务未启用", 403);
}
try {
const user = await requireIdentityUser(
getIdentityAuthServices().admin.api,
new Headers(await headers()),
);
const authorization = authorizeAdminAccess(user, access);
if (!authorization.allowed) {
throw new AdminAuthorizationError("无权执行此操作", authorization.status);
}
return { user, role: authorization.role };
} catch (error) {
if (error instanceof AdminAuthorizationError) throw error;
if (error instanceof IdentityAuthorizationError) {
throw new AdminAuthorizationError(
error.status === 401 ? "请先登录" : "无权访问后台",
error.status,
);
}
throw error;
}
}
+79
View File
@@ -0,0 +1,79 @@
import "server-only";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import type { IdentityUser } from "@/modules/identity/contracts";
import type { AdminRole } from "./auth";
export type RedemptionCodeRecord = {
id: string;
mask: string;
credits: number;
expiresAt: string | null;
note: string | null;
createdAt: string;
redeemedBy: string | null;
redeemedEmail: string | null;
redeemedAt: string | null;
revokedBy: string | null;
revokedAt: string | null;
status: "available" | "expired" | "redeemed" | "revoked";
};
type RpcCodeRow = {
id: string;
code_mask: string;
credits: number;
expires_at: string | null;
note: string | null;
created_at: string;
redeemed_by: string | null;
redeemed_email: string | null;
redeemed_at: string | null;
revoked_by: string | null;
revoked_at: string | null;
};
export function codeStatus(row: RpcCodeRow): RedemptionCodeRecord["status"] {
if (row.redeemed_at) return "redeemed";
if (row.revoked_at) return "revoked";
if (row.expires_at && Date.parse(row.expires_at) <= Date.now()) return "expired";
return "available";
}
export function mapCode(row: RpcCodeRow): RedemptionCodeRecord {
return {
id: row.id,
mask: row.code_mask,
credits: row.credits,
expiresAt: row.expires_at,
note: row.note,
createdAt: row.created_at,
redeemedBy: row.redeemed_by,
redeemedEmail: row.redeemed_email,
redeemedAt: row.redeemed_at,
revokedBy: row.revoked_by,
revokedAt: row.revoked_at,
status: codeStatus(row),
};
}
export async function runCodeRpc(
functionName:
| "admin_create_redemption_codes"
| "admin_update_redemption_code"
| "admin_revoke_redemption_code",
session: { user: IdentityUser; role: AdminRole },
id: string,
args: Record<string, unknown>,
): Promise<RedemptionCodeRecord[]> {
const admin = createAdminSupabaseClient();
const { data, error } = await admin.rpc(functionName, {
p_actor_user_id: session.user.id,
p_actor_email: session.user.email,
p_actor_role: session.role,
p_request_id: id,
...args,
});
if (error) throw new Error(error.message);
return ((data ?? []) as RpcCodeRow[]).map(mapCode);
}
+41
View File
@@ -0,0 +1,41 @@
import "server-only";
import { Pool, type QueryResultRow } from "pg";
import { readDatabaseUrl } from "@/lib/db/config";
const poolGlobal = globalThis as typeof globalThis & {
jyotishaAdminReadPool?: Pool;
};
export function adminReadPool(): Pool {
if (
process.env.AUTH_PROVIDER?.trim() !== "self-hosted"
|| process.env.APP_ENV?.trim() === "production"
) {
throw new Error("admin reads require the staging self-hosted identity service");
}
poolGlobal.jyotishaAdminReadPool ??= new Pool({
connectionString: readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
allowExitOnIdle: true,
application_name: "jyotisha-admin-read",
});
return poolGlobal.jyotishaAdminReadPool;
}
export async function queryAdminRows<T extends QueryResultRow>(
sql: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const result = await adminReadPool().query<T>(sql, [...values]);
return result.rows;
}
export type PageResult<T> = { data: T[]; total: number };
export function pageOffset(page: number, pageSize: number) {
return (page - 1) * pageSize;
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { AdminAuthorizationError } from "./auth";
export const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sort: z.string().trim().max(64).optional(),
order: z.enum(["asc", "desc"]).default("desc"),
q: z.string().trim().max(200).optional(),
status: z.string().trim().max(50).optional(),
});
export type ListQuery = z.infer<typeof listQuerySchema>;
export function parseListQuery(request: Request) {
return listQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams.entries()),
);
}
export function requestId(request: Request): string {
const supplied = request.headers.get("x-request-id")?.trim();
return supplied && supplied.length <= 200 ? supplied : crypto.randomUUID();
}
export function adminErrorResponse(error: unknown) {
if (error instanceof AdminAuthorizationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
return NextResponse.json(
{ error: "后台服务暂时不可用" },
{ status: 500 },
);
}
export function invalidQueryResponse(details?: unknown) {
return NextResponse.json(
{ error: "查询参数不正确", ...(details ? { details } : {}) },
{ status: 400 },
);
}
export async function readonlyAdminMutation() {
try {
const { requireAdminSession } = await import("./auth");
await requireAdminSession();
return NextResponse.json({ error: "此资源只读" }, { status: 405 });
} catch (error) {
return adminErrorResponse(error);
}
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import type {
AccessControlProvider,
AuthProvider,
BaseRecord,
CrudFilter,
DataProvider,
HttpError,
CreateParams,
DeleteOneParams,
GetListParams,
GetOneParams,
UpdateParams,
} from "@refinedev/core";
export type AdminIdentity = {
id: string;
email: string;
name: string;
role: "admin" | "viewer";
};
const apiBase = "/api/admin";
let identityCache: AdminIdentity | null = null;
function logicalFilters(filters: CrudFilter[] | undefined) {
return (filters ?? []).filter(
(filter): filter is Extract<CrudFilter, { field: string }> => "field" in filter,
);
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
cache: "no-store",
credentials: "same-origin",
...init,
headers: {
...(init?.body ? { "content-type": "application/json" } : {}),
...init?.headers,
},
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload && typeof payload.error === "string"
? payload.error
: "后台请求失败";
throw { message, statusCode: response.status } satisfies HttpError;
}
return payload as T;
}
function listParams(
pagination: { currentPage?: number; pageSize?: number } | undefined,
sorters: { field: string; order: "asc" | "desc" }[] | undefined,
filters: CrudFilter[] | undefined,
) {
const search = new URLSearchParams({
page: String(pagination?.currentPage ?? 1),
pageSize: String(pagination?.pageSize ?? 20),
});
const sorter = sorters?.[0];
if (sorter) {
search.set("sort", sorter.field);
search.set("order", sorter.order);
}
for (const filter of logicalFilters(filters)) {
if (filter.value === undefined || filter.value === null || filter.value === "") continue;
if (filter.field === "q" || filter.field === "status") {
search.set(filter.field, String(filter.value));
}
}
return search;
}
export const adminDataProvider: DataProvider = {
async getList<TData extends BaseRecord>({ resource, pagination, sorters, filters }: GetListParams) {
const search = listParams(pagination, sorters, filters);
return requestJson<{ data: TData[]; total: number }>(
`${apiBase}/${resource}?${search}`,
);
},
async getOne<TData extends BaseRecord>({ resource, id }: GetOneParams) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`);
},
async create<TData extends BaseRecord, TVariables>({ resource, variables }: CreateParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}`, {
method: "POST",
body: JSON.stringify(variables),
});
},
async update<TData extends BaseRecord, TVariables>({ resource, id, variables }: UpdateParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
method: "PATCH",
body: JSON.stringify(variables),
});
},
async deleteOne<TData extends BaseRecord, TVariables>({ resource, id }: DeleteOneParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
method: "DELETE",
});
},
getApiUrl: () => apiBase,
};
async function loadIdentity(): Promise<AdminIdentity> {
if (identityCache) return identityCache;
const payload = await requestJson<{ user: AdminIdentity }>(`${apiBase}/session`);
identityCache = payload.user;
return identityCache;
}
export const adminAuthProvider: AuthProvider = {
async login() {
return { success: false, redirectTo: "/login" };
},
async logout() {
identityCache = null;
await fetch("/api/auth/sign-out", {
method: "POST",
credentials: "same-origin",
});
return { success: true, redirectTo: "/login" };
},
async check() {
try {
await loadIdentity();
return { authenticated: true };
} catch (error) {
const status = (error as HttpError).statusCode;
return {
authenticated: false,
redirectTo: status === 401 ? "/login" : "/",
error: error as HttpError,
};
}
},
async onError(error) {
const status = (error as HttpError)?.statusCode;
if (status === 401) return { redirectTo: "/login", logout: true };
if (status === 403) return { error: error as HttpError };
return { error: error as HttpError };
},
async getPermissions() {
return (await loadIdentity()).role;
},
async getIdentity() {
return loadIdentity();
},
};
const readOnlyResources = new Set([
"users",
"credit-transactions",
"consultations",
"audit-logs",
]);
export const adminAccessControlProvider: AccessControlProvider = {
async can({ resource, action }) {
const role = (await loadIdentity()).role;
if (action === "list" || action === "show") return { can: true };
if (readOnlyResources.has(resource ?? "")) {
return { can: false, reason: "此资源只读" };
}
return role === "admin"
? { can: true }
: { can: false, reason: "viewer 仅可查看" };
},
options: {
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
},
};