Files
arcadia-admin/app/lib/arcadia/storage-configs.ts
jules c2730e3c77 profile: real avatar upload + storage form fix
- Add a digital-objects client (uploadFile: open session → PUT to
  presigned URL → complete) and a profile client (getProfile,
  updateProfile, pickAvatarUrl variant resolver).
- Wire profile.tsx avatar upload to use the real flow: validate
  image+size, upload to digital_objects tagged "avatar", PATCH
  /api/v1/profile with avatar_digital_object_id, mirror the resolved
  URL into local prefs so the existing <AvatarImage> binding keeps
  working. Show Uploading… state and an inline error banner. Clear
  detaches via avatar_digital_object_id: null.
- Fix the storage form sending the wrong field name for the local
  backend — arcadia's StorageConfig changeset requires `base_path`,
  not `path`. The 422 was silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:02:52 +10:00

170 lines
5.5 KiB
TypeScript

// 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 backend's filesystem root. Backend changeset rejects "path" — must
// be `base_path`. Keep this in sync with `Arcadia.Storage.Adapters.Local`.
local: ["base_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 === "***"
}