diff --git a/.env.example b/.env.example index ff00b57..5855460 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ GEMINI_API_KEY= GEMINI_TEXT_MODEL=gemini-2.5-flash GEMINI_LIVE_MODEL=gemini-3.1-flash-live-preview +GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts # Interviewer voice — Gemini HD prebuilt voice (same voices as the Live API # native audio). Options: Aoede, Kore, Leda, Zephyr, Callirrhoe, Puck, Charon, ... GEMINI_TTS_VOICE=Aoede @@ -26,6 +27,11 @@ DNLA_QUESTION_API_URL= DNLA_QUESTION_API_KEY= # --- DNLA Partner API (real psychometric assessment) ------------------------- +# Release controls fail closed. Keep all three false until the platform owner +# explicitly authorizes a licensed DNLA rollout. +DNLA_ENABLED=false +NEXT_PUBLIC_DNLA_ENABLED=false +DNLA_TEST_MODE_ENABLED=false # Server-only partner key for backend.dnla.com. Powers: create TAN -> DNLA-hosted # questionnaire -> completion webhook -> results -> Fit Score. Empty = "pending" mode. DNLA_API_KEY= @@ -33,9 +39,18 @@ DNLA_API_BASE=https://backend.dnla.com DNLA_AREA=ESK # Shared secret for DNLA's completion webhook: /api/dnla/webhook?secret= DNLA_WEBHOOK_SECRET= -# Result-scale calibration — CONFIRM WITH DNLA (max raw value + polarity). -DNLA_SCALE_MAX=9 +# TEMPORARY TEST ONLY: comma/space-separated pre-issued TANs. The server +# reads this pool only when BOTH DNLA_ENABLED=true and +# DNLA_TEST_MODE_ENABLED=true. Store real TANs only in Secret Manager. +DNLA_TEST_TANS= +DNLA_TEST_START_BASE=https://next.dnla.com/ +# Supplied DNLA reports display the core Social Competence scale from 1–7, +# with the optimum range beginning at 4. Leadership balance factors are handled +# separately in code (50%–150%, optimum 90%–110%). Override only if the Partner +# API contract for your account returns a different raw scale. +DNLA_SCALE_MAX=7 DNLA_SCALE_HIGHER_IS_BETTER=true +DNLA_BENCHMARK_RAW=4 # --- Email (off-campus candidate invites, PRD §4.5) -------------------------- # OPT-IN: when RESEND_API_KEY is set, recruiter off-campus invite links are @@ -46,6 +61,10 @@ RESEND_API_KEY= EMAIL_FROM=Taledge # --- Authentication enforcement ---------------------------------------------- +# Emergency production pause. When true, all pages and API routes return 503; +# only /api/health remains available. Keep false for normal local development. +MAINTENANCE_MODE=false + # false (default): demo/dev mode. Routes resolve a stable non-authoritative # "demo-user" principal so the seeded personas stay browsable without login. # true: production mode. Every API route and protected page REQUIRES a verified @@ -56,6 +75,10 @@ AUTH_ENFORCED=false # at their OWN uid-keyed workspace instead of the shared seeded persona id. # Keep this in sync with AUTH_ENFORCED. NEXT_PUBLIC_AUTH_ENFORCED=false +# Showcase data is never auto-inserted into a configured Firebase project. +# Set true only for a disposable Firebase demo project. The no-credential local +# file demo keeps its historical sample data unless this is explicitly false. +TALEDGE_SEED_DEMO_DATA=false # Off-campus recruiter invites payment gate. OFF (default) ⇒ invites send FREE # (pilot); each completed invite still runs a Gemini-billed assessment, so you @@ -64,13 +87,23 @@ NEXT_PUBLIC_AUTH_ENFORCED=false # NEXT_PUBLIC_PAYMENTS_ENABLED is the client mirror (button/price copy). Keep in sync. PAYMENTS_ENABLED=false NEXT_PUBLIC_PAYMENTS_ENABLED=false +# Optional institute-company onboarding checkout/quote URL. When empty, the +# institute UI opens an addressed request to tech@athreix.com. After the +# provider is selected, set this to the hosted checkout/quote page and connect +# its verified webhook before enforcing paid company-slot limits. +NEXT_PUBLIC_COMPANY_ONBOARDING_CHECKOUT_URL= -# Live AI interview in an ENFORCED (production) deployment. Off by default: the -# Live path hands the raw GEMINI_API_KEY to the browser, so it's normally refused -# when AUTH_ENFORCED=true. Set to "true" to opt in for a controlled pilot — the -# key becomes visible to LOGGED-IN users (route is auth + rate-limited). In demo -# mode (AUTH_ENFORCED=false) Live is always on regardless of this flag. +# Live AI interview in an ENFORCED (production) deployment. The authenticated +# server mints a short-lived, constrained Gemini credential only after the paid, +# face-verified session checks pass; the raw GEMINI_API_KEY stays server-side. +# Keep this opt-in false locally unless Live runtime testing is intentional. LIVE_INTERVIEW_ENABLED=false +# Optional controlled QA identities. Every exact Firebase email must ALSO have +# the Admin-only `qaUnlimitedCredits: true` custom claim. Use a comma-separated +# list; leave blank to fall back to the legacy single address or disable. +# This never unlocks or pays for DNLA. +QA_UNLIMITED_INTERVIEW_EMAILS= +QA_UNLIMITED_INTERVIEW_EMAIL= # --- Firebase Admin (server-side token verification + privileged Firestore) -- # Required when AUTH_ENFORCED=true. Provide EITHER: diff --git a/.env.local.example b/.env.local.example index cb84671..2f96708 100644 --- a/.env.local.example +++ b/.env.local.example @@ -11,6 +11,11 @@ DNLA_QUESTION_API_URL="" DNLA_QUESTION_API_KEY="" # --- DNLA Partner API (real psychometric assessment) ------------------------- +# Fail-closed master switches. Do not enable without explicit product-owner +# authorization; a configured provider key/TAN is insufficient by itself. +DNLA_ENABLED="false" +NEXT_PUBLIC_DNLA_ENABLED="false" +DNLA_TEST_MODE_ENABLED="false" # Server-only partner key for backend.dnla.com (the `api_key` shown as required # on every endpoint). Powers the real flow: create TAN -> DNLA-hosted # questionnaire -> completion webhook -> results -> Fit Score. Leave empty to @@ -34,4 +39,4 @@ DNLA_SCALE_HIGHER_IS_BETTER="true" # To upgrade to Google Cloud Chirp 3 HD voices, enable the Cloud Text-to-Speech # API and set a key here; leave empty to keep the default Gemini voice. GOOGLE_TTS_API_KEY="" -GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda" \ No newline at end of file +GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda" diff --git a/.gitignore b/.gitignore index e47b589..7f010af 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ node_modules/ # Local env files .env +/.env.* +!/.env.example /*.local # OS @@ -31,8 +33,28 @@ node_modules/ # Firebase service account key (local only - never commit) serviceAccount.json +*firebase-adminsdk*.json +*service-account*.json # Generated interview evaluation PDFs (contain candidate data) /reports/ # k6 load-test artifacts loadtest/summary*.json + +# Local QA, analysis and recovery workspaces (never deploy) +/.qa-artifacts/ +/qa/ +/playwright.config.ts +/graphify-out/ +/self/ +/.codex-* +/playwright-report/ +/test-results/ + +# Internal meeting/QA material; the GitHub repository is public +/docs/ADMIN_INSTITUTE_QA_TRACEABILITY_*.md +/docs/ENTERPRISE_MVP_IMPLEMENTATION_STATUS.md +/docs/ENTERPRISE_WORKSPACE_ARCHITECTURE.md +/docs/QA_RELEASE_CANDIDATE_REPORT_*.md +/docs/RECRUITER_ENTERPRISE_QA_AUDIT_*.md +/docs/TalEdge_PRD_Gap_Analysis_Meeting_* diff --git a/app/admin/AdminDashboard.tsx b/app/admin/AdminDashboard.tsx new file mode 100644 index 0000000..e16fb61 --- /dev/null +++ b/app/admin/AdminDashboard.tsx @@ -0,0 +1,841 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { + Activity, + ArrowUpRight, + Building2, + BriefcaseBusiness, + ClipboardCheck, + CircleAlert, + CreditCard, + Link2, + Loader2, + ShieldCheck, + Users, +} from "lucide-react"; +import { authedFetch } from "@/lib/api-client"; +import type { + AdminOverview, + ApprovalStatus, + AccessStatus, +} from "@/lib/mvp-admin-store"; +import { DashboardHeader, DashboardShell, KPIGrid, Section } from "@/components/dashboard"; +import { Badge, Button, Card, useToast } from "@/components/ui"; +import { + ASSESSMENT_KIND_VALUES, + assessmentKindLabel, + type AssessmentKind, +} from "@/lib/assessment-products"; +import { StudentServiceRequestsPanel } from "./StudentServiceRequestsPanel"; + +const DNLA_RELEASE_ENABLED = + process.env.NEXT_PUBLIC_DNLA_ENABLED === "true"; + +type AdminAction = + | { action: "organisation_status"; organisationId: string; status: ApprovalStatus } + | { action: "adjust_credits"; organisationId: string; delta: number; reason: string; idempotencyKey: string } + | { action: "assessment_active"; assessmentId: string; active: boolean } + | { action: "package_active"; packageId: string; active: boolean } + | { action: "access_status"; accessId: string; status: AccessStatus } + | { action: "upsert_package"; packageId: string; name: string; credits: number; priceInr: number; active: boolean } + | { action: "upsert_assessment"; assessmentId: string; name: string; kind: AssessmentKind; creditCost: number; active: boolean } + | { action: "transfer_credits"; fromOrganisationId: string; toOrganisationId: string; amount: number; reason: string; approvalEmail: string; idempotencyKey: string }; + +function approvalTone(status: ApprovalStatus) { + return status === "approved" ? "success" : status === "suspended" ? "danger" : "warn"; +} + +function accessTone(status: AccessStatus) { + return status === "approved" ? "success" : status === "revoked" ? "danger" : "warn"; +} + +export type AdminSection = + | "overview" + | "organisations" + | "connections" + | "billing" + | "assessments" + | "usage"; + +const sectionMeta: Record< + AdminSection, + { title: string; description: string } +> = { + overview: { + title: "Platform overview", + description: "Current platform health, tenant footprint, and work requiring attention.", + }, + organisations: { + title: "Organisation governance", + description: "Review, approve, suspend, and audit institutes and companies.", + }, + connections: { + title: "Access connections", + description: "Control every institute–company relationship and its candidate-access boundary.", + }, + billing: { + title: "Billing and credits", + description: "Manage packages, balances, corrections, and approved credit transfers.", + }, + assessments: { + title: "Assessment catalogue", + description: "Control assessment availability, type, and credit consumption.", + }, + usage: { + title: "Platform usage", + description: "Monitor durable assessment, interview, report, job, and credit activity.", + }, +}; + +export function AdminDashboard({ + initialOverview, + section, +}: { + initialOverview: AdminOverview; + section: AdminSection; +}) { + const [overview, setOverview] = useState(initialOverview); + const [busy, setBusy] = useState(""); + const [refreshing, setRefreshing] = useState(false); + const [packageDraft, setPackageDraft] = useState({ id: "", name: "", credits: "100", priceInr: "0" }); + const [assessmentDraft, setAssessmentDraft] = useState({ + id: "", + name: "", + kind: "technical_interview" as AssessmentKind, + creditCost: "1", + }); + const [transferDraft, setTransferDraft] = useState({ from: "", to: "", amount: "10", reason: "Super-admin approved reassignment", approvalEmail: "" }); + const retryableBillingOperations = useRef( + new Map() + ); + const { toast } = useToast(); + const names = useMemo( + () => new Map(overview.organisations.map((item) => [item.id, item.name])), + [overview.organisations] + ); + const organisationsForReview = useMemo( + () => + [...overview.organisations].sort((a, b) => { + if (a.status === "pending" && b.status !== "pending") return -1; + if (a.status !== "pending" && b.status === "pending") return 1; + return a.name.localeCompare(b.name); + }), + [overview.organisations] + ); + const pendingOrganisationCount = overview.organisations.filter( + (item) => item.status === "pending" + ).length; + const pendingAccessCount = overview.access.filter( + (item) => item.status === "pending" + ).length; + const pendingActionCount = pendingOrganisationCount + pendingAccessCount; + const meta = sectionMeta[section]; + + const refresh = async (showProgress = false) => { + if (showProgress) setRefreshing(true); + try { + const response = await authedFetch("/api/admin/overview"); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) { + throw new Error(data?.error || "Could not refresh platform data"); + } + setOverview(data.overview); + } finally { + if (showProgress) setRefreshing(false); + } + }; + + const run = async (key: string, action: AdminAction) => { + setBusy(key); + try { + let requestAction = action; + if ( + action.action === "adjust_credits" || + action.action === "transfer_credits" + ) { + const fingerprint = JSON.stringify({ + ...action, + idempotencyKey: undefined, + }); + const previous = retryableBillingOperations.current.get(key); + const idempotencyKey = + previous?.fingerprint === fingerprint + ? previous.idempotencyKey + : action.idempotencyKey; + retryableBillingOperations.current.set(key, { + fingerprint, + idempotencyKey, + }); + requestAction = { ...action, idempotencyKey }; + } + const response = await authedFetch("/api/admin/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestAction), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) throw new Error(data?.error || "Administrator action failed"); + retryableBillingOperations.current.delete(key); + await refresh(); + toast("Platform configuration updated.", "success"); + } catch (error) { + toast(error instanceof Error ? error.message : "Administrator action failed", "error"); + } finally { + setBusy(""); + } + }; + + const confirmAndRun = ( + key: string, + action: AdminAction, + message: string + ) => { + if (!window.confirm(message)) return; + void run(key, action); + }; + + return ( + + + void refresh(true).catch((error) => + toast( + error instanceof Error + ? error.message + : "Could not refresh platform data", + "error" + ) + ) + } + disabled={!!busy || refreshing} + > + {refreshing ? : } + Refresh + + } + /> + + {section === "overview" && ( + <> + , tone: "brand" }, + { label: "Companies", value: overview.usage.companies, icon: }, + { + label: "Pending actions", + value: pendingActionCount, + hint: `${pendingOrganisationCount} organisation · ${pendingAccessCount} connection`, + icon: , + tone: pendingActionCount > 0 ? "warn" : "success", + }, + { label: "Credits used", value: overview.usage.creditsUsed, icon: , tone: "warn" }, + ]} + /> +
} + > +
+ {[ + { + href: "/admin/organisations", + title: "Organisation approvals", + detail: `${pendingOrganisationCount} awaiting review`, + icon: Building2, + }, + { + href: "/admin/connections", + title: "Access connections", + detail: `${pendingAccessCount} awaiting review`, + icon: Link2, + }, + { + href: "/admin/recruiters", + title: "Recruiter governance", + detail: "Invitations, SPOCs and company roles", + icon: Users, + }, + { + href: "/admin/billing", + title: "Billing and credits", + detail: `${overview.usage.creditsAllocated} credits allocated`, + icon: CreditCard, + }, + { + href: "/admin/assessments", + title: "Assessment catalogue", + detail: `${overview.assessments.length} configured assessments`, + icon: ClipboardCheck, + }, + { + href: "/admin/usage", + title: "Usage monitoring", + detail: `${overview.usage.interviewSessions} interview sessions`, + icon: Activity, + }, + ].map((item) => { + const Icon = item.icon; + return ( + + +
+ + + + +
+

{item.title}

+

{item.detail}

+
+ + ); + })} +
+
+ + )} + + {section === "organisations" &&
0 + ? `${pendingOrganisationCount} organisation approval${pendingOrganisationCount === 1 ? "" : "s"} require action. Pending rows are shown first.` + : "No organisation approvals are pending." + } + icon={} + actions={ + 0 ? "warn" : "success"}> + {pendingOrganisationCount} pending + + } + > + +
+ + + + + + + + + + + {organisationsForReview.map((item) => ( + + + + + + + ))} + +
OrganisationTypeStatusAction
+

{item.name}

+

{item.id}

+ {item.primaryAdminEmail && ( +

+ {item.primaryAdminEmail} +

+ )} + {(item.profile?.industry || item.profile?.location) && ( +

+ {[item.profile?.industry, item.profile?.location] + .filter(Boolean) + .join(" · ")} +

+ )} + {item.profile?.website && + /^https?:\/\//i.test(item.profile.website) && ( + + {item.profile.website} + + )} + {item.profile?.description && ( +

+ {item.profile.description} +

+ )} +

+ Submitted {new Date(item.createdAt).toLocaleString()} +

+
{item.kind} + {item.status} + +
+ {item.status !== "approved" && ( + + )} + {item.status !== "suspended" && ( + + )} +
+
+
+
+
} + + {section === "billing" &&
} + > +
+ +
+ {overview.credits.map((account) => ( +
+
+

+ {names.get(account.organisationId) || account.organisationId} +

+

+ Allocated {account.allocated} · Used {account.used} +

+
+
+

{account.balance}

+

available

+
+
+ + +
+
+ ))} +
+
+

Transfer unused credits

+
+ + + setTransferDraft((current) => ({ ...current, amount: event.target.value }))} placeholder="Amount" className="h-10 rounded-lg border border-ink-200 bg-white px-3 text-sm" /> + setTransferDraft((current) => ({ ...current, reason: event.target.value }))} placeholder="Reason" className="h-10 rounded-lg border border-ink-200 bg-white px-3 text-sm" /> + setTransferDraft((current) => ({ ...current, approvalEmail: event.target.value }))} placeholder="Source institute approval email" className="h-10 rounded-lg border border-ink-200 bg-white px-3 text-sm sm:col-span-2" /> +
+ +
+
+
+ {overview.packages.map((item) => ( + +
+
+

{item.name}

+

+ {item.credits} credits · ₹{item.priceInr.toLocaleString("en-IN")} +

+
+ {item.active ? "Active" : "Inactive"} +
+ +
+ ))} + +

Create or update package

+
+ setPackageDraft((current) => ({ ...current, id: event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") }))} placeholder="package-id" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> + setPackageDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Package name" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+ setPackageDraft((current) => ({ ...current, credits: event.target.value }))} placeholder="Credits" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> + setPackageDraft((current) => ({ ...current, priceInr: event.target.value }))} placeholder="Price INR" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+
+ +
+
+
+ +
} + + {section === "assessments" &&
} + > +
+ {overview.assessments.map((item) => ( + +
+
+

{item.name}

+

+ {assessmentKindLabel(item.kind)} · {item.creditCost} credit{item.creditCost === 1 ? "" : "s"} per attempt +

+
+ + {item.releaseLocked + ? "Provider locked" + : item.active + ? "Active" + : "Inactive"} + +
+ +
+ ))} + +

Create or update assessment

+
+ setAssessmentDraft((current) => ({ ...current, id: event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") }))} placeholder="assessment-id" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> + setAssessmentDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Assessment name" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+ + setAssessmentDraft((current) => ({ ...current, creditCost: event.target.value }))} placeholder="Credit cost" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+
+ +
+
+
} + + {section === "connections" &&
0 + ? `${pendingAccessCount} connection request${pendingAccessCount === 1 ? "" : "s"} require approval or rejection.` + : "No connection requests are awaiting action." + } + icon={} + actions={ + 0 ? "warn" : "success"}> + {pendingAccessCount} pending + + } + > +
+ {overview.access.map((item) => { + const requestedByKind = item.requestedByKind ?? "company"; + const requesterId = + item.requestedByOrganisationId ?? + (requestedByKind === "institute" ? item.instituteId : item.companyId); + const targetId = + requestedByKind === "institute" ? item.companyId : item.instituteId; + return ( + +
+
+

+ {item.requestType === "recruiter_invitation" + ? "Institute-issued recruiter invitation" + : item.requestType === "network_connection" + ? "Network connection request" + : "Candidate access request"} +

+

+ {names.get(requesterId) || requesterId} + + {names.get(targetId) || targetId} +

+
+ {item.status} +
+
+
+
Requested by
+
+ {names.get(requesterId) || requesterId} · {requestedByKind} +
+ {item.requestedByEmail && ( +
{item.requestedByEmail}
+ )} +
+
+
Target
+
+ {names.get(targetId) || targetId} · {requestedByKind === "company" ? "institute" : "company"} +
+
+
+
Purpose
+
+ {item.purpose || "Purpose was not recorded for this older request."} +
+
+
+
Requested
+
+ {new Date(item.requestedAt ?? item.updatedAt).toLocaleString()} +
+
+
+
Access boundary
+
Only students who opt in to a relevant job
+
+
+
+ {item.status !== "approved" && ( + + )} + {item.status !== "revoked" && ( + + )} +
+
+ ); + })} + {overview.access.length === 0 && ( + +

No connection requests

+

+ New company or institute requests will appear here with their purpose and requester identity. +

+
+ )} +
+
} + + {section === "usage" &&
} + className="pb-8" + > + + + + + + + + + +
} +
+ ); +} + +function UsageMetric({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/app/admin/RecruiterGovernancePanel.tsx b/app/admin/RecruiterGovernancePanel.tsx new file mode 100644 index 0000000..d7232dd --- /dev/null +++ b/app/admin/RecruiterGovernancePanel.tsx @@ -0,0 +1,973 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Ban, + Check, + History, + Mail, + RefreshCw, + RotateCw, + ShieldCheck, + UserRoundPlus, + Users, + X, +} from "lucide-react"; +import { authedFetch } from "@/lib/api-client"; +import type { RecruiterGovernanceOverview } from "@/lib/recruiter-governance"; +import { + COMPANY_MEMBER_ROLES, + type CompanyMemberRole, +} from "@/lib/recruiter-governance-model"; +import { + Badge, + Button, + Card, + Input, + Select, + Textarea, + useToast, +} from "@/components/ui"; +import { + DashboardHeader, + DashboardShell, + KPIGrid, + Section, +} from "@/components/dashboard"; + +type GovernanceAction = + | ({ + action: "create_invitation"; + } & InvitationDraft) + | { action: "reissue_invitation"; invitationId: string } + | { action: "revoke_invitation"; invitationId: string } + | { + action: "review_company_change"; + requestId: string; + status: "approved" | "rejected"; + note?: string; + } + | { + action: "upsert_company_member"; + companyId: string; + email: string; + role: CompanyMemberRole; + status: "active" | "suspended"; + }; + +type InvitationDraft = { + instituteId: string; + targetCompanyId: string; + companyRole: CompanyMemberRole; + companyName: string; + companyWebsite: string; + companyAddress: string; + jobRole: string; + ctcRange: string; + recruiterName: string; + recruiterDesignation: string; + recruiterEmail: string; + recruiterMobile: string; + purpose: string; +}; + +const emptyInvitation: InvitationDraft = { + instituteId: "", + targetCompanyId: "", + companyRole: "company_admin", + companyName: "", + companyWebsite: "", + companyAddress: "", + jobRole: "", + ctcRange: "", + recruiterName: "", + recruiterDesignation: "", + recruiterEmail: "", + recruiterMobile: "", + purpose: "", +}; + +const roleLabel: Record = { + company_admin: "Company admin", + recruiter: "Recruiter", + hiring_manager: "Hiring manager", + interviewer: "Interviewer", +}; + +function statusTone( + status: string +): "success" | "danger" | "warn" | "neutral" | "brand" { + if (status === "approved" || status === "accepted" || status === "active") { + return "success"; + } + if (status === "rejected" || status === "revoked" || status === "suspended") { + return "danger"; + } + if (status === "pending" || status === "expired") return "warn"; + return "neutral"; +} + +export function RecruiterGovernancePanel({ + initialGovernance, +}: { + initialGovernance: RecruiterGovernanceOverview; +}) { + const [governance, setGovernance] = useState(initialGovernance); + const [invitation, setInvitation] = useState(emptyInvitation); + const [member, setMember] = useState<{ + companyId: string; + email: string; + role: CompanyMemberRole; + status: "active" | "suspended"; + }>({ + companyId: "", + email: "", + role: "recruiter", + status: "active", + }); + const [busy, setBusy] = useState(""); + const [manualLink, setManualLink] = useState(""); + const { toast } = useToast(); + + const pendingInvitations = governance.invitations.filter( + (item) => item.status === "pending" + ).length; + const pendingProfileChanges = governance.profileChanges.filter( + (item) => item.status === "pending" + ).length; + const activeMembers = governance.members.filter( + (item) => item.status === "active" + ).length; + const companies = useMemo( + () => new Map(governance.companies.map((item) => [item.id, item.name])), + [governance.companies] + ); + + const refresh = async () => { + const response = await authedFetch("/api/admin/recruiter-governance"); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) { + throw new Error(data?.error || "Could not refresh recruiter governance"); + } + setGovernance(data.governance); + }; + + const run = async (key: string, action: GovernanceAction) => { + setBusy(key); + try { + const response = await authedFetch("/api/admin/recruiter-governance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(action), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) { + throw new Error(data?.error || "Governance action failed"); + } + if ( + action.action === "create_invitation" || + action.action === "reissue_invitation" + ) { + setManualLink(data.path || ""); + toast( + data.emailed + ? "Secure invitation sent by email." + : "Secure invitation created. Copy the one-time link.", + "success" + ); + } else { + toast("Recruiter governance updated.", "success"); + } + await refresh(); + return data; + } catch (error) { + toast( + error instanceof Error ? error.message : "Governance action failed", + "error" + ); + return null; + } finally { + setBusy(""); + } + }; + + const createInvitation = async () => { + const result = await run("invitation-create", { + action: "create_invitation", + ...invitation, + }); + if (result) setInvitation(emptyInvitation); + }; + + const saveMember = async () => { + if ( + member.status === "suspended" && + !window.confirm( + `Suspend ${member.email || "this company member"}? Their company workspace access will be removed.` + ) + ) { + return; + } + const result = await run("member-save", { + action: "upsert_company_member", + ...member, + }); + if (result) { + setMember({ + companyId: member.companyId, + email: "", + role: "recruiter", + status: "active", + }); + } + }; + + return ( + + { + setBusy("refresh"); + void refresh() + .then(() => toast("Governance data refreshed.", "success")) + .catch((error) => + toast( + error instanceof Error ? error.message : "Refresh failed", + "error" + ) + ) + .finally(() => setBusy("")); + }} + > + + Refresh + + } + /> + + , + }, + { + label: "Identity reviews", + value: pendingProfileChanges, + tone: pendingProfileChanges ? "warn" : "success", + icon: , + }, + { + label: "Active company members", + value: activeMembers, + icon: , + }, + { + label: "Audited events", + value: governance.audits.length, + icon: , + }, + ]} + /> + +
} + > + +
+ + + + + + + + + + + + setInvitation((current) => ({ + ...current, + companyName: event.target.value, + })) + } + placeholder="Company legal or trading name" + /> + + + + setInvitation((current) => ({ + ...current, + companyWebsite: event.target.value, + })) + } + placeholder="https://company.example" + /> + + + + setInvitation((current) => ({ + ...current, + companyAddress: event.target.value, + })) + } + placeholder="City, state" + /> + + + + setInvitation((current) => ({ + ...current, + jobRole: event.target.value, + })) + } + placeholder="Backend Engineer" + /> + + + + setInvitation((current) => ({ + ...current, + ctcRange: event.target.value, + })) + } + placeholder="INR 8–10 LPA" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterName: event.target.value, + })) + } + placeholder="Recruiter name" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterDesignation: event.target.value, + })) + } + placeholder="Talent Acquisition Manager" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterEmail: event.target.value, + })) + } + placeholder="recruiter@company.com" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterMobile: event.target.value, + })) + } + placeholder="+91 98765 43210" + /> + + +