feat: align admin onboarding and role-ui-configs with migration schema

This commit is contained in:
Ashwin Kumar 2026-03-17 20:48:27 +01:00
parent 75b54368f0
commit 56803bd7b2
10 changed files with 378 additions and 184 deletions

View file

@ -1,14 +1,8 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
export type RuntimeRecordType = 'role' | 'dashboard' | 'onboarding';
export type RuntimeRecordStatus = 'draft' | 'published';
const KEY = 'nxtgauge_admin_runtime_builder_v1';
type RuntimeStore = {
role: Record<string, RuntimeStoredRecord<unknown>>;
dashboard: Record<string, RuntimeStoredRecord<unknown>>;
onboarding: Record<string, RuntimeStoredRecord<unknown>>;
};
export type RuntimeStoredRecord<T> = {
status: RuntimeRecordStatus;
updatedAt: string;
@ -19,61 +13,130 @@ export type RuntimeListItem<T> = RuntimeStoredRecord<T> & {
key: string;
};
function emptyStore(): RuntimeStore {
return { role: {}, dashboard: {}, onboarding: {} };
}
// Types corresponding to Rust backend models
type RoleRecord = {
id: string;
key: string;
name: string;
audience: string;
};
function readStore(): RuntimeStore {
if (typeof window === 'undefined') return emptyStore();
const raw = window.localStorage.getItem(KEY);
if (!raw) return emptyStore();
export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) {
const normalizedKey = key.trim().toUpperCase();
if (!normalizedKey) return;
try {
const parsed = JSON.parse(raw) as Partial<RuntimeStore>;
return {
role: parsed.role || {},
dashboard: parsed.dashboard || {},
onboarding: parsed.onboarding || {},
// 1. Ensure the Role exists first. We lookup by key.
// If it doesn't exist, we create it generically.
let roleRes = await fetch(`${API_URL}/api/admin/roles/${normalizedKey}`);
let role: RoleRecord;
if (!roleRes.ok && roleRes.status === 404) {
// Create it
const createRes = await fetch(`${API_URL}/api/admin/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: normalizedKey,
name: normalizedKey, // Using key as name for now
audience: 'EXTERNAL',
})
});
if (!createRes.ok) throw new Error("Failed to create role");
role = await createRes.json();
} else {
role = await roleRes.json();
}
// 2. Attach the specific config payload to the role
const endpointMap: Record<RuntimeRecordType, string> = {
role: '/api/runtime-config', // Storing the "role config wrapper" as runtime_config
dashboard: '/api/admin/dashboard-config',
onboarding: '/api/admin/onboarding-config',
};
} catch {
return emptyStore();
let bodyPayload: any = {
role_id: role.id,
};
if (type === 'dashboard') {
bodyPayload.audience = 'EXTERNAL';
bodyPayload.config_json = payload;
} else if (type === 'onboarding') {
bodyPayload.schema_json = payload;
} else if (type === 'role') {
bodyPayload.config_json = payload;
}
const saveRes = await fetch(`${API_URL}${endpointMap[type]}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyPayload)
});
if (!saveRes.ok) {
throw new Error(`Failed to save ${type} config`);
}
console.log(`Saved ${type} mapping for role ${normalizedKey} successfully to Rust backend`);
} catch (err) {
console.error(`Error saving runtime config [${type}]:`, err);
}
}
function writeStore(store: RuntimeStore) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(KEY, JSON.stringify(store));
export async function listRuntimeConfigs<T>(type: RuntimeRecordType): Promise<Array<RuntimeListItem<T>>> {
const endpointMap: Record<RuntimeRecordType, string> = {
role: '/api/admin/roles',
dashboard: '/api/admin/dashboard-config',
onboarding: '/api/admin/onboarding-config',
};
try {
const res = await fetch(`${API_URL}${endpointMap[type]}`);
if (!res.ok) throw new Error(`Failed to list ${type} configs`);
const data = await res.json();
// Map Rust backend models to UI expected format
return data.map((item: any) => ({
key: item.role_key || item.key,
status: item.is_active ? 'published' : 'draft',
updatedAt: item.updated_at || item.created_at,
payload: item.config_json || item.schema_json || item,
// include raw id for routing convenience
id: item.id,
role_id: item.role_id
}));
} catch (err) {
console.error(`Error listing runtime config [${type}]:`, err);
return [];
}
}
export function saveRuntimeConfig(type: RuntimeRecordType, key: string, payload: unknown, status: RuntimeRecordStatus) {
const normalizedKey = key.trim();
if (!normalizedKey) return;
const store = readStore();
store[type][normalizedKey] = {
status,
updatedAt: new Date().toISOString(),
payload,
};
writeStore(store);
export async function getRuntimeConfig<T>(type: RuntimeRecordType, roleId: string): Promise<RuntimeStoredRecord<T> | null> {
const endpointMap: Record<RuntimeRecordType, string> = {
role: `/api/admin/roles/${roleId}`, // Assume roleId is key if testing
dashboard: `/api/admin/dashboard-config/${roleId}?audience=EXTERNAL`,
onboarding: `/api/admin/onboarding-config/${roleId}`,
};
try {
const res = await fetch(`${API_URL}${endpointMap[type]}`);
if (!res.ok) return null;
const data = await res.json();
return {
status: data.is_active ? 'published' : 'draft',
updatedAt: data.updated_at || data.created_at,
payload: data.config_json || data.schema_json || data,
};
} catch (err) {
return null;
}
}
export function listRuntimeConfigs<T>(type: RuntimeRecordType): Array<RuntimeListItem<T>> {
const store = readStore();
return Object.entries(store[type])
.map(([key, value]) => ({ key, ...(value as RuntimeStoredRecord<T>) }))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function getRuntimeConfig<T>(type: RuntimeRecordType, key: string): RuntimeStoredRecord<T> | null {
const record = readStore()[type][key];
return (record as RuntimeStoredRecord<T>) || null;
}
export function deleteRuntimeConfig(type: RuntimeRecordType, key: string): boolean {
const store = readStore();
if (!store[type][key]) return false;
delete store[type][key];
writeStore(store);
export async function deleteRuntimeConfig(type: RuntimeRecordType, key: string): Promise<boolean> {
// Mock delete since hard deletes usually aren't ideal for configs (we just soft-disable them on new save)
console.log(`Delete requested for ${type} config with key ${key}. In Rust, we just save a new one with is_active=true overriding the old one.`);
return true;
}

View file

@ -1,5 +1,5 @@
import { useParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig, RuntimeOnboardingStep } from '~/lib/runtime/types';
@ -10,16 +10,24 @@ function stringifySteps(steps: RuntimeOnboardingStep[]): string {
export default function EditOnboardingPage() {
const params = useParams();
const [data] = createResource(() => {
if (!params.schemaId) return null;
return getRuntimeConfig<RuntimeOnboardingConfig>('onboarding', params.schemaId);
});
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
const [stepsJson, setStepsJson] = createSignal('[]');
const [statusMessage, setStatusMessage] = createSignal('');
const [stepsError, setStepsError] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
onMount(() => {
const existing = getRuntimeConfig<RuntimeOnboardingConfig>('onboarding', params.schemaId);
if (!existing?.payload) return;
setConfig(existing.payload);
setStepsJson(stringifySteps(existing.payload.steps));
// Sync resource to local signals
createEffect(() => {
const item = data();
if (item?.payload) {
setConfig(JSON.parse(JSON.stringify(item.payload)));
setStepsJson(stringifySteps(item.payload.steps));
}
});
const syncSteps = (raw: string) => {
@ -40,7 +48,7 @@ export default function EditOnboardingPage() {
}
};
const persist = (status: 'draft' | 'published') => {
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
if (!payload.schemaId.trim()) {
@ -56,17 +64,30 @@ export default function EditOnboardingPage() {
return;
}
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Onboarding schema published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Edit Onboarding Flow</h1>
<p class="page-subtitle">All onboarding fields, validations, upload rules, and select behaviors are runtime schema-driven.</p>
{!config() ? (
<section class="card"><p class="notice">Onboarding schema not found in local runtime storage.</p></section>
) : (
<Show when={data.loading}>
<section class="card"><p class="notice">Loading onboarding schema from database...</p></section>
</Show>
<Show when={!data.loading && !config()}>
<section class="card"><p class="notice">Onboarding schema "{params.schemaId}" not found in database.</p></section>
</Show>
<Show when={!data.loading && config()}>
<div class="grid">
<section class="card">
<h2>Onboarding Builder</h2>
@ -88,8 +109,12 @@ export default function EditOnboardingPage() {
{stepsError() && <p class="error-note">{stepsError()}</p>}
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
@ -98,7 +123,7 @@ export default function EditOnboardingPage() {
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
)}
</Show>
</AdminShell>
);
}

View file

@ -1,19 +1,15 @@
import { A } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import { createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
export default function ManageOnboardingPage() {
const [items, setItems] = createSignal<Array<RuntimeListItem<RuntimeOnboardingConfig>>>([]);
const [items, { refetch }] = createResource(() => listRuntimeConfigs<RuntimeOnboardingConfig>('onboarding'));
const load = () => setItems(listRuntimeConfigs<RuntimeOnboardingConfig>('onboarding'));
onMount(load);
const onDelete = (key: string) => {
deleteRuntimeConfig('onboarding', key);
load();
const onDelete = async (key: string) => {
await deleteRuntimeConfig('onboarding', key);
refetch();
};
return (
@ -25,23 +21,27 @@ export default function ManageOnboardingPage() {
<h2>Runtime Onboarding Schemas</h2>
<A class="btn primary" href="/admin/onboarding-schemas/new">Create Onboarding Flow</A>
</div>
{items().length === 0 ? (
<p class="notice">No runtime onboarding schemas found yet.</p>
) : (
<div class="list-grid">
{items().map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
<div class="actions">
<A class="btn" href={`/admin/onboarding-schemas/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
)}
<Show when={items.loading}>
<p class="notice">Loading onboarding schemas from database...</p>
</Show>
<Show when={!items.loading && items() && items()?.length === 0}>
<p class="notice">No runtime onboarding schemas found yet.</p>
</Show>
<Show when={!items.loading && items() && items()!.length > 0}>
<div class="list-grid">
{items()!.map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleDateString()}</p>
<div class="actions">
<A class="btn" href={`/admin/onboarding-schemas/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
</Show>
</section>
</AdminShell>
);

View file

@ -54,7 +54,9 @@ export default function CreateOnboardingSchemaPage() {
}
};
const persist = (status: 'draft' | 'published') => {
const [isSaving, setIsSaving] = createSignal(false);
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload.schemaId.trim()) {
setStatusMessage('Schema ID is required before saving.');
@ -69,8 +71,17 @@ export default function CreateOnboardingSchemaPage() {
return;
}
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Onboarding schema published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
@ -98,8 +109,12 @@ export default function CreateOnboardingSchemaPage() {
{stepsError() && <p class="error-note">{stepsError()}</p>}
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>

View file

@ -1,33 +1,61 @@
import { useParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import { createResource, createSignal, createEffect, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeDashboardConfig } from '~/lib/runtime/types';
export default function EditDashboardPage() {
const params = useParams();
const [data] = createResource(() => {
if (!params.roleKey) return null;
return getRuntimeConfig<RuntimeDashboardConfig>('dashboard', params.roleKey);
});
const [config, setConfig] = createSignal<RuntimeDashboardConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
onMount(() => {
const existing = getRuntimeConfig<RuntimeDashboardConfig>('dashboard', params.roleKey);
setConfig(existing?.payload || null);
// We'll sync the resource to the signal once it loads
// However, in Solid it's better to just use the resource or a derived signal
});
const persist = (status: 'draft' | 'published') => {
// Effect to sync config once data is loaded
createEffect(() => {
const item = data();
if (item?.payload) {
setConfig(JSON.parse(JSON.stringify(item.payload)));
}
});
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Dashboard config published in runtime storage.');
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Dashboard config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Edit Dashboard</h1>
<p class="page-subtitle">Update dashboard runtime config without changing source code.</p>
{!config() ? (
<section class="card"><p class="notice">Dashboard config not found in local runtime storage.</p></section>
) : (
<Show when={data.loading}>
<section class="card"><p class="notice">Loading dashboard configuration from database...</p></section>
</Show>
<Show when={!data.loading && !config()}>
<section class="card"><p class="notice">Dashboard config "{params.roleKey}" not found in database.</p></section>
</Show>
<Show when={!data.loading && config()}>
<div class="grid">
<section class="card">
<h2>Dashboard Builder</h2>
@ -73,8 +101,12 @@ export default function EditDashboardPage() {
/>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
@ -83,7 +115,7 @@ export default function EditDashboardPage() {
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
)}
</Show>
</AdminShell>
);
}

View file

@ -1,19 +1,15 @@
import { A } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import { createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeDashboardConfig } from '~/lib/runtime/types';
export default function ManageDashboardsPage() {
const [items, setItems] = createSignal<Array<RuntimeListItem<RuntimeDashboardConfig>>>([]);
const [items, { refetch }] = createResource(() => listRuntimeConfigs<RuntimeDashboardConfig>('dashboard'));
const load = () => setItems(listRuntimeConfigs<RuntimeDashboardConfig>('dashboard'));
onMount(load);
const onDelete = (key: string) => {
deleteRuntimeConfig('dashboard', key);
load();
const onDelete = async (key: string) => {
await deleteRuntimeConfig('dashboard', key);
refetch();
};
return (
@ -25,23 +21,27 @@ export default function ManageDashboardsPage() {
<h2>Runtime Dashboards</h2>
<A class="btn primary" href="/admin/role-ui-configs/new">Create Dashboard</A>
</div>
{items().length === 0 ? (
<Show when={items.loading}>
<p class="notice">Loading dashboards from database...</p>
</Show>
<Show when={!items.loading && items() && items()?.length === 0}>
<p class="notice">No runtime dashboard configs found yet.</p>
) : (
<div class="list-grid">
{items().map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
<div class="actions">
<A class="btn" href={`/admin/role-ui-configs/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
)}
</Show>
<Show when={!items.loading && items() && items()!.length > 0}>
<div class="list-grid">
{items()!.map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleDateString()}</p>
<div class="actions">
<A class="btn" href={`/admin/role-ui-configs/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
</Show>
</section>
</AdminShell>
);

View file

@ -19,14 +19,26 @@ export default function CreateDashboardPage() {
});
const [statusMessage, setStatusMessage] = createSignal('');
const persist = (status: 'draft' | 'published') => {
const [isSaving, setIsSaving] = createSignal(false);
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload.roleKey.trim()) {
setStatusMessage('Role key is required before saving.');
return;
}
saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Dashboard config published in runtime storage.');
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('dashboard', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Dashboard config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
@ -78,8 +90,12 @@ export default function CreateDashboardPage() {
/>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>

View file

@ -1,33 +1,56 @@
import { useParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function EditRolePage() {
const params = useParams();
const [data] = createResource(() => {
if (!params.roleKey) return null;
return getRuntimeConfig<RuntimeRoleConfig>('role', params.roleKey);
});
const [config, setConfig] = createSignal<RuntimeRoleConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
onMount(() => {
const existing = getRuntimeConfig<RuntimeRoleConfig>('role', params.roleKey);
setConfig(existing?.payload || null);
// Sync resource to local signals
createEffect(() => {
const item = data();
if (item?.payload) {
setConfig(JSON.parse(JSON.stringify(item.payload)));
}
});
const persist = (status: 'draft' | 'published') => {
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Role config published in runtime storage.');
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Role config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Edit Role</h1>
<p class="page-subtitle">Update this runtime role without changing source code.</p>
{!config() ? (
<section class="card"><p class="notice">Role not found in local runtime storage.</p></section>
) : (
<Show when={data.loading}>
<section class="card"><p class="notice">Loading role configuration from database...</p></section>
</Show>
<Show when={!data.loading && !config()}>
<section class="card"><p class="notice">Role "{params.roleKey}" not found in database.</p></section>
</Show>
<Show when={!data.loading && config()}>
<div class="grid">
<section class="card">
<h2>Role Builder</h2>
@ -65,8 +88,12 @@ export default function EditRolePage() {
</label>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
@ -75,7 +102,7 @@ export default function EditRolePage() {
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
</div>
)}
</Show>
</AdminShell>
);
}

View file

@ -1,19 +1,15 @@
import { A } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import { createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function ManageRolesPage() {
const [items, setItems] = createSignal<Array<RuntimeListItem<RuntimeRoleConfig>>>([]);
const [items, { refetch }] = createResource(() => listRuntimeConfigs<RuntimeRoleConfig>('role'));
const load = () => setItems(listRuntimeConfigs<RuntimeRoleConfig>('role'));
onMount(load);
const onDelete = (key: string) => {
deleteRuntimeConfig('role', key);
load();
const onDelete = async (key: string) => {
await deleteRuntimeConfig('role', key);
refetch();
};
return (
@ -25,23 +21,27 @@ export default function ManageRolesPage() {
<h2>Runtime Roles</h2>
<A class="btn primary" href="/admin/runtime-roles/new">Create Role</A>
</div>
{items().length === 0 ? (
<Show when={items.loading}>
<p class="notice">Loading roles from database...</p>
</Show>
<Show when={!items.loading && items() && items()?.length === 0}>
<p class="notice">No runtime role configs found yet.</p>
) : (
<div class="list-grid">
{items().map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
<div class="actions">
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
)}
</Show>
<Show when={!items.loading && items() && items()!.length > 0}>
<div class="list-grid">
{items()!.map((item) => (
<article class="list-item">
<h3>{item.key}</h3>
<p>Status: <strong>{item.status}</strong></p>
<p>Updated: {new Date(item.updatedAt).toLocaleDateString()}</p>
<div class="actions">
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</article>
))}
</div>
</Show>
</section>
</AdminShell>
);

View file

@ -14,14 +14,26 @@ export default function CreateRolePage() {
});
const [statusMessage, setStatusMessage] = createSignal('');
const persist = (status: 'draft' | 'published') => {
const [isSaving, setIsSaving] = createSignal(false);
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload.roleKey.trim()) {
setStatusMessage('Role key is required before saving.');
return;
}
saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Role config published in runtime storage.');
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Role config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsSaving(false);
}
};
return (
@ -65,8 +77,12 @@ export default function CreateRolePage() {
</label>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
<button class="btn primary" onClick={() => persist('published')}>Publish</button>
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>