nxtgauge-frontend-solid/src/components/dashboard/VerificationStatusPage.tsx
Ashwin Kumar Sivakumar cab192d946
All checks were successful
build-and-release / build (push) Successful in 1m48s
Fix job seeker document submission: hide raw storage URL, fix error masking
Two issues in the job seeker (and shared profile) dashboard:
- The uploaded document's full Backblaze URL was rendered as visible text
  and linked directly; view-document now resolves a short-lived signed
  URL on demand instead of exposing the permanent storage link.
- "Submit for Verification" always showed a generic "Network error"
  message on any failure because request() throws on non-2xx responses,
  making the destructured status/data unreachable in the catch-free path
  (e.g. a 409 "verification already in progress" looked identical to a
  network failure). Now surfaces the real backend error message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:07:37 +05:30

576 lines
25 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* VerificationStatusPage — shows the user their current verification state.
* Handles: NOT_SUBMITTED, PENDING, UNDER_REVIEW, DOCUMENTS_REQUESTED,
* REVISION_REQUESTED, APPROVED, REJECTED.
* Tabs: approval status, documents, activity
*/
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { ShieldCheck, FileText, Activity } from 'lucide-solid';
import { CARD, BTN_ORANGE, BTN_GHOST } from '~/components/DashboardShell';
import { getDocFields } from '~/lib/profile-fields-config';
import { presignDocumentUrl } from '~/lib/api';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
type TabKey = 'approval_status' | 'documents' | 'activity';
// The K8s ingress routes /api/* directly to the Rust gateway service in
// production — there is no /api/gateway rewrite layer, so paths (already
// fully-qualified /api/... from callers) are hit as-is.
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(path, {
...opts,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
// ── Status config ─────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, {
emoji: string;
label: string;
color: string;
bg: string;
border: string;
description: string;
}> = {
NOT_SUBMITTED: {
emoji: '📋',
label: 'Not Submitted',
color: '#6B7280',
bg: '#F9FAFB',
border: '#E5E7EB',
description: 'Complete your My Profile and My Portfolio, then submit for verification to start using the platform.',
},
PENDING: {
emoji: '⏳',
label: 'Pending Review',
color: '#92400E',
bg: '#FFFBEB',
border: '#FDE68A',
description: 'Your profile has been submitted and is in the review queue. We typically respond within 2448 hours.',
},
UNDER_REVIEW: {
emoji: '🔍',
label: 'Under Review',
color: '#1E40AF',
bg: '#EEF2FF',
border: '#BFDBFE',
description: 'Our team is actively reviewing your submission. You will be notified once a decision is made.',
},
DOCUMENTS_REQUESTED: {
emoji: '📎',
label: 'Documents Requested',
color: '#C2410C',
bg: '#FFF7ED',
border: '#FED7AA',
description: 'Admin has requested additional or clearer documents. Please review the message below and resubmit.',
},
REVISION_REQUESTED: {
emoji: '✏️',
label: 'Revision Requested',
color: '#C2410C',
bg: '#FFF7ED',
border: '#FED7AA',
description: 'Admin has requested changes to your profile information. Please update and resubmit.',
},
APPROVED: {
emoji: '✅',
label: 'Approved',
color: '#065F46',
bg: '#ECFDF5',
border: '#6EE7B7',
description: 'Your profile has been verified and approved. You now have full access to the platform.',
},
REJECTED: {
emoji: '❌',
label: 'Rejected',
color: '#991B1B',
bg: '#FEF2F2',
border: '#FECACA',
description: 'Your verification was rejected. Please review the reason below, update your profile, and resubmit.',
},
};
const FLOW_STEPS = [
{ key: 'submit', label: 'Submit Profile' },
{ key: 'review', label: 'Under Review' },
{ key: 'verify', label: 'Verified' },
{ key: 'approved', label: 'Approved' },
];
function stepIndex(status: string): number {
switch (status) {
case 'NOT_SUBMITTED': return 0;
case 'PENDING': return 1;
case 'UNDER_REVIEW': return 2;
case 'DOCUMENTS_REQUESTED':
case 'REVISION_REQUESTED': return 2;
case 'APPROVED': return 4;
default: return 1;
}
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
roleKey: string;
onNavigate?: (sidebar: string) => void;
onVerificationStatusChange?: (status: string) => void;
}
interface VerificationDocument {
type: string;
value: any;
status: string;
}
interface VerificationStatusResponse {
status: string;
reference_number: string;
document_request?: string | null;
rejection_reason?: string | null;
updated_at?: string | null;
activity_log?: Array<{ date: string; action: string; details: string }>;
documents: VerificationDocument[];
}
// Best-effort extraction of a viewable URL from a document's `value` payload,
// which may be a raw URL string or an upload-response object.
function docUrl(value: any): string | null {
if (!value) return null;
if (typeof value === 'string') return value;
if (typeof value === 'object') {
return value.url ?? value.file_url ?? value.fileUrl ?? value.path ?? null;
}
return null;
}
export default function VerificationStatusPage(props: Props) {
const [activeTab, setActiveTab] = createSignal<TabKey>('approval_status');
const [status, setStatus] = createSignal('NOT_SUBMITTED');
const [referenceNumber, setReferenceNumber] = createSignal<string | null>(null);
const [docRequest, setDocRequest] = createSignal<string | null>(null);
const [rejectionReason, setRejectionReason] = createSignal<string | null>(null);
const [updatedAt, setUpdatedAt] = createSignal<string | null>(null);
const [loading, setLoading] = createSignal(true);
const [resubmitting, setResubmitting] = createSignal(false);
const [resubmitMsg, setResubmitMsg] = createSignal('');
const [activityLog, setActivityLog] = createSignal<Array<{ date: string; action: string; details: string }>>([]);
const [documents, setDocuments] = createSignal<VerificationDocument[]>([]);
onMount(async () => {
try {
const res = await apiFetch(`/api/me/verification-status?roleKey=${props.roleKey}`);
if (res.ok) {
const d: VerificationStatusResponse = await res.json();
const nextStatus = String(d.status ?? 'NOT_SUBMITTED');
setStatus(nextStatus);
props.onVerificationStatusChange?.(nextStatus);
setReferenceNumber(d.reference_number ?? null);
setDocRequest(d.document_request ?? null);
setRejectionReason(d.rejection_reason ?? null);
setUpdatedAt(d.updated_at ?? null);
if (d.activity_log) {
setActivityLog(d.activity_log);
}
if (d.documents) {
setDocuments(d.documents);
}
}
} finally {
setLoading(false);
}
});
const cfg = () => STATUS_CONFIG[status()] ?? STATUS_CONFIG.NOT_SUBMITTED;
const currentStep = () => stepIndex(status());
const docFields = createMemo(() => getDocFields(props.roleKey));
const canResubmit = () => ['DOCUMENTS_REQUESTED', 'REVISION_REQUESTED', 'REJECTED'].includes(status());
const handleResubmit = async () => {
setResubmitting(true);
setResubmitMsg('');
try {
const res = await apiFetch('/api/profile/submit-for-verification', {
method: 'POST',
body: JSON.stringify({ roleKey: props.roleKey }),
});
const d = await res.json().catch(() => ({}));
if (res.ok) {
setStatus('PENDING');
props.onVerificationStatusChange?.('PENDING');
setDocRequest(null);
setRejectionReason(null);
setResubmitMsg('Resubmitted successfully! We will review your profile.');
} else if (res.status === 409) {
setResubmitMsg(d.error ?? 'A verification is already in progress.');
} else {
setResubmitMsg(d.error ?? 'Resubmission failed. Please try again.');
}
} catch {
setResubmitMsg('Network error. Please try again.');
} finally {
setResubmitting(false);
}
};
const TABS: { key: TabKey; label: string; Icon: any }[] = [
{ key: 'approval_status', label: 'Approval Status', Icon: ShieldCheck },
{ key: 'documents', label: 'Documents', Icon: FileText },
{ key: 'activity', label: 'Activity', Icon: Activity },
];
return (
<div style={{ 'max-width': '640px' }}>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
"margin-bottom": "14px",
}}
>
<span style={{ color: ORANGE }}>
<ShieldCheck size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Verification Portal
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Track verification progress, documents, and updates.
</p>
<Show when={referenceNumber()}>
<p style={{ margin: "6px 0 0", "font-size": "13px", "font-family": "monospace", color: "rgba(255,255,255,0.85)" }}>
{referenceNumber()}
</p>
</Show>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '0', 'border-bottom': '1px solid #E5E7EB', 'margin-bottom': '14px' }}>
<For each={TABS}>
{(tab) => (
<button
type="button"
onClick={() => setActiveTab(tab.key)}
style={{
padding: "10px 16px",
"border-bottom": activeTab() === tab.key ? '2px solid #FF5E13' : '2px solid transparent',
background: "transparent",
color: activeTab() === tab.key ? "#FF5E13" : "#6B7280",
"font-size": "14px",
"font-weight": activeTab() === tab.key ? "700" : "500",
cursor: "pointer",
display: "flex",
"align-items": "center",
gap: "6px",
}}
>
<tab.Icon size={16} />
{tab.label}
</button>
)}
</For>
</div>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF' }}>
Loading verification status…
</div>
</Show>
<Show when={!loading()}>
{/* ── Approval Status Tab ─────────────────────────────────────── */}
<Show when={activeTab() === 'approval_status'}>
{/* Main status card */}
<div style={{
...CARD,
background: cfg().bg,
border: `1px solid ${cfg().border}`,
'margin-bottom': '16px',
display: 'flex',
'flex-direction': 'column',
gap: '12px',
}}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
<span style={{ 'font-size': '36px', 'line-height': '1' }}>{cfg().emoji}</span>
<div>
<p style={{ margin: '0', 'font-size': '11px', 'text-transform': 'uppercase', 'letter-spacing': '0.08em', color: cfg().color, 'font-weight': '700' }}>
Verification Status
</p>
<p style={{ margin: '2px 0 0', 'font-size': '22px', 'font-weight': '800', color: cfg().color }}>
{cfg().label}
</p>
</div>
</div>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151', 'line-height': '1.6' }}>
{cfg().description}
</p>
<Show when={updatedAt()}>
<p style={{ margin: '0', 'font-size': '11px', color: '#9CA3AF' }}>
Last updated: {new Date(updatedAt()!).toLocaleString('en-IN')}
</p>
</Show>
</div>
{/* Doc request / rejection reason */}
<Show when={docRequest()}>
<div style={{ ...CARD, background: '#FFF7ED', border: '1px solid #FED7AA', 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#C2410C' }}>
Document Request from Admin
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
{docRequest()}
</p>
</div>
</Show>
<Show when={rejectionReason()}>
<div style={{ ...CARD, background: '#FEF2F2', border: '1px solid #FECACA', 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#B91C1C' }}>
Rejection Reason
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
{rejectionReason()}
</p>
</div>
</Show>
{/* Progress timeline */}
<Show when={status() !== 'APPROVED'}>
<div style={{ ...CARD, 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 14px', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
Verification Progress
</p>
<div style={{ display: 'flex', 'align-items': 'center', gap: '0' }}>
<For each={FLOW_STEPS}>
{(step, idx) => {
const done = currentStep() > idx();
const active = currentStep() === idx() + 1;
return (
<>
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', 'flex-shrink': '0' }}>
<div style={{
width: '28px',
height: '28px',
'border-radius': '999px',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
'font-size': '11px',
'font-weight': '800',
background: done ? '#FF5E13' : active ? '#FFF3EE' : '#F3F4F6',
color: done ? '#fff' : active ? '#FF5E13' : '#9CA3AF',
border: active ? '2px solid #FF5E13' : '2px solid transparent',
}}>
{done ? '✓' : idx() + 1}
</div>
<p style={{ margin: '4px 0 0', 'font-size': '10px', 'font-weight': '600', color: done || active ? '#374151' : '#9CA3AF', 'white-space': 'nowrap', 'text-align': 'center' }}>
{step.label}
</p>
</div>
<Show when={idx() < FLOW_STEPS.length - 1}>
<div style={{ flex: '1', height: '2px', background: done ? '#FF5E13' : '#E5E7EB', 'margin-bottom': '18px' }} />
</Show>
</>
);
}}
</For>
</div>
</div>
</Show>
{/* Actions */}
<div style={{ display: 'flex', gap: '10px', 'flex-wrap': 'wrap' }}>
<Show when={status() === 'NOT_SUBMITTED'}>
<button type="button" onClick={() => props.onNavigate?.('My Profile')} style={BTN_ORANGE}>
Fill My Profile
</button>
<button type="button" onClick={() => props.onNavigate?.('My Portfolio')} style={BTN_GHOST}>
Fill My Portfolio
</button>
</Show>
<Show when={canResubmit()}>
<button type="button" onClick={() => props.onNavigate?.('My Profile')} style={BTN_GHOST}>
Update My Profile
</button>
<button type="button" onClick={handleResubmit} disabled={resubmitting()} style={{ ...BTN_ORANGE, opacity: resubmitting() ? '0.7' : '1' }}>
{resubmitting() ? 'Resubmitting…' : 'Resubmit for Verification'}
</button>
</Show>
</div>
<Show when={resubmitMsg()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', 'font-weight': '600', color: resubmitMsg().includes('successfully') ? '#10B981' : '#EF4444' }}>
{resubmitMsg()}
</p>
</Show>
<Show when={status() === 'APPROVED'}>
<div style={{ ...CARD, background: '#ECFDF5', border: '1px solid #6EE7B7', 'text-align': 'center', padding: '32px' }}>
<p style={{ margin: '0', 'font-size': '48px' }}>🎉</p>
<p style={{ margin: '12px 0 4px', 'font-size': '20px', 'font-weight': '800', color: '#065F46' }}>
You're Verified!
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#047857', 'line-height': '1.6' }}>
Your profile is approved. Start exploring opportunities on Nxtgauge.
</p>
</div>
</Show>
</Show>
{/* ── Documents Tab ───────────────────────────────────────────── */}
<Show when={activeTab() === 'documents'}>
<div style={CARD}>
<p style={{ margin: '0 0 14px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
Submitted Documents
</p>
<Show when={!docRequest() && !rejectionReason() && status() === 'NOT_SUBMITTED'}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
No documents submitted yet. Complete your profile to submit documents for verification.
</p>
</Show>
<Show when={docRequest()}>
<div style={{ background: '#FFF7ED', border: '1px solid #FED7AA', 'border-radius': '10px', padding: '14px', 'margin-bottom': '12px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '13px', 'font-weight': '700', color: '#C2410C' }}>
Document Request
</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151' }}>{docRequest()}</p>
</div>
</Show>
<Show when={rejectionReason()}>
<div style={{ background: '#FEF2F2', border: '1px solid #FECACA', 'border-radius': '10px', padding: '14px', 'margin-bottom': '12px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '13px', 'font-weight': '700', color: '#B91C1C' }}>
Rejection Reason
</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151' }}>{rejectionReason()}</p>
</div>
</Show>
<Show when={status() !== 'NOT_SUBMITTED'}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<For each={documents().length > 0 ? documents() : docFields().map((f) => ({ type: f.key, value: null, status: status() }))}>
{(doc) => {
const fieldLabel = () => docFields().find((f) => f.key === doc.type)?.label ?? doc.type;
const url = () => docUrl(doc.value);
return (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#111827' }}>{fieldLabel()}</p>
<Show
when={url()}
fallback={
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>No file uploaded</p>
}
>
<button
type="button"
onClick={async () => {
const signed = await presignDocumentUrl(url()!);
window.open(signed, '_blank', 'noopener,noreferrer');
}}
style={{
margin: '4px 0 0', 'font-size': '12px', color: '#FF5E13',
'text-decoration': 'underline', display: 'inline-block',
background: 'none', border: 'none', padding: '0', cursor: 'pointer',
}}
>
View document
</button>
</Show>
</div>
<p style={{
margin: '0',
'font-size': '12px',
'font-weight': '600',
color: doc.status === 'APPROVED' ? '#059669' : doc.status === 'REJECTED' ? '#DC2626' : '#6B7280',
}}>
{doc.status === 'APPROVED' ? '✓ Verified' : doc.status === 'REJECTED' ? '✕ Rejected' : doc.status === 'SUBMITTED' ? '◌ Submitted' : '◌ Pending review'}
</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</Show>
{/* ── Activity Tab ────────────────────────────────────────────── */}
<Show when={activeTab() === 'activity'}>
<div style={CARD}>
<p style={{ margin: '0 0 14px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
Verification Activity
</p>
<Show when={activityLog().length === 0}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px' }}>
<Show when={status() === 'NOT_SUBMITTED'}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: '#E5E7EB', 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>Profile created</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{updatedAt() ? new Date(updatedAt()!).toLocaleString('en-IN') : 'Just now'}</p>
</div>
</div>
</Show>
<Show when={!['NOT_SUBMITTED'].includes(status())}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: '#FDE68A', 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>Profile submitted for verification</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{updatedAt() ? new Date(updatedAt()!).toLocaleString('en-IN') : 'Recently'}</p>
</div>
</div>
</Show>
<Show when={status() === 'APPROVED'}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: '#10B981', 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>Profile approved</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{updatedAt() ? new Date(updatedAt()!).toLocaleString('en-IN') : 'Recently'}</p>
</div>
</div>
</Show>
</div>
</Show>
<Show when={activityLog().length > 0}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<For each={activityLog()}>
{(item) => (
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: ORANGE, 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>{item.action}</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{item.date}</p>
<Show when={item.details}>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{item.details}</p>
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</Show>
</div>
);
}