feat(ui): add identity management for tenants and users
CI / build-and-test (push) Successful in 1m47s
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:
+84
-3
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user