SuperAPI AI Directory

iblai-vibe-application

General & other

View repository

Updated 10-15 22:16

About this skill

/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/mcp in .mcp.json)
  • @iblai/iblai-js recent enough to export both surfaces. Verify:
    grep -rl "ApplyWizard" node_modules/@iblai/iblai-js/dist/web-containers | head -1
    
    Nothing printed → upgrade (/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_only platforms render an informational notice instead (the gate mode, 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
  onInviteClick={() => {}}
  onClose={() => 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()}
        onInviteClick={() => {}}
      />
    </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()}
      onComplete={() => router.push(redirectTo() || "/")}
    />
  );
}

Provider requirements for the applicant routes

  • The wizard needs the data-layer store + initializeDataLayer only.

  • 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 in providers/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 AuthProvider in 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 (info displays content and collects nothing).
  • Validators: min_length, max_length, pattern, min, max, min_items, max_items — each { check, value, error? }.
  • visible_if operators: 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_mb may tighten that, never widen it.
  • sensitive on a section or field masks the answer in the reviewer workspace (the detail payload lists masked paths in sensitive_masked).
  • The question dialog edits label, type, required, placeholder, help text, options, must_be_true, and info content — it preserves visible_if, validators, and file_config untouched, so author those in the schema and they survive edits. A field's key is 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:

  1. pnpm build — must pass with zero errors
  2. pnpm test — vitest must pass
  3. 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 mentorReducer and mentorMiddleware; the platform API slice ships inside those bundles.
  • initializeDataLayer(): 5 args (v1.2+).
  • @reduxjs/toolkit: deduplicated via webpack aliases in next.config.ts.
  • Peer dep: sonner — the admin surfaces toast on save/decide.
  • Token nuance: the wizard's token is held in memory and never written to storage, so the selected organization's dm_token stays 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 accepted candidates.
  • 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