initial commit after split
This commit is contained in:
519
webui/src/api/admin.ts
Normal file
519
webui/src/api/admin.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PermissionItem = {
|
||||
scope: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
level: "tenant" | "system";
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type AdminOverview = {
|
||||
active_tenant_id: string;
|
||||
active_tenant_name: string;
|
||||
tenant_count?: number | null;
|
||||
system_account_count?: number | null;
|
||||
system_group_template_count?: number | null;
|
||||
system_role_template_count?: number | null;
|
||||
user_count: number;
|
||||
active_user_count: number;
|
||||
group_count: number;
|
||||
role_count: number;
|
||||
active_api_key_count: number;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
export type TenantAdminItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
counts: Record<string, number>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type RoleSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
effective_permission_count: number;
|
||||
is_builtin: boolean;
|
||||
is_assignable: boolean;
|
||||
user_assignments: number;
|
||||
group_assignments: number;
|
||||
level: "tenant" | "system";
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type GroupSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
member_count: number;
|
||||
member_ids: string[];
|
||||
roles: RoleSummary[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type UserAdminItem = {
|
||||
id: string;
|
||||
account_id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
account_is_active: boolean;
|
||||
password_reset_required: boolean;
|
||||
last_login_at?: string | null;
|
||||
groups: GroupSummary[];
|
||||
roles: RoleSummary[];
|
||||
effective_scopes: string[];
|
||||
is_owner: boolean;
|
||||
is_last_active_owner: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
|
||||
roles: RoleSummary[];
|
||||
last_login_at?: string | null;
|
||||
};
|
||||
|
||||
|
||||
export type SystemMembershipDraft = {
|
||||
tenant_id: string;
|
||||
is_active: boolean;
|
||||
role_ids: string[];
|
||||
group_ids: string[];
|
||||
is_owner?: boolean;
|
||||
is_last_active_owner?: boolean;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyFieldKey =
|
||||
| "store_raw_campaign_json"
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days"
|
||||
| "audit_detail_level";
|
||||
|
||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
||||
|
||||
export type PrivacyRetentionPolicy = {
|
||||
store_raw_campaign_json: boolean;
|
||||
raw_campaign_json_retention_days?: number | null;
|
||||
generated_eml_retention_days?: number | null;
|
||||
stored_report_detail_retention_days?: number | null;
|
||||
mock_mailbox_retention_days?: number | null;
|
||||
audit_detail_retention_days?: number | null;
|
||||
audit_detail_level: "full" | "redacted" | "minimal";
|
||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
||||
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
|
||||
};
|
||||
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
policy: PrivacyRetentionPolicyPatch;
|
||||
effective_policy: PrivacyRetentionPolicy;
|
||||
parent_policy?: PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RetentionRunResponse = {
|
||||
result: {
|
||||
dry_run: boolean;
|
||||
policy: PrivacyRetentionPolicy;
|
||||
cutoffs: Record<string, string | null>;
|
||||
effective_policy_scope?: string;
|
||||
counts: Record<string, Record<string, number>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GovernanceAssignment = {
|
||||
tenant_id: string;
|
||||
mode: "available" | "required";
|
||||
};
|
||||
|
||||
export type GovernanceTemplateItem = {
|
||||
id: string;
|
||||
kind: "group" | "role";
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
effective_permission_count: number;
|
||||
is_active: boolean;
|
||||
assignments: GovernanceAssignment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ApiKeyAdminItem = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
last_used_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuditAdminItem = {
|
||||
id: string;
|
||||
scope: "tenant" | "system";
|
||||
tenant_id?: string | null;
|
||||
actor_email?: string | null;
|
||||
action: string;
|
||||
object_type?: string | null;
|
||||
object_id?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
||||
return apiFetch(settings, "/api/v1/admin/overview");
|
||||
}
|
||||
|
||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
||||
return response.permissions;
|
||||
}
|
||||
|
||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
||||
return response.tenants;
|
||||
}
|
||||
|
||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
|
||||
return response.accounts;
|
||||
}
|
||||
|
||||
export function createTenant(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
owner_account_id?: string | null;
|
||||
description?: string | null;
|
||||
default_locale?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
}): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
}>): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
||||
return response.users;
|
||||
}
|
||||
|
||||
export function createUser(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
password?: string | null;
|
||||
password_reset_required?: boolean;
|
||||
is_active?: boolean;
|
||||
group_ids?: string[];
|
||||
role_ids?: string[];
|
||||
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
|
||||
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
|
||||
display_name: string | null;
|
||||
is_active: boolean;
|
||||
group_ids: string[];
|
||||
role_ids: string[];
|
||||
}>): Promise<UserAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
||||
return response.groups;
|
||||
}
|
||||
|
||||
export function createGroup(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
member_ids?: string[];
|
||||
role_ids?: string[];
|
||||
}): Promise<GroupSummary> {
|
||||
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
member_ids: string[];
|
||||
role_ids: string[];
|
||||
}>): Promise<GroupSummary> {
|
||||
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
|
||||
return response.roles;
|
||||
}
|
||||
|
||||
export function createRole(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateRole(settings: ApiSettings, roleId: string, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
is_assignable: boolean;
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
|
||||
return response.roles;
|
||||
}
|
||||
|
||||
export function createSystemRole(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: string[];
|
||||
is_assignable: boolean;
|
||||
}): Promise<RoleSummary> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/accounts");
|
||||
}
|
||||
|
||||
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
|
||||
display_name?: string | null;
|
||||
is_active?: boolean;
|
||||
role_ids?: string[];
|
||||
}): Promise<SystemAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ role_ids: roleIds })
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeRevoked) params.set("include_revoked", "true");
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
|
||||
return response.api_keys;
|
||||
}
|
||||
|
||||
export function createApiKey(settings: ApiSettings, payload: {
|
||||
name: string;
|
||||
user_id?: string | null;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
}): Promise<ApiKeyAdminItem & { secret: string }> {
|
||||
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export type AuditQueryOptions = {
|
||||
tenantId?: string | null;
|
||||
allTenants?: boolean;
|
||||
scope?: "tenant" | "system";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
|
||||
sortDirection?: "asc" | "desc";
|
||||
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
|
||||
};
|
||||
|
||||
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
if (options.allTenants) params.set("all_tenants", "true");
|
||||
if (options.scope) params.set("scope", options.scope);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.offset) params.set("offset", String(options.offset));
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
|
||||
for (const [column, value] of Object.entries(options.filters ?? {})) {
|
||||
if (value?.trim()) params.set(`filter_${column}`, value);
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
|
||||
}
|
||||
|
||||
|
||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
password?: string | null;
|
||||
password_reset_required?: boolean;
|
||||
is_active?: boolean;
|
||||
role_ids?: string[];
|
||||
memberships?: SystemMembershipDraft[];
|
||||
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
|
||||
method: "PUT", body: JSON.stringify({ memberships })
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||
}
|
||||
|
||||
export type SystemSettingsUpdatePayload = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
|
||||
}
|
||||
|
||||
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
|
||||
}
|
||||
|
||||
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||
return response.templates;
|
||||
}
|
||||
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
||||
}
|
||||
37
webui/src/api/auth.ts
Normal file
37
webui/src/api/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ApiSettings, AuthInfo, LoginResponse } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export async function login(
|
||||
settings: ApiSettings,
|
||||
payload: { email: string; password: string }
|
||||
): Promise<LoginResponse> {
|
||||
return apiFetch<LoginResponse>({ ...settings, accessToken: "", apiKey: "" }, "/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
|
||||
}
|
||||
|
||||
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tenant_id: tenantId })
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(settings: ApiSettings): Promise<void> {
|
||||
await apiFetch(settings, "/api/v1/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
settings: ApiSettings,
|
||||
payload: { display_name?: string | null; tenant_display_name?: string | null }
|
||||
): Promise<AuthInfo> {
|
||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
155
webui/src/api/client.ts
Normal file
155
webui/src/api/client.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
const STORAGE_KEY = "multimailer.apiSettings";
|
||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly body: string;
|
||||
|
||||
constructor(status: number, statusText: string, body: string) {
|
||||
super(`${status} ${statusText}: ${body}`);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(error: unknown, ...statuses: number[]): error is ApiError {
|
||||
return error instanceof ApiError && (statuses.length === 0 || statuses.includes(error.status));
|
||||
}
|
||||
|
||||
/**
|
||||
* API endpoint helpers already pass paths beginning with /api/v1. The configured
|
||||
* value is therefore an origin only. Normalize legacy values that included the
|
||||
* API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs.
|
||||
*/
|
||||
export function normalizeApiBaseUrl(value: string): string {
|
||||
const withoutTrailingSlash = value.trim().replace(/\/+$/, "");
|
||||
return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, "");
|
||||
}
|
||||
|
||||
export function apiUrl(settings: ApiSettings, path: string): string {
|
||||
const baseUrl = normalizeApiBaseUrl(settings.apiBaseUrl);
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
|
||||
}
|
||||
|
||||
export function loadApiSettings(): ApiSettings {
|
||||
const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`);
|
||||
const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl);
|
||||
|
||||
// Repair legacy values once loaded, while keeping an empty value for same-origin use.
|
||||
if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) {
|
||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl);
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
|
||||
return {
|
||||
// Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI.
|
||||
apiBaseUrl,
|
||||
apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "",
|
||||
accessToken: ""
|
||||
};
|
||||
}
|
||||
|
||||
export function saveApiSettings(settings: ApiSettings): void {
|
||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
|
||||
localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey);
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
}
|
||||
|
||||
export function clearAccessToken(): void {
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
}
|
||||
|
||||
|
||||
function readCookie(name: string): string {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(prefix))
|
||||
?.slice(prefix.length) ?? "";
|
||||
}
|
||||
|
||||
export function csrfToken(): string {
|
||||
const value = readCookie(CSRF_COOKIE_NAME);
|
||||
return value ? decodeURIComponent(value) : "";
|
||||
}
|
||||
|
||||
function isUnsafeMethod(method?: string): boolean {
|
||||
const normalized = (method || "GET").toUpperCase();
|
||||
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
|
||||
}
|
||||
|
||||
export function authHeaders(settings: ApiSettings): Headers {
|
||||
const headers = new Headers();
|
||||
if (settings.accessToken) {
|
||||
headers.set("Authorization", `Bearer ${settings.accessToken}`);
|
||||
} else if (settings.apiKey) {
|
||||
headers.set("X-API-Key", settings.apiKey);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
|
||||
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
for (const [key, value] of authHeaders(settings)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const csrf = csrfToken();
|
||||
if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", csrf);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return (await response.text()) as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
|
||||
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
|
||||
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
27
webui/src/api/platform.ts
Normal file
27
webui/src/api/platform.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { PlatformModuleInfo } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||
|
||||
export type PlatformPermission = {
|
||||
scope: string;
|
||||
module_id: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
level: "system" | "tenant";
|
||||
deprecated: boolean;
|
||||
};
|
||||
|
||||
export type PlatformPermissionsResponse = { permissions: PlatformPermission[] };
|
||||
|
||||
export async function fetchPlatformModules(settings: ApiSettings): Promise<PlatformModulesResponse> {
|
||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
|
||||
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
|
||||
}
|
||||
Reference in New Issue
Block a user