feat(admin): add audited Refine staging console
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user