feat(admin): add operations resources and audit UI
This commit is contained in:
@@ -7,18 +7,20 @@ import type {
|
||||
CrudFilter,
|
||||
DataProvider,
|
||||
HttpError,
|
||||
CreateParams,
|
||||
DeleteOneParams,
|
||||
GetListParams,
|
||||
GetOneParams,
|
||||
UpdateParams,
|
||||
CreateParams,
|
||||
DeleteOneParams,
|
||||
GetListParams,
|
||||
GetOneParams,
|
||||
UpdateParams,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type AdminIdentity = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin";
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
requiresMfa: boolean;
|
||||
};
|
||||
|
||||
const apiBase = "/api/admin";
|
||||
@@ -26,11 +28,15 @@ let identityCache: AdminIdentity | null = null;
|
||||
|
||||
function logicalFilters(filters: CrudFilter[] | undefined) {
|
||||
return (filters ?? []).filter(
|
||||
(filter): filter is Extract<CrudFilter, { field: string }> => "field" in filter,
|
||||
(filter): filter is Extract<CrudFilter, { field: string }> =>
|
||||
"field" in filter,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
export async function adminRequestJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
@@ -42,10 +48,13 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
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;
|
||||
const message =
|
||||
payload && typeof payload.error === "string"
|
||||
? payload.error
|
||||
: "后台请求失败";
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode: response.status,
|
||||
} satisfies Partial<HttpError>);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
@@ -65,7 +74,12 @@ function listParams(
|
||||
search.set("order", sorter.order);
|
||||
}
|
||||
for (const filter of logicalFilters(filters)) {
|
||||
if (filter.value === undefined || filter.value === null || filter.value === "") continue;
|
||||
if (
|
||||
filter.value === undefined ||
|
||||
filter.value === null ||
|
||||
filter.value === ""
|
||||
)
|
||||
continue;
|
||||
if (filter.field === "q" || filter.field === "status") {
|
||||
search.set(filter.field, String(filter.value));
|
||||
}
|
||||
@@ -74,29 +88,44 @@ function listParams(
|
||||
}
|
||||
|
||||
export const adminDataProvider: DataProvider = {
|
||||
async getList<TData extends BaseRecord>({ resource, pagination, sorters, filters }: GetListParams) {
|
||||
async getList<TData extends BaseRecord>({
|
||||
resource,
|
||||
pagination,
|
||||
sorters,
|
||||
filters,
|
||||
}: GetListParams) {
|
||||
const search = listParams(pagination, sorters, filters);
|
||||
return requestJson<{ data: TData[]; total: number }>(
|
||||
return adminRequestJson<{ data: TData[]; total: number }>(
|
||||
`${apiBase}/${resource}?${search}`,
|
||||
);
|
||||
},
|
||||
async getOne<TData extends BaseRecord>({ resource, id }: GetOneParams) {
|
||||
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`);
|
||||
return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`);
|
||||
},
|
||||
async create<TData extends BaseRecord, TVariables>({ resource, variables }: CreateParams<TVariables>) {
|
||||
return requestJson<{ data: TData }>(`${apiBase}/${resource}`, {
|
||||
async create<TData extends BaseRecord, TVariables>({
|
||||
resource,
|
||||
variables,
|
||||
}: CreateParams<TVariables>) {
|
||||
return adminRequestJson<{ 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}`, {
|
||||
async update<TData extends BaseRecord, TVariables>({
|
||||
resource,
|
||||
id,
|
||||
variables,
|
||||
}: UpdateParams<TVariables>) {
|
||||
return adminRequestJson<{ 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}`, {
|
||||
async deleteOne<TData extends BaseRecord, TVariables>({
|
||||
resource,
|
||||
id,
|
||||
}: DeleteOneParams<TVariables>) {
|
||||
return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
@@ -105,7 +134,9 @@ export const adminDataProvider: DataProvider = {
|
||||
|
||||
async function loadIdentity(): Promise<AdminIdentity> {
|
||||
if (identityCache) return identityCache;
|
||||
const payload = await requestJson<{ user: AdminIdentity }>(`${apiBase}/session`);
|
||||
const payload = await adminRequestJson<{ user: AdminIdentity }>(
|
||||
`${apiBase}/session`,
|
||||
);
|
||||
identityCache = payload.user;
|
||||
return identityCache;
|
||||
}
|
||||
@@ -142,30 +173,49 @@ export const adminAuthProvider: AuthProvider = {
|
||||
return { error: error as HttpError };
|
||||
},
|
||||
async getPermissions() {
|
||||
return (await loadIdentity()).role;
|
||||
return (await loadIdentity()).permissions;
|
||||
},
|
||||
async getIdentity() {
|
||||
return loadIdentity();
|
||||
},
|
||||
};
|
||||
|
||||
const readOnlyResources = new Set([
|
||||
"users",
|
||||
"credit-transactions",
|
||||
"consultations",
|
||||
"audit-logs",
|
||||
]);
|
||||
const resourcePermissions: Record<string, { read: string; write?: string }> = {
|
||||
administrators: {
|
||||
read: "admin.users.read",
|
||||
write: "admin.users.manage_roles",
|
||||
},
|
||||
roles: { read: "admin.users.read" },
|
||||
customers: { read: "admin.customers.read" },
|
||||
codes: { read: "billing.orders.read", write: "billing.adjustments.write" },
|
||||
"credit-transactions": { read: "billing.orders.read" },
|
||||
consultations: { read: "billing.orders.read" },
|
||||
"audit-logs": { read: "audit.read" },
|
||||
payments: { read: "billing.orders.read" },
|
||||
packages: { read: "billing.products.read", write: "billing.products.write" },
|
||||
products: { read: "billing.products.read", write: "billing.products.write" },
|
||||
subscriptions: {
|
||||
read: "billing.orders.read",
|
||||
write: "billing.adjustments.write",
|
||||
},
|
||||
orders: { read: "billing.orders.read", write: "billing.adjustments.write" },
|
||||
usage: { read: "billing.orders.read" },
|
||||
models: { read: "models.read", write: "models.write" },
|
||||
"model-releases": { read: "models.read", write: "models.publish" },
|
||||
"feature-flags": { read: "admin.access", write: "ops.flags.write" },
|
||||
security: { read: "admin.access", write: "admin.access" },
|
||||
};
|
||||
|
||||
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"
|
||||
const identity = await loadIdentity();
|
||||
const required = resourcePermissions[resource ?? ""];
|
||||
if (!required) return { can: false, reason: "未知管理资源" };
|
||||
const permission =
|
||||
action === "list" || action === "show" ? required.read : required.write;
|
||||
return permission && identity.permissions.includes(permission)
|
||||
? { can: true }
|
||||
: { can: false, reason: "无管理员权限" };
|
||||
: { can: false, reason: "无此操作权限" };
|
||||
},
|
||||
options: {
|
||||
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
|
||||
|
||||
Reference in New Issue
Block a user