feat(admin-builders): align role and onboarding management flows with next tabs/forms
This commit is contained in:
parent
931a1db71b
commit
54cd1a156c
10 changed files with 688 additions and 417 deletions
377
src/components/admin/ExternalRoleForm.tsx
Normal file
377
src/components/admin/ExternalRoleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/admin/ExternalRoleTabs.tsx
Normal file
25
src/components/admin/ExternalRoleTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/admin/OnboardingManagementTabs.tsx
Normal file
18
src/components/admin/OnboardingManagementTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue