Add secure administrator operations console
Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import type {
|
||||
AdminOperator,
|
||||
AdminOperatorCreateRequest,
|
||||
AdminOperatorProvisioning,
|
||||
AdminSecuritySummary,
|
||||
AdminLoginResponse,
|
||||
AuditLogEntry,
|
||||
CreditGrantRequest,
|
||||
CreditGrantResponse,
|
||||
LedgerEntry,
|
||||
Overview,
|
||||
PageResult,
|
||||
ReferralOverview,
|
||||
SessionResponse,
|
||||
UserDetail,
|
||||
UserSummary,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = "/v1/admin";
|
||||
const REQUEST_TIMEOUT_MS = 12_000;
|
||||
|
||||
interface ApiErrorBody {
|
||||
code?: string;
|
||||
error?: {
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
let csrfToken = "";
|
||||
|
||||
export function setCsrfToken(token?: string): void {
|
||||
csrfToken = token?.trim() ?? "";
|
||||
}
|
||||
|
||||
function csrfTokenFromCookie(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = "osg_admin_csrf=";
|
||||
return (
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(prefix))
|
||||
?.slice(prefix.length) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function safeMessage(status: number, code?: string): string {
|
||||
const messages: Record<string, string> = {
|
||||
INVALID_CREDENTIALS: "用户名、密码或动态验证码错误",
|
||||
INVALID_TOTP: "动态验证码无效或已过期",
|
||||
VALIDATION_ERROR: "提交内容不符合要求",
|
||||
USER_NOT_FOUND: "未找到该用户",
|
||||
INSUFFICIENT_PERMISSION: "无权执行此操作",
|
||||
IDEMPOTENCY_CONFLICT: "该赠送请求与已有记录冲突,请核对审计日志",
|
||||
ADMIN_OPERATOR_NOT_FOUND: "未找到该管理员",
|
||||
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
|
||||
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
|
||||
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
|
||||
RATE_LIMITED: "操作过于频繁,请稍后再试",
|
||||
};
|
||||
if (code && messages[code]) return messages[code];
|
||||
if (status === 401) return "登录状态已失效,请重新登录";
|
||||
if (status === 403) return "无权执行此操作";
|
||||
if (status === 409) return "请求冲突,请刷新后重试";
|
||||
if (status === 429) return "操作过于频繁,请稍后再试";
|
||||
if (status >= 500) return "服务暂时不可用,请稍后再试";
|
||||
return "请求失败,请检查后重试";
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const method = options.method?.toUpperCase() ?? "GET";
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
if (options.body) headers.set("Content-Type", "application/json");
|
||||
const csrfRequired =
|
||||
!["GET", "HEAD", "OPTIONS"].includes(method) && path !== "/auth/login";
|
||||
if (csrfRequired) {
|
||||
const requestToken =
|
||||
csrfToken || csrfTokenFromCookie() || headers.get("X-CSRF-Token") || "";
|
||||
if (!requestToken) {
|
||||
throw new ApiError("CSRF_TOKEN_MISSING", "安全令牌缺失,请重新登录", 403);
|
||||
}
|
||||
headers.set("X-CSRF-Token", requestToken);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body: ApiErrorBody = {};
|
||||
try {
|
||||
body = (await response.json()) as ApiErrorBody;
|
||||
} catch {
|
||||
// 非 JSON 错误响应不会暴露给界面。
|
||||
}
|
||||
const apiError = new ApiError(
|
||||
body.code ?? body.error?.code ?? `HTTP_${response.status}`,
|
||||
safeMessage(response.status, body.code ?? body.error?.code),
|
||||
response.status,
|
||||
);
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!path.startsWith("/auth/") &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
window.dispatchEvent(new Event("admin:unauthorized"));
|
||||
}
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
if (error instanceof DOMException && error.name === "TimeoutError") {
|
||||
throw new ApiError("REQUEST_TIMEOUT", "请求超时,请稍后再试", 0);
|
||||
}
|
||||
throw new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0);
|
||||
}
|
||||
}
|
||||
|
||||
function query(params: Record<string, string | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) search.set(key, value);
|
||||
});
|
||||
const result = search.toString();
|
||||
return result ? `?${result}` : "";
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
session: () => request<SessionResponse>("/auth/session"),
|
||||
|
||||
login: (username: string, password: string, totpCode: string) =>
|
||||
request<AdminLoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password, totpCode }),
|
||||
}),
|
||||
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
|
||||
overview: (range: string) =>
|
||||
request<Overview>(`/overview${query({ range })}`),
|
||||
|
||||
referrals: (range: string) =>
|
||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||
|
||||
users: (search: string, cursor?: string) =>
|
||||
request<PageResult<UserSummary>>(
|
||||
`/users${query({ q: search.trim(), cursor })}`,
|
||||
),
|
||||
|
||||
user: (userId: string) =>
|
||||
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
||||
|
||||
ledger: (userId: string, cursor?: string) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
||||
),
|
||||
|
||||
grantCredits: (payload: CreditGrantRequest) =>
|
||||
request<CreditGrantResponse>("/credits/grants", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
amount: payload.amount,
|
||||
reason: payload.reason,
|
||||
}),
|
||||
headers: { "Idempotency-Key": payload.idempotencyKey },
|
||||
}),
|
||||
|
||||
auditLogs: (cursor?: string) =>
|
||||
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
|
||||
|
||||
operators: (cursor?: string) =>
|
||||
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
|
||||
|
||||
operatorSummary: () =>
|
||||
request<AdminSecuritySummary>("/operators/summary"),
|
||||
|
||||
createOperator: (payload: AdminOperatorCreateRequest) =>
|
||||
request<AdminOperatorProvisioning>("/operators", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
setOperatorEnabled: (operatorId: string, enabled: boolean) =>
|
||||
request<void>(
|
||||
`/operators/${encodeURIComponent(operatorId)}/${enabled ? "enable" : "disable"}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
unlockOperator: (operatorId: string) =>
|
||||
request<void>(`/operators/${encodeURIComponent(operatorId)}/unlock`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
resetOperatorCredentials: (operatorId: string, password: string) =>
|
||||
request<AdminOperatorProvisioning>(
|
||||
`/operators/${encodeURIComponent(operatorId)}/credentials/reset`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
},
|
||||
),
|
||||
|
||||
revokeOperatorSessions: (operatorId: string) =>
|
||||
request<void>(
|
||||
`/operators/${encodeURIComponent(operatorId)}/sessions/revoke`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user