feat(admin-builders): align role and onboarding management flows with next tabs/forms

This commit is contained in:
Ashwin Kumar 2026-03-19 15:29:22 +01:00
parent 931a1db71b
commit 54cd1a156c
10 changed files with 688 additions and 417 deletions

View file

@ -0,0 +1,377 @@
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
export type ExternalRoleConfig = {
id?: string;
roleKey: string;
displayName: string;
vertical: 'jobs' | 'marketplace';
roleCategory: 'provider' | 'employer' | 'consumer' | 'specialist';
enabledModules: string[];
permissions: Record<string, string[]>;
onboardingSchemaId: string;
requiresOnboardingApproval: boolean;
requiresLeadApproval: boolean;
requiresJobApproval: boolean;
featureLimits: Record<string, unknown>;
runtimeConfigVersion: number;
isActive: boolean;
};
type RolePermissionAction = {
key: string;
label: string;
};
type ModuleOption = {
key: string;
label: string;
description: string;
};
type ExternalRoleFormProps = {
initialValue: ExternalRoleConfig;
saving?: boolean;
submitLabel: string;
onSubmit: (value: ExternalRoleConfig) => Promise<void> | void;
};
const ROLE_PERMISSION_ACTIONS: RolePermissionAction[] = [
{ key: 'read', label: 'Read' },
{ key: 'create', label: 'Create' },
{ key: 'update', label: 'Update' },
{ key: 'delete', label: 'Delete' },
{ key: 'approve', label: 'Approve' },
];
const ONBOARDING_SCHEMA_OPTIONS = [
'company_onboarding_v1',
'job_seeker_onboarding_v1',
'photographer_onboarding_v1',
'default_onboarding_v1',
];
const MODULES_BY_VERTICAL: Record<'jobs' | 'marketplace', ModuleOption[]> = {
jobs: [
{ key: 'jobs', label: 'Jobs', description: 'Manage job postings and candidate flow.' },
{ key: 'applications', label: 'Applications', description: 'Review incoming applications.' },
{ key: 'responses', label: 'Responses', description: 'Track response lifecycle states.' },
{ key: 'profile', label: 'Profile', description: 'Maintain role profile and preferences.' },
{ key: 'notifications', label: 'Notifications', description: 'View and manage alerts.' },
],
marketplace: [
{ key: 'leads', label: 'Leads', description: 'Handle customer lead requests.' },
{ key: 'portfolio', label: 'Portfolio', description: 'Publish portfolio and service highlights.' },
{ key: 'verification', label: 'Verification', description: 'Track onboarding verification progress.' },
{ key: 'pricing', label: 'Pricing', description: 'Manage plans, pricing, and packages.' },
{ key: 'support', label: 'Support', description: 'Access support workflows and help content.' },
],
};
const DEFAULT_PRESETS: Record<string, ExternalRoleConfig> = {
company: {
roleKey: 'company',
displayName: 'Company',
vertical: 'jobs',
roleCategory: 'employer',
enabledModules: ['jobs', 'applications', 'responses', 'profile'],
permissions: {
jobs: ['read', 'create', 'update'],
applications: ['read', 'approve'],
responses: ['read', 'update'],
profile: ['read', 'update'],
},
onboardingSchemaId: 'company_onboarding_v1',
requiresOnboardingApproval: true,
requiresLeadApproval: false,
requiresJobApproval: true,
featureLimits: { maxActiveJobs: 5 },
runtimeConfigVersion: 1,
isActive: true,
},
photographer: {
roleKey: 'photographer',
displayName: 'Photographer',
vertical: 'marketplace',
roleCategory: 'provider',
enabledModules: ['leads', 'portfolio', 'verification', 'pricing'],
permissions: {
leads: ['read', 'update'],
portfolio: ['read', 'create', 'update'],
verification: ['read'],
pricing: ['read', 'update'],
},
onboardingSchemaId: 'photographer_onboarding_v1',
requiresOnboardingApproval: true,
requiresLeadApproval: true,
requiresJobApproval: false,
featureLimits: { maxOpenLeads: 10 },
runtimeConfigVersion: 1,
isActive: true,
},
};
function slugifyRoleKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function parseFeatureLimits(raw: string): Record<string, unknown> {
const parsed = JSON.parse(raw || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Feature limits must be a JSON object.');
}
return parsed as Record<string, unknown>;
}
export default function ExternalRoleForm(props: ExternalRoleFormProps) {
const [config, setConfig] = createSignal<ExternalRoleConfig>(props.initialValue);
const [featureLimitsRaw, setFeatureLimitsRaw] = createSignal(JSON.stringify(props.initialValue.featureLimits || {}, null, 2));
const [errors, setErrors] = createSignal<Record<string, string>>({});
createEffect(() => {
setConfig(props.initialValue);
setFeatureLimitsRaw(JSON.stringify(props.initialValue.featureLimits || {}, null, 2));
setErrors({});
});
const moduleOptions = createMemo(() => MODULES_BY_VERTICAL[config().vertical]);
createEffect(() => {
const allowed = new Set(moduleOptions().map((item) => item.key));
setConfig((current) => ({
...current,
enabledModules: current.enabledModules.filter((item) => allowed.has(item)),
permissions: Object.fromEntries(Object.entries(current.permissions).filter(([key]) => allowed.has(key))),
}));
});
const setConfigPatch = (patch: Partial<ExternalRoleConfig>) => setConfig((current) => ({ ...current, ...patch }));
const applyPreset = (presetKey: string) => {
const preset = DEFAULT_PRESETS[presetKey];
if (!preset) return;
const next = {
...preset,
runtimeConfigVersion: (config().runtimeConfigVersion || 1) + 1,
};
setConfig(next);
setFeatureLimitsRaw(JSON.stringify(next.featureLimits || {}, null, 2));
setErrors({});
};
const updateModuleSelection = (moduleKey: string, enabled: boolean) => {
setConfig((current) => {
const enabledModules = enabled
? Array.from(new Set([...current.enabledModules, moduleKey]))
: current.enabledModules.filter((value) => value !== moduleKey);
const nextPermissions = { ...current.permissions };
if (!enabled) delete nextPermissions[moduleKey];
return { ...current, enabledModules, permissions: nextPermissions };
});
};
const togglePermission = (moduleKey: string, action: string) => {
setConfig((current) => {
const next = new Set(current.permissions[moduleKey] || []);
if (next.has(action)) next.delete(action);
else next.add(action);
return {
...current,
permissions: {
...current.permissions,
[moduleKey]: Array.from(next),
},
};
});
};
const validateAndSubmit = async () => {
const nextErrors: Record<string, string> = {};
const normalizedRoleKey = slugifyRoleKey(config().roleKey);
if (!normalizedRoleKey) nextErrors.roleKey = 'Role key is required.';
if (!config().displayName.trim()) nextErrors.displayName = 'Display name is required.';
if (config().enabledModules.length === 0) nextErrors.enabledModules = 'Enable at least one module.';
const modulesWithoutPermissions = config().enabledModules.filter((moduleKey) => !(config().permissions[moduleKey] || []).length);
if (modulesWithoutPermissions.length > 0) {
nextErrors.permissions = `Assign at least one permission for: ${modulesWithoutPermissions.join(', ')}.`;
}
let featureLimits: Record<string, unknown> = {};
try {
featureLimits = parseFeatureLimits(featureLimitsRaw());
} catch (error: any) {
nextErrors.featureLimits = error?.message || 'Feature limits JSON is invalid.';
}
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
await props.onSubmit({
...config(),
roleKey: normalizedRoleKey,
featureLimits,
});
};
return (
<div class="space-y-6">
<section class="card">
<div class="list-header">
<div>
<h2>Role Configuration</h2>
<p class="notice" style="margin-top:2px">
Configure the external runtime role model for jobs and marketplace verticals.
</p>
</div>
<div class="field" style="min-width:220px;margin:0">
<label>Load preset</label>
<select
value=""
onChange={(event) => {
if (event.currentTarget.value) {
applyPreset(event.currentTarget.value);
event.currentTarget.value = '';
}
}}
>
<option value="">Choose a default role</option>
<option value="company">Company</option>
<option value="photographer">Photographer</option>
</select>
</div>
</div>
<div class="field-grid-2">
<div class="field">
<label>Role Key</label>
<input value={config().roleKey} onInput={(event) => setConfigPatch({ roleKey: event.currentTarget.value })} placeholder="company" />
<Show when={errors().roleKey}><p class="error-note">{errors().roleKey}</p></Show>
</div>
<div class="field">
<label>Display Name</label>
<input value={config().displayName} onInput={(event) => setConfigPatch({ displayName: event.currentTarget.value })} placeholder="Company" />
<Show when={errors().displayName}><p class="error-note">{errors().displayName}</p></Show>
</div>
<div class="field">
<label>Vertical</label>
<select value={config().vertical} onChange={(event) => setConfigPatch({ vertical: event.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="jobs">Jobs</option>
<option value="marketplace">Marketplace</option>
</select>
</div>
<div class="field">
<label>Role Category</label>
<select value={config().roleCategory} onChange={(event) => setConfigPatch({ roleCategory: event.currentTarget.value as ExternalRoleConfig['roleCategory'] })}>
<option value="provider">Provider</option>
<option value="employer">Employer</option>
<option value="consumer">Consumer</option>
<option value="specialist">Specialist</option>
</select>
</div>
<div class="field">
<label>Onboarding Schema</label>
<select value={config().onboardingSchemaId} onChange={(event) => setConfigPatch({ onboardingSchemaId: event.currentTarget.value })}>
<For each={ONBOARDING_SCHEMA_OPTIONS}>
{(schemaId) => <option value={schemaId}>{schemaId}</option>}
</For>
</select>
</div>
<div class="field" style="display:flex;align-items:flex-end">
<label class="checkbox-label">
<input type="checkbox" checked={config().isActive} onChange={(event) => setConfigPatch({ isActive: event.currentTarget.checked })} />
Active role
</label>
</div>
</div>
</section>
<section class="card">
<h2 style="margin-bottom:4px">Enabled Modules</h2>
<p class="notice" style="margin:0 0 10px">Choose the modules this role can access at runtime.</p>
<div class="field-grid-2">
<For each={moduleOptions()}>
{(option) => {
const checked = () => config().enabledModules.includes(option.key);
return (
<label class="nested-card" style={checked() ? 'border-color:#fd6216;background:#fff7ed' : ''}>
<div style="display:flex;gap:10px;align-items:flex-start">
<input type="checkbox" checked={checked()} onChange={(event) => updateModuleSelection(option.key, event.currentTarget.checked)} />
<div>
<p style="margin:0;font-size:14px;font-weight:700;color:#0f172a">{option.label}</p>
<p class="notice" style="margin-top:2px">{option.description}</p>
</div>
</div>
</label>
);
}}
</For>
</div>
<Show when={errors().enabledModules}><p class="error-note">{errors().enabledModules}</p></Show>
</section>
<section class="card">
<h2 style="margin-bottom:4px">Permission Matrix</h2>
<p class="notice" style="margin:0 0 10px">Assign CRUD and approval capabilities per enabled module.</p>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Module</th>
<For each={ROLE_PERMISSION_ACTIONS}>
{(action) => <th class="align-right">{action.label}</th>}
</For>
</tr>
</thead>
<tbody>
<Show when={config().enabledModules.length === 0}>
<tr><td colspan={ROLE_PERMISSION_ACTIONS.length + 1} style="text-align:center;padding:18px;color:#64748b">Enable modules to configure permissions.</td></tr>
</Show>
<For each={moduleOptions().filter((option) => config().enabledModules.includes(option.key))}>
{(option) => (
<tr>
<td>{option.label}</td>
<For each={ROLE_PERMISSION_ACTIONS}>
{(action) => {
const checked = () => (config().permissions[option.key] || []).includes(action.key);
return (
<td class="align-right">
<input type="checkbox" checked={checked()} onChange={() => togglePermission(option.key, action.key)} />
</td>
);
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Show when={errors().permissions}><p class="error-note">{errors().permissions}</p></Show>
</section>
<section class="card">
<h2 style="margin-bottom:6px">Approval Rules & Limits</h2>
<div class="field-grid-2">
<label class="checkbox-label"><input type="checkbox" checked={config().requiresOnboardingApproval} onChange={(event) => setConfigPatch({ requiresOnboardingApproval: event.currentTarget.checked })} />Requires onboarding approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresLeadApproval} onChange={(event) => setConfigPatch({ requiresLeadApproval: event.currentTarget.checked })} />Requires lead approval</label>
<label class="checkbox-label"><input type="checkbox" checked={config().requiresJobApproval} onChange={(event) => setConfigPatch({ requiresJobApproval: event.currentTarget.checked })} />Requires job approval</label>
</div>
<div class="field" style="margin-top:10px">
<label>Feature Limits (JSON)</label>
<textarea rows="8" value={featureLimitsRaw()} onInput={(event) => setFeatureLimitsRaw(event.currentTarget.value)} />
<Show when={errors().featureLimits}><p class="error-note">{errors().featureLimits}</p></Show>
</div>
</section>
<div class="actions" style="justify-content:flex-end">
<button class="btn primary" onClick={() => void validateAndSubmit()} disabled={props.saving}>
{props.saving ? 'Saving...' : props.submitLabel}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { A, useLocation, useSearchParams } from '@solidjs/router';
import { createMemo } from 'solid-js';
type Props = {
roleKey?: string;
};
export default function ExternalRoleTabs(props: Props) {
const location = useLocation();
const [searchParams] = useSearchParams();
const activeRoleKey = createMemo(() => props.roleKey || searchParams.roleKey || '');
const pathname = createMemo(() => location.pathname);
const isRoles = createMemo(() => pathname() === '/admin/runtime-roles' || pathname().startsWith('/admin/runtime-roles/'));
const isCreate = createMemo(() => pathname() === '/admin/runtime-roles/new');
const isInspector = createMemo(() => pathname() === '/admin/role-ui-configs');
return (
<div class="admin-link-tabs" style="margin-top:0">
<A class={`admin-link-tab ${isRoles() ? 'active' : ''}`} href="/admin/runtime-roles">View Roles</A>
<A class={`admin-link-tab ${isCreate() ? 'active' : ''}`} href="/admin/runtime-roles/new">Create Role</A>
<A class={`admin-link-tab ${isInspector() ? 'active' : ''}`} href={activeRoleKey() ? `/admin/role-ui-configs?roleKey=${encodeURIComponent(activeRoleKey())}` : '/admin/role-ui-configs'}>Inspector</A>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { A, useLocation } from '@solidjs/router';
import { createMemo } from 'solid-js';
export default function OnboardingManagementTabs() {
const location = useLocation();
const pathname = createMemo(() => location.pathname);
const onList = createMemo(() => pathname() === '/admin/onboarding-schemas');
const onCreate = createMemo(() => pathname() === '/admin/onboarding-schemas/new');
const onRoleMapping = createMemo(() => pathname() === '/admin/runtime-roles' || pathname().startsWith('/admin/runtime-roles/') || pathname() === '/admin/role-ui-configs');
return (
<div class="admin-link-tabs" style="margin-top:0">
<A class={`admin-link-tab ${onList() ? 'active' : ''}`} href="/admin/onboarding-schemas">View Flows</A>
<A class={`admin-link-tab ${onCreate() ? 'active' : ''}`} href="/admin/onboarding-schemas/new">Create Flow</A>
<A class={`admin-link-tab ${onRoleMapping() ? 'active' : ''}`} href="/admin/runtime-roles">Role Mapping</A>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
const API = '/api/gateway';
@ -140,11 +141,7 @@ export default function EditOnboardingFlowPage() {
return (
<AdminShell>
<nav class="admin-link-tabs" aria-label="Onboarding Management Navigation">
<A class="admin-link-tab active" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<OnboardingManagementTabs />
<div class="page-hero-card page-actions">
<div>
@ -155,7 +152,7 @@ export default function EditOnboardingFlowPage() {
<button class="btn" type="button" disabled={saving()} onClick={() => handleSubmit(data()?.is_active ? 'draft' : 'published')}>
{data()?.is_active ? 'Unpublish' : 'Publish'}
</button>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding</A>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { A } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
const API = '/api/gateway';
@ -54,19 +55,15 @@ export default function OnboardingSchemasPage() {
return (
<AdminShell>
<div class="page-actions">
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Onboarding Management</h1>
<p class="page-subtitle">Manage onboarding flows, role assignments, and step groups for external users.</p>
<p class="page-subtitle">Manage onboarding flows, role assignments, and previewable step groups for external users.</p>
</div>
<A class="btn navy" href="/admin/onboarding-schemas/new">Create Onboarding Flow</A>
</div>
<nav class="admin-link-tabs" aria-label="Onboarding Management Navigation">
<A class="admin-link-tab active" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<OnboardingManagementTabs />
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>

View file

@ -1,6 +1,7 @@
import { A, useNavigate } from '@solidjs/router';
import { createMemo, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
const API = '/api/gateway';
@ -103,18 +104,14 @@ export default function CreateOnboardingFlowPage() {
return (
<AdminShell>
<nav class="admin-link-tabs" aria-label="Onboarding Management Navigation">
<A class="admin-link-tab active" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<OnboardingManagementTabs />
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Create Onboarding Flow</h1>
<p class="page-subtitle">Build one onboarding flow at a time. Start with the role, add steps, then configure fields for each step.</p>
<p class="page-subtitle">Build one onboarding flow at a time. Start with the role, choose questions, arrange steps, and finish with the final submission message.</p>
</div>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding</A>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
</div>
<Show when={error()}>

View file

@ -1,62 +1,144 @@
import { A } from '@solidjs/router';
import { createResource, Show } from 'solid-js';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, 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';
import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
export default function ManageDashboardsPage() {
const [items, { refetch }] = createResource(() => listRuntimeConfigs<RuntimeDashboardConfig>('dashboard'));
const API = '/api/gateway';
const onDelete = async (key: string) => {
await deleteRuntimeConfig('dashboard', key);
refetch();
type ExternalRole = {
id: string;
roleKey: string;
displayName: string;
vertical: string;
onboardingSchemaId: string;
enabledModules: string[];
permissions: Record<string, string[]>;
featureLimits: Record<string, unknown>;
isActive: boolean;
};
function parseRole(item: any): ExternalRole {
const cfg = item.config_json || {};
return {
id: String(item.id || ''),
roleKey: String(cfg.roleKey || item.key || item.role_key || ''),
displayName: String(cfg.displayName || item.name || ''),
vertical: String(cfg.vertical || ''),
onboardingSchemaId: String(cfg.onboardingSchemaId || ''),
enabledModules: Array.isArray(cfg.enabledModules) ? cfg.enabledModules : [],
permissions: (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {},
featureLimits: (cfg.featureLimits && typeof cfg.featureLimits === 'object') ? cfg.featureLimits : {},
isActive: item.is_active !== false,
};
}
async function loadRoles(): Promise<ExternalRole[]> {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) return [];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
return rows.map(parseRole);
}
async function loadRoleByKey(roleKey: string): Promise<ExternalRole | null> {
const roles = await loadRoles();
const key = roleKey.toLowerCase();
return roles.find((item) => item.roleKey.toLowerCase() === key) || null;
}
export default function RoleUiConfigsViewPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const roleKey = createMemo(() => searchParams.roleKey || '');
const [rows] = createResource(loadRoles);
const [selected] = createResource(roleKey, async (key) => (key ? loadRoleByKey(key) : null));
return (
<AdminShell>
<h1 class="page-title">External Dashboard Management</h1>
<p class="page-subtitle">Inspect and manage published runtime dashboard configurations.</p>
<section class="card">
<div class="list-header">
<h2>Runtime Dashboards</h2>
<A class="btn primary" href="/admin/role-ui-configs/new">Create Dashboard</A>
</div>
<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>
</Show>
<Show when={!items.loading && items() && items()!.length > 0}>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Role</th>
<th>Status</th>
<th>Updated</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
{items()!.map((item) => (
<tr>
<td>{item.key}</td>
<td><span class={`status-chip ${item.status === 'published' ? 'active' : ''}`}>{item.status}</span></td>
<td>{new Date(item.updatedAt).toLocaleDateString()}</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/role-ui-configs/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div class="page-hero-card">
<h1 class="page-title">External Dashboard Management</h1>
<p class="page-subtitle">Read-only view of the currently published external dashboard and runtime role configuration.</p>
</div>
<ExternalRoleTabs roleKey={roleKey()} />
<div style="display:grid;grid-template-columns:420px minmax(0,1fr);gap:16px">
<section class="card">
<div class="list-header">
<h2 style="margin:0">External Dashboard Roles</h2>
<A class="btn" href="/admin/roles">Open Roles</A>
</div>
</Show>
</section>
<p class="notice" style="margin-top:2px">
Select a role to inspect its published dashboard modules, onboarding assignment, and permissions.
</p>
<Show when={rows.loading}>
<p class="notice">Loading runtime role configs...</p>
</Show>
<Show when={!rows.loading && (rows()?.length || 0) === 0}>
<p class="notice">No runtime roles available.</p>
</Show>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:10px">
{(rows() || []).map((item) => {
const isActive = roleKey().toLowerCase() === item.roleKey.toLowerCase();
return (
<button
type="button"
onClick={() => navigate(`/admin/role-ui-configs?roleKey=${encodeURIComponent(item.roleKey)}`)}
style={`text-align:left;border:1px solid ${isActive ? '#fd6216' : '#e2e8f0'};border-radius:12px;padding:12px;background:${isActive ? '#fff7ed' : '#fff'}`}
>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<div>
<p style="margin:0;font-size:14px;font-weight:700;color:#0f172a">{item.displayName}</p>
<p style="margin:3px 0 0;font-size:11px;color:#64748b">{item.roleKey}</p>
</div>
<span class={`status-pill ${item.isActive ? 'status-approved' : 'status-rejected'}`}>{item.isActive ? 'Active' : 'Inactive'}</span>
</div>
<p class="notice" style="margin-top:8px">{item.enabledModules.length} modules schema {item.onboardingSchemaId || '—'}</p>
</button>
);
})}
</div>
</section>
<section style="display:flex;flex-direction:column;gap:12px">
<div class="card">
<div class="list-header">
<div>
<h2 style="margin:0">Dashboard Detail</h2>
<p class="notice" style="margin-top:3px">This page is the safe inspector for published external dashboard and role configuration.</p>
</div>
<Show when={selected()}>
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(selected()!.roleKey)}`}>Open Role Detail</A>
</Show>
</div>
</div>
<Show when={selected.loading}>
<div class="card"><p class="notice">Loading config detail...</p></div>
</Show>
<Show when={!selected.loading && !selected() && roleKey()}>
<div class="card"><p class="notice">No runtime role found for "{roleKey()}".</p></div>
</Show>
<Show when={!selected.loading && !selected() && !roleKey()}>
<div class="card"><p class="notice">Select an external role to view its current dashboard configuration.</p></div>
</Show>
<Show when={selected()}>
<div class="card">
<div class="field-grid-2">
<div class="kv-item"><p class="kv-label">Role Key</p><p class="kv-value">{selected()!.roleKey}</p></div>
<div class="kv-item"><p class="kv-label">Display Name</p><p class="kv-value">{selected()!.displayName}</p></div>
<div class="kv-item"><p class="kv-label">Vertical</p><p class="kv-value">{selected()!.vertical || '—'}</p></div>
<div class="kv-item"><p class="kv-label">Schema</p><p class="kv-value">{selected()!.onboardingSchemaId || '—'}</p></div>
<div class="kv-item" style="grid-column:1 / -1"><p class="kv-label">Enabled Modules</p><p class="kv-value">{selected()!.enabledModules.join(', ') || '—'}</p></div>
</div>
<h2 style="margin:14px 0 8px">Published Runtime Config</h2>
<pre class="json">{JSON.stringify(selected(), null, 2)}</pre>
</div>
</Show>
</section>
</div>
</AdminShell>
);
}

View file

@ -1,42 +1,38 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import ExternalRoleForm, { type ExternalRoleConfig } from '~/components/admin/ExternalRoleForm';
import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
const API = '/api/gateway';
type Config = {
id: string;
roleKey: string;
displayName: string;
vertical: 'jobs' | 'marketplace';
onboardingSchemaId: string;
enabledModules: string[];
requiresOnboardingApproval: boolean;
requiresLeadApproval: boolean;
requiresJobApproval: boolean;
isActive: boolean;
};
async function loadRole(roleKey: string): Promise<Config | null> {
async function loadRole(roleKey: string): Promise<ExternalRoleConfig | null> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) return null;
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
const r = rows.find((r: any) => (r.key || r.role_key || r.roleKey || '') === decodeURIComponent(roleKey));
if (!r) return null;
return {
id: r.id,
roleKey: r.key || r.role_key || r.roleKey || '',
displayName: r.name || r.displayName || r.display_name || '',
vertical: r.config_json?.vertical || r.vertical || 'marketplace',
onboardingSchemaId: r.config_json?.onboardingSchemaId || r.onboarding_schema_id || '',
enabledModules: r.config_json?.enabledModules || r.enabled_modules || [],
requiresOnboardingApproval: r.config_json?.requiresOnboardingApproval ?? true,
requiresLeadApproval: r.config_json?.requiresLeadApproval ?? false,
requiresJobApproval: r.config_json?.requiresJobApproval ?? false,
isActive: r.is_active !== false,
};
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) return null;
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
const normalizedRoleKey = decodeURIComponent(roleKey).toLowerCase();
const row = rows.find((item: any) => String(item.key || item.role_key || item.roleKey || '').toLowerCase() === normalizedRoleKey);
if (!row) return null;
const cfg = row.config_json || {};
return {
id: row.id,
roleKey: String(cfg.roleKey || row.key || row.role_key || ''),
displayName: String(cfg.displayName || row.name || row.display_name || ''),
vertical: cfg.vertical === 'jobs' ? 'jobs' : 'marketplace',
roleCategory: cfg.roleCategory || 'provider',
enabledModules: Array.isArray(cfg.enabledModules) ? cfg.enabledModules : [],
permissions: (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {},
onboardingSchemaId: String(cfg.onboardingSchemaId || ''),
requiresOnboardingApproval: cfg.requiresOnboardingApproval ?? true,
requiresLeadApproval: cfg.requiresLeadApproval ?? false,
requiresJobApproval: cfg.requiresJobApproval ?? false,
featureLimits: (cfg.featureLimits && typeof cfg.featureLimits === 'object') ? cfg.featureLimits : {},
runtimeConfigVersion: Number(cfg.version || cfg.runtimeConfigVersion || 1),
isActive: row.is_active !== false,
};
} catch {
return null;
}
@ -46,58 +42,49 @@ export default function EditExternalRolePage() {
const params = useParams();
const navigate = useNavigate();
const [data] = createResource(() => params.roleKey, loadRole);
const [config, setConfig] = createSignal<Config | null>(null);
const [modulesRaw, setModulesRaw] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const roleKey = createMemo(() => decodeURIComponent(params.roleKey || ''));
createEffect(() => {
const d = data();
if (!d) return;
setConfig(d);
setModulesRaw(d.enabledModules.join(', '));
});
const set = (patch: Partial<Config>) => setConfig({ ...config()!, ...patch });
const handleSave = async () => {
const c = config();
if (!c) return;
if (!c.roleKey.trim()) { setError('Role Key is required'); return; }
if (!c.displayName.trim()) { setError('Display Name is required'); return; }
const handleSubmit = async (config: ExternalRoleConfig) => {
if (!config.id) {
setError('Missing role id, cannot update.');
return;
}
try {
setSaving(true);
setError('');
const enabledModules = modulesRaw().split(',').map((s) => s.trim()).filter(Boolean);
const res = await fetch(`${API}/api/admin/roles/${c.id}`, {
const res = await fetch(`${API}/api/admin/roles/${config.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: c.roleKey.toUpperCase().replace(/\s+/g, '_'),
name: c.displayName,
key: config.roleKey.toUpperCase(),
name: config.displayName,
audience: 'EXTERNAL',
config_json: {
roleKey: c.roleKey,
displayName: c.displayName,
vertical: c.vertical,
onboardingSchemaId: c.onboardingSchemaId,
enabledModules,
requiresOnboardingApproval: c.requiresOnboardingApproval,
requiresLeadApproval: c.requiresLeadApproval,
requiresJobApproval: c.requiresJobApproval,
roleKey: config.roleKey,
displayName: config.displayName,
vertical: config.vertical,
roleCategory: config.roleCategory,
enabledModules: config.enabledModules,
permissions: config.permissions,
onboardingSchemaId: config.onboardingSchemaId,
requiresOnboardingApproval: config.requiresOnboardingApproval,
requiresLeadApproval: config.requiresLeadApproval,
requiresJobApproval: config.requiresJobApproval,
featureLimits: config.featureLimits || {},
version: config.runtimeConfigVersion || 1,
},
is_active: c.isActive,
is_active: config.isActive,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to update external role');
throw new Error(body.message || 'Failed to update runtime role');
}
navigate('/admin/runtime-roles');
} catch (err: any) {
setError(err.message || 'Failed to update external role');
navigate(`/admin/runtime-roles?roleKey=${encodeURIComponent(config.roleKey)}`);
} catch (nextError: any) {
setError(nextError?.message || 'Failed to update runtime role');
} finally {
setSaving(false);
}
@ -108,144 +95,44 @@ export default function EditExternalRolePage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">External Role Management</h1>
<p class="page-subtitle">Manage one external role in detail, including modules, onboarding assignment, and approval gates.</p>
<p class="page-subtitle">
Manage one external role in detail, including modules, permissions, onboarding assignment, approval gates, and limits.
</p>
</div>
<div class="actions" style="margin-top:0">
<A class="btn" href={`/admin/role-ui-configs?roleKey=${encodeURIComponent(params.roleKey)}`}>Open Inspector</A>
<A class="btn" href="/admin/runtime-roles">Back to Runtime Roles</A>
<div class="page-actions-right">
<A class="btn" href={`/admin/role-ui-configs?roleKey=${encodeURIComponent(roleKey())}`}>Open Inspector</A>
<A class="btn" href="/admin/runtime-roles">Back to External Roles</A>
</div>
</div>
<nav class="admin-link-tabs" aria-label="External Management Navigation">
<A class="admin-link-tab active" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<Show when={data.loading}>
<div class="card"><p class="notice">Loading role...</p></div>
</Show>
<Show when={data.error}>
<div class="error-box">Failed to load role. Check that the backend is running.</div>
</Show>
<Show when={!data.loading && !data.error && !data()}>
<div class="card"><p class="notice">Role "{params.roleKey}" not found.</p></div>
</Show>
<ExternalRoleTabs roleKey={roleKey()} />
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
<Show when={!data.loading && config()}>
<section class="card" style="margin-bottom:16px">
<h2 style="margin-bottom:6px">Publishing Model</h2>
<p class="notice" style="margin:0">
Changes on this screen update the published runtime role config through the admin API.
</p>
</section>
<div class="card">
<h2 style="margin-bottom:6px">Publishing Model</h2>
<p class="notice" style="margin:0">
This page writes directly through the external-role admin API and updates published runtime-config for the matching role.
</p>
</div>
<div class="grid">
<section class="card external-role-form">
<h2>External Role Builder</h2>
<Show when={data.loading}>
<div class="card"><p class="notice">Loading runtime role...</p></div>
</Show>
<div class="field">
<label>Role Key <span style="color:#ef4444">*</span></label>
<input
value={config()!.roleKey}
onInput={(e) => set({ roleKey: e.currentTarget.value.toUpperCase().replace(/\s+/g, '_') })}
placeholder="e.g. PHOTOGRAPHER"
/>
<p class="hint">Uppercase, underscores only. e.g. MAKEUP_ARTIST</p>
</div>
<Show when={!data.loading && !data()}>
<div class="card"><p class="notice">Role "{roleKey()}" not found.</p></div>
</Show>
<div class="field">
<label>Display Name <span style="color:#ef4444">*</span></label>
<input
value={config()!.displayName}
onInput={(e) => set({ displayName: e.currentTarget.value })}
placeholder="e.g. Photographer"
/>
</div>
<div class="field">
<label>Vertical</label>
<select value={config()!.vertical} onChange={(e) => set({ vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="marketplace">Marketplace</option>
<option value="jobs">Jobs</option>
</select>
</div>
<div class="field">
<label>Onboarding Schema ID</label>
<input
value={config()!.onboardingSchemaId}
onInput={(e) => set({ onboardingSchemaId: e.currentTarget.value })}
placeholder="e.g. photographer_onboarding_v1"
/>
</div>
<div class="field">
<label>Enabled Modules <span style="font-weight:400;color:#64748b">(comma separated)</span></label>
<input
value={modulesRaw()}
onInput={(e) => setModulesRaw(e.currentTarget.value)}
placeholder="profile, leads, portfolio, verification, notifications"
/>
<p class="hint">e.g. profile, leads, portfolio</p>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.requiresOnboardingApproval} onChange={(e) => set({ requiresOnboardingApproval: e.currentTarget.checked })} />
Requires onboarding approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.requiresLeadApproval} onChange={(e) => set({ requiresLeadApproval: e.currentTarget.checked })} />
Requires lead approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.requiresJobApproval} onChange={(e) => set({ requiresJobApproval: e.currentTarget.checked })} />
Requires job approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.isActive} onChange={(e) => set({ isActive: e.currentTarget.checked })} />
Active (publish immediately)
</label>
</div>
<div class="actions">
<button class="btn primary" onClick={handleSave} disabled={saving()}>
{saving() ? 'Saving...' : 'Save Changes'}
</button>
</div>
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify({
roleKey: config()!.roleKey,
displayName: config()!.displayName,
vertical: config()!.vertical,
onboardingSchemaId: config()!.onboardingSchemaId,
enabledModules: modulesRaw().split(',').map((s) => s.trim()).filter(Boolean),
requiresOnboardingApproval: config()!.requiresOnboardingApproval,
requiresLeadApproval: config()!.requiresLeadApproval,
requiresJobApproval: config()!.requiresJobApproval,
isActive: config()!.isActive,
}, null, 2)}</pre>
</section>
</div>
<Show when={!data.loading && data()}>
<ExternalRoleForm
initialValue={data()!}
saving={saving()}
submitLabel="Save External Role"
onSubmit={handleSubmit}
/>
</Show>
</AdminShell>
);

View file

@ -1,6 +1,7 @@
import { A, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
const API = '/api/gateway';
@ -57,22 +58,18 @@ export default function RuntimeRolesPage() {
return (
<AdminShell>
<div class="page-actions">
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">External Role Management</h1>
<p class="page-subtitle">Manage canonical external runtime roles, enabled modules, onboarding assignment, and approval gates from one place.</p>
<p class="page-subtitle">Manage canonical external runtime roles, enabled modules, onboarding assignment, approval gates, and default runtime destinations from one place.</p>
</div>
<div class="actions" style="margin-top:0">
<div class="page-actions-right">
<A class="btn" href="/admin/role-ui-configs">Inspector</A>
<A class="btn navy" href="/admin/runtime-roles/new">Create External Role</A>
</div>
</div>
<nav class="admin-link-tabs" aria-label="External Management Navigation">
<A class="admin-link-tab active" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<ExternalRoleTabs />
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
@ -82,7 +79,7 @@ export default function RuntimeRolesPage() {
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e2e8f0">
<div>
<h2 style="margin:0;font-size:17px;font-weight:700">Published External Roles</h2>
<p style="margin:4px 0 0;font-size:12px;color:#64748b">Only canonical external runtime roles are shown here.</p>
<p style="margin:4px 0 0;font-size:12px;color:#64748b">Only canonical external runtime roles are shown here. Legacy or malformed role rows are hidden from this management surface.</p>
</div>
<Show when={!roles.loading}>
<span style="font-size:13px;color:#64748b">{roles()?.length || 0} roles</span>

View file

@ -1,79 +1,69 @@
import { A, useNavigate } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import ExternalRoleForm, { type ExternalRoleConfig } from '~/components/admin/ExternalRoleForm';
import ExternalRoleTabs from '~/components/admin/ExternalRoleTabs';
const API = '/api/gateway';
type Config = {
roleKey: string;
displayName: string;
vertical: 'jobs' | 'marketplace';
onboardingSchemaId: string;
enabledModules: string[];
requiresOnboardingApproval: boolean;
requiresLeadApproval: boolean;
requiresJobApproval: boolean;
isActive: boolean;
};
function defaultConfig(): Config {
function defaultConfig(): ExternalRoleConfig {
return {
roleKey: '',
displayName: '',
vertical: 'marketplace',
onboardingSchemaId: '',
roleCategory: 'provider',
enabledModules: [],
permissions: {},
onboardingSchemaId: 'default_onboarding_v1',
requiresOnboardingApproval: true,
requiresLeadApproval: false,
requiresJobApproval: false,
featureLimits: {},
runtimeConfigVersion: 1,
isActive: true,
};
}
export default function CreateExternalRolePage() {
const navigate = useNavigate();
const [config, setConfig] = createSignal<Config>(defaultConfig());
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [modulesRaw, setModulesRaw] = createSignal('');
const set = (patch: Partial<Config>) => setConfig({ ...config(), ...patch });
const handleSubmit = async () => {
if (!config().roleKey.trim()) { setError('Role Key is required'); return; }
if (!config().displayName.trim()) { setError('Display Name is required'); return; }
const handleSubmit = async (config: ExternalRoleConfig) => {
try {
setSaving(true);
setError('');
const enabledModules = modulesRaw()
.split(',').map((s) => s.trim()).filter(Boolean);
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: config().roleKey.toUpperCase().replace(/\s+/g, '_'),
name: config().displayName,
key: config.roleKey.toUpperCase(),
name: config.displayName,
audience: 'EXTERNAL',
config_json: {
roleKey: config().roleKey,
displayName: config().displayName,
vertical: config().vertical,
onboardingSchemaId: config().onboardingSchemaId,
enabledModules,
requiresOnboardingApproval: config().requiresOnboardingApproval,
requiresLeadApproval: config().requiresLeadApproval,
requiresJobApproval: config().requiresJobApproval,
roleKey: config.roleKey,
displayName: config.displayName,
vertical: config.vertical,
roleCategory: config.roleCategory,
enabledModules: config.enabledModules,
permissions: config.permissions,
onboardingSchemaId: config.onboardingSchemaId,
requiresOnboardingApproval: config.requiresOnboardingApproval,
requiresLeadApproval: config.requiresLeadApproval,
requiresJobApproval: config.requiresJobApproval,
featureLimits: config.featureLimits || {},
version: config.runtimeConfigVersion || 1,
},
is_active: config().isActive,
is_active: config.isActive,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to create external role');
throw new Error(body.message || 'Failed to create runtime role');
}
navigate('/admin/runtime-roles');
} catch (err: any) {
setError(err.message || 'Failed to create external role');
} catch (nextError: any) {
setError(nextError?.message || 'Failed to create runtime role');
} finally {
setSaving(false);
}
@ -84,121 +74,25 @@ export default function CreateExternalRolePage() {
<div class="page-hero-card page-actions">
<div>
<h1 class="page-title">Create Runtime Role</h1>
<p class="page-subtitle">Create an API-backed runtime role for the external runtime shell.</p>
<p class="page-subtitle">
Create an API-backed runtime role for the external runtime shell. This writes through the current runtime roles admin endpoint.
</p>
</div>
<A class="btn" href="/admin/runtime-roles">Back to Runtime Roles</A>
</div>
<nav class="admin-link-tabs" aria-label="External Management Navigation">
<A class="admin-link-tab active" href="/admin/runtime-roles">External Runtime Roles</A>
<A class="admin-link-tab" href="/admin/onboarding-schemas">Onboarding Schemas</A>
<A class="admin-link-tab" href="/admin/roles">Internal Roles</A>
</nav>
<ExternalRoleTabs />
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
<div class="grid">
<section class="card external-role-form">
<h2>Role Builder</h2>
<div class="field">
<label>Role Key <span style="color:#ef4444">*</span></label>
<input
value={config().roleKey}
onInput={(e) => set({ roleKey: e.currentTarget.value.toUpperCase().replace(/\s+/g, '_') })}
placeholder="e.g. PHOTOGRAPHER"
/>
<p class="hint">Uppercase, underscores only. e.g. MAKEUP_ARTIST</p>
</div>
<div class="field">
<label>Display Name <span style="color:#ef4444">*</span></label>
<input
value={config().displayName}
onInput={(e) => set({ displayName: e.currentTarget.value })}
placeholder="e.g. Photographer"
/>
</div>
<div class="field">
<label>Vertical</label>
<select value={config().vertical} onChange={(e) => set({ vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="marketplace">Marketplace</option>
<option value="jobs">Jobs</option>
</select>
</div>
<div class="field">
<label>Onboarding Schema ID</label>
<input
value={config().onboardingSchemaId}
onInput={(e) => set({ onboardingSchemaId: e.currentTarget.value })}
placeholder="e.g. photographer_onboarding_v1"
/>
</div>
<div class="field">
<label>Enabled Modules <span style="font-weight:400;color:#64748b">(comma separated)</span></label>
<input
value={modulesRaw()}
onInput={(e) => setModulesRaw(e.currentTarget.value)}
placeholder="profile, leads, portfolio, verification, notifications"
/>
<p class="hint">e.g. profile, leads, portfolio</p>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().requiresOnboardingApproval} onChange={(e) => set({ requiresOnboardingApproval: e.currentTarget.checked })} />
Requires onboarding approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().requiresLeadApproval} onChange={(e) => set({ requiresLeadApproval: e.currentTarget.checked })} />
Requires lead approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().requiresJobApproval} onChange={(e) => set({ requiresJobApproval: e.currentTarget.checked })} />
Requires job approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().isActive} onChange={(e) => set({ isActive: e.currentTarget.checked })} />
Active (publish immediately)
</label>
</div>
<div class="actions">
<button class="btn primary" onClick={handleSubmit} disabled={saving()}>
{saving() ? 'Creating...' : 'Create Runtime Role'}
</button>
</div>
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify({
roleKey: config().roleKey,
displayName: config().displayName,
vertical: config().vertical,
onboardingSchemaId: config().onboardingSchemaId,
enabledModules: modulesRaw().split(',').map((s) => s.trim()).filter(Boolean),
requiresOnboardingApproval: config().requiresOnboardingApproval,
requiresLeadApproval: config().requiresLeadApproval,
requiresJobApproval: config().requiresJobApproval,
isActive: config().isActive,
}, null, 2)}</pre>
</section>
</div>
<ExternalRoleForm
initialValue={defaultConfig()}
saving={saving()}
submitLabel="Create Runtime Role"
onSubmit={handleSubmit}
/>
</AdminShell>
);
}