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:
+10
-10
@@ -11,22 +11,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^25.6.0",
|
||||
"svelte": "^5.55.5",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"svelte": "^5.56.6",
|
||||
"svelte-check": "^4.7.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/svelte": "^10.51.0",
|
||||
"@sentry/svelte": "^10.67.0",
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@tanstack/svelte-virtual": "^3.13.24",
|
||||
"@tanstack/svelte-virtual": "^3.13.33",
|
||||
"@warkypublic/artemis-kit": "^1.0.10",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"@warkypublic/svelix": "^0.1.40"
|
||||
"@warkypublic/svelix": "^0.2.5"
|
||||
}
|
||||
}
|
||||
Generated
+435
-390
File diff suppressed because it is too large
Load Diff
+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[] = [];
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { IdentityData, IdentityKey, Tenant, TenantUser } from '../../types';
|
||||
import BooleanStatusBadge from '../shared/BooleanStatusBadge.svelte';
|
||||
|
||||
let identity = $state<IdentityData>({ tenants: [], users: [], keys: [] });
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let message = $state('');
|
||||
let busy = $state(false);
|
||||
let tenantName = $state('');
|
||||
let userTenantID = $state('');
|
||||
let userName = $state('');
|
||||
let userEmail = $state('');
|
||||
let keyTenantID = $state('');
|
||||
let keyUserID = $state('');
|
||||
let keyDescription = $state('');
|
||||
let revealedSecret = $state<string | null>(null);
|
||||
|
||||
const usersForTenant = (tenantID: string): TenantUser[] =>
|
||||
identity.users.filter((user) => user.tenant_id === tenantID);
|
||||
|
||||
function tenantNameFor(id: string): string {
|
||||
return identity.tenants.find((tenant) => tenant.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function userNameFor(id?: string): string {
|
||||
if (!id) return 'Unassigned';
|
||||
const user = identity.users.find((candidate) => candidate.id === id);
|
||||
return user ? `${user.name} (${user.email})` : id;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
identity = await api.identity.get();
|
||||
if (!userTenantID && identity.tenants[0]) userTenantID = identity.tenants[0].id;
|
||||
if (!keyTenantID && identity.tenants[0]) keyTenantID = identity.tenants[0].id;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to load identity data.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTenant() {
|
||||
if (!tenantName.trim()) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const tenant = await api.identity.createTenant(tenantName.trim());
|
||||
identity.tenants = [...identity.tenants, tenant];
|
||||
userTenantID ||= tenant.id;
|
||||
keyTenantID ||= tenant.id;
|
||||
tenantName = '';
|
||||
message = `Created tenant ${tenant.name}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to create tenant.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
if (!userTenantID || !userName.trim() || !userEmail.trim()) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const user = await api.identity.createUser({ tenant_id: userTenantID, name: userName.trim(), email: userEmail.trim() });
|
||||
identity.users = [...identity.users, user];
|
||||
userName = ''; userEmail = '';
|
||||
message = `Created user ${user.name}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to create user.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
if (!keyTenantID || !keyDescription.trim()) return;
|
||||
busy = true; error = ''; message = ''; revealedSecret = null;
|
||||
try {
|
||||
const result = await api.identity.createKey({
|
||||
tenant_id: keyTenantID,
|
||||
...(keyUserID ? { user_id: keyUserID } : {}),
|
||||
description: keyDescription.trim()
|
||||
});
|
||||
identity.keys = [...identity.keys, result.key];
|
||||
keyDescription = '';
|
||||
revealedSecret = result.secret;
|
||||
message = `Created key ${result.key.id}. Copy the secret now; it will not be shown again.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to create key.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function adoptLegacy(tenant: Tenant) {
|
||||
if (!window.confirm(`Move all unassigned legacy data to ${tenant.name}? This cannot be undone from the UI.`)) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
await api.identity.adoptLegacy(tenant.id);
|
||||
message = `Moved legacy data to ${tenant.name}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to adopt legacy data.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function updateKey(key: IdentityKey, field: 'tenant' | 'user' | 'description' | 'enabled', value: string | boolean) {
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const data = {
|
||||
tenant_id: field === 'tenant' ? String(value) : key.tenant_id,
|
||||
...(field === 'user' ? { user_id: value ? String(value) : null } : { user_id: key.user_id }),
|
||||
...(field === 'description' ? { description: String(value) } : {}),
|
||||
...(field === 'enabled' ? { enabled: Boolean(value) } : {})
|
||||
};
|
||||
const updated = await api.identity.updateKey(key.id, data);
|
||||
identity.keys = identity.keys.map((candidate) => candidate.id === updated.id ? updated : candidate);
|
||||
message = `Updated key ${updated.id}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to update key.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Identity</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Group multiple API keys under a tenant, optionally assigning each key to a user.</p>
|
||||
</div>
|
||||
<button class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 hover:bg-white/10" onclick={load} disabled={loading || busy}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}<div class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div>{/if}
|
||||
{#if message}<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{message}</div>{/if}
|
||||
{#if revealedSecret}
|
||||
<section class="rounded-2xl border border-amber-300/30 bg-amber-400/10 p-4">
|
||||
<h3 class="font-semibold text-amber-100">New API key secret</h3>
|
||||
<p class="mt-1 text-sm text-amber-50/80">Copy this value now. It is shown only once.</p>
|
||||
<code class="mt-3 block overflow-x-auto rounded-lg bg-slate-950/80 p-3 text-sm text-amber-100">{revealedSecret}</code>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-3">
|
||||
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createTenant(); }}>
|
||||
<h3 class="font-semibold text-white">New tenant</h3>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="tenant-name">Tenant name</label>
|
||||
<input id="tenant-name" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={tenantName} required />
|
||||
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy}>Create tenant</button>
|
||||
</form>
|
||||
|
||||
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createUser(); }}>
|
||||
<h3 class="font-semibold text-white">New user</h3>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="user-tenant">Tenant</label>
|
||||
<select id="user-tenant" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userTenantID} required>
|
||||
<option value="" disabled>Select a tenant</option>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}
|
||||
</select>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="user-name">Name</label><input id="user-name" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userName} required />
|
||||
<label class="mt-3 block text-sm text-slate-300" for="user-email">Email</label><input id="user-email" type="email" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userEmail} required />
|
||||
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy || identity.tenants.length === 0}>Create user</button>
|
||||
</form>
|
||||
|
||||
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createKey(); }}>
|
||||
<h3 class="font-semibold text-white">New managed key</h3>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="key-tenant">Tenant</label>
|
||||
<select id="key-tenant" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyTenantID} onchange={() => { keyUserID = ''; }} required>
|
||||
<option value="" disabled>Select a tenant</option>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}
|
||||
</select>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="key-user">User (optional)</label>
|
||||
<select id="key-user" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyUserID}><option value="">Unassigned</option>{#each usersForTenant(keyTenantID) as user}<option value={user.id}>{user.name} ({user.email})</option>{/each}</select>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="key-description">Description</label><input id="key-description" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyDescription} placeholder="e.g. production deployer" required />
|
||||
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy || identity.tenants.length === 0}>Create key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 py-12 text-center text-slate-400">Loading identity data…</div>
|
||||
{:else}
|
||||
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="font-semibold text-white">Tenants ({identity.tenants.length})</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Adopting legacy data is explicit: it assigns every unassigned pre-tenancy record to one tenant.</p>
|
||||
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase tracking-wider text-slate-500"><tr><th class="pb-2 pr-4">Name</th><th class="pb-2 pr-4">Users</th><th class="pb-2 pr-4">Created</th><th class="pb-2"></th></tr></thead><tbody class="divide-y divide-white/5">{#each identity.tenants as tenant}<tr><td class="py-3 pr-4 font-medium text-white">{tenant.name}<div class="mt-1 font-mono text-xs text-slate-500">{tenant.id}</div></td><td class="py-3 pr-4 text-slate-300">{usersForTenant(tenant.id).length}</td><td class="py-3 pr-4 text-slate-400">{formatDate(tenant.created_at)}</td><td class="py-3 text-right"><button class="rounded-lg border border-amber-300/30 bg-amber-400/10 px-3 py-1.5 text-xs text-amber-100 hover:bg-amber-400/20 disabled:opacity-50" onclick={() => void adoptLegacy(tenant)} disabled={busy}>Adopt legacy data</button></td></tr>{:else}<tr><td class="py-5 text-slate-500" colspan="4">No tenants yet.</td></tr>{/each}</tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="font-semibold text-white">API keys ({identity.keys.length})</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Configured keys can be reassigned here; their secret values are never displayed.</p>
|
||||
<div class="mt-3 overflow-x-auto"><table class="min-w-[860px] w-full text-sm"><thead class="text-left text-xs uppercase tracking-wider text-slate-500"><tr><th class="pb-2 pr-4">Key</th><th class="pb-2 pr-4">Tenant</th><th class="pb-2 pr-4">User</th><th class="pb-2 pr-4">Description</th><th class="pb-2 pr-4">Status</th><th class="pb-2">Created</th></tr></thead><tbody class="divide-y divide-white/5">{#each identity.keys as key}<tr><td class="py-3 pr-4"><code class="text-xs text-cyan-100">{key.id}</code><div class="mt-1 text-xs text-slate-500">{key.source}</div></td><td class="py-3 pr-4"><select aria-label={`Tenant for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.tenant_id} onchange={(event) => void updateKey(key, 'tenant', event.currentTarget.value)} disabled={busy}>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}</select></td><td class="py-3 pr-4"><select aria-label={`User for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.user_id ?? ''} onchange={(event) => void updateKey(key, 'user', event.currentTarget.value)} disabled={busy}><option value="">Unassigned</option>{#each usersForTenant(key.tenant_id) as user}<option value={user.id}>{user.name}</option>{/each}</select></td><td class="py-3 pr-4"><input aria-label={`Description for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.description} onchange={(event) => void updateKey(key, 'description', event.currentTarget.value)} disabled={busy} /></td><td class="py-3 pr-4"><label class="flex items-center gap-2 text-slate-300"><input type="checkbox" class="accent-cyan-400" checked={key.enabled} onchange={(event) => void updateKey(key, 'enabled', event.currentTarget.checked)} disabled={busy} />{#if key.enabled}Enabled{:else}<BooleanStatusBadge value={key.enabled} falseLabel="Disabled" />{/if}</label></td><td class="py-3 text-slate-400">{formatDate(key.created_at)}</td></tr>{:else}<tr><td class="py-5 text-slate-500" colspan="6">No keys available.</td></tr>{/each}</tbody></table></div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
value: boolean;
|
||||
falseLabel: 'Disabled' | 'Inactive';
|
||||
};
|
||||
|
||||
let { value, falseLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if !value}
|
||||
<span class="inline-flex rounded-full bg-slate-700 px-2 py-0.5 text-xs font-medium text-slate-200">
|
||||
{falseLabel}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -10,7 +10,10 @@
|
||||
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||
import SkillsPage from '../skills/SkillsPage.svelte';
|
||||
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
|
||||
import IdentityPage from '../identity/IdentityPage.svelte';
|
||||
import AppSidebar from './AppSidebar.svelte';
|
||||
import { fromStore } from 'svelte/store';
|
||||
import { selectedTenantID } from '../../tenantScope';
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
@@ -29,14 +32,19 @@
|
||||
onnavigate: (page: ShellPage) => void;
|
||||
onrefresh: () => void;
|
||||
} = $props();
|
||||
|
||||
const tenantScope = fromStore(selectedTenantID);
|
||||
</script>
|
||||
|
||||
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
||||
<AppSidebar {currentPage} {onnavigate} {onlogout} />
|
||||
|
||||
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
||||
{#key tenantScope.current}
|
||||
{#if currentPage === 'dashboard'}
|
||||
<DashboardPage {data} {loading} {error} {onrefresh} />
|
||||
{:else if currentPage === 'identity'}
|
||||
<IdentityPage />
|
||||
{:else if currentPage === 'projects'}
|
||||
<ProjectsPage />
|
||||
{:else if currentPage === 'thoughts'}
|
||||
@@ -56,5 +64,6 @@
|
||||
{:else if currentPage === 'maintenance'}
|
||||
<MaintenancePage />
|
||||
{/if}
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { NavItem, ShellPage } from '../../types';
|
||||
import { api } from '../../api';
|
||||
import { selectedTenantID, setSelectedTenantID } from '../../tenantScope';
|
||||
|
||||
let tenants = $state<{ id: string; name: string }[]>([]);
|
||||
let tenantError = $state('');
|
||||
let tenantLoading = $state(false);
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
@@ -13,6 +20,7 @@
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
|
||||
{ id: 'identity', label: 'Identity', description: 'Tenants, users, and API keys.' },
|
||||
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
||||
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
||||
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
|
||||
@@ -23,6 +31,25 @@
|
||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
||||
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
|
||||
];
|
||||
|
||||
async function loadTenants(): Promise<void> {
|
||||
tenantLoading = true;
|
||||
tenantError = '';
|
||||
try {
|
||||
tenants = (await api.identity.get()).tenants;
|
||||
if ($selectedTenantID && !tenants.some((tenant) => tenant.id === $selectedTenantID)) {
|
||||
setSelectedTenantID('');
|
||||
}
|
||||
} catch (cause) {
|
||||
tenantError = cause instanceof Error ? cause.message : 'Failed to load tenants.';
|
||||
} finally {
|
||||
tenantLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadTenants();
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
||||
@@ -32,6 +59,26 @@
|
||||
<p class="mt-2 text-sm text-slate-400">Memory server control panel.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-white/10 bg-slate-950/40 p-3">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-slate-400" for="tenant-selector">Tenant scope</label>
|
||||
<select
|
||||
id="tenant-selector"
|
||||
class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 disabled:opacity-50"
|
||||
value={$selectedTenantID}
|
||||
onchange={(event) => setSelectedTenantID(event.currentTarget.value)}
|
||||
disabled={tenantLoading}
|
||||
>
|
||||
<option value="">Default key tenant</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-slate-500">Applies to tenant data in this admin session.</p>
|
||||
{#if tenantError}
|
||||
<p class="mt-2 text-xs text-rose-300">{tenantError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 space-y-1">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
|
||||
@@ -576,7 +576,6 @@
|
||||
adapter={projectBoxerAdapter}
|
||||
value={state.values?.project_id ?? null}
|
||||
clearable
|
||||
searchable
|
||||
onChange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
import { installTenantScopedFetch } from './tenantScope';
|
||||
|
||||
installTenantScopedFetch();
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
const STORAGE_KEY = 'amcs.admin.selected-tenant-id';
|
||||
const TENANT_HEADER = 'X-AMCS-Tenant-ID';
|
||||
let tenantScopedFetchInstalled = false;
|
||||
|
||||
function initialTenantID(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return window.localStorage.getItem(STORAGE_KEY)?.trim() ?? '';
|
||||
}
|
||||
|
||||
export const selectedTenantID = writable<string>(initialTenantID());
|
||||
|
||||
export function setSelectedTenantID(tenantID: string): void {
|
||||
const normalized = tenantID.trim();
|
||||
selectedTenantID.set(normalized);
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
if (normalized) {
|
||||
window.localStorage.setItem(STORAGE_KEY, normalized);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantScopeHeaders(): Record<string, string> {
|
||||
const tenantID = currentTenantID();
|
||||
return tenantID ? { [TENANT_HEADER]: tenantID } : {};
|
||||
}
|
||||
|
||||
export function currentTenantID(): string {
|
||||
return get(selectedTenantID).trim();
|
||||
}
|
||||
|
||||
function shouldScopeRequest(input: RequestInfo | URL): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const rawURL = input instanceof Request ? input.url : input.toString();
|
||||
const url = new URL(rawURL, window.location.origin);
|
||||
return url.origin === window.location.origin && (url.pathname.startsWith('/api/rs') || url.pathname.startsWith('/api/admin'));
|
||||
}
|
||||
|
||||
// Gridler and Former perform their own fetches. Scope those requests here so
|
||||
// their ResolveSpec calls use the same tenant as the rest of the admin UI.
|
||||
export function installTenantScopedFetch(): void {
|
||||
if (typeof window === 'undefined' || tenantScopedFetchInstalled) return;
|
||||
|
||||
const baseFetch = window.fetch.bind(window);
|
||||
|
||||
async function scopedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const headersToAdd = tenantScopeHeaders();
|
||||
if (!Object.keys(headersToAdd).length || !shouldScopeRequest(input)) {
|
||||
return baseFetch(input, init);
|
||||
}
|
||||
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||
if (init?.headers) {
|
||||
new Headers(init.headers).forEach((value, name) => headers.set(name, value));
|
||||
}
|
||||
Object.entries(headersToAdd).forEach(([name, value]) => headers.set(name, value));
|
||||
return baseFetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
window.fetch = scopedFetch;
|
||||
tenantScopedFetchInstalled = true;
|
||||
}
|
||||
+31
-1
@@ -66,7 +66,7 @@ export type NavItem = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
||||
export type ShellPage = 'dashboard' | 'identity' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@@ -295,3 +295,33 @@ export type MetadataRetryResult = {
|
||||
failed: number;
|
||||
dry_run: boolean;
|
||||
};
|
||||
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TenantUser = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type IdentityKey = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
user_id?: string;
|
||||
description: string;
|
||||
source: 'configured' | 'managed';
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type IdentityData = {
|
||||
tenants: Tenant[];
|
||||
users: TenantUser[];
|
||||
keys: IdentityKey[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user