Add Storage, Users, Secrets, Webhooks, Scheduled tasks, Audit log screens
Full management surfaces for the platform-admin tenant, mirroring the existing Tenants pattern (DataTable + row actions + create/edit dialogs + ConfirmDialog for destructive ops, all data-action tagged for the command bus, useRegisterAdminContext publishing for the assistant). - Storage (/storage): backends + credentials. Write-only secret fields, Validate/Activate/Deactivate/Set-default/Mark-degraded/Maintenance. - Users (/users): tabs for Users, Invitations, Roles. Per-user View drawer with profile, role add/remove, API keys (one-time reveal on create), usage + quota. - Secrets (/secrets): /api/v1/admin/secrets — create/rotate/rollback, versions dialog, enable/disable, generate-value helper. - Webhooks (/webhooks): CRUD, pause/resume, regenerate-secret with one-time reveal, send test event, deliveries dialog. - Scheduled tasks (/scheduled-tasks): cron CRUD, run-now trigger, enable/disable, expandable run history. - Audit log (/activity): replaces the empty stub. Filter by severity, resource type, date range; click for full JSON detail. All endpoints are hand-rolled HTTP because most aren't covered by the generated OpenAPI typed paths yet — switch to arcadia.typed.* when the backend wires them into OpenApiSpex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
67
app/lib/arcadia/api-keys.ts
Normal file
67
app/lib/arcadia/api-keys.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Arcadia per-user API key helpers (v2 multi-key path).
|
||||
//
|
||||
// `POST /api/v1/users/:user_id/api_keys` returns the raw key value exactly
|
||||
// once — list/show endpoints only return the prefix. Callers must surface
|
||||
// the value to the user immediately on create.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface ApiKey {
|
||||
id: string
|
||||
key_prefix: string
|
||||
description: string | null
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
expires_at: string | null
|
||||
revoked_at: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateInput {
|
||||
description?: string
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface ApiKeyCreated {
|
||||
api_key: string
|
||||
key_id: string
|
||||
key_prefix: string
|
||||
user_id: string
|
||||
description: string | null
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
warning: string
|
||||
}
|
||||
|
||||
export async function listUserApiKeys(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
): Promise<ApiKey[]> {
|
||||
const res = await arcadia.GET<{ data: ApiKey[] }>(
|
||||
`/api/v1/users/${userId}/api_keys`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createUserApiKey(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
input: ApiKeyCreateInput,
|
||||
): Promise<ApiKeyCreated> {
|
||||
const res = await arcadia.POST<{ data: ApiKeyCreated }>(
|
||||
`/api/v1/users/${userId}/api_keys`,
|
||||
{ body: input },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function revokeUserApiKey(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
keyId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/users/${userId}/api_keys/${keyId}`, {
|
||||
body: reason ? { reason } : undefined,
|
||||
})
|
||||
}
|
||||
76
app/lib/arcadia/audit-logs.ts
Normal file
76
app/lib/arcadia/audit-logs.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Audit log + observability helpers.
|
||||
// All endpoints are read-only; the backend writes audit events itself.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type AuditSeverity = "info" | "warning" | "error" | "critical" | string
|
||||
|
||||
export interface AuditUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string
|
||||
tenant_id: string
|
||||
user_id: string | null
|
||||
user: AuditUser | null
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: string | null
|
||||
changes: Record<string, unknown> | null
|
||||
metadata: Record<string, unknown> | null
|
||||
severity: AuditSeverity
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface AuditListParams {
|
||||
action?: string
|
||||
resource_type?: string
|
||||
severity?: AuditSeverity
|
||||
user_id?: string
|
||||
from?: string // ISO8601
|
||||
to?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
total: number
|
||||
by_action: Record<string, number>
|
||||
by_severity: Record<string, number>
|
||||
by_resource_type: Record<string, number>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listAuditLogs(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: AuditListParams,
|
||||
): Promise<AuditLog[]> {
|
||||
const res = await arcadia.GET<{ data: AuditLog[] }>(
|
||||
"/api/v1/observability/audit_logs",
|
||||
{ params: params as Record<string, string | number | boolean | null | undefined> },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAuditLog(arcadia: ArcadiaClient, id: string): Promise<AuditLog> {
|
||||
const res = await arcadia.GET<{ data: AuditLog }>(
|
||||
`/api/v1/observability/audit_logs/${id}`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAuditStats(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { from?: string; to?: string },
|
||||
): Promise<AuditStats> {
|
||||
const res = await arcadia.GET<{ data: AuditStats }>(
|
||||
"/api/v1/observability/audit_stats",
|
||||
{ params: params as Record<string, string | undefined> },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
65
app/lib/arcadia/invitations.ts
Normal file
65
app/lib/arcadia/invitations.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Arcadia invitations API helpers.
|
||||
// Backed by /api/v1/invitations.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface InvitationRole {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface InvitationInviter {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string
|
||||
email: string
|
||||
role: InvitationRole
|
||||
invited_by: InvitationInviter | null
|
||||
expires_at: string | null
|
||||
accepted_at: string | null
|
||||
revoked_at: string | null
|
||||
revocation_reason: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export type InvitationStatus = "pending" | "accepted" | "revoked" | "expired"
|
||||
|
||||
export function invitationStatus(inv: Invitation): InvitationStatus {
|
||||
if (inv.accepted_at) return "accepted"
|
||||
if (inv.revoked_at) return "revoked"
|
||||
if (inv.expires_at && new Date(inv.expires_at).getTime() < Date.now()) return "expired"
|
||||
return "pending"
|
||||
}
|
||||
|
||||
export interface InvitationInput {
|
||||
email: string
|
||||
role_id: string
|
||||
}
|
||||
|
||||
export async function listInvitations(arcadia: ArcadiaClient): Promise<Invitation[]> {
|
||||
const res = await arcadia.GET<{ data: Invitation[] }>("/api/v1/invitations")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createInvitation(
|
||||
arcadia: ArcadiaClient,
|
||||
input: InvitationInput,
|
||||
): Promise<Invitation> {
|
||||
const res = await arcadia.POST<{ data: Invitation }>("/api/v1/invitations", {
|
||||
body: { invitation: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function revokeInvitation(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/invitations/${id}`)
|
||||
}
|
||||
|
||||
export async function resendInvitation(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.POST(`/api/v1/invitations/${id}/resend`)
|
||||
}
|
||||
55
app/lib/arcadia/roles.ts
Normal file
55
app/lib/arcadia/roles.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Arcadia roles API helpers.
|
||||
// Backed by /api/v1/roles (resources route, except :new and :edit).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
permissions: string[]
|
||||
is_system: boolean
|
||||
metadata: Record<string, unknown>
|
||||
tenant_id: string
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoleInput {
|
||||
name: string
|
||||
slug: string
|
||||
description?: string | null
|
||||
permissions?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function listRoles(arcadia: ArcadiaClient): Promise<Role[]> {
|
||||
const res = await arcadia.GET<{ data: Role[] }>("/api/v1/roles")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getRole(arcadia: ArcadiaClient, id: string): Promise<Role> {
|
||||
const res = await arcadia.GET<{ data: Role }>(`/api/v1/roles/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createRole(arcadia: ArcadiaClient, input: RoleInput): Promise<Role> {
|
||||
const res = await arcadia.POST<{ data: Role }>("/api/v1/roles", { body: { role: input } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<RoleInput>,
|
||||
): Promise<Role> {
|
||||
const res = await arcadia.PATCH<{ data: Role }>(`/api/v1/roles/${id}`, {
|
||||
body: { role: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteRole(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/roles/${id}`)
|
||||
}
|
||||
130
app/lib/arcadia/scheduled-tasks.ts
Normal file
130
app/lib/arcadia/scheduled-tasks.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Scheduled tasks (cron) helpers.
|
||||
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type ScheduledTaskAction = "webhook" | "event"
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
cron_expression: string
|
||||
timezone: string
|
||||
action_type: ScheduledTaskAction
|
||||
/** Backend-encrypted; rendered as null on read but accepted on writes. */
|
||||
action_config?: Record<string, unknown> | null
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
last_run_at: string | null
|
||||
next_run_at: string | null
|
||||
max_retries: number
|
||||
timeout_seconds: number
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ScheduledTaskInput {
|
||||
name: string
|
||||
description?: string | null
|
||||
cron_expression: string
|
||||
timezone?: string
|
||||
action_type: ScheduledTaskAction
|
||||
action_config: Record<string, unknown>
|
||||
tags?: string[]
|
||||
enabled?: boolean
|
||||
max_retries?: number
|
||||
timeout_seconds?: number
|
||||
}
|
||||
|
||||
export interface TaskRun {
|
||||
id: string
|
||||
task_id: string
|
||||
status: "pending" | "running" | "succeeded" | "failed" | string
|
||||
attempt: number
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
response_status: number | null
|
||||
response_body: string | null
|
||||
error: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/scheduled-tasks"
|
||||
|
||||
export async function listScheduledTasks(arcadia: ArcadiaClient): Promise<ScheduledTask[]> {
|
||||
const res = await arcadia.GET<{ data: ScheduledTask[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<ScheduledTask> {
|
||||
const res = await arcadia.GET<{ data: ScheduledTask }>(`${BASE}/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ScheduledTaskInput,
|
||||
): Promise<ScheduledTask> {
|
||||
const res = await arcadia.POST<{ data: ScheduledTask }>(BASE, {
|
||||
body: { scheduled_task: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<ScheduledTaskInput>,
|
||||
): Promise<ScheduledTask> {
|
||||
const res = await arcadia.PATCH<{ data: ScheduledTask }>(`${BASE}/${id}`, {
|
||||
body: { scheduled_task: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function enableScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<ScheduledTask> {
|
||||
const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/enable`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function disableScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<ScheduledTask> {
|
||||
const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/disable`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function triggerScheduledTask(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<TaskRun> {
|
||||
const res = await arcadia.POST<{ data: TaskRun }>(`${BASE}/${id}/trigger`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listTaskRuns(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
): Promise<TaskRun[]> {
|
||||
const res = await arcadia.GET<{ data: TaskRun[] }>(`${BASE}/${id}/runs`, {
|
||||
params: params as Record<string, number | undefined>,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
171
app/lib/arcadia/secrets.ts
Normal file
171
app/lib/arcadia/secrets.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// Arcadia secrets API helpers.
|
||||
//
|
||||
// Backed by /api/v1/admin/secrets — the platform Secrets Manager. Values are
|
||||
// AES-encrypted at rest and never returned by index/show; only metadata is
|
||||
// exposed by these endpoints. Tenant-side resolution (returning the value)
|
||||
// goes through a separate runtime endpoint that's not used by the admin UI.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type SecretCategory =
|
||||
| "api_key"
|
||||
| "smtp"
|
||||
| "oauth_token"
|
||||
| "webhook_secret"
|
||||
| "generic"
|
||||
|
||||
export type SecretEnvironment = "production" | "staging" | "development" | "all"
|
||||
|
||||
export interface Secret {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
category: SecretCategory
|
||||
environment: SecretEnvironment
|
||||
tags: string[]
|
||||
used_by: string[]
|
||||
allowed_ips: string[]
|
||||
read_once: boolean
|
||||
read_once_consumed: boolean
|
||||
expires_at: string | null
|
||||
last_rotated_at: string | null
|
||||
rotation_interval_days: number | null
|
||||
rotation_due: boolean
|
||||
expired: boolean
|
||||
enabled: boolean
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SecretVersion {
|
||||
id: string
|
||||
secret_id: string
|
||||
version: number
|
||||
note: string | null
|
||||
inserted_by: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface SecretCreateInput {
|
||||
name: string
|
||||
value: string
|
||||
category?: SecretCategory
|
||||
description?: string | null
|
||||
environment?: SecretEnvironment
|
||||
tags?: string[]
|
||||
used_by?: string[]
|
||||
allowed_ips?: string[]
|
||||
read_once?: boolean
|
||||
expires_at?: string | null
|
||||
rotation_interval_days?: number | null
|
||||
}
|
||||
|
||||
export type SecretMetaInput = Omit<Partial<SecretCreateInput>, "value" | "name">
|
||||
|
||||
export interface RotateInput {
|
||||
value: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export async function listSecrets(arcadia: ArcadiaClient): Promise<Secret[]> {
|
||||
const res = await arcadia.GET<{ data: Secret[] }>("/api/v1/admin/secrets")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
|
||||
const res = await arcadia.GET<{ data: Secret }>(`/api/v1/admin/secrets/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createSecret(
|
||||
arcadia: ArcadiaClient,
|
||||
input: SecretCreateInput,
|
||||
): Promise<Secret> {
|
||||
const res = await arcadia.POST<{ data: Secret }>("/api/v1/admin/secrets", {
|
||||
body: { secret: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateSecretMeta(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: SecretMetaInput,
|
||||
): Promise<Secret> {
|
||||
const res = await arcadia.PATCH<{ data: Secret }>(`/api/v1/admin/secrets/${id}`, {
|
||||
body: { secret: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteSecret(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/admin/secrets/${id}`)
|
||||
}
|
||||
|
||||
export async function rotateSecret(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: RotateInput,
|
||||
): Promise<Secret> {
|
||||
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/rotate`, {
|
||||
body: { secret: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function rollbackSecret(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
version: number,
|
||||
): Promise<Secret> {
|
||||
const res = await arcadia.POST<{ data: Secret }>(
|
||||
`/api/v1/admin/secrets/${id}/rollback/${version}`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function enableSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
|
||||
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/enable`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function disableSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
|
||||
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/disable`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listSecretVersions(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<SecretVersion[]> {
|
||||
const res = await arcadia.GET<{ data: SecretVersion[] }>(
|
||||
`/api/v1/admin/secrets/${id}/versions`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function generateSecretValue(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { length?: number; charset?: string },
|
||||
): Promise<string> {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>("/api/v1/admin/secrets/generate", {
|
||||
params: params as Record<string, string | number | boolean | null | undefined>,
|
||||
})
|
||||
return res.data.value
|
||||
}
|
||||
|
||||
export const SECRET_CATEGORIES: { value: SecretCategory; label: string }[] = [
|
||||
{ value: "api_key", label: "API key" },
|
||||
{ value: "oauth_token", label: "OAuth token" },
|
||||
{ value: "smtp", label: "SMTP credentials" },
|
||||
{ value: "webhook_secret", label: "Webhook secret" },
|
||||
{ value: "generic", label: "Generic" },
|
||||
]
|
||||
|
||||
export const SECRET_ENVIRONMENTS: { value: SecretEnvironment; label: string }[] = [
|
||||
{ value: "all", label: "All environments" },
|
||||
{ value: "production", label: "Production" },
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "development", label: "Development" },
|
||||
]
|
||||
167
app/lib/arcadia/storage-configs.ts
Normal file
167
app/lib/arcadia/storage-configs.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Arcadia storage configs API helpers.
|
||||
//
|
||||
// `GET /api/v1/storage_configs` and `POST /api/v1/storage_configs` are the
|
||||
// only operations with full OpenAPI coverage today. Update/delete and the
|
||||
// state-transition actions (activate, deactivate, mark-degraded,
|
||||
// mark-maintenance, set-default, validate) are listed in the spec but their
|
||||
// operations are still stubbed as `never`, so we hand-roll types and use the
|
||||
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
|
||||
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type StorageBackend = "s3" | "local" | "gcs"
|
||||
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
|
||||
|
||||
export interface StorageConfig {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
backend_type: StorageBackend
|
||||
status: StorageStatus
|
||||
is_default: boolean
|
||||
max_file_size_bytes: number | null
|
||||
allowed_content_types: string[] | null
|
||||
// Backend-specific fields. Secret fields are returned as "***" by the API.
|
||||
config: Record<string, unknown>
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface StorageConfigInput {
|
||||
name: string
|
||||
backend_type: StorageBackend
|
||||
config: Record<string, unknown>
|
||||
is_default?: boolean
|
||||
max_file_size_bytes?: number | null
|
||||
allowed_content_types?: string[]
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
total_objects: number
|
||||
total_size_bytes: number
|
||||
by_backend: Record<string, unknown>
|
||||
by_user: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface StorageProvidersResponse {
|
||||
data: Record<StorageBackend, { required_fields: string[]; optional_fields?: string[] }>
|
||||
}
|
||||
|
||||
export async function listStorageConfigs(arcadia: ArcadiaClient): Promise<StorageConfig[]> {
|
||||
const res = await arcadia.GET<{ data: StorageConfig[] }>("/api/v1/storage_configs")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
|
||||
const res = await arcadia.GET<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createStorageConfig(
|
||||
arcadia: ArcadiaClient,
|
||||
input: StorageConfigInput,
|
||||
): Promise<StorageConfig> {
|
||||
const res = await arcadia.POST<{ data: StorageConfig }>("/api/v1/storage_configs", {
|
||||
body: { storage_config: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateStorageConfig(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<StorageConfigInput>,
|
||||
): Promise<StorageConfig> {
|
||||
const res = await arcadia.PATCH<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`, {
|
||||
body: { storage_config: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteStorageConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/storage_configs/${id}`)
|
||||
}
|
||||
|
||||
export async function activateStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
|
||||
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/activate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deactivateStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
|
||||
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/deactivate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function markStorageConfigDegraded(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<StorageConfig> {
|
||||
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/mark-degraded`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function markStorageConfigMaintenance(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<StorageConfig> {
|
||||
const res = await arcadia.POST<{ data: StorageConfig }>(
|
||||
`/api/v1/storage_configs/${id}/mark-maintenance`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function setDefaultStorageConfig(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<StorageConfig> {
|
||||
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/set-default`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface ValidateResult {
|
||||
ok: boolean
|
||||
message?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export async function validateStorageConfig(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<ValidateResult> {
|
||||
return arcadia.POST<ValidateResult>(`/api/v1/storage_configs/${id}/validate`)
|
||||
}
|
||||
|
||||
export async function getStorageStats(arcadia: ArcadiaClient): Promise<StorageStats> {
|
||||
const res = await arcadia.GET<{ data: StorageStats }>("/api/v1/storage_configs/stats")
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Backend-specific config field schemas. Secret fields appear as "***" on
|
||||
// reads — the form treats them as write-only and only sends a value when the
|
||||
// user has typed a fresh one.
|
||||
export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
s3: ["secret_access_key"],
|
||||
gcs: ["service_account_json"],
|
||||
local: [],
|
||||
}
|
||||
|
||||
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
||||
gcs: ["bucket", "service_account_json"],
|
||||
local: ["path"],
|
||||
}
|
||||
|
||||
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
s3: ["endpoint", "prefix"],
|
||||
gcs: ["prefix"],
|
||||
local: [],
|
||||
}
|
||||
|
||||
export function isSecretField(backend: StorageBackend, field: string): boolean {
|
||||
return SECRET_FIELDS[backend].includes(field)
|
||||
}
|
||||
|
||||
export function isMaskedSecret(value: unknown): boolean {
|
||||
return typeof value === "string" && value === "***"
|
||||
}
|
||||
56
app/lib/arcadia/user-stats.ts
Normal file
56
app/lib/arcadia/user-stats.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Per-user usage + quota helpers.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface UserUsage {
|
||||
storage_used_bytes: number
|
||||
object_count: number
|
||||
}
|
||||
|
||||
export interface UserQuota {
|
||||
id: string
|
||||
tenant_id: string
|
||||
user_id: string
|
||||
storage_limit_bytes: number | null
|
||||
storage_used_bytes: number
|
||||
object_count_limit: number | null
|
||||
object_count: number
|
||||
storage_remaining: number | null
|
||||
objects_remaining: number | null
|
||||
storage_usage_percentage: number | null
|
||||
object_count_usage_percentage: number | null
|
||||
storage_exceeded: boolean
|
||||
object_count_exceeded: boolean
|
||||
quota_exceeded: boolean
|
||||
metadata: Record<string, unknown>
|
||||
last_calculated_at: string | null
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function getUserUsage(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
): Promise<UserUsage> {
|
||||
const res = await arcadia.GET<{ data: UserUsage }>(
|
||||
`/api/v1/users/${userId}/usage`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getUserQuota(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
): Promise<UserQuota | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: UserQuota }>(
|
||||
`/api/v1/users/${userId}/quota`,
|
||||
)
|
||||
return res.data
|
||||
} catch (err) {
|
||||
// 404 == no quota set for this user. Treat as null rather than throwing.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/404|not[_ ]found/i.test(msg)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
111
app/lib/arcadia/users.ts
Normal file
111
app/lib/arcadia/users.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Arcadia users API helpers.
|
||||
//
|
||||
// Backed by /api/v1/users (resources route). The OpenAPI spec doesn't yet
|
||||
// describe these operations as typed paths, so we hand-roll types and use
|
||||
// the generic verb methods on the client. Same pattern as tenants.ts.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type UserStatus = "active" | "inactive" | "suspended"
|
||||
|
||||
export interface UserRoleSummary {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string
|
||||
status: UserStatus
|
||||
email_verified: boolean
|
||||
email_verified_at: string | null
|
||||
last_sign_in_at: string | null
|
||||
tenant_id: string
|
||||
roles: UserRoleSummary[]
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UserListParams {
|
||||
status?: UserStatus
|
||||
email_verified?: boolean
|
||||
}
|
||||
|
||||
export interface UserInput {
|
||||
email: string
|
||||
first_name?: string | null
|
||||
last_name?: string | null
|
||||
status?: UserStatus
|
||||
password?: string
|
||||
role_ids?: string[]
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: UserListParams,
|
||||
): Promise<User[]> {
|
||||
const queryParams = params
|
||||
? {
|
||||
status: params.status,
|
||||
email_verified: params.email_verified == null ? undefined : String(params.email_verified),
|
||||
}
|
||||
: undefined
|
||||
const res = await arcadia.GET<{ data: User[] }>("/api/v1/users", { params: queryParams })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getUser(arcadia: ArcadiaClient, id: string): Promise<User> {
|
||||
const res = await arcadia.GET<{ data: User }>(`/api/v1/users/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createUser(arcadia: ArcadiaClient, input: UserInput): Promise<User> {
|
||||
const res = await arcadia.POST<{ data: User }>("/api/v1/users", { body: { user: input } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<UserInput>,
|
||||
): Promise<User> {
|
||||
const res = await arcadia.PATCH<{ data: User }>(`/api/v1/users/${id}`, {
|
||||
body: { user: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteUser(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/users/${id}`)
|
||||
}
|
||||
|
||||
export async function assignRole(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<User> {
|
||||
const res = await arcadia.POST<{ data: User }>(`/api/v1/users/${userId}/roles/${roleId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function removeRole(
|
||||
arcadia: ArcadiaClient,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<User> {
|
||||
const res = await arcadia.DELETE<{ data: User }>(`/api/v1/users/${userId}/roles/${roleId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function setUserStatus(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
status: UserStatus,
|
||||
): Promise<User> {
|
||||
return updateUser(arcadia, id, { status })
|
||||
}
|
||||
161
app/lib/arcadia/webhooks.ts
Normal file
161
app/lib/arcadia/webhooks.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// Outbound webhook helpers.
|
||||
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type WebhookStatus = "active" | "paused" | "disabled"
|
||||
export type WebhookRetryStrategy = "linear" | "exponential"
|
||||
|
||||
export interface Webhook {
|
||||
id: string
|
||||
tenant_id: string
|
||||
url: string
|
||||
description: string | null
|
||||
status: WebhookStatus
|
||||
events: string[]
|
||||
headers: Record<string, string>
|
||||
max_retries: number
|
||||
retry_strategy: WebhookRetryStrategy
|
||||
last_triggered_at: string | null
|
||||
success_count: number
|
||||
failure_count: number
|
||||
/** Only populated on create / regenerate-secret responses. */
|
||||
secret?: string | null
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WebhookInput {
|
||||
url: string
|
||||
description?: string | null
|
||||
events?: string[]
|
||||
headers?: Record<string, string>
|
||||
max_retries?: number
|
||||
retry_strategy?: WebhookRetryStrategy
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string
|
||||
webhook_endpoint_id: string
|
||||
event_type: string
|
||||
status: "pending" | "delivered" | "failed" | string
|
||||
attempt: number
|
||||
request_url: string
|
||||
request_headers: Record<string, string>
|
||||
response_status: number | null
|
||||
response_time_ms: number | null
|
||||
error_message: string | null
|
||||
sent_at: string | null
|
||||
completed_at: string | null
|
||||
next_retry_at: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface WebhookStats {
|
||||
success_rate: number
|
||||
delivery_count: number
|
||||
failure_count: number
|
||||
avg_response_time_ms: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listWebhooks(arcadia: ArcadiaClient): Promise<Webhook[]> {
|
||||
const res = await arcadia.GET<{ data: Webhook[] }>("/api/v1/webhooks")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
|
||||
const res = await arcadia.GET<{ data: Webhook }>(`/api/v1/webhooks/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createWebhook(
|
||||
arcadia: ArcadiaClient,
|
||||
input: WebhookInput,
|
||||
): Promise<Webhook> {
|
||||
const res = await arcadia.POST<{ data: Webhook }>("/api/v1/webhooks", {
|
||||
body: { webhook_endpoint: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateWebhook(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<WebhookInput>,
|
||||
): Promise<Webhook> {
|
||||
const res = await arcadia.PATCH<{ data: Webhook }>(`/api/v1/webhooks/${id}`, {
|
||||
body: { webhook_endpoint: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteWebhook(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/webhooks/${id}`)
|
||||
}
|
||||
|
||||
export async function pauseWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
|
||||
const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/pause`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function resumeWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
|
||||
const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/resume`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function regenerateWebhookSecret(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Webhook> {
|
||||
const res = await arcadia.POST<{ data: Webhook }>(
|
||||
`/api/v1/webhooks/${id}/regenerate-secret`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listWebhookDeliveries(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
): Promise<WebhookDelivery[]> {
|
||||
const res = await arcadia.GET<{ data: WebhookDelivery[] }>(
|
||||
`/api/v1/webhooks/${id}/deliveries`,
|
||||
{ params: params as Record<string, number | undefined> },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getWebhookStats(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<WebhookStats> {
|
||||
const res = await arcadia.GET<{ data: WebhookStats }>(
|
||||
`/api/v1/webhooks/${id}/stats`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function testWebhook(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<{ ok: boolean; message?: string; details?: unknown }> {
|
||||
return arcadia.POST(`/api/v1/webhooks/${id}/test`)
|
||||
}
|
||||
|
||||
// A starter list of platform events. Free-form by design — different deployments
|
||||
// emit different events. Users can type custom values.
|
||||
export const COMMON_WEBHOOK_EVENTS = [
|
||||
"user.created",
|
||||
"user.updated",
|
||||
"user.deleted",
|
||||
"tenant.created",
|
||||
"tenant.updated",
|
||||
"object.uploaded",
|
||||
"object.deleted",
|
||||
"secret.rotated",
|
||||
"invitation.sent",
|
||||
"invitation.accepted",
|
||||
"scheduled_task.completed",
|
||||
"scheduled_task.failed",
|
||||
]
|
||||
Reference in New Issue
Block a user