diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx
index cffdfbf..683f512 100644
--- a/src/components/DashboardShell.tsx
+++ b/src/components/DashboardShell.tsx
@@ -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) {
{titleCase(item)}
+
+ ✓
+
);
}}
diff --git a/src/components/dashboard/ProfilePage.tsx b/src/components/dashboard/ProfilePage.tsx
index 1cb50c1..110a388 100644
--- a/src/components/dashboard/ProfilePage.tsx
+++ b/src/components/dashboard/ProfilePage.tsx
@@ -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>(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 => {
@@ -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();
+ 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 (
-
}>
+
{
+ setVerificationStatus(status);
+ props.onVerificationStatusChange?.(status);
+ }}
+ />
+ }
+ >
)}
@@ -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
-
+
+
);
}
diff --git a/src/components/dashboard/RoleWizard.tsx b/src/components/dashboard/RoleWizard.tsx
new file mode 100644
index 0000000..4911612
--- /dev/null
+++ b/src/components/dashboard/RoleWizard.tsx
@@ -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(null);
+ const [loading, setLoading] = createSignal(true);
+ const [wizardStep, setWizardStep] = createSignal(0);
+ const [form, setForm] = createSignal>({});
+ const [docUrls, setDocUrls] = createSignal>({});
+ const [docErrors, setDocErrors] = createSignal>({});
+ const [docUploading, setDocUploading] = createSignal>({});
+ const [portfolioForm, setPortfolioForm] = createSignal>({});
+ 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(() => 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 (
+ Loading...
}>
+ 0}
+ fallback={
+
+ No verification wizard is configured for this role yet.
+
+ }
+ >
+
+
+
+
✓
+
Submitted!
+
+ We'll review your profile and notify you once it's checked.
+
+
+
+
+
+ {/* ── Stepper ─────────────────────────────────────────────── */}
+
+
+ {(step, i) => (
+ <>
+ i() ? "#FFF" : "#F3F4F6",
+ color: wizardStep() === i() ? "#FFF" : wizardStep() > i() ? "#FF5E13" : "#9CA3AF",
+ border: wizardStep() >= i() ? "2px solid #FF5E13" : "2px solid #E5E7EB",
+ }}
+ >
+ {wizardStep() > i() ? "✓" : i() + 1}
+
+
+ i() ? "#FF5E13" : "#E5E7EB" }} />
+
+ >
+ )}
+
+
+
+ Step {wizardStep() + 1} of {steps().length} — {currentStep()?.title}
+
+
+
+ {/* ── basic ─────────────────────────────────────────────── */}
+
+
+
+ {(field) => (
+
+
+
+
+
+
+
+
+
+ setField(field.id, e.currentTarget.value)}
+ style={{ ...INPUT }}
+ />
+
+
+ {fieldNote(field)}
+
+ )}
+
+
+
+
+ {/* ── documents ─────────────────────────────────────────── */}
+
+
+
+ {(field) => (
+
+
+ {field.label}
+ *
+
+
+ {field.helperText}
+
+
+ {
+ const file = e.currentTarget.files?.[0];
+ if (file) void handleFileSelect(field, file);
+ e.currentTarget.value = "";
+ }}
+ />
+
+ No file chosen
+
+ }
+ >
+
+ ✓ Uploaded
+
+
+
+ {docErrors()[field.id]}
+
+
+ )}
+
+
+
+
+ {/* ── portfolio ─────────────────────────────────────────── */}
+
+
+
+ {(field) => (
+
+
+
+ )}
+
+
+
+
+ {/* ── review ────────────────────────────────────────────── */}
+
+
+
s.type !== "review")}>
+ {(step) => (
+
+
{step.title}
+
+ {(field) => {
+ const value = step.type === "documents"
+ ? (docUrls()[field.id] ? "Uploaded" : "—")
+ : step.type === "portfolio"
+ ? (portfolioForm()[field.id] || "—")
+ : (form()[field.id] || "—");
+ return (
+
+ {field.label}: {value}
+
+ );
+ }}
+
+
+ )}
+
+
+
+
+ {/* ── nav buttons ───────────────────────────────────────── */}
+
+
+
+ {submitting() ? "Submitting..." : "Submit for Verification"}
+
+ }
+ >
+
+
+
+
+
+
+ {submitMsg()}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/runtime/storage.ts b/src/lib/runtime/storage.ts
index a61d335..3b18ad6 100644
--- a/src/lib/runtime/storage.ts
+++ b/src/lib/runtime/storage.ts
@@ -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 {
+ 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 {
const next: OnboardingSubmission = {
id: crypto.randomUUID(),
diff --git a/src/lib/runtime/types.ts b/src/lib/runtime/types.ts
index 4a84c95..8b3cf83 100644
--- a/src/lib/runtime/types.ts
+++ b/src/lib/runtime/types.ts
@@ -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 = {
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx
index b8aaec3..a5e1764 100644
--- a/src/routes/dashboard.tsx
+++ b/src/routes/dashboard.tsx
@@ -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,14 +582,21 @@ 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"
- ? ["my portfolio", "credits"]
- : []),
+ ...(wizardEnabled ? [] : ["my profile"]),
+ ...(!wizardEnabled && (PROFESSIONAL_ROLE_SET.has(_role) || _role === "JOB_SEEKER")
+ ? ["my portfolio", "credits"]
+ : []),
]);
merged = merged.filter((item) => restricted.has(item.trim().toLowerCase()));
}
@@ -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'}
>