/iblai-vibe-application
Add the platform application gate (admissions) — the surface a platform uses to make membership application-only: staff author application forms and review submissions, applicants fill the form on a public link and track their outcome.
It ships as two independent halves:
| Half | Component | Where it lives |
|---|---|---|
| Admin | Admin → Applications tab |
Inside the account/settings page (Account → Management tab), or mounted directly |
| Applicant | ApplyWizard |
Two public routes: /applications/<platform-key> and /applications/<platform-key>/<form-id> |
The two connect through a copyable link: an admin copies a form's applicant link from the Applications tab and shares it; the link opens the wizard for that form.
Common setup (brand, conventions, env files, verification): see docs/skill-setup.md.
Prerequisites
- Auth must be set up first (
/iblai-vibe-auth) - MCP server + skills configured (
@iblai/mcpin.mcp.json) @iblai/iblai-jsrecent enough to export both surfaces. Verify:
Nothing printed → upgrade (grep -rl "ApplyWizard" node_modules/@iblai/iblai-js/dist/web-containers | head -1/iblai-vibe-ops-upgrade).- The admin half needs a reviewer account on the platform. The manage
endpoints are RBAC-gated — see
/iblai-vibe-rbac. - The platform must be in application mode for the wizard to render a
form.
open/invite_onlyplatforms render an informational notice instead (the gatemode, set platform-side, not from this UI).
Step 1: Check Environment
Before proceeding, check for an iblai.env in the project root. Look for
PLATFORM, DOMAIN, and TOKEN variables. If the file does not exist or
is missing these variables, tell the user:
"You need an iblai.env with your platform configuration. Download the
template and fill in your values:
curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env"
Step 2: Mount the Admin Applications Tab
The Applications tab lives inside the Admin component, which the Account
component renders under its Management tab. If the app already has an
account page (/iblai-vibe-account), the tab is one prop away — pass
authURL so the copyable applicant links resolve:
// app/(app)/account/page.tsx — existing Account page
<Account
tenant={tenantKey}
tenants={tenants}
username={username}
isAdmin={isAdmin}
authURL={config.authUrl()} // ← required for the copyable applicant link
=> {}}
=> router.push("/")}
targetTab="management" // opens straight on Management
/>
To give applications their own page instead, mount Admin directly:
// app/(app)/applications/page.tsx
"use client";
import { Admin } from "@iblai/iblai-js/web-containers";
import config from "@/lib/iblai/config";
import { resolveAppTenant } from "@/lib/iblai/tenant";
export default function ApplicationsAdminPage() {
const tenantKey = resolveAppTenant();
return (
<div className="mx-auto w-full flex-1 overflow-auto px-4 py-8 md:w-[85vw] md:px-0">
<Admin
tenant={tenantKey}
authSpaUrl={config.authUrl()}
=> {}}
/>
</div>
);
}
Admin renders the Applications tab unconditionally — every other tab
(Users, Groups, Roles, Policies, Teams, Alerts) is behind a
has*TabPermission prop, so with none of them passed the page shows
Applications only. The tab reads platformKey from tenant and fetches
everything itself; no other props are required.
Step 3: Add the Public Applicant Routes
The wizard is what a form's copied link opens. Create two routes so both the default-form link and per-form links work:
app/applications/[platformKey]/page.tsx → the platform's DEFAULT form
app/applications/[platformKey]/[formId]/page.tsx → one specific form
Both render the same component:
// app/applications/[platformKey]/page.tsx (and .../[formId]/page.tsx)
"use client";
import ApplicationPage from "@/components/applications/application-page";
export const dynamic = "force-dynamic";
export default ApplicationPage;
// components/applications/application-page.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { ApplyWizard } from "@iblai/iblai-js/web-containers";
import { useGetAppTokensMutation } from "@iblai/iblai-js/data-layer";
import config from "@/lib/iblai/config";
/** Origin to send the applicant back to after submitting (?redirect-to=). */
function redirectTo(): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("redirect-to");
}
export default function ApplicationPage() {
const router = useRouter();
const { platformKey, formId } = useParams<{
platformKey: string;
formId?: string;
}>();
const [getAppTokens] = useGetAppTokensMutation();
const [dmToken, setDmToken] = useState<string | null>(null);
const requested = useRef(false);
// The gate calls run with the MAIN tenant's DM token — applicants are
// usually not members of the platform they are applying to. Held in memory
// only, so the selected tenant's stored token is left untouched.
useEffect(() => {
if (requested.current) return;
requested.current = true;
(async () => {
const body = new FormData();
body.append("platform_key", config.mainTenantKey() || "main");
const { data } = await getAppTokens(body).unwrap();
setDmToken(data?.dm_token?.token ?? null);
})();
}, [getAppTokens]);
if (!platformKey || !dmToken) return <div className="p-8">Loading…</div>;
return (
<ApplyWizard
platformKey={platformKey}
{...(formId ? { formId } : {})}
token={dmToken}
dmUrl={config.dmUrl()}
=> router.push(redirectTo() || "/")}
/>
);
}
Provider requirements for the applicant routes
The wizard needs the data-layer store +
initializeDataLayeronly.Do not wrap these routes in
TenantProvider— it blocks on organization membership, and applicants are usually not members. In a vibe-starter app the provider chain lives inproviders/iblai-providers.tsx; skip the organization layer on these routes the same way it already skips SSO routes:const isApplyRoute = pathname?.startsWith("/applications") ?? false; // … <TenantProvider skip={isSsoRoute || isApplyRoute} /* … */>Leave
AuthProviderin place — applicants must still be authenticated. It sends unauthenticated visitors through SSO with the current path and query preserved, so the link's?redirect-to=survives the round trip.Import the SDK stylesheet once:
@iblai/iblai-js/web-containers/styles.Keep the routes at
app/applications/…, outside the(app)group, so the navbar/chrome layout does not wrap the applicant experience.
The applicant link
The Applications tab copies links in this shape — build them the same way if the app needs to render one itself (the SDK's internal helper is not exported):
<AUTH_URL>/applications/<platform-key>/<form-id>?redirect-to=<origin>
<AUTH_URL>/applications/<platform-key>?redirect-to=<origin> # default form
AUTH_URL comes from the authSpaUrl / authURL prop, else
window.__ENV__.NEXT_PUBLIC_AUTH_URL, else process.env.NEXT_PUBLIC_AUTH_URL.
With none of them resolvable the copy button hides itself. Hosting the routes
in this app instead of the auth SPA? Pass that origin as authSpaUrl.
Surfaces
Admin (inside the Applications tab)
| Surface | What it does |
|---|---|
| Forms list (entry) | Every application form of the platform: title, version, enabled/disabled, default badge. Per-row: copy applicant link, view submissions, edit. Header: Blocked users, All submissions (count from pipeline stats), New form |
| Submissions pipeline | Paginated rows (20/page) with status filter (all + the 9 statuses), debounced search, a structured student filter (key=value → candidate.<key>=<value>), and sort. Filtering by form is client-side per page — the endpoint has no form filter |
| Submission workspace | One application: applicant header, per-student (candidate) cards with status / identity / enrollment-phase badges, decide dialogs (per student or family-wide), answers rendered against the form-version snapshot, attachment downloads, the submit-blocking checklist with waivers + override submit, placements and course assignments for accepted students, internal notes, audit trail |
| Questions editor | Create or edit a form: title, description, enabled toggle, "make default", sections (reorder, repeatable, one candidate section) and fields via the question dialog. Save errors come back per section.field |
| Blocked users | The ban list (entries are created by decline-with-block) with removal |
Applicant (the wizard)
| State | What renders |
|---|---|
| Steps | One step per section, a repeatable candidate section ("Add another student"), explicit Save draft, a review step, then submit |
| Resume | An editable submission (draft, needs_more_info) seeds the steps with saved answers |
| Status page | A decided/locked submission: family status, per-student outcomes with decision messages, withdraw while undecided, Renew once approved (pre-filled from the approved application), the caller's other applications |
| Mode notice | Platform is open or invite_only — nothing to fill in |
| Gate block | already_member, open_submission_exists, reapply_cooldown, renewal_priority_window, cannot_apply render as terminal states |
| 404 | Bare link with no default form, an unresolvable form id, or no renderable schema |
Form schema contract
A form is sections → fields, saved as the record's schema. The backend
re-validates on save and on submit and stays authoritative.
{
"title": "Fall Admission",
"description": "Apply for the fall term.",
"sections": [
{
"key": "students",
"title": "Students",
"repeatable": true, // applicant may add several entries
"candidate": true, // ← entries become per-student candidates (max one such section)
"item_label": "student", // "Add another student"
"min_items": 1,
"fields": [
{ "key": "name", "type": "text", "label": "Full name", "required": true },
{ "key": "grade", "type": "select", "label": "Grade",
"options": [{ "value": "k", "label": "Kindergarten" }],
"ui": { "widget": "radio" } },
{ "key": "transcript", "type": "file", "label": "Transcript",
"file_config": { "allowed_extensions": ["pdf"], "max_size_mb": 5, "max_files": 2 } },
{ "key": "consent", "type": "boolean", "label": "I agree", "must_be_true": true },
{ "key": "sibling", "type": "text", "label": "Sibling name",
"visible_if": { "field": "has_sibling", "operator": "equals", "value": true } }
]
}
],
"resources": [{ "label": "Handbook", "url": "https://…" }]
}
- Field types:
text,textarea,email,phone,number,date,boolean,select,multi_select,file,signature,info(infodisplays content and collects nothing). - Validators:
min_length,max_length,pattern,min,max,min_items,max_items— each{ check, value, error? }. visible_ifoperators:equals,not_equals,in,gte,lte. The field is"section.field"(absolute) or a bare"field"(same repeatable entry).- Uploads are capped at 5 MB client-side; a field's
file_config.max_size_mbmay tighten that, never widen it. sensitiveon a section or field masks the answer in the reviewer workspace (the detail payload lists masked paths insensitive_masked).- The question dialog edits label, type, required, placeholder, help text,
options,
must_be_true, andinfocontent — it preservesvisible_if,validators, andfile_configuntouched, so author those in the schema and they survive edits. A field'skeyis slugified from its label on creation and never changes afterward.
Statuses and transitions
| Family (application) | Candidate (per student) |
|---|---|
draft, submitted, under_review, needs_more_info, interview_required, waitlisted, approved, denied, withdrawn |
pending, under_review, needs_more_info, interview_required, waitlisted, accepted, declined |
Reviewer actions (transitionApplication, family-wide or with a
candidate_id): start_review, request_info, require_interview,
waitlist, accept, decline. decline with block: true adds the
applicant to the ban list. message reaches the applicant (shown on the
status page); note is internal.
draft and needs_more_info are the editable statuses — the applicant
resumes the wizard; everything else routes to the status page.
<ApplyWizard> Props
Import from @iblai/iblai-js/web-containers (framework-agnostic bundle).
| Prop | Type | Required | Description |
|---|---|---|---|
platformKey |
string |
Yes | Platform whose gate/form is fetched |
formId |
number | string |
No | Specific form; omit to resolve the platform default |
token |
string |
No | Explicit DM token overriding the stored one on every gate call — use the main organization's token when the wizard runs outside the target organization's app |
dmUrl |
string |
No | DM base URL; the top bar then shows <dmUrl>/api/core/orgs/<platformKey>/logo/ |
logoUrl |
string |
No | Explicit logo URL; wins over dmUrl |
brandName |
string |
No | Top-bar name. Defaults to the form title |
showTopBar |
boolean |
No | Default true |
showLogo |
boolean |
No | Default true (renders only when a URL resolves) |
className |
string |
No | Wrapper class |
onComplete |
() => void |
No | Fires from the Continue CTA on the submitted / gate-blocked screens. Omitted → no CTA |
<Admin> Props (applications-relevant subset)
Import from @iblai/iblai-js/web-containers.
| Prop | Type | Required | Description |
|---|---|---|---|
tenant |
string |
Yes | Platform key — becomes the tab's platformKey |
onInviteClick |
() => void |
Yes | Users-tab callback; pass a no-op when mounting for applications only |
authSpaUrl |
string |
No | Base URL for the copyable applicant link. Falls back to runtime env |
has*TabPermission |
boolean |
No | Gate the other tabs (Users, Groups, Roles, Policies, Teams, Alerts). Applications is always shown |
Data-layer hooks
From @iblai/iblai-js/data-layer — all under
/api/catalog/applications/platform/… on the DM service. Every
applicant-facing hook takes an optional token.
Applicant: useGetApplicationStatusQuery (the gate state that drives
routing), useGetApplicationFormQuery, useCreateApplicationDraftMutation,
useUpdateApplicationMutation (replaces the whole responses object, not a
delta), useSubmitApplicationMutation,
useUploadApplicationAttachmentMutation,
useDeleteApplicationAttachmentMutation, useListOwnApplicationsQuery,
useRenewApplicationMutation, useWithdrawApplicationMutation.
Forms management: useGetApplicationFormsQuery,
useGetApplicationFormDetailQuery, useCreateApplicationFormMutation,
useUpdateApplicationFormMutation.
Reviewer: useGetApplicationsPipelineQuery,
useGetApplicationsPipelineStatsQuery, useGetApplicationAdminDetailQuery,
useTransitionApplicationMutation, useCreateApplicationWaiverMutation,
useDeleteApplicationWaiverMutation, useSubmitApplicationOverrideMutation,
useGetApplicationNotesQuery + create/update/delete,
useLazyGetApplicationAttachmentFileQuery, useGetApplicationBlocksQuery,
useDeleteApplicationBlockMutation, useGetApplicationPlacementsQuery +
create/update, useGetApplicationCourseAssignmentsQuery + create/delete,
useInviteApplicationCandidateMutation,
useLinkApplicationCandidateMutation.
Build custom application UI on these hooks rather than calling the endpoints
directly — they carry the cache tags (platform_applications,
platform_application_forms) the SDK components invalidate.
Step 4: Use MCP Tools for Customization
get_component_info("ApplyWizard")
get_component_info("Admin")
get_component_info("Account")
get_api_query_info("useGetApplicationStatusQuery")
Step 5: Verify
Run /iblai-vibe-ops-test before telling the user the work is ready:
pnpm build— must pass with zero errorspnpm test— vitest must pass- Start dev server and touch test both halves:
pnpm dev & npx playwright screenshot http://localhost:3000/account /tmp/applications-admin.png npx playwright screenshot http://localhost:3000/applications/<platform-key> /tmp/apply.png
End-to-end check: create a form in the editor, enable it, mark it default, copy its link, open it in a private window as a non-member account, submit, then decide it from the pipeline.
Important Notes
- Redux store: must include
mentorReducerandmentorMiddleware; the platform API slice ships inside those bundles. initializeDataLayer(): 5 args (v1.2+).@reduxjs/toolkit: deduplicated via webpack aliases innext.config.ts.- Peer dep:
sonner— the admin surfaces toast on save/decide. - Token nuance: the wizard's
tokenis held in memory and never written to storage, so the selected organization'sdm_tokenstays intact. Skip it and the wizard uses the current organization's stored token — correct only when the applicant is already a member of that organization. - Application rules are per form. A family may hold applications on two forms of one platform; a submission on a sibling form does not block a fresh apply on this one.
- No default form set and several forms active → a bare
/applications/<key>link 404s. Either mark one form default or hand out per-form links. - Blocking checklist: a reviewer can waive individual required items
(excused items stop blocking) or force-submit a stuck draft with
submitApplicationOverride, which waives every missing required item. - Placements and course assignments only apply to
acceptedcandidates. - The fee panel is deliberately not implemented in the reviewer workspace — fee state comes back on the gate payload but no UI drives it.
- Brand guidelines: BRAND.md