feat(ui): add identity management for tenants and users
CI / build-and-test (push) Successful in 1m47s

* Implement tenant and user creation in IdentityPage
* Add API calls for managing tenants and users
* Introduce tenant-scoped API requests
* Update sidebar to include identity navigation
* Create BooleanStatusBadge component for key status
This commit is contained in:
2026-07-20 22:50:55 +02:00
parent 3d4e6d0939
commit 196b543bee
64 changed files with 3363 additions and 615 deletions
+84 -3
View File
@@ -1,8 +1,9 @@
import { GlobalStateStore } from './shellState';
import { currentTenantID, tenantScopeHeaders } from './tenantScope';
function authHeaders(): HeadersInit {
const token = GlobalStateStore.getState().session.authToken;
return token ? { Authorization: `Bearer ${token}` } : {};
return { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...tenantScopeHeaders() };
}
type ResolveSpecResponse<T> = {
@@ -18,6 +19,62 @@ type ResolveSpecFilter = {
value?: unknown;
};
const TENANT_OWNED_RESOLVE_SPEC_ENTITIES = new Set([
'projects',
'thoughts',
'learnings',
'plans',
'stored_files',
'agent_skills',
'agent_guardrails',
'agent_personas',
'agent_parts',
'agent_traits',
'character_arcs',
'chat_histories'
]);
function resolveSpecEntity(path: string): string | null {
const match = path.match(/^\/api\/rs\/public\/([^/?]+)/);
return match?.[1] ?? null;
}
function tenantScopedResolveSpecPayload(
path: string,
operation: 'read' | 'create' | 'update' | 'delete',
payload?: { data?: unknown; options?: unknown }
): { data?: unknown; options?: unknown } | undefined {
const entity = resolveSpecEntity(path);
const tenantID = currentTenantID();
if (!entity || !tenantID || !TENANT_OWNED_RESOLVE_SPEC_ENTITIES.has(entity)) return payload;
if (operation === 'create') {
const data = payload?.data;
return {
...payload,
// A selected tenant is authoritative; do not permit a form value to
// accidentally create data in a different tenant.
data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data, tenant_id: tenantID } : data
};
}
const existingOptions = payload?.options && typeof payload.options === 'object' && !Array.isArray(payload.options)
? payload.options as Record<string, unknown>
: {};
const filters = Array.isArray(existingOptions.filters)
? existingOptions.filters.filter((filter): filter is ResolveSpecFilter =>
Boolean(filter) && typeof filter === 'object' && (filter as ResolveSpecFilter).column !== 'tenant_id')
: [];
return {
...payload,
options: {
...existingOptions,
filters: [...filters, { column: 'tenant_id', operator: 'eq', value: tenantID }]
}
};
}
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((tag) => String(tag).trim()).filter(Boolean);
@@ -70,18 +127,29 @@ async function del(path: string): Promise<void> {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
async function patch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
async function rsCall<T>(
path: string,
operation: 'read' | 'create' | 'update' | 'delete',
payload?: { data?: unknown; options?: unknown }
): Promise<T> {
const scopedPayload = tenantScopedResolveSpecPayload(path, operation, payload);
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
operation,
...(payload?.data !== undefined ? { data: payload.data } : {}),
...(payload?.options !== undefined ? { options: payload.options } : {})
...(scopedPayload?.data !== undefined ? { data: scopedPayload.data } : {}),
...(scopedPayload?.options !== undefined ? { options: scopedPayload.options } : {})
})
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
@@ -333,6 +401,19 @@ export const api = {
dry_run: input?.dry_run ?? false
})
},
identity: {
get: () => get<import('./types').IdentityData>('/api/admin/identity'),
createTenant: (name: string) =>
post<import('./types').Tenant>('/api/admin/identity/tenants', { name }),
adoptLegacy: (id: string) =>
post<unknown>(`/api/admin/identity/tenants/${id}/adopt-legacy`, {}),
createUser: (data: { tenant_id: string; name: string; email: string }) =>
post<import('./types').TenantUser>('/api/admin/identity/users', data),
createKey: (data: { tenant_id: string; user_id?: string; description: string }) =>
post<{ key: import('./types').IdentityKey; secret: string }>('/api/admin/identity/keys', data),
updateKey: (id: string, data: { tenant_id: string; user_id?: string | null; description?: string; enabled?: boolean }) =>
patch<import('./types').IdentityKey>(`/api/admin/identity/keys/${id}`, data)
},
plans: {
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
const filters: ResolveSpecFilter[] = [];