diff --git a/.gitignore b/.gitignore
index 4d3d5ead..111494f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,6 +71,7 @@ src-tauri/gen/apple/
.cache/
.yalc
.yalc.lock
+yalc.lock
# signing artifacts (never commit provisioning profiles)
*.provisionprofile
diff --git a/app/globals.css b/app/globals.css
index c2a20944..afe7d964 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,4 +1,5 @@
@import 'tailwindcss';
+@source "../node_modules/@iblai/web-containers/dist";
@import 'tw-animate-css';
@tailwind utilities;
diff --git a/app/onboarding/__tests__/page.test.tsx b/app/onboarding/__tests__/page.test.tsx
new file mode 100644
index 00000000..77f03fcb
--- /dev/null
+++ b/app/onboarding/__tests__/page.test.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+const { state, push } = vi.hoisted(() => ({
+ state: { tenantKey: 'acme' },
+ push: vi.fn(),
+}));
+
+vi.mock('@/hooks/use-onboarding', () => ({
+ useOnboardingAccess: () => ({ canAccess: true, tenantKey: state.tenantKey }),
+}));
+
+vi.mock('@/hooks/use-user', () => ({ useUsername: () => 'tester' }));
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }));
+
+vi.mock('@/lib/initial-loader', () => ({ hideInitialLoader: vi.fn() }));
+vi.mock('@/components/spinner', () => ({ Spinner: () =>
spinner
}));
+
+// The OS-local final screen; stub it so the page test doesn't pull the data layer.
+vi.mock('@/components/onboarding/onboarding-create-agent-step', () => ({
+ OnboardingCreateAgentStep: () => create-agent-step
,
+}));
+
+// The route renders the SDK wizard from @iblai/iblai-js/web-containers; stub it,
+// expose onComplete, and render whatever final step the page passes. The wizard
+// persists the answers itself (default), so the page no longer wires that.
+vi.mock('@iblai/iblai-js/web-containers', () => ({
+ OnboardingWizard: ({
+ tenant,
+ username,
+ onComplete,
+ renderFinalStep,
+ }: {
+ tenant: string;
+ username: string;
+ onComplete: (agentId: string | null) => void;
+ renderFinalStep?: (context: unknown) => React.ReactNode;
+ }) => (
+
+
+ onboarding-wizard:{tenant}:{username}
+
+
onComplete('agent-1')}>
+ complete-with-agent
+
+
onComplete(null)}>
+ complete-no-agent
+
+
+ {renderFinalStep?.({
+ tenant,
+ username,
+ answers: { organizationName: '', sector: null },
+ goBack: () => {},
+ complete: () => {},
+ })}
+
+
+ ),
+}));
+
+import OnboardingPage from '../page';
+
+describe('OnboardingPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.tenantKey = 'acme';
+ });
+
+ it('renders the SDK wizard with the tenant + username and the OS create-agent final step', () => {
+ render( );
+ expect(
+ screen.getByText('onboarding-wizard:acme:tester'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('create-agent-step')).toBeInTheDocument();
+ });
+
+ it('shows a loader while the current tenant resolves', () => {
+ state.tenantKey = '';
+ render( );
+ expect(screen.getByText('spinner')).toBeInTheDocument();
+ expect(screen.queryByText(/onboarding-wizard/)).not.toBeInTheDocument();
+ });
+
+ it('redirects to the agent explore page on complete (tenant-level when no agent)', () => {
+ render( );
+ fireEvent.click(
+ screen.getByRole('button', { name: 'complete-with-agent' }),
+ );
+ expect(push).toHaveBeenCalledWith('/platform/acme/agent-1/explore');
+
+ fireEvent.click(screen.getByRole('button', { name: 'complete-no-agent' }));
+ expect(push).toHaveBeenCalledWith('/platform/acme/explore');
+ });
+});
diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx
new file mode 100644
index 00000000..9206f20d
--- /dev/null
+++ b/app/onboarding/page.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { OnboardingWizard } from '@iblai/iblai-js/web-containers';
+
+import { Spinner } from '@/components/spinner';
+import { hideInitialLoader } from '@/lib/initial-loader';
+import { useOnboardingAccess } from '@/hooks/use-onboarding';
+import { useUsername } from '@/hooks/use-user';
+import { OnboardingCreateAgentStep } from '@/components/onboarding/onboarding-create-agent-step';
+
+/**
+ * The `/onboarding` route renders the SDK onboarding wizard
+ * (`@iblai/iblai-js/web-containers`). The app supplies identity (tenant +
+ * username) and the create-agent final step; the wizard persists the answers
+ * itself (under the user's platform metadata `onboarding` key) since no
+ * `onAnswersSubmit` is passed. On completion it redirects to an agent's explore
+ * page. It performs NO automatic redirects into onboarding; access gating is
+ * wired up elsewhere. While the current tenant resolves, a brief loader shows.
+ */
+export default function OnboardingPage() {
+ const { tenantKey } = useOnboardingAccess();
+ const username = useUsername();
+ const router = useRouter();
+
+ useEffect(() => {
+ hideInitialLoader();
+ }, []);
+
+ if (!tenantKey) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ router.push(
+ agentId
+ ? `/platform/${tenantKey}/${agentId}/explore`
+ : `/platform/${tenantKey}/explore`,
+ )
+ }
+ renderFinalStep={(context) => }
+ />
+ );
+}
diff --git a/components/onboarding/__tests__/onboarding-create-agent-step.test.tsx b/components/onboarding/__tests__/onboarding-create-agent-step.test.tsx
new file mode 100644
index 00000000..b01b2459
--- /dev/null
+++ b/components/onboarding/__tests__/onboarding-create-agent-step.test.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+const { mockCreateMentor, mockUnwrap } = vi.hoisted(() => ({
+ mockCreateMentor: vi.fn(),
+ mockUnwrap: vi.fn(),
+}));
+const mockUseCreateMentorMutation = vi.fn();
+const mockUseGetMentorsQuery = vi.fn();
+
+vi.mock('@iblai/iblai-js/data-layer', () => ({
+ useCreateMentorMutation: (...args: unknown[]) =>
+ mockUseCreateMentorMutation(...args),
+ useGetMentorsQuery: (...args: unknown[]) => mockUseGetMentorsQuery(...args),
+}));
+
+// Stub the SDK shell pieces so the test doesn't load the whole web-containers bundle.
+vi.mock('@iblai/iblai-js/web-containers', () => ({
+ StepHeader: ({ title }: { title: string }) => {title} ,
+ onboardingPrimaryButtonClass: 'btn',
+}));
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('@/lib/config', () => ({
+ config: { iblTemplateMentor: () => 'ai-mentor' },
+}));
+
+import { OnboardingCreateAgentStep } from '../onboarding-create-agent-step';
+
+const baseContext = {
+ tenant: 'acme',
+ username: 'tester',
+ answers: { organizationName: 'Acme', segment: 'higher_education' },
+ suggestedAgent: { name: 'Campus Assistant', description: 'Helps students.' },
+ goBack: vi.fn(),
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockUnwrap.mockResolvedValue({ unique_id: 'new-agent' });
+ mockCreateMentor.mockReturnValue({ unwrap: mockUnwrap });
+ mockUseCreateMentorMutation.mockReturnValue([
+ mockCreateMentor,
+ { isLoading: false },
+ ]);
+ mockUseGetMentorsQuery.mockReturnValue({
+ data: { count: 1, results: [{ unique_id: 'existing-agent' }] },
+ isLoading: false,
+ });
+});
+
+describe('OnboardingCreateAgentStep', () => {
+ it('prefills name + description from the segment suggestion', () => {
+ render( );
+ expect(screen.getByText('Make your first agent')).toBeInTheDocument();
+ expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe(
+ 'Campus Assistant',
+ );
+ expect(
+ (screen.getByLabelText('Description') as HTMLTextAreaElement).value,
+ ).toBe('Helps students.');
+ });
+
+ it('kicks off creation without waiting and completes with an existing agent id', async () => {
+ // Slow endpoint — never resolves, to prove we don't wait for it.
+ mockUnwrap.mockReturnValue(new Promise(() => {}));
+ const complete = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));
+
+ await waitFor(() => expect(mockCreateMentor).toHaveBeenCalledTimes(1));
+ const payload = mockCreateMentor.mock.calls[0][0];
+ expect(payload.org).toBe('acme');
+ expect(payload.userId).toBe('tester');
+ expect(payload.formData.new_mentor_name).toBe('Campus Assistant');
+ expect(payload.formData.mentor_visibility).toBe(
+ 'viewable_by_tenant_students',
+ );
+ expect(payload.formData.metadata).toEqual({ category: null });
+ expect(payload.formData.categories).toBeUndefined();
+
+ // Completes IMMEDIATELY with the EXISTING agent's id (not the new one).
+ expect(complete).toHaveBeenCalledWith('existing-agent');
+ });
+
+ it('completes with null when the tenant has no agents yet', () => {
+ mockUseGetMentorsQuery.mockReturnValue({
+ data: { count: 0, results: [] },
+ isLoading: false,
+ });
+ const complete = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));
+ expect(complete).toHaveBeenCalledWith(null);
+ });
+
+ it('swaps the form for a loading state once submitted', () => {
+ // Slow endpoint that never resolves — the loading state must show regardless.
+ mockUnwrap.mockReturnValue(new Promise(() => {}));
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));
+
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Creating your agent…',
+ );
+ // The form is replaced.
+ expect(screen.queryByLabelText('Name')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Create agent' }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/components/onboarding/onboarding-create-agent-step.tsx b/components/onboarding/onboarding-create-agent-step.tsx
new file mode 100644
index 00000000..58138394
--- /dev/null
+++ b/components/onboarding/onboarding-create-agent-step.tsx
@@ -0,0 +1,187 @@
+'use client';
+
+import { useState } from 'react';
+import { Loader2, Sparkles } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ useCreateMentorMutation,
+ useGetMentorsQuery,
+} from '@iblai/iblai-js/data-layer';
+import {
+ StepHeader,
+ onboardingPrimaryButtonClass,
+ type OnboardingFinalStepContext,
+} from '@iblai/iblai-js/web-containers';
+
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ DEFAULT_PROMPTS,
+ MENTOR_VISIBILITY_VALUES,
+ MODEL_AGENTS,
+} from '@/lib/constants';
+import { config } from '@/lib/config';
+
+/**
+ * OS-local final screen for onboarding: a minimal Name + Description form that
+ * creates the first agent with the app's defaults (Students visibility, default
+ * prompts, neutral template, no category). It is wired into the SDK
+ * `OnboardingWizard` via its `renderFinalStep` slot (the SDK ships no built-in
+ * final screen — the create page lives here, in the OS app, only).
+ *
+ * The create endpoint is slow, so submission is fire-and-forget: the mutation
+ * is kicked off and the wizard completes IMMEDIATELY with an existing agent's
+ * id (any agent, fetched up front) so the page redirects without waiting —
+ * `null` when the tenant has no agents yet. A failed creation surfaces as a
+ * deferred toast.
+ */
+export function OnboardingCreateAgentStep({
+ tenant,
+ username,
+ suggestedAgent,
+ goBack,
+ complete,
+}: OnboardingFinalStepContext) {
+ const [name, setName] = useState(suggestedAgent?.name ?? '');
+ const [description, setDescription] = useState(
+ suggestedAgent?.description ?? '',
+ );
+ // Flipped on submit so the form is swapped for a loading state while the page
+ // redirects to the new workspace (creation itself is fire-and-forget).
+ const [submitting, setSubmitting] = useState(false);
+ const [createMentor, { isLoading }] = useCreateMentorMutation();
+
+ // Any existing agent's id, fetched up front — what the page redirects to the
+ // moment creation is kicked off.
+ const { data: existingAgents, isLoading: isLoadingAgents } =
+ useGetMentorsQuery(
+ { org: tenant, username, limit: 1, offset: 0 },
+ { skip: !tenant || !username },
+ );
+ const anyAgentId: string | null =
+ (existingAgents?.results?.[0] as { unique_id?: string } | undefined)
+ ?.unique_id ?? null;
+
+ const canCreate = name.trim().length > 0 && !isLoading && !isLoadingAgents;
+
+ const submit = () => {
+ if (!canCreate) return;
+ // Swap to the loading state up front, then kick the (slow) create off and
+ // leave immediately — the redirect happens while this spinner is showing.
+ setSubmitting(true);
+ createMentor({
+ org: tenant,
+ formData: {
+ new_mentor_name: name.trim(),
+ display_name: name.trim(),
+ description: description.trim(),
+ template_name: MODEL_AGENTS[0]?.value || config.iblTemplateMentor(),
+ metadata: { category: null },
+ system_prompt: DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT,
+ proactive_prompt: DEFAULT_PROMPTS.DEFAULT_PROACTIVE_PROMPT,
+ moderation_system_prompt: DEFAULT_PROMPTS.DEFAULT_MODERATION_PROMPT,
+ guided_prompt_instructions: DEFAULT_PROMPTS.DEFAULT_GUIDED_PROMPT,
+ mentor_visibility: MENTOR_VISIBILITY_VALUES.STUDENTS,
+ },
+ // @ts-ignore userId is accepted by the API but absent from the RTK type
+ userId: username,
+ })
+ .unwrap()
+ .catch((error: unknown) => {
+ const message =
+ (error as { error?: { error?: string } })?.error?.error ??
+ 'Failed to create agent';
+ toast.error(message);
+ });
+ complete(anyAgentId);
+ };
+
+ if (submitting) {
+ return (
+
+
+
+
+ Creating your agent…
+
+
+ Taking you to your workspace.
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md
index 307aa1fe..35c98a7a 100644
--- a/e2e/COVERAGE.md
+++ b/e2e/COVERAGE.md
@@ -1,6 +1,6 @@
# MentorAI E2E Coverage — User Journey Checklist
-> Last updated: 2026-06-11 | 434 checkpoints (415 covered, 7 not-reproducible in default env, 12 deprecated) | 50 journeys (49 active, 1 deprecated in #1431) | 100% covered | Auth: admin + non-admin storageState
+> Last updated: 2026-06-13 | 440 checkpoints (421 covered, 7 not-reproducible in default env, 12 deprecated) | 51 journeys (50 active, 1 deprecated in #1431) | 100% covered | Auth: admin + non-admin storageState
## How This Works
@@ -838,3 +838,18 @@ Standalone top-level tab rendered by the SDK's `AgentScreenShareTab` (`@iblai/we
---
> **Note:** `cleanup.spec.ts` runs after all journeys to delete test artifacts. It is not a user journey.
+
+---
+
+## Journey 49: Onboarding (6 checkpoints) — `journeys/46-onboarding.spec.ts`
+
+**Source files:** `app/onboarding/page.tsx`, `components/onboarding/onboarding-create-agent-step.tsx`
+
+The `/onboarding` route renders the SDK onboarding wizard (`OnboardingWizard` from `@iblai/iblai-js/web-containers`) with an OS-local create-agent final step. Flow: Organization → Sector → Invite team → first agent. The wizard persists the answers itself (under the user's platform metadata `onboarding` key — no `onAnswersSubmit` is passed); on completion the page redirects to `/platform///explore`. Tests walk the flow without submitting the final create (no throwaway agent / redirect side-effect).
+
+- [x] onboarding-01: The `/onboarding` route renders the wizard — progress bar and the Organization step (org-name input + Continue) are visible
+- [x] onboarding-02: Organization step — Continue is disabled until an organization name is entered, then enabled
+- [x] onboarding-03: Sector step — multiple sectors render as radios; Continue is disabled until one is selected
+- [x] onboarding-04: Invite step — the embedded team-invite block renders (collaborate copy + Continue)
+- [x] onboarding-05: Back navigation returns to the previous step and preserves the typed organization name
+- [x] onboarding-06: Final step — the create-agent form (Name prefilled from the chosen sector + Description + "Create agent") renders _(admin-only; persists answers to platform metadata first)_
diff --git a/e2e/coverage.json b/e2e/coverage.json
index f6c77875..7ff262c8 100644
--- a/e2e/coverage.json
+++ b/e2e/coverage.json
@@ -1,14 +1,14 @@
{
"version": 2,
- "lastUpdated": "2026-06-11",
+ "lastUpdated": "2026-06-13",
"summary": {
- "totalCheckpoints": 434,
- "coveredCheckpoints": 415,
+ "totalCheckpoints": 440,
+ "coveredCheckpoints": 421,
"deprecatedCheckpoints": 12,
"notReproducibleCheckpoints": 7,
"percent": 100,
- "totalJourneys": 50,
- "activeJourneys": 49
+ "totalJourneys": 51,
+ "activeJourneys": 50
},
"journeys": [
{
@@ -211,7 +211,7 @@
},
{
"id": "mentor-discovery-explore-page",
- "name": "Mentor Discovery \u2014 Explore Page",
+ "name": "Mentor Discovery — Explore Page",
"spec": "05-mentor-discovery-explore-page.spec.ts",
"sourceFiles": [
"app/platform/[tenantKey]/[mentorId]/explore/page.tsx",
@@ -287,7 +287,7 @@
},
{
"id": "mentor-management-admin",
- "name": "Mentor Management \u2014 Admin",
+ "name": "Mentor Management — Admin",
"spec": "06-mentor-management-admin.spec.ts",
"sourceFiles": [
"components/modals/edit-mentor-modal/index.tsx",
@@ -354,24 +354,24 @@
},
{
"id": "mgmt-12",
- "description": "My Agents list is scoped to created_by=username for non-admins (useMentorsWithPagination); admin sees full list; unit-covered in settings-modal.test.tsx \u2014 not e2e-reproducible without RBAC seeding",
+ "description": "My Agents list is scoped to created_by=username for non-admins (useMentorsWithPagination); admin sees full list; unit-covered in settings-modal.test.tsx — not e2e-reproducible without RBAC seeding",
"status": "not-reproducible"
},
{
"id": "mgmt-13",
- "description": "Student with /mentors/#create RBAC permission sees New Agent + My Agents in sidebar and can click agent row to open Edit Agent dialog (studentCanCreateMentors flag); unit-covered \u2014 requires RBAC env to reproduce in e2e",
+ "description": "Student with /mentors/#create RBAC permission sees New Agent + My Agents in sidebar and can click agent row to open Edit Agent dialog (studentCanCreateMentors flag); unit-covered — requires RBAC env to reproduce in e2e",
"status": "not-reproducible"
},
{
"id": "mgmt-14",
- "description": "Analytics shown to student mentor-creator only for their own mentor (created_by===username) or when holding per-mentor view_analytics permission; unit-covered \u2014 requires RBAC env to reproduce in e2e",
+ "description": "Analytics shown to student mentor-creator only for their own mentor (created_by===username) or when holding per-mentor view_analytics permission; unit-covered — requires RBAC env to reproduce in e2e",
"status": "not-reproducible"
}
]
},
{
"id": "mentor-settings-tab-unique-id",
- "name": "Mentor Settings Tab \u2014 Unique ID",
+ "name": "Mentor Settings Tab — Unique ID",
"spec": "07-mentor-settings-tab-unique-id.spec.ts",
"sourceFiles": [
"components/modals/edit-mentor-modal/tabs/settings-tab.tsx"
@@ -517,7 +517,7 @@
},
{
"id": "vc-06",
- "description": "Full voice call with real LiveKit (skipped \u2014 requires real infrastructure)",
+ "description": "Full voice call with real LiveKit (skipped — requires real infrastructure)",
"status": "covered"
},
{
@@ -529,7 +529,7 @@
},
{
"id": "canvas-ai-document-editor",
- "name": "Canvas \u2014 AI Document Editor",
+ "name": "Canvas — AI Document Editor",
"spec": "10-canvas-ai-document-editor.spec.ts",
"sourceFiles": ["components/canvas/canvas-component.tsx"],
"checkpoints": [
@@ -592,7 +592,7 @@
},
{
"id": "canvas-embed",
- "name": "Canvas \u2014 Embed",
+ "name": "Canvas — Embed",
"spec": "11-canvas-embed.spec.ts",
"sourceFiles": ["components/modals/edit-mentor-modal/tabs/embed-tab.tsx"],
"checkpoints": [
@@ -1573,7 +1573,7 @@
},
{
"id": "accessibility-wcag",
- "name": "Accessibility \u2014 WCAG 2.1 AA",
+ "name": "Accessibility — WCAG 2.1 AA",
"spec": "29-accessibility-wcag.spec.ts",
"sourceFiles": [
"components/accessibility/accessibility-toolbar.tsx",
@@ -1688,21 +1688,21 @@
},
{
"id": "a11y-21",
- "description": "Chat composer stays visible at 640 px viewport width when canvas is open \u2014 WCAG 1.4.10 Reflow (issue #1596)",
+ "description": "Chat composer stays visible at 640 px viewport width when canvas is open — WCAG 1.4.10 Reflow (issue #1596)",
"status": "deprecated",
"deprecatedIn": "scope-revert/1596",
"deprecatedReason": "Reflow refactor reverted as out-of-scope; only WCAG 4.1.2 (button aria-labels) is in scope for #1596. Track WCAG 1.4.10 separately."
},
{
"id": "a11y-22",
- "description": "Exactly one #chat-input-textarea exists in the DOM when canvas is open at 640 px \u2014 no duplicate mobile composer (issue #1596)",
+ "description": "Exactly one #chat-input-textarea exists in the DOM when canvas is open at 640 px — no duplicate mobile composer (issue #1596)",
"status": "deprecated",
"deprecatedIn": "scope-revert/1596",
"deprecatedReason": "Duplicate-composer removal reverted with the reflow refactor; out of scope for #1596."
},
{
"id": "a11y-23",
- "description": "Skip-link keyboard journey: Tab makes \"Skip to chat input\" link visible, Enter moves focus to #chat-input-textarea \u2014 WCAG 2.4.1 (issue #1596)",
+ "description": "Skip-link keyboard journey: Tab makes \"Skip to chat input\" link visible, Enter moves focus to #chat-input-textarea — WCAG 2.4.1 (issue #1596)",
"status": "deprecated",
"deprecatedIn": "scope-revert/1596",
"deprecatedReason": "Skip-link added by the reflow refactor reverted as out-of-scope; track WCAG 2.4.1 separately."
@@ -2517,7 +2517,7 @@
},
{
"id": "ac-03",
- "description": "Access tab shows roles table or empty state \u2014 never a blank crash screen",
+ "description": "Access tab shows roles table or empty state — never a blank crash screen",
"status": "covered"
},
{
@@ -2788,6 +2788,47 @@
"status": "covered"
}
]
+ },
+ {
+ "id": "onboarding",
+ "name": "Onboarding",
+ "spec": "46-onboarding.spec.ts",
+ "sourceFiles": [
+ "app/onboarding/page.tsx",
+ "components/onboarding/onboarding-create-agent-step.tsx"
+ ],
+ "checkpoints": [
+ {
+ "id": "onboarding-01",
+ "description": "The /onboarding route renders the wizard with a progress bar and the organization step",
+ "status": "covered"
+ },
+ {
+ "id": "onboarding-02",
+ "description": "Organization step: Continue is disabled until an organization name is entered",
+ "status": "covered"
+ },
+ {
+ "id": "onboarding-03",
+ "description": "Sector step lists selectable sectors and gates Continue until one is chosen",
+ "status": "covered"
+ },
+ {
+ "id": "onboarding-04",
+ "description": "Invite step embeds the team-invite block",
+ "status": "covered"
+ },
+ {
+ "id": "onboarding-05",
+ "description": "Back navigation returns to the previous step and preserves the organization name",
+ "status": "covered"
+ },
+ {
+ "id": "onboarding-06",
+ "description": "Final step shows the create-agent form prefilled from the chosen sector",
+ "status": "covered"
+ }
+ ]
}
]
}
diff --git a/e2e/journeys/46-onboarding.spec.ts b/e2e/journeys/46-onboarding.spec.ts
new file mode 100644
index 00000000..ab188495
--- /dev/null
+++ b/e2e/journeys/46-onboarding.spec.ts
@@ -0,0 +1,141 @@
+import { test, expect } from '../fixtures/mentor-test';
+import type { Page } from '@playwright/test';
+import { navigateToMentorApp, checkAdminStatus } from '../utils/auth';
+import { waitForPageReady } from '../utils/resilient';
+
+/**
+ * Journey 46: Onboarding — `app/onboarding/page.tsx` renders the SDK onboarding
+ * wizard (`@iblai/iblai-js/web-containers`) with an OS-local create-agent final
+ * step (`components/onboarding/onboarding-create-agent-step.tsx`).
+ *
+ * Flow: Organization → Sector → Invite team → first agent. The wizard persists
+ * the answers itself (platform metadata) and, on completion, the page redirects
+ * to an agent's explore page. These tests walk the flow without submitting the
+ * final create (no throwaway agent / redirect side-effect).
+ */
+
+const ORG_NAME = 'Playwright Test Org';
+
+const orgInput = (page: Page) => page.getByLabel('Organization name');
+const continueButton = (page: Page) =>
+ page.getByRole('button', { name: 'Continue' });
+const backButton = (page: Page) => page.getByRole('button', { name: 'Back' });
+
+/** Open the wizard at /onboarding once a tenant session is established. */
+async function openOnboarding(page: Page): Promise {
+ // Establish auth + current tenant (persisted to localStorage), then open the
+ // self-contained /onboarding route directly.
+ await navigateToMentorApp(page);
+ await page.goto('/onboarding', { waitUntil: 'domcontentloaded' });
+ await waitForPageReady(page);
+ await expect(orgInput(page)).toBeVisible({ timeout: 30_000 });
+}
+
+/** Fill the org name and advance to the sector step. */
+async function gotoSectorStep(page: Page): Promise {
+ await orgInput(page).fill(ORG_NAME);
+ await continueButton(page).click();
+ await expect(page.getByText('What best describes your sector?')).toBeVisible({
+ timeout: 15_000,
+ });
+}
+
+/** Pick the first sector and advance to the invite step. */
+async function gotoInviteStep(page: Page): Promise {
+ await gotoSectorStep(page);
+ await page.getByRole('radio').first().click();
+ await continueButton(page).click();
+ await expect(page.getByText('Invite your team')).toBeVisible({
+ timeout: 15_000,
+ });
+}
+
+test.describe('Journey 46: Onboarding', () => {
+ // onboarding-01: the route renders the wizard
+ test('renders the onboarding wizard with a progress bar and the organization step', async ({
+ page,
+ }) => {
+ await openOnboarding(page);
+
+ await expect(page.getByRole('progressbar')).toBeVisible();
+ await expect(orgInput(page)).toBeVisible();
+ await expect(continueButton(page)).toBeVisible();
+ });
+
+ // onboarding-02: organization step gates Continue
+ test('Continue is disabled until an organization name is entered', async ({
+ page,
+ }) => {
+ await openOnboarding(page);
+
+ await expect(continueButton(page)).toBeDisabled();
+ await orgInput(page).fill(ORG_NAME);
+ await expect(continueButton(page)).toBeEnabled();
+ });
+
+ // onboarding-03: sector step lists sectors and gates Continue until one is chosen
+ test('sector step lists selectable sectors and gates Continue until one is chosen', async ({
+ page,
+ }) => {
+ await openOnboarding(page);
+ await gotoSectorStep(page);
+
+ const radios = page.getByRole('radio');
+ await expect(radios.first()).toBeVisible();
+ expect(await radios.count()).toBeGreaterThan(1);
+
+ await expect(continueButton(page)).toBeDisabled();
+ await radios.first().click();
+ await expect(continueButton(page)).toBeEnabled();
+ });
+
+ // onboarding-04: invite step embeds the team-invite UI
+ test('invite step embeds the team-invite block', async ({ page }) => {
+ await openOnboarding(page);
+ await gotoInviteStep(page);
+
+ await expect(
+ page.getByText('Teammates can collaborate on agents and your workspace.'),
+ ).toBeVisible();
+ await expect(continueButton(page)).toBeVisible();
+ });
+
+ // onboarding-05: Back navigation preserves earlier answers
+ test('Back navigation returns to the previous step and preserves the organization name', async ({
+ page,
+ }) => {
+ await openOnboarding(page);
+ await gotoSectorStep(page);
+
+ await backButton(page).click();
+ await expect(orgInput(page)).toHaveValue(ORG_NAME, { timeout: 10_000 });
+ });
+
+ // onboarding-06: final create-agent step renders, prefilled from the sector
+ test('final step shows the create-agent form prefilled from the chosen sector', async ({
+ page,
+ }) => {
+ await openOnboarding(page);
+
+ const isAdmin = await checkAdminStatus(page);
+ if (!isAdmin) {
+ test.skip(true, 'Creating the first agent requires admin access');
+ return;
+ }
+
+ await gotoInviteStep(page);
+ // Leaving the invite step persists the answers (platform metadata) first.
+ await continueButton(page).click();
+
+ await expect(page.getByText('Make your first agent')).toBeVisible({
+ timeout: 20_000,
+ });
+ // Name is prefilled from the chosen sector's suggested first agent.
+ await expect(page.getByLabel('Name', { exact: true })).not.toHaveValue('');
+ await expect(page.getByLabel('Description', { exact: true })).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Create agent' }),
+ ).toBeVisible();
+ // Intentionally NOT submitting — avoids creating a throwaway agent + redirect.
+ });
+});
diff --git a/features/onboarding/__tests__/utils.test.ts b/features/onboarding/__tests__/utils.test.ts
new file mode 100644
index 00000000..1be0e9b7
--- /dev/null
+++ b/features/onboarding/__tests__/utils.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import {
+ ONBOARDING_METADATA_KEY,
+ ONBOARDING_METADATA_VERSION,
+} from '../constants';
+import type { PlatformUser } from '@/features/tenants/types';
+import {
+ buildOnboardingMetadata,
+ countPlatformAdmins,
+ isOnboardingDoneLocally,
+ isUserMetadataEmpty,
+ markOnboardingDoneLocally,
+} from '../utils';
+
+const admin = (is_admin: boolean) => ({ is_admin }) as PlatformUser;
+
+describe('onboarding/utils', () => {
+ describe('isUserMetadataEmpty', () => {
+ it('treats null / undefined / empty object as empty', () => {
+ expect(isUserMetadataEmpty(null)).toBe(true);
+ expect(isUserMetadataEmpty(undefined)).toBe(true);
+ expect(isUserMetadataEmpty({})).toBe(true);
+ });
+
+ it('treats a populated object as not empty', () => {
+ expect(isUserMetadataEmpty({ onboarding: { completed: true } })).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('countPlatformAdmins', () => {
+ it('counts only admins', () => {
+ expect(
+ countPlatformAdmins([admin(true), admin(false), admin(true)]),
+ ).toBe(2);
+ });
+
+ it('returns 0 for undefined or no admins', () => {
+ expect(countPlatformAdmins(undefined)).toBe(0);
+ expect(countPlatformAdmins([admin(false)])).toBe(0);
+ });
+ });
+
+ describe('buildOnboardingMetadata', () => {
+ it('wraps trimmed answers under the onboarding key with version + completed', () => {
+ const metadata = buildOnboardingMetadata(
+ { organizationName: ' Acme ', sector: 'universities' },
+ '2026-01-01T00:00:00.000Z',
+ );
+
+ expect(metadata).toEqual({
+ [ONBOARDING_METADATA_KEY]: {
+ version: ONBOARDING_METADATA_VERSION,
+ completed: true,
+ organization_name: 'Acme',
+ sector: 'universities',
+ completed_at: '2026-01-01T00:00:00.000Z',
+ },
+ });
+ });
+ });
+
+ describe('local "done" flag', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('round-trips per tenant', () => {
+ expect(isOnboardingDoneLocally('acme')).toBe(false);
+ markOnboardingDoneLocally('acme');
+ expect(isOnboardingDoneLocally('acme')).toBe(true);
+ // Namespaced per platform — a different tenant is unaffected.
+ expect(isOnboardingDoneLocally('other')).toBe(false);
+ });
+ });
+});
diff --git a/features/onboarding/constants.ts b/features/onboarding/constants.ts
new file mode 100644
index 00000000..ccad080b
--- /dev/null
+++ b/features/onboarding/constants.ts
@@ -0,0 +1,25 @@
+// The sector catalog now ships with the SDK onboarding wizard — import
+// ONBOARDING_SECTORS / getSectorById from '@iblai/iblai-js/web-containers' if
+// needed. Only the app-side gating / persistence glue remains here.
+
+/** Bump when the persisted onboarding metadata shape changes. */
+export const ONBOARDING_METADATA_VERSION = 1;
+
+/** Key under the user's platform metadata where onboarding state is stored. */
+export const ONBOARDING_METADATA_KEY = 'onboarding';
+
+/**
+ * localStorage key (namespaced per platform) used as a fast-path so the gate can
+ * short-circuit without re-hitting the API once onboarding is done/dismissed.
+ * The API metadata remains the source of truth.
+ */
+export const onboardingDoneStorageKey = (tenantKey: string) =>
+ `ibl_onboarding_done:${tenantKey}`;
+
+/**
+ * Routes where the onboarding overlay must never appear (auth / public / utility
+ * routes). The gate also requires an authenticated admin, so this is defense in
+ * depth.
+ */
+export const ONBOARDING_SKIP_ROUTES =
+ /^\/(sso-login|mobile-sso-login|share|error|version|uploads|google-oauth-callback|provider-association|mobile)(\/|$)/;
diff --git a/features/onboarding/types.ts b/features/onboarding/types.ts
new file mode 100644
index 00000000..9f5b63fb
--- /dev/null
+++ b/features/onboarding/types.ts
@@ -0,0 +1,19 @@
+// `OnboardingSector` now ships with the SDK wizard
+// (`@iblai/iblai-js/web-containers`). Only the app-side answer + persisted
+// metadata shapes remain here.
+
+/** Answers collected across the wizard steps. */
+export interface OnboardingAnswers {
+ organizationName: string;
+ /** Selected sector id, or null until chosen. */
+ sector: string | null;
+}
+
+/** Shape persisted under the user's platform metadata `onboarding` key. */
+export interface OnboardingMetadata {
+ version: number;
+ completed: boolean;
+ organization_name: string;
+ sector: string | null;
+ completed_at: string;
+}
diff --git a/features/onboarding/utils.ts b/features/onboarding/utils.ts
new file mode 100644
index 00000000..afda9c98
--- /dev/null
+++ b/features/onboarding/utils.ts
@@ -0,0 +1,67 @@
+import type { PlatformUser } from '@/features/tenants/types';
+
+import {
+ ONBOARDING_METADATA_KEY,
+ ONBOARDING_METADATA_VERSION,
+ onboardingDoneStorageKey,
+} from './constants';
+import type { OnboardingAnswers, OnboardingMetadata } from './types';
+
+/**
+ * Whether the user's platform metadata is "empty" — i.e. they have never been
+ * onboarded. True when the metadata is null/undefined or an object with no keys.
+ */
+export function isUserMetadataEmpty(
+ metadata: Record | null | undefined,
+): boolean {
+ if (!metadata) return true;
+ if (typeof metadata !== 'object') return false;
+ return Object.keys(metadata).length === 0;
+}
+
+/** Count how many of the platform's users are admins. */
+export function countPlatformAdmins(users: PlatformUser[] | undefined): number {
+ if (!users) return 0;
+ return users.reduce((total, user) => total + (user.is_admin ? 1 : 0), 0);
+}
+
+/**
+ * Build the metadata payload written when onboarding completes. The wizard only
+ * runs when metadata is empty, so this becomes the user's whole metadata object.
+ */
+export function buildOnboardingMetadata(
+ answers: OnboardingAnswers,
+ completedAt: string,
+): Record {
+ const onboarding: OnboardingMetadata = {
+ version: ONBOARDING_METADATA_VERSION,
+ completed: true,
+ organization_name: answers.organizationName.trim(),
+ sector: answers.sector,
+ completed_at: completedAt,
+ };
+ return { [ONBOARDING_METADATA_KEY]: onboarding };
+}
+
+/** Fast-path: has the current admin already finished onboarding on this device? */
+export function isOnboardingDoneLocally(tenantKey: string): boolean {
+ if (typeof window === 'undefined') return false;
+ try {
+ return (
+ window.localStorage.getItem(onboardingDoneStorageKey(tenantKey)) ===
+ 'true'
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** Record that onboarding is done so the gate short-circuits on next load. */
+export function markOnboardingDoneLocally(tenantKey: string): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(onboardingDoneStorageKey(tenantKey), 'true');
+ } catch {
+ /* ignore quota / privacy-mode errors */
+ }
+}
diff --git a/features/tenants/api-slice.ts b/features/tenants/api-slice.ts
index 0282655c..5647da34 100644
--- a/features/tenants/api-slice.ts
+++ b/features/tenants/api-slice.ts
@@ -1,9 +1,14 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import type {
+ GetPlatformUsersArgs,
GetTenantMetadataArgs,
+ GetUserPlatformMetadataArgs,
+ PlatformUsersResponse,
Tenant,
TenantMetadata,
+ UpdateUserPlatformMetadataArgs,
+ UserPlatformMetadata,
} from '@/features/tenants/types';
import {
TENANTS_ENDPOINTS,
@@ -20,6 +25,8 @@ export const tenantsApiSlice = createApi({
tagTypes: [
TENANTS_QUERY_KEYS.GET_USER_TENANTS(),
TENANTS_QUERY_KEYS.GET_PLATFORM_METADATA(),
+ TENANTS_QUERY_KEYS.GET_USER_PLATFORM_METADATA(),
+ TENANTS_QUERY_KEYS.GET_PLATFORM_USERS(),
],
endpoints: (builder) => ({
@@ -43,6 +50,59 @@ export const tenantsApiSlice = createApi({
},
],
}),
+ // Current admin's per-platform metadata. Empty metadata gates first-run onboarding.
+ getUserPlatformMetadata: builder.query<
+ UserPlatformMetadata,
+ GetUserPlatformMetadataArgs
+ >({
+ query: (args) => ({
+ url: TENANTS_ENDPOINTS.GET_USER_PLATFORM_METADATA.path(),
+ service: TENANTS_ENDPOINTS.GET_USER_PLATFORM_METADATA.service,
+ params: { platform_key: args.tenantKey },
+ }),
+ providesTags: (_result, _error, arg) => [
+ {
+ type: TENANTS_QUERY_KEYS.GET_USER_PLATFORM_METADATA(),
+ id: arg.tenantKey,
+ },
+ ],
+ }),
+ // Merge keys into the current admin's per-platform metadata (PATCH). Used to
+ // persist onboarding answers + mark complete WITHOUT clobbering any other
+ // metadata keys (important now that existing admins can re-run onboarding).
+ updateUserPlatformMetadata: builder.mutation<
+ UserPlatformMetadata,
+ UpdateUserPlatformMetadataArgs
+ >({
+ query: (args) => ({
+ url: TENANTS_ENDPOINTS.UPDATE_USER_PLATFORM_METADATA.path(),
+ service: TENANTS_ENDPOINTS.UPDATE_USER_PLATFORM_METADATA.service,
+ method: 'PATCH',
+ params: { platform_key: args.tenantKey },
+ body: { metadata: args.metadata },
+ }),
+ invalidatesTags: (_result, _error, arg) => [
+ {
+ type: TENANTS_QUERY_KEYS.GET_USER_PLATFORM_METADATA(),
+ id: arg.tenantKey,
+ },
+ ],
+ }),
+ // Platform users (paginated) — used to count admins.
+ getPlatformUsers: builder.query<
+ PlatformUsersResponse,
+ GetPlatformUsersArgs
+ >({
+ query: (args) => ({
+ url: TENANTS_ENDPOINTS.GET_PLATFORM_USERS.path(),
+ service: TENANTS_ENDPOINTS.GET_PLATFORM_USERS.service,
+ params: {
+ platform_key: args.tenantKey,
+ page_size: args.pageSize ?? 100,
+ },
+ }),
+ providesTags: () => [TENANTS_QUERY_KEYS.GET_PLATFORM_USERS()],
+ }),
}),
});
@@ -51,4 +111,7 @@ export const {
useGetTenantMetadataQuery,
useLazyGetUserTenantsQuery,
useLazyGetTenantMetadataQuery,
+ useGetUserPlatformMetadataQuery,
+ useUpdateUserPlatformMetadataMutation,
+ useGetPlatformUsersQuery,
} = tenantsApiSlice;
diff --git a/features/tenants/constants.ts b/features/tenants/constants.ts
index dda71fa9..f9ca0eed 100644
--- a/features/tenants/constants.ts
+++ b/features/tenants/constants.ts
@@ -9,11 +9,29 @@ export const TENANTS_ENDPOINTS = {
service: SERVICES.AXD,
path: (tenantKey: string) => `/api/core/orgs/${tenantKey}/metadata/`,
},
+ // Per-user, per-platform metadata — read/replace the current admin's metadata
+ // (used to gate first-run onboarding). `platform_key` is passed as a query param.
+ GET_USER_PLATFORM_METADATA: {
+ service: SERVICES.DM,
+ path: () => `/api/core/users/platform-metadata/`,
+ },
+ UPDATE_USER_PLATFORM_METADATA: {
+ service: SERVICES.DM,
+ path: () => `/api/core/users/platform-metadata/`,
+ },
+ // Paginated list of platform users (with `is_admin`) — used to detect whether
+ // the current user is the only admin of the platform.
+ GET_PLATFORM_USERS: {
+ service: SERVICES.DM,
+ path: () => `/api/core/platform/users/`,
+ },
};
export const TENANTS_QUERY_KEYS = {
GET_USER_TENANTS: () => 'USER_TENANTS',
GET_PLATFORM_METADATA: () => 'TENANT_METADATA',
+ GET_USER_PLATFORM_METADATA: () => 'USER_PLATFORM_METADATA',
+ GET_PLATFORM_USERS: () => 'PLATFORM_USERS',
};
export const TENANTS_REDUCER_KEY = 'tenantsApiSliceLocal';
diff --git a/features/tenants/types.ts b/features/tenants/types.ts
index e93fc572..1c87fc58 100644
--- a/features/tenants/types.ts
+++ b/features/tenants/types.ts
@@ -33,3 +33,47 @@ export type GetUserTenantsArgs = null;
export type GetTenantMetadataArgs = {
tenantKey: string;
};
+
+/**
+ * Per-user, per-platform metadata stored at
+ * `/api/core/users/platform-metadata/`. `metadata` is a free-form JSON object
+ * (empty for a user who has never been onboarded).
+ */
+export interface UserPlatformMetadata {
+ username: string;
+ platform_key: string;
+ metadata: Record | null;
+ created_at: string;
+ updated_at: string;
+}
+
+/** A single user returned by `/api/core/platform/users/`. */
+export interface PlatformUser {
+ username: string;
+ user_id: number;
+ name: string;
+ email: string;
+ is_admin: boolean;
+ is_staff: boolean;
+ active: boolean;
+ platform_key: string;
+ platform_org: string;
+ added_on: string;
+ expired_on: string | null;
+}
+
+export interface PlatformUsersResponse {
+ count: number;
+ next_page: number | null;
+ previous_page: number | null;
+ results: PlatformUser[];
+}
+
+export type GetUserPlatformMetadataArgs = { tenantKey: string };
+
+export type UpdateUserPlatformMetadataArgs = {
+ tenantKey: string;
+ metadata: Record;
+};
+
+export type GetPlatformUsersArgs = { tenantKey: string; pageSize?: number };
diff --git a/hooks/use-onboarding.ts b/hooks/use-onboarding.ts
new file mode 100644
index 00000000..372ebe72
--- /dev/null
+++ b/hooks/use-onboarding.ts
@@ -0,0 +1,128 @@
+'use client';
+
+import { useCallback, useEffect } from 'react';
+import { usePathname } from 'next/navigation';
+
+import {
+ useGetPlatformUsersQuery,
+ useGetUserPlatformMetadataQuery,
+ useUpdateUserPlatformMetadataMutation,
+} from '@/features/tenants/api-slice';
+import type { Tenant } from '@/features/tenants/types';
+import { useCurrentTenant, useIsAdmin } from '@/hooks/use-user';
+import { isTauriApp } from '@/types/tauri';
+import { isTauriOfflineMode } from '@/hooks/use-tauri-offline';
+
+import { ONBOARDING_SKIP_ROUTES } from '@/features/onboarding/constants';
+import type { OnboardingAnswers } from '@/features/onboarding/types';
+import {
+ buildOnboardingMetadata,
+ countPlatformAdmins,
+ isOnboardingDoneLocally,
+ isUserMetadataEmpty,
+ markOnboardingDoneLocally,
+} from '@/features/onboarding/utils';
+
+/** Dedicated full-screen route that hosts the onboarding wizard. */
+export const ONBOARDING_ROUTE = '/onboarding';
+
+export interface OnboardingGateState {
+ /** Both conditions met: only platform admin + empty user metadata. */
+ isEligible: boolean;
+ /** Still waiting on the metadata/users lookups (avoid redirecting too early). */
+ isResolving: boolean;
+ tenantKey: string;
+}
+
+/**
+ * Decides whether first-run onboarding should run for the current user:
+ * 1. the user is a tenant admin, AND
+ * 2. they are the ONLY admin of the platform, AND
+ * 3. their platform metadata is empty (never onboarded).
+ *
+ * Skips entirely (no API calls) for non-admins, offline/Tauri, public routes,
+ * or once a local "done" flag is set — so it's cheap on every render.
+ */
+export function useOnboardingGate(): OnboardingGateState {
+ const isAdmin = useIsAdmin();
+ const { currentTenant } = useCurrentTenant();
+ const pathname = usePathname();
+
+ const tenant = currentTenant as unknown as Tenant | null;
+ const tenantKey = tenant?.key ?? '';
+
+ const offline = isTauriApp() && isTauriOfflineMode();
+ const onSkipRoute = ONBOARDING_SKIP_ROUTES.test(pathname ?? '');
+ const doneLocally = tenantKey ? isOnboardingDoneLocally(tenantKey) : false;
+
+ const skip =
+ typeof window === 'undefined' ||
+ !isAdmin ||
+ !tenantKey ||
+ offline ||
+ onSkipRoute ||
+ doneLocally;
+
+ const { data: metadata, isLoading: metaLoading } =
+ useGetUserPlatformMetadataQuery({ tenantKey }, { skip });
+ const { data: users, isLoading: usersLoading } = useGetPlatformUsersQuery(
+ { tenantKey },
+ { skip },
+ );
+
+ const metadataEmpty = !!metadata && isUserMetadataEmpty(metadata.metadata);
+ const onlyAdmin = !!users && countPlatformAdmins(users.results) === 1;
+
+ // Once we've confirmed metadata is NON-empty (already onboarded — possibly on
+ // another device), remember it locally so later loads skip the lookups.
+ useEffect(() => {
+ if (!skip && metadata && !metadataEmpty && tenantKey) {
+ markOnboardingDoneLocally(tenantKey);
+ }
+ }, [skip, metadata, metadataEmpty, tenantKey]);
+
+ return {
+ isEligible: !skip && metadataEmpty && onlyAdmin,
+ isResolving: !skip && (metaLoading || usersLoading),
+ tenantKey,
+ };
+}
+
+/**
+ * Whether the current user may OPEN the onboarding route manually — ANY platform
+ * admin, independent of the auto-trigger conditions. This lets existing admins
+ * revisit `/onboarding`; it just never auto-launches for them (only
+ * `useOnboardingGate` drives the automatic redirect).
+ */
+export function useOnboardingAccess(): {
+ canAccess: boolean;
+ tenantKey: string;
+} {
+ const isAdmin = useIsAdmin();
+ const { currentTenant } = useCurrentTenant();
+ const tenant = currentTenant as unknown as Tenant | null;
+ const tenantKey = tenant?.key ?? '';
+ const offline = isTauriApp() && isTauriOfflineMode();
+
+ return { canAccess: !!isAdmin && !!tenantKey && !offline, tenantKey };
+}
+
+/** Persist onboarding answers + mark onboarding complete (so it never re-runs). */
+export function useCompleteOnboarding() {
+ const [updateMetadata, { isLoading }] =
+ useUpdateUserPlatformMetadataMutation();
+
+ const completeOnboarding = useCallback(
+ async (tenantKey: string, answers: OnboardingAnswers) => {
+ const metadata = buildOnboardingMetadata(
+ answers,
+ new Date().toISOString(),
+ );
+ await updateMetadata({ tenantKey, metadata }).unwrap();
+ markOnboardingDoneLocally(tenantKey);
+ },
+ [updateMetadata],
+ );
+
+ return { completeOnboarding, isSavingMetadata: isLoading };
+}
diff --git a/package.json b/package.json
index 8629b586..4fd1ff5d 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"dependencies": {
"@iblai/agent-ai": "2.5.2",
"@iblai/iblai-api": "4.166.0-ai",
- "@iblai/iblai-js": "1.17.25",
+ "@iblai/iblai-js": "1.18.0",
"@iblai/iblai-web-mentor": "1.3.4",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index deea10bc..692ef036 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,8 +39,8 @@ importers:
specifier: 4.166.0-ai
version: 4.166.0-ai
'@iblai/iblai-js':
- specifier: 1.17.25
- version: 1.17.25(42ea00cef529f8d05a76c6945e050e01)
+ specifier: 1.18.0
+ version: 1.18.0(42ea00cef529f8d05a76c6945e050e01)
'@iblai/iblai-web-mentor':
specifier: 1.3.4
version: 1.3.4(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.60.2)(typescript@5.9.3)
@@ -817,8 +817,8 @@ packages:
react: 19.1.0
react-dom: 19.1.0
- '@iblai/data-layer@1.7.5':
- resolution: {integrity: sha512-0tCacexVFE3RbMBZh1+hLy/uS2jd10HfLdufT4xI8GxOx/h6r9K191p+ca0kYs68r+Q78yyOWX7rqv7ue3oi+w==}
+ '@iblai/data-layer@1.8.0':
+ resolution: {integrity: sha512-/NYfcU8GrqkYptN2+JYanHTmdhWH6OT+hBOTMAii2Mj3yNoLd5+x9f30nv/IsE/i7haT44KMfT7ZzSFcl8JhCA==}
engines: {node: 25.3.0}
peerDependencies:
'@reduxjs/toolkit': 2.7.0
@@ -829,8 +829,8 @@ packages:
'@iblai/iblai-api@4.166.0-ai':
resolution: {integrity: sha512-dY9Jv+CkXEyTxRsOQFay5YvYe8K2LSQluJJvtssGQV2RbrxPPzONaKfnhcyarDs7Ft35Xqk1fuErjhUwNG3OIg==}
- '@iblai/iblai-js@1.17.25':
- resolution: {integrity: sha512-4rIl/9kBpUyBd8iLVI/vFBgCM4EsEG2ul+I2nYIOFe1C/RUOhlvoCJyGfmjgTnkHrLhNGWs/M6AESQrjfdO4+A==}
+ '@iblai/iblai-js@1.18.0':
+ resolution: {integrity: sha512-Pph9A1lzX/ERRupWvwh3IDQd4Q+SnEzpInXYFq8tuWW2pG5Yr5heqyKcB5NvM3jwf6b3ncyBL9Se0H0762pxAQ==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
@@ -854,18 +854,16 @@ packages:
'@iblai/iblai-web-mentor@1.3.4':
resolution: {integrity: sha512-w0K22cQl/LquXDDnVi7DgiOeuS+iha85poZxuLCtJqvwyCxAMLch1DWN3LbNCxw+YGhrV7BOEENp8s7bZ+7ZGw==}
- '@iblai/mcp@1.5.8':
- resolution: {integrity: sha512-OSb2gYXzfZItOFOnk/Lrw8yfv5oU4bLOzis07+XFGrA124DsNx21ymsHhv+nxbT08K2IpZ6nQDLSpOF+cdWGWQ==}
- '@iblai/mcp@1.5.8':
- resolution: {integrity: sha512-OSb2gYXzfZItOFOnk/Lrw8yfv5oU4bLOzis07+XFGrA124DsNx21ymsHhv+nxbT08K2IpZ6nQDLSpOF+cdWGWQ==}
+ '@iblai/mcp@1.6.0':
+ resolution: {integrity: sha512-e2D3Q1w8KTvnfhzayCMH23dbSH3m8/sJYGsgB4cWFLBt+8VffByeLR3fm5yPG2+cnxHD4DMgL9zYCUXnVWFOxA==}
engines: {node: '>=20.0.0'}
hasBin: true
- '@iblai/web-containers@1.8.19':
- resolution: {integrity: sha512-K0E+xGuzzE2mrX4JvQUObZRD/mZY0hAuePM9iNTxa2PSe/CVhOz0kXRjuuJxwLwoQHgdilxwqtUn7krVD93zHQ==}
+ '@iblai/web-containers@1.9.0':
+ resolution: {integrity: sha512-b1RPrwl2tpigGXJXiMhn8tLVIxfPqelgOjijRG1t8UgLLpNa60lrLurHC6dbYk1ojcO+CH4v1zcEe3Mv4PxDEg==}
engines: {node: 25.3.0}
peerDependencies:
- '@iblai/data-layer': 1.7.5
+ '@iblai/data-layer': 1.8.0
'@iblai/iblai-api': 4.166.0-ai
'@iblai/web-utils': 1.10.11
'@livekit/components-react': 2.8.1
@@ -8095,6 +8093,7 @@ packages:
recharts@2.15.2:
resolution: {integrity: sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==}
engines: {node: '>=14'}
+ deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -9725,7 +9724,7 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
- '@iblai/data-layer@1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)':
+ '@iblai/data-layer@1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)':
dependencies:
'@iblai/iblai-api': 4.166.0-ai
'@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
@@ -9739,13 +9738,13 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@iblai/iblai-js@1.17.25(42ea00cef529f8d05a76c6945e050e01)':
+ '@iblai/iblai-js@1.18.0(42ea00cef529f8d05a76c6945e050e01)':
dependencies:
- '@iblai/data-layer': 1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
+ '@iblai/data-layer': 1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
'@iblai/iblai-api': 4.166.0-ai
- '@iblai/mcp': 1.5.8
- '@iblai/web-containers': 1.8.19(679dbf192084d9adf610ce9619da4661)
- '@iblai/web-utils': 1.10.11(@iblai/data-layer@1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
+ '@iblai/mcp': 1.6.0
+ '@iblai/web-containers': 1.9.0(609a714cbdba35dfd27a809678dd0e00)
+ '@iblai/web-utils': 1.10.11(@iblai/data-layer@1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@reduxjs/toolkit': 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
axios: 1.16.1
@@ -9786,8 +9785,7 @@ snapshots:
- supports-color
- typescript
- '@iblai/mcp@1.5.8':
- '@iblai/mcp@1.5.8':
+ '@iblai/mcp@1.6.0':
dependencies:
'@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6)
zod: 4.3.6
@@ -9795,11 +9793,11 @@ snapshots:
- '@cfworker/json-schema'
- supports-color
- '@iblai/web-containers@1.8.19(679dbf192084d9adf610ce9619da4661)':
+ '@iblai/web-containers@1.9.0(609a714cbdba35dfd27a809678dd0e00)':
dependencies:
- '@iblai/data-layer': 1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
+ '@iblai/data-layer': 1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
'@iblai/iblai-api': 4.166.0-ai
- '@iblai/web-utils': 1.10.11(@iblai/data-layer@1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
+ '@iblai/web-utils': 1.10.11(@iblai/data-layer@1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-accordion': 1.2.8(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-avatar': 1.1.7(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-checkbox': 1.2.3(@types/react-dom@19.2.1(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -9881,9 +9879,9 @@ snapshots:
- supports-color
- vinxi
- '@iblai/web-utils@1.10.11(@iblai/data-layer@1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
+ '@iblai/web-utils@1.10.11(@iblai/data-layer@1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(@iblai/iblai-api@4.166.0-ai)(@tauri-apps/api@2.11.0)(@tauri-apps/plugin-os@2.3.2)(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(redux@5.0.1)(sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
dependencies:
- '@iblai/data-layer': 1.7.5(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
+ '@iblai/data-layer': 1.8.0(@reduxjs/toolkit@2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
'@iblai/iblai-api': 4.166.0-ai
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0)
axios: 1.16.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index a41b88db..3303011c 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,3 +5,6 @@ onlyBuiltDependencies:
- esbuild
- sharp
- unrs-resolver
+minimumReleaseAgeExclude:
+ - '@iblai/*'
+
diff --git a/providers/index.tsx b/providers/index.tsx
index 83d69de0..86d608e2 100644
--- a/providers/index.tsx
+++ b/providers/index.tsx
@@ -235,6 +235,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {
// Workflow pages manage their own mentor context; skip MentorProvider's mentor check
// to prevent it from redirecting when the URL's mentorId changes during navigation.
const isWorkflowPage = /\/workflows\//.test(pathname);
+ // The onboarding route is self-contained; keep MentorProvider from redirecting a
+ // brand-new admin (who has no mentors yet) off it to /create-mentor.
+ const isOnboardingPage = /^\/onboarding(\/|$)/.test(pathname);
// Use the same offline check (already computed above)
const isTauriOffline = isTauriOfflineEarly;
@@ -258,14 +261,17 @@ export default function Providers({ children }: { children: React.ReactNode }) {
}
function redirectToNoMentorsPage() {
+ if (isOnboardingPage) return;
router.push(`/platform/${tenantKey}/explore`);
}
function redirectToCreateMentor() {
+ if (isOnboardingPage) return;
router.push('/create-mentor');
}
function redirectToMentor(tenantKey: string, mentorId: string) {
+ if (isOnboardingPage) return;
router.push(`/platform/${tenantKey}/${mentorId}`);
}