From f4b9885974653721a8ca869eaaae4f5d0419b0a7 Mon Sep 17 00:00:00 2001
From: Ashwin Kumar Sivakumar
Date: Tue, 28 Jul 2026 20:10:41 +0530
Subject: [PATCH] Add schema-driven verification wizard, replacing per-role
hardcoding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/components/DashboardShell.tsx | 5 +
src/components/dashboard/ProfilePage.tsx | 79 ++++-
src/components/dashboard/RoleWizard.tsx | 351 +++++++++++++++++++++++
src/lib/runtime/storage.ts | 30 ++
src/lib/runtime/types.ts | 16 ++
src/routes/dashboard.tsx | 32 ++-
6 files changed, 496 insertions(+), 17 deletions(-)
create mode 100644 src/components/dashboard/RoleWizard.tsx
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 (