Add schema-driven verification wizard, replacing per-role hardcoding
All checks were successful
build-and-release / build (push) Successful in 1m42s

Generalizes the COMPANY-only 3-step wizard into RoleWizard, a component
driven entirely by the role's active onboarding_configs schema (fetched
via fetchOnboardingSchemaForRole) rather than hardcoded field/step
lists. Which roles get the wizard, and when, is now a runtime toggle
(enableWizardFlow) set through the new admin schema editor:

- ProfilePage shows the wizard while unverified/sent-back-for-fixes,
  falls back to the existing free-form tabs once approved, and greys
  out (both client- and server-side) any field the schema marks
  lockAfterApproval once approved — previously isLocked() only covered
  the in-review window and unlocked everything again at APPROVED.
- dashboard.tsx hides My Profile/My Portfolio from the sidebar
  pre-approval only for roles that have the wizard enabled (roles not
  yet rolled out keep direct access, so they aren't left with no way to
  complete their profile), and shows a checkmark on the Verification
  nav item once approved.

Rolling out to more roles is now purely an admin-panel config change —
no further code needed, since RoleWizard has no role-specific branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-28 20:10:41 +05:30
parent 5fad33634a
commit f4b9885974
6 changed files with 496 additions and 17 deletions

View file

@ -87,6 +87,8 @@ interface Props {
roleKey: string;
userName?: string;
isAdmin?: boolean;
/** Shows a checkmark on the Verification nav item once the profile is approved. */
verificationApproved?: boolean;
children: JSX.Element;
}
@ -196,6 +198,9 @@ export default function DashboardShell(props: Props) {
<SidebarIcon label={item} />
</span>
{titleCase(item)}
<Show when={props.verificationApproved && item.toLowerCase() === "verification"}>
<span style={{ "margin-left": "auto", color: "#10B981", "font-weight": "700" }}></span>
</Show>
</button>
);
}}

View file

@ -28,6 +28,8 @@ import {
type BasicField,
type DocField,
} from "~/lib/profile-fields-config";
import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage";
import RoleWizard from "~/components/dashboard/RoleWizard";
// The K8s ingress routes /api/* directly to the Rust gateway service in
// production — there is no /api/gateway rewrite layer, so paths below are
@ -566,8 +568,32 @@ export default function ProfilePage(props: Props) {
const [wizardSubmitMsg, setWizardSubmitMsg] = createSignal("");
const [wizardSubmitSuccess, setWizardSubmitSuccess] = createSignal(false);
// ── Runtime onboarding config: wizard-vs-tabs + per-field post-approval lock ──
// Admin-configurable via the Onboarding Schema Editor (onboarding_configs
// table) — nothing here is hardcoded per role.
const [wizardEnabled, setWizardEnabled] = createSignal(false);
const [lockedFieldIds, setLockedFieldIds] = createSignal<Set<string>>(new Set());
const isCompany = () => props.roleKey === "COMPANY";
const rolePrefix = () =>
props.roleKey === "JOB_SEEKER"
? "jobseeker"
: props.roleKey === "COMPANY"
? "companies"
: props.roleKey.toLowerCase().replace(/_/g, "-") + "s";
// Statuses during which the guided wizard (rather than free-form tabs)
// should be shown: first-time setup, or the admin sent it back for fixes.
const WIZARD_STATUSES = ["NOT_SUBMITTED", "DOCUMENTS_REQUESTED", "REVISION_REQUESTED", "REJECTED"];
const showWizard = () => wizardEnabled() && WIZARD_STATUSES.includes(verificationStatus());
// A field is permanently locked once the profile has ever been approved,
// if the active onboarding schema marked it lockAfterApproval — enforced
// server-side too (apps/users/src/handlers/profile.rs), this just keeps
// the UI honest about it.
const fieldLocked = (key: string) => isLocked() || (verificationStatus() === "APPROVED" && lockedFieldIds().has(key));
const requiresPortfolio = () => props.roleKey === "JOB_SEEKER" || Boolean(PORTFOLIO_PREFIX[props.roleKey]);
const refreshPortfolioSubmission = async (): Promise<string[]> => {
@ -651,6 +677,18 @@ export default function ProfilePage(props: Props) {
// Load saved profile + verification status on mount
onMount(async () => {
void fetchOnboardingSchemaForRole(props.roleKey).then((cfg) => {
if (!cfg) return;
setWizardEnabled(Boolean(cfg.enableWizardFlow));
const locked = new Set<string>();
for (const step of cfg.steps) {
for (const field of step.fields) {
if (field.lockAfterApproval) locked.add(field.id);
}
}
setLockedFieldIds(locked);
});
const [profileRes, statusRes] = await Promise.all([
apiFetch(`/api/profile?roleKey=${props.roleKey}`),
apiFetch(`/api/me/verification-status?roleKey=${props.roleKey}`),
@ -956,7 +994,7 @@ export default function ProfilePage(props: Props) {
return (
<div style={{ "max-width": "760px" }}>
<Show when={!isCompany()} fallback={<CompanyWizard
<Show when={!(isCompany() && showWizard())} fallback={<CompanyWizard
wizardStep={wizardStep}
setWizardStep={setWizardStep}
wizardDocs={wizardDocs}
@ -981,6 +1019,19 @@ export default function ProfilePage(props: Props) {
setWizardSubmitSuccess(false);
}}
/>}>
<Show
when={!showWizard()}
fallback={
<RoleWizard
roleKey={props.roleKey}
rolePrefix={rolePrefix()}
onSubmitted={(status) => {
setVerificationStatus(status);
props.onVerificationStatusChange?.(status);
}}
/>
}
>
<Show when={submitMsg()}>
<div
style={{
@ -1095,7 +1146,7 @@ export default function ProfilePage(props: Props) {
<Match when={field.type === "textarea"}>
<textarea
rows={3}
disabled={isLocked()}
disabled={fieldLocked(field.key)}
value={form()[field.key] ?? ""}
onInput={(e) => setField(field.key, e.currentTarget.value)}
style={{
@ -1103,16 +1154,16 @@ export default function ProfilePage(props: Props) {
height: "auto",
padding: "10px 12px",
resize: "vertical",
opacity: isLocked() ? "0.6" : "1",
opacity: fieldLocked(field.key) ? "0.6" : "1",
}}
/>
</Match>
<Match when={field.type === "select"}>
<select
disabled={isLocked()}
disabled={fieldLocked(field.key)}
value={form()[field.key] ?? ""}
onChange={(e) => setField(field.key, e.currentTarget.value)}
style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
style={{ ...INPUT, opacity: fieldLocked(field.key) ? "0.6" : "1" }}
>
<option value="">Select</option>
<For each={field.options ?? []}>
@ -1123,14 +1174,19 @@ export default function ProfilePage(props: Props) {
<Match when={true}>
<input
type={field.type ?? "text"}
disabled={isLocked()}
disabled={fieldLocked(field.key)}
value={form()[field.key] ?? ""}
onInput={(e) => setField(field.key, e.currentTarget.value)}
style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
style={{ ...INPUT, opacity: fieldLocked(field.key) ? "0.6" : "1" }}
/>
</Match>
</Switch>
{fieldNote(field.key, field.label, !!field.required, field.type)}
<Show when={verificationStatus() === "APPROVED" && lockedFieldIds().has(field.key)}>
<p style={{ margin: "4px 0 0", "font-size": "11px", color: "#9CA3AF" }}>
Locked after verification.
</p>
</Show>
</div>
)}
</For>
@ -1189,7 +1245,7 @@ export default function ProfilePage(props: Props) {
type="file"
id={`file-${doc.key}`}
style={{ display: "none" }}
disabled={isLocked()}
disabled={fieldLocked(doc.key)}
onChange={async (e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
@ -1218,8 +1274,8 @@ export default function ProfilePage(props: Props) {
display: "inline-flex",
"align-items": "center",
"line-height": "1",
opacity: isLocked() ? "0.5" : "1",
cursor: isLocked() ? "not-allowed" : "pointer",
opacity: fieldLocked(doc.key) ? "0.5" : "1",
cursor: fieldLocked(doc.key) ? "not-allowed" : "pointer",
}}
>
Choose File
@ -1243,7 +1299,7 @@ export default function ProfilePage(props: Props) {
>
Uploaded
</span>
<Show when={!isLocked()}>
<Show when={!fieldLocked(doc.key)}>
<button
type="button"
onClick={() => {
@ -1348,6 +1404,7 @@ export default function ProfilePage(props: Props) {
</div>
</Show>
</Show>
</Show>
</div>
);
}

View file

@ -0,0 +1,351 @@
/**
* RoleWizard schema-driven verification wizard, generalized from the
* COMPANY-only 3-step wizard that used to live inline in ProfilePage.tsx.
* Steps/fields/lock-after-approval flags come from the role's active
* onboarding_configs schema (admin-editable via the Onboarding Schema Editor),
* fetched through fetchOnboardingSchemaForRole() nothing here is
* per-role-hardcoded.
*
* COMPANY keeps using the separate CompanyWizard component (still in
* ProfilePage.tsx) since its documents step submits via a dedicated bulk
* multipart endpoint (submitCompanyProfileWithDocuments) rather than the
* per-file immediate-upload flow every other role's document endpoints use.
*/
import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";
import { CARD, BTN_GHOST, INPUT, LABEL, BTN_PRIMARY } from "~/components/DashboardShell";
import { request, uploadDocument } from "~/lib/api";
import { updateJobSeekerCustomData } from "~/lib/job-seeker-custom-data";
import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage";
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeOnboardingStep } from "~/lib/runtime/types";
interface Props {
roleKey: string;
rolePrefix: string; // e.g. "jobseeker", "photographers" — matches the role's document-upload API path
onSubmitted: (status: string) => void;
}
function fieldRequired(field: RuntimeOnboardingField): boolean {
return Boolean(field.required);
}
export default function RoleWizard(props: Props) {
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
const [loading, setLoading] = createSignal(true);
const [wizardStep, setWizardStep] = createSignal(0);
const [form, setForm] = createSignal<Record<string, string>>({});
const [docUrls, setDocUrls] = createSignal<Record<string, string>>({});
const [docErrors, setDocErrors] = createSignal<Record<string, string>>({});
const [docUploading, setDocUploading] = createSignal<Record<string, boolean>>({});
const [portfolioForm, setPortfolioForm] = createSignal<Record<string, string>>({});
const [submitting, setSubmitting] = createSignal(false);
const [submitMsg, setSubmitMsg] = createSignal("");
const [submitSuccess, setSubmitSuccess] = createSignal(false);
onMount(async () => {
const cfg = await fetchOnboardingSchemaForRole(props.roleKey);
setConfig(cfg);
setLoading(false);
});
const steps = createMemo<RuntimeOnboardingStep[]>(() => config()?.steps ?? []);
const currentStep = createMemo(() => steps()[wizardStep()]);
const setField = (key: string, val: string) => setForm((prev) => ({ ...prev, [key]: val }));
const setPortfolioField = (key: string, val: string) => setPortfolioForm((prev) => ({ ...prev, [key]: val }));
const stepIsComplete = (step: RuntimeOnboardingStep | undefined): boolean => {
if (!step) return true;
if (step.type === "documents") {
return step.fields.filter(fieldRequired).every((f) => Boolean(docUrls()[f.id]));
}
if (step.type === "portfolio") {
return step.fields.filter(fieldRequired).every((f) => String(portfolioForm()[f.id] ?? "").trim().length > 0);
}
if (step.type === "review") return true;
// basic
return step.fields.filter(fieldRequired).every((f) => String(form()[f.id] ?? "").trim().length > 0);
};
const canAdvance = createMemo(() => stepIsComplete(currentStep()));
const handleFileSelect = async (field: RuntimeOnboardingField, file: File) => {
setDocUploading((prev) => ({ ...prev, [field.id]: true }));
setDocErrors((prev) => ({ ...prev, [field.id]: "" }));
try {
const result = await uploadDocument(props.rolePrefix, file, field.id);
const url = result?.url ?? result?.file_url ?? result?.path ?? result?.file_name ?? "uploaded";
setDocUrls((prev) => ({ ...prev, [field.id]: url }));
} catch (err: any) {
setDocErrors((prev) => ({ ...prev, [field.id]: err?.message ?? "Upload failed" }));
} finally {
setDocUploading((prev) => ({ ...prev, [field.id]: false }));
}
};
const savePortfolio = async () => {
const model = config()?.portfolioModel;
if (!model || model === "none") return;
if (model === "custom_data") {
await updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: portfolioForm() }));
} else if (model === "professional") {
await request("/api/profile", {
method: "PATCH",
body: { roleKey: props.roleKey, profile_data: portfolioForm() },
}).catch(() => undefined);
}
};
const handleSubmit = async () => {
setSubmitting(true);
setSubmitMsg("");
try {
await savePortfolio();
const profile_data = { ...form(), ...docUrls() };
await request("/api/profile", { method: "PATCH", body: { roleKey: props.roleKey, profile_data } });
const { status } = await request("/api/profile/submit-for-verification", {
method: "POST",
body: { roleKey: props.roleKey, profile_data },
});
if (status === 200 || status === 201) {
setSubmitSuccess(true);
props.onSubmitted("PENDING");
}
} catch (err: any) {
setSubmitMsg(err?.message || "Submission failed. Please try again.");
} finally {
setSubmitting(false);
}
};
const fieldNote = (field: RuntimeOnboardingField) => null;
return (
<Show when={!loading()} fallback={<p style={{ "font-size": "13px", color: "#6B7280" }}>Loading...</p>}>
<Show
when={config() && steps().length > 0}
fallback={
<p style={{ "font-size": "13px", color: "#6B7280" }}>
No verification wizard is configured for this role yet.
</p>
}
>
<div>
<Show when={submitSuccess()}>
<div style={{ ...CARD, "margin-bottom": "16px", padding: "24px", background: "#ECFDF5", border: "1px solid #6EE7B7", "text-align": "center" }}>
<div style={{ "font-size": "36px", "margin-bottom": "8px" }}></div>
<h3 style={{ margin: "0 0 8px", "font-size": "18px", color: "#065F46" }}>Submitted!</h3>
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#065F46" }}>
We'll review your profile and notify you once it's checked.
</p>
</div>
</Show>
<Show when={!submitSuccess()}>
{/* ── Stepper ─────────────────────────────────────────────── */}
<div style={{ display: "flex", "align-items": "center", gap: "8px", "margin-bottom": "24px" }}>
<For each={steps()}>
{(step, i) => (
<>
<div
style={{
width: "32px", height: "32px", "border-radius": "50%",
display: "flex", "align-items": "center", "justify-content": "center",
"font-weight": "700", "font-size": "14px",
background: wizardStep() === i() ? "#FF5E13" : wizardStep() > i() ? "#FFF" : "#F3F4F6",
color: wizardStep() === i() ? "#FFF" : wizardStep() > i() ? "#FF5E13" : "#9CA3AF",
border: wizardStep() >= i() ? "2px solid #FF5E13" : "2px solid #E5E7EB",
}}
>
{wizardStep() > i() ? "✓" : i() + 1}
</div>
<Show when={i() < steps().length - 1}>
<div style={{ flex: 1, height: "2px", background: wizardStep() > i() ? "#FF5E13" : "#E5E7EB" }} />
</Show>
</>
)}
</For>
</div>
<p style={{ "font-size": "13px", color: "#6B7280", "margin-bottom": "16px" }}>
Step {wizardStep() + 1} of {steps().length} {currentStep()?.title}
</p>
<div style={CARD}>
{/* ── basic ─────────────────────────────────────────────── */}
<Show when={currentStep()?.type === "basic" || !currentStep()?.type}>
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "10px" }}>
<For each={currentStep()?.fields ?? []}>
{(field) => (
<div style={{ "grid-column": field.type === "textarea" ? "span 2" : "span 1" }}>
<label style={LABEL}>
{field.label}
<Show when={field.required}><span style={{ color: "#EF4444" }}> *</span></Show>
</label>
<Switch>
<Match when={field.type === "textarea"}>
<textarea
rows={3}
value={form()[field.id] ?? ""}
onInput={(e) => setField(field.id, e.currentTarget.value)}
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
/>
</Match>
<Match when={field.type === "select"}>
<select value={form()[field.id] ?? ""} onChange={(e) => setField(field.id, e.currentTarget.value)} style={{ ...INPUT }}>
<option value="">Select</option>
<For each={field.options ?? []}>{(opt) => <option value={opt.value}>{opt.label}</option>}</For>
</select>
</Match>
<Match when={true}>
<input
type={field.type ?? "text"}
value={form()[field.id] ?? ""}
onInput={(e) => setField(field.id, e.currentTarget.value)}
style={{ ...INPUT }}
/>
</Match>
</Switch>
{fieldNote(field)}
</div>
)}
</For>
</div>
</Show>
{/* ── documents ─────────────────────────────────────────── */}
<Show when={currentStep()?.type === "documents"}>
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
<For each={currentStep()?.fields ?? []}>
{(field) => (
<div style={{ border: "1px dashed #E5E7EB", "border-radius": "10px", padding: "16px" }}>
<p style={{ margin: "0", "font-size": "13px", "font-weight": "700", color: "#111827" }}>
{field.label}
<Show when={field.required}><span style={{ color: "#EF4444" }}> *</span></Show>
</p>
<Show when={field.helperText}>
<p style={{ margin: "2px 0 10px", "font-size": "11px", color: "#9CA3AF" }}>{field.helperText}</p>
</Show>
<Show
when={docUrls()[field.id]}
fallback={
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<input
type="file"
id={`rw-file-${field.id}`}
style={{ display: "none" }}
disabled={docUploading()[field.id]}
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void handleFileSelect(field, file);
e.currentTarget.value = "";
}}
/>
<label for={`rw-file-${field.id}`} style={{ ...BTN_GHOST, display: "inline-flex", "align-items": "center", "line-height": "1", cursor: "pointer" }}>
{docUploading()[field.id] ? "Uploading…" : "Choose File"}
</label>
<span style={{ "font-size": "12px", color: "#9CA3AF" }}>No file chosen</span>
</div>
}
>
<span style={{ "font-size": "12px", "font-weight": "600", color: "#10B981", background: "#ECFDF5", padding: "4px 10px", "border-radius": "6px" }}>
Uploaded
</span>
</Show>
<Show when={docErrors()[field.id]}>
<p style={{ margin: "4px 0 0", "font-size": "11px", color: "#EF4444" }}>{docErrors()[field.id]}</p>
</Show>
</div>
)}
</For>
</div>
</Show>
{/* ── portfolio ─────────────────────────────────────────── */}
<Show when={currentStep()?.type === "portfolio"}>
<div style={{ display: "grid", gap: "10px" }}>
<For each={currentStep()?.fields ?? []}>
{(field) => (
<div>
<label style={LABEL}>
{field.label}
<Show when={field.required}><span style={{ color: "#EF4444" }}> *</span></Show>
</label>
<textarea
rows={3}
value={portfolioForm()[field.id] ?? ""}
onInput={(e) => setPortfolioField(field.id, e.currentTarget.value)}
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
/>
</div>
)}
</For>
</div>
</Show>
{/* ── review ────────────────────────────────────────────── */}
<Show when={currentStep()?.type === "review"}>
<div style={{ padding: "16px", background: "#F9FAFB", "border-radius": "8px" }}>
<For each={steps().filter((s) => s.type !== "review")}>
{(step) => (
<div style={{ "margin-bottom": "12px" }}>
<p style={{ margin: "0 0 4px", "font-size": "12px", "font-weight": "700", color: "#111827" }}>{step.title}</p>
<For each={step.fields}>
{(field) => {
const value = step.type === "documents"
? (docUrls()[field.id] ? "Uploaded" : "—")
: step.type === "portfolio"
? (portfolioForm()[field.id] || "—")
: (form()[field.id] || "—");
return (
<p style={{ margin: "2px 0", "font-size": "12px", color: "#374151" }}>
<strong>{field.label}:</strong> {value}
</p>
);
}}
</For>
</div>
)}
</For>
</div>
</Show>
{/* ── nav buttons ───────────────────────────────────────── */}
<div style={{ display: "flex", "justify-content": "space-between", "margin-top": "24px" }}>
<button
type="button"
onClick={() => setWizardStep((s) => s - 1)}
disabled={wizardStep() === 0}
style={{ ...BTN_GHOST, opacity: wizardStep() === 0 ? 0.5 : 1 }}
>
Back
</button>
<Show
when={wizardStep() < steps().length - 1}
fallback={
<button type="button" onClick={handleSubmit} disabled={submitting()} style={{ ...BTN_PRIMARY, opacity: submitting() ? 0.7 : 1 }}>
{submitting() ? "Submitting..." : "Submit for Verification"}
</button>
}
>
<button
type="button"
onClick={() => setWizardStep((s) => s + 1)}
disabled={!canAdvance()}
style={{ ...BTN_PRIMARY, opacity: canAdvance() ? 1 : 0.5 }}
>
Next
</button>
</Show>
</div>
<Show when={submitMsg()}>
<div style={{ "margin-top": "12px", padding: "10px 14px", background: "#FEF2F2", border: "1px solid #FECACA", color: "#B91C1C", "font-size": "13px", "font-weight": "600", "border-radius": "6px" }}>
{submitMsg()}
</div>
</Show>
</div>
</Show>
</div>
</Show>
</Show>
);
}

View file

@ -83,6 +83,36 @@ export function getRuntimeOnboardingSchema(input: { schemaId?: string; roleKey?:
return null;
}
/**
* Fetches a role's active verification-wizard schema from the backend
* (GET /api/config/onboarding/by-key/{roleKey}, public, admin-authored via
* the Onboarding Schema Editor). Falls back to the localStorage-based
* getRuntimeOnboardingSchema() only if the request fails, so the wizard can
* still render something during a transient network error.
*/
export async function fetchOnboardingSchemaForRole(roleKey: string): Promise<RuntimeOnboardingConfig | null> {
try {
const res = await fetch(`/api/config/onboarding/by-key/${encodeURIComponent(roleKey.toUpperCase())}`, {
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!res.ok) return getRuntimeOnboardingSchema({ roleKey });
const data = await res.json();
const schemaJson = data?.schema_json;
if (!schemaJson || !Array.isArray(schemaJson.steps)) return getRuntimeOnboardingSchema({ roleKey });
return {
schemaId: String(data?.id ?? `${roleKey}_v${data?.version ?? 1}`),
roleKey: roleKey.toUpperCase(),
version: typeof data?.version === 'number' ? data.version : 1,
steps: schemaJson.steps,
portfolioModel: schemaJson.portfolioModel,
enableWizardFlow: Boolean(schemaJson.enableWizardFlow),
};
} catch {
return getRuntimeOnboardingSchema({ roleKey });
}
}
export function saveOnboardingSubmission(payload: Omit<OnboardingSubmission, 'id' | 'submittedAt'>): OnboardingSubmission {
const next: OnboardingSubmission = {
id: crypto.randomUUID(),

View file

@ -40,6 +40,12 @@ export type RuntimeOnboardingField = {
helperText?: string;
defaultValue?: string | number | boolean | string[];
readOnly?: boolean;
/**
* If true, this field (or document) becomes permanently read-only once the
* profile's verification status reaches APPROVED enforced server-side in
* apps/users/src/handlers/profile.rs's save_profile, not just in this UI.
*/
lockAfterApproval?: boolean;
multiple?: boolean;
options?: RuntimeOption[];
accept?: string;
@ -49,19 +55,29 @@ export type RuntimeOnboardingField = {
visibleWhen?: RuntimeVisibilityCondition[];
};
export type RuntimeStepType = 'basic' | 'documents' | 'portfolio' | 'review';
export type RuntimeOnboardingStep = {
id: string;
title: string;
description?: string;
/** How the wizard should render this step; unrecognized/missing defaults to "basic". */
type?: RuntimeStepType;
visibleWhen?: RuntimeVisibilityCondition[];
fields: RuntimeOnboardingField[];
};
export type RuntimePortfolioModel = 'none' | 'custom_data' | 'professional';
export type RuntimeOnboardingConfig = {
schemaId: string;
roleKey: string;
version: number;
steps: RuntimeOnboardingStep[];
/** Which persistence model the portfolio step (if any) should save into. */
portfolioModel?: RuntimePortfolioModel;
/** Admin-configurable per-role toggle: render the wizard flow, or fall back to legacy tabs. */
enableWizardFlow?: boolean;
};
export type UploadedFileMeta = {

View file

@ -10,6 +10,7 @@ import {
} from "solid-js";
import { useNavigate } from "@solidjs/router";
import { useAuth, RequireAuth, getToken } from "~/lib/auth";
import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage";
import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview";
import DashboardShell from "~/components/DashboardShell";
import ProfilePage from "~/components/dashboard/ProfilePage";
@ -560,7 +561,8 @@ function mergeSidebar(
_role: RoleKey,
runtimeSidebar: string[],
userRoles: string[] = [],
verificationStatus?: string
verificationStatus?: string,
wizardEnabled = false
): string[] {
const base = ROLE_BASED_SIDEBAR[_role] || [];
const fromRuntime = runtimeSidebar.map((item) => String(item || "").trim()).filter(Boolean);
@ -580,12 +582,19 @@ function mergeSidebar(
const status = String(verificationStatus || "").toUpperCase();
const approved = status === "APPROVED";
if (!approved && status) {
// Once a role's verification wizard is enabled (admin-configurable, see
// OnboardingSchemaEditor), My Profile / My Portfolio move inside the
// wizard itself and stay hidden from the sidebar until approved — the
// wizard is the only way to complete them pre-approval. Roles that
// haven't been rolled out to the wizard yet keep the old behavior
// (profile/portfolio remain directly accessible) so they aren't left
// with no way to fill in their profile at all.
const restricted = new Set([
"my profile",
"help center",
"settings",
"verification",
...(PROFESSIONAL_ROLE_SET.has(_role) || _role === "JOB_SEEKER"
...(wizardEnabled ? [] : ["my profile"]),
...(!wizardEnabled && (PROFESSIONAL_ROLE_SET.has(_role) || _role === "JOB_SEEKER")
? ["my portfolio", "credits"]
: []),
]);
@ -742,6 +751,10 @@ export default function RuntimeDashboardPage() {
});
const [bundle] = createResource(() => role(), loadRoleBundle);
const [wizardEnabledForRole] = createResource(
() => role(),
async (r) => Boolean((await fetchOnboardingSchemaForRole(r))?.enableWizardFlow)
);
const activeSidebarKey = createMemo(() => normalizeSidebarKey(activeSidebar()));
createEffect(() => {
@ -754,7 +767,13 @@ export default function RuntimeDashboardPage() {
);
const sidebarItems = createMemo(() =>
mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.userRoles || [], effectiveVerificationStatus())
mergeSidebar(
role(),
bundle()?.sidebarItems || [],
bundle()?.userRoles || [],
effectiveVerificationStatus(),
wizardEnabledForRole() ?? false
)
);
createEffect(() => {
@ -833,6 +852,7 @@ export default function RuntimeDashboardPage() {
roleKey={role()}
userName={userName()}
isAdmin={isAdmin()}
verificationApproved={String(effectiveVerificationStatus() || '').toUpperCase() === 'APPROVED'}
>
<Switch>
<Match when={activeSidebarKey() === "my dashboard"}>