/* profile.jsx * * /profile page — three tabs: * 1. Account — email, username, created date, delete-account * 2. My Certifications — owned-certs list + add/edit/delete + PDF upload * 3. My Plan — placeholder for now (Batch C wires the plan view) * * Exposes: * window.ProfilePage — the page component (mounted by app.jsx) * * Data sources: * - window.AuthStore.user — account info (email, username, is_admin, created_at) * - window.UserData.getOwnedCerts() — declared owned certs (metadata) * - GET /api/user-certs — server-side wallet records (PDF + verification) * - POST /api/user-certs//upload — PDF upload (multipart) * - GET /api/user-certs//pdf — auth-gated PDF download * - DELETE /api/user-certs//pdf — remove PDF * * The two surfaces (client-side metadata vs server-side wallet record) are * keyed by the owned-cert's local UUID. On render we merge them so each row * shows both the user-entered fields and the wallet/verification state. */ (function () { "use strict"; const e = React.createElement; // Certs available in the picker — the 5 visible + 5 stubs, in chooser order. // We pull from window.CERT_META so a single source-of-truth drives everything. function getPickerOptions() { const cm = window.CERT_META || {}; const visibleOrder = ["secplus", "netplus", "ccna", "aplus1", "aplus2"]; const stubOrder = ["linuxplus", "cloudplus", "cysaplus", "pentestplus", "caspplus"]; const out = []; visibleOrder.forEach(id => { if (cm[id]) out.push({ id, meta: cm[id], comingSoon: false }); }); stubOrder.forEach(id => { if (cm[id]) out.push({ id, meta: cm[id], comingSoon: true }); }); // CCNP is read-only / hidden from picker — not a typical achievement path. return out; } // Pretty-format an ISO date for display function fmtDate(iso) { if (!iso) return "—"; try { const d = new Date(iso); return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } catch (_) { return iso; } } // Pretty-format an ISO date for (YYYY-MM-DD) function asInputDate(iso) { if (!iso) return ""; return iso.length >= 10 ? iso.slice(0, 10) : iso; } // Format bytes as "324 KB" etc. function fmtBytes(b) { if (!b) return "—"; if (b < 1024) return b + " B"; if (b < 1024 * 1024) return Math.round(b / 1024) + " KB"; return (b / (1024 * 1024)).toFixed(1) + " MB"; } // ────────────────────────────────────────────────────────── // CertModal — add or edit an owned cert // ────────────────────────────────────────────────────────── function CertModal({ initial, onClose, onSaved }) { const editing = !!(initial && initial.id); const [certId, setCertId] = React.useState(initial?.certId || ""); const [customName, setCustomName] = React.useState(initial?.customName || ""); const [issuer, setIssuer] = React.useState(initial?.issuer || ""); const [earnedDate, setEarnedDate] = React.useState(asInputDate(initial?.earnedDate)); const [expirationDate, setExpirationDate] = React.useState(asInputDate(initial?.expirationDate)); const [verificationId, setVerificationId] = React.useState(initial?.verificationId || ""); const [pdfFile, setPdfFile] = React.useState(null); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(""); const pickerOptions = getPickerOptions(); const selectedMeta = pickerOptions.find(o => o.id === certId); const isOther = certId === "other"; const isStub = selectedMeta && selectedMeta.comingSoon; // Auto-fill issuer from cert meta if user picks a known cert and issuer is blank React.useEffect(() => { if (!issuer && selectedMeta) setIssuer(selectedMeta.meta.vendor || ""); }, [certId]); async function handleSave() { setError(""); if (!certId) { setError("Please pick a certification"); return; } if (isOther && !customName.trim()) { setError("Please enter the cert name"); return; } setSaving(true); try { const entry = { id: initial?.id, certId, customName: isOther ? customName.trim() : null, issuer: issuer.trim() || null, earnedDate: earnedDate || null, expirationDate: expirationDate || null, verificationId: verificationId.trim() || null, }; const saved = window.UserData.saveOwnedCert(entry); // Wait briefly for the debounced backend push so that subsequent // PDF upload finds the entry. The save is fire-and-forget, so this // is best-effort — PDF upload doesn't actually require the metadata // to be on the server (the cert wallet table is keyed only by // owned_cert_id and user_id). if (pdfFile) { try { const fd = new FormData(); fd.append("file", pdfFile); const resp = await fetch(`/api/user-certs/${encodeURIComponent(saved.id)}/upload`, { method: "POST", headers: { Authorization: `Bearer ${window.AuthStore.token}` }, body: fd, }); if (!resp.ok) { const txt = await resp.text(); throw new Error(txt || resp.statusText); } } catch (uperr) { setError("Saved metadata but PDF upload failed: " + (uperr.message || uperr)); setSaving(false); return; } } onSaved && onSaved(saved); onClose(); } catch (err) { setError(err.message || String(err)); setSaving(false); } } return e("div", { className: "pf-modal-backdrop", onClick: onClose }, e("div", { className: "pf-modal", onClick: ev => ev.stopPropagation() }, e("div", { className: "pf-modal-head" }, e("h2", { className: "pf-modal-title" }, editing ? "Edit certification" : "Add certification"), e("button", { className: "pf-modal-close", onClick: onClose, "aria-label": "Close" }, "×") ), e("div", { className: "pf-modal-body" }, e("label", { className: "pf-field" }, e("span", { className: "pf-label" }, "Certification"), e("select", { className: "pf-input", value: certId, onChange: ev => setCertId(ev.target.value), disabled: editing, }, e("option", { value: "" }, "— pick one —"), pickerOptions.map(o => e("option", { key: o.id, value: o.id, }, o.meta.label || o.id, o.comingSoon ? " (coming soon)" : "")), e("option", { value: "other" }, "Other (specify below)") ) ), isOther && e("label", { className: "pf-field" }, e("span", { className: "pf-label" }, "Cert name"), e("input", { className: "pf-input", type: "text", value: customName, onChange: ev => setCustomName(ev.target.value), maxLength: 100, placeholder: "e.g. AWS Cloud Practitioner", }) ), e("label", { className: "pf-field" }, e("span", { className: "pf-label" }, "Issuer"), e("input", { className: "pf-input", type: "text", value: issuer, onChange: ev => setIssuer(ev.target.value), maxLength: 80, placeholder: "e.g. CompTIA, Cisco, AWS", }) ), e("div", { className: "pf-field-row" }, e("label", { className: "pf-field pf-field-half" }, e("span", { className: "pf-label" }, "Earned date"), e("input", { className: "pf-input", type: "date", value: earnedDate, onChange: ev => setEarnedDate(ev.target.value), }) ), e("label", { className: "pf-field pf-field-half" }, e("span", { className: "pf-label" }, "Expiration date (optional)"), e("input", { className: "pf-input", type: "date", value: expirationDate, onChange: ev => setExpirationDate(ev.target.value), }) ) ), e("label", { className: "pf-field" }, e("span", { className: "pf-label" }, "Verification ID (optional)"), e("input", { className: "pf-input", type: "text", value: verificationId, onChange: ev => setVerificationId(ev.target.value), maxLength: 80, placeholder: "From your cert credential", }), e("span", { className: "pf-help" }, "Where employers can verify with the issuer directly.") ), e("label", { className: "pf-field" }, e("span", { className: "pf-label" }, "Certificate PDF (optional)"), e("input", { className: "pf-input pf-input-file", type: "file", accept: "application/pdf,.pdf", onChange: ev => setPdfFile(ev.target.files && ev.target.files[0] || null), }), e("span", { className: "pf-help" }, "Uploading submits this cert for verification review. Max 5 MB. ", isStub ? "This cert isn't studyable in CyberStudy yet, but you can still record it." : "" ) ), isStub && e("div", { className: "pf-stub-note" }, "🔒 ", (selectedMeta?.meta?.label || ""), " isn't available for studying in CyberStudy yet — but recording it here lets the career planner count it toward your path." ), error && e("div", { className: "pf-modal-error" }, error) ), e("div", { className: "pf-modal-foot" }, e("button", { className: "btn btn-ghost", onClick: onClose, disabled: saving }, "Cancel"), e("button", { className: "btn btn-primary", onClick: handleSave, disabled: saving }, saving ? "Saving…" : (editing ? "Save changes" : "Add cert")) ) ) ); } // ────────────────────────────────────────────────────────── // Status badge — verification state // ────────────────────────────────────────────────────────── function VerifyBadge({ wallet }) { if (!wallet || !wallet.pdf_filename) { return e("span", { className: "pf-badge pf-badge-self" }, "Self-reported"); } if (wallet.verified) { return e("span", { className: "pf-badge pf-badge-verified", title: `Verified ${fmtDate(wallet.verified_at)}` }, "✓ Verified"); } if (wallet.review_status === "pending") { return e("span", { className: "pf-badge pf-badge-pending" }, "Pending review"); } if (wallet.review_status === "rejected") { return e("span", { className: "pf-badge pf-badge-rejected", title: wallet.review_notes || "" }, "Rejected"); } return e("span", { className: "pf-badge pf-badge-self" }, "PDF on file"); } // ────────────────────────────────────────────────────────── // CertCard — one owned cert row // ────────────────────────────────────────────────────────── function CertCard({ owned, wallet, onEdit, onRemove, onViewPdf, onRemovePdf }) { const cm = (window.CERT_META || {})[owned.certId] || {}; const label = owned.customName || cm.label || owned.certId; const examCode = cm.examCode || ""; return e("div", { className: "pf-cert-card" }, e("div", { className: "pf-cert-card-head" }, e("div", { className: "pf-cert-card-title-row" }, e("h3", { className: "pf-cert-card-title" }, label), e(VerifyBadge, { wallet }) ), examCode && e("div", { className: "pf-cert-card-examcode" }, examCode) ), e("div", { className: "pf-cert-card-meta" }, owned.issuer && e("div", { className: "pf-cert-card-row" }, e("span", { className: "pf-cert-card-key" }, "Issuer"), e("span", { className: "pf-cert-card-val" }, owned.issuer) ), owned.earnedDate && e("div", { className: "pf-cert-card-row" }, e("span", { className: "pf-cert-card-key" }, "Earned"), e("span", { className: "pf-cert-card-val" }, fmtDate(owned.earnedDate)) ), owned.expirationDate && e("div", { className: "pf-cert-card-row" }, e("span", { className: "pf-cert-card-key" }, "Expires"), e("span", { className: "pf-cert-card-val" }, fmtDate(owned.expirationDate)) ), owned.verificationId && e("div", { className: "pf-cert-card-row" }, e("span", { className: "pf-cert-card-key" }, "Verification ID"), e("span", { className: "pf-cert-card-val pf-mono" }, owned.verificationId) ), wallet && wallet.pdf_filename && e("div", { className: "pf-cert-card-row" }, e("span", { className: "pf-cert-card-key" }, "PDF"), e("span", { className: "pf-cert-card-val" }, e("button", { className: "pf-link-btn", onClick: () => onViewPdf(owned.id) }, "View"), " · ", fmtBytes(wallet.pdf_size_bytes) ) ), wallet && wallet.review_status === "rejected" && wallet.review_notes && e("div", { className: "pf-cert-card-row pf-cert-card-rejected" }, e("span", { className: "pf-cert-card-key" }, "Reviewer notes"), e("span", { className: "pf-cert-card-val" }, wallet.review_notes) ) ), e("div", { className: "pf-cert-card-actions" }, e("button", { className: "btn btn-ghost btn-sm", onClick: () => onEdit(owned) }, "Edit"), wallet && wallet.pdf_filename && e("button", { className: "btn btn-ghost btn-sm", onClick: () => onRemovePdf(owned.id), }, "Remove PDF"), e("button", { className: "btn btn-ghost btn-sm pf-cert-card-remove", onClick: () => onRemove(owned), }, "Delete") ) ); } // ────────────────────────────────────────────────────────── // CertsTab — list, add, edit, delete // ────────────────────────────────────────────────────────── function CertsTab() { const [owned, setOwned] = React.useState(() => window.UserData.getOwnedCerts()); const [walletMap, setWalletMap] = React.useState({}); // owned_cert_id -> wallet record const [loadingWallet, setLoadingWallet] = React.useState(true); const [modal, setModal] = React.useState(null); // null | { initial } for add/edit const [confirmRemove, setConfirmRemove] = React.useState(null); // null | owned-cert const [busy, setBusy] = React.useState(false); const [bannerErr, setBannerErr] = React.useState(""); React.useEffect(() => { function onChange() { setOwned(window.UserData.getOwnedCerts()); } window.addEventListener(window.UserData.EVT_OWNED, onChange); window.addEventListener(window.UserData.EVT_HYDRATE, onChange); return () => { window.removeEventListener(window.UserData.EVT_OWNED, onChange); window.removeEventListener(window.UserData.EVT_HYDRATE, onChange); }; }, []); async function refreshWallet() { setLoadingWallet(true); try { const data = await window.AuthStore.api("/api/user-certs"); setWalletMap(data || {}); } catch (err) { // Anonymous users have no wallet; treat as empty. setWalletMap({}); } finally { setLoadingWallet(false); } } React.useEffect(() => { refreshWallet(); }, []); async function handleSaved() { // After save (which may include a PDF upload), refresh wallet so the // verification badge updates. await refreshWallet(); } async function handleRemove(o) { setBusy(true); setBannerErr(""); try { // If there's a PDF on the server, remove it first so the file actually // goes away (the metadata-only delete from UserData won't trigger // the server cleanup). if (walletMap[o.id] && walletMap[o.id].pdf_filename) { try { await window.AuthStore.api(`/api/user-certs/${encodeURIComponent(o.id)}/pdf`, { method: "DELETE" }); } catch (_) { /* best-effort; metadata delete still proceeds */ } } window.UserData.removeOwnedCert(o.id); await refreshWallet(); } catch (err) { setBannerErr(err.message || String(err)); } finally { setBusy(false); setConfirmRemove(null); } } async function handleRemovePdf(ownedCertId) { setBusy(true); setBannerErr(""); try { await window.AuthStore.api(`/api/user-certs/${encodeURIComponent(ownedCertId)}/pdf`, { method: "DELETE" }); await refreshWallet(); } catch (err) { setBannerErr(err.message || String(err)); } finally { setBusy(false); } } function handleViewPdf(ownedCertId) { // Open in new tab. We need to include the bearer token; can't do that // with a plain link. So fetch as blob, create object URL, open. (async () => { try { const resp = await fetch(`/api/user-certs/${encodeURIComponent(ownedCertId)}/pdf`, { headers: { Authorization: `Bearer ${window.AuthStore.token}` }, }); if (!resp.ok) throw new Error(await resp.text() || resp.statusText); const blob = await resp.blob(); const url = URL.createObjectURL(blob); window.open(url, "_blank"); // Revoke after 60s — gives the browser plenty of time to load. setTimeout(() => URL.revokeObjectURL(url), 60000); } catch (err) { setBannerErr("Could not load PDF: " + (err.message || err)); } })(); } return e("div", { className: "pf-tab pf-certs-tab" }, e("div", { className: "pf-tab-head" }, e("div", null, e("h2", { className: "pf-tab-title" }, "My Certifications"), e("p", { className: "pf-tab-sub" }, "Track certs you've earned. Self-reported certs count toward your career path. Upload a PDF to request verification.") ), e("button", { className: "btn btn-primary", onClick: () => setModal({ initial: null }), disabled: busy, }, "+ Add certification") ), bannerErr && e("div", { className: "pf-banner-error" }, bannerErr), owned.length === 0 && !loadingWallet && e("div", { className: "pf-empty" }, e("div", { className: "pf-empty-title" }, "No certs recorded yet"), e("p", { className: "pf-empty-body" }, "Add a cert you've already earned — it'll count toward your career path and we'll surface renewal reminders when expiration dates get close.") ), owned.length > 0 && e("div", { className: "pf-cert-list" }, owned.map(o => e(CertCard, { key: o.id, owned: o, wallet: walletMap[o.id] || null, onEdit: oc => setModal({ initial: oc }), onRemove: oc => setConfirmRemove(oc), onViewPdf: handleViewPdf, onRemovePdf: handleRemovePdf, })) ), modal && e(CertModal, { initial: modal.initial, onClose: () => setModal(null), onSaved: handleSaved, }), confirmRemove && e("div", { className: "pf-modal-backdrop", onClick: () => setConfirmRemove(null) }, e("div", { className: "pf-modal pf-modal-small", onClick: ev => ev.stopPropagation() }, e("div", { className: "pf-modal-head" }, e("h2", { className: "pf-modal-title" }, "Delete certification?"), ), e("div", { className: "pf-modal-body" }, e("p", null, "This will remove ", e("strong", null, confirmRemove.customName || (window.CERT_META?.[confirmRemove.certId]?.label || confirmRemove.certId)), " from your profile", walletMap[confirmRemove.id]?.pdf_filename ? " and delete the uploaded PDF." : ".") ), e("div", { className: "pf-modal-foot" }, e("button", { className: "btn btn-ghost", onClick: () => setConfirmRemove(null), disabled: busy }, "Cancel"), e("button", { className: "btn btn-danger", onClick: () => handleRemove(confirmRemove), disabled: busy }, busy ? "Deleting…" : "Delete") ) ) ) ); } // ────────────────────────────────────────────────────────── // AccountTab — read-only info + delete-account // ────────────────────────────────────────────────────────── function AccountTab() { const user = window.AuthStore && window.AuthStore.user; if (!user) return e("div", null, "Not signed in."); return e("div", { className: "pf-tab pf-account-tab" }, e("h2", { className: "pf-tab-title" }, "Account"), e("p", { className: "pf-tab-sub" }, "Your CyberStudy account info."), e("div", { className: "pf-account-grid" }, e("div", { className: "pf-account-row" }, e("span", { className: "pf-account-key" }, "Email"), e("span", { className: "pf-account-val" }, user.email) ), e("div", { className: "pf-account-row" }, e("span", { className: "pf-account-key" }, "Username"), e("span", { className: "pf-account-val pf-mono" }, user.username) ), e("div", { className: "pf-account-row" }, e("span", { className: "pf-account-key" }, "Name"), e("span", { className: "pf-account-val" }, user.name) ), e("div", { className: "pf-account-row" }, e("span", { className: "pf-account-key" }, "Created"), e("span", { className: "pf-account-val" }, fmtDate(user.created_at)) ), e("div", { className: "pf-account-row" }, e("span", { className: "pf-account-key" }, "Sign-in"), e("span", { className: "pf-account-val" }, user.has_password && "Password", user.has_password && user.has_google && " · ", user.has_google && "Google", !user.has_password && !user.has_google && "—" ) ), user.is_admin && e("div", { className: "pf-account-row" }, e("span", { className: "pf-account-key" }, "Role"), e("span", { className: "pf-account-val" }, "Admin") ) ), e("div", { className: "pf-account-danger" }, e("h3", { className: "pf-danger-title" }, "Delete account"), e("p", { className: "pf-danger-body" }, "Deletes your account and all study data after a 30-day grace period. ", "You can sign back in within that window to cancel."), e("button", { className: "btn btn-danger", onClick: () => { // Reuse the existing account-deletion flow on the /account page. // Profile is the discovery surface; the actual delete dialog lives there. if (window.Router) window.Router.navigate("account"); } }, "Go to delete-account →") ) ); } // ────────────────────────────────────────────────────────── // PlanTab — placeholder until Batch C // ────────────────────────────────────────────────────────── function PlanTab() { const enrollment = window.UserData?.getCareerEnrollment(); if (!enrollment) { return e("div", { className: "pf-tab pf-plan-tab" }, e("h2", { className: "pf-tab-title" }, "My Plan"), e("p", { className: "pf-tab-sub" }, "You're not enrolled in a career path yet."), e("div", { className: "pf-empty" }, e("div", { className: "pf-empty-title" }, "Pick a career path"), e("p", { className: "pf-empty-body" }, "Browse careers to find one that fits — we'll surface a recommended cert sequence and track your progress."), e("button", { className: "btn btn-primary", onClick: () => window.Router && window.Router.navigate("career"), }, "Browse careers →") ) ); } const career = (window.CAREER_PATHS || []).find(c => c.id === enrollment.careerId); return e("div", { className: "pf-tab pf-plan-tab" }, e("h2", { className: "pf-tab-title" }, "My Plan"), e("p", { className: "pf-tab-sub" }, "You're enrolled in ", e("strong", null, career ? career.name : enrollment.careerId), "."), e("button", { className: "btn btn-primary", onClick: () => { if (window.Router && window.Router.navigateCareer) { window.Router.navigateCareer({ kind: "plan", careerId: enrollment.careerId }); } }, }, "View plan →") ); } // ────────────────────────────────────────────────────────── // AppearanceTab — color theme picker (changes accent + background tint) // ────────────────────────────────────────────────────────── function AppearanceTab() { return e("div", { className: "pf-tab pf-appearance-tab" }, e("h2", { className: "pf-tab-title" }, "Appearance"), e("p", { className: "pf-tab-sub" }, "Pick a color theme. It re-tints the whole interface — background, " + "accents, and highlights — and is saved to this browser."), // window.ThemePicker is defined in app.jsx and exposed globally. Guard in // case profile.jsx renders before app.jsx has evaluated. window.ThemePicker ? e(window.ThemePicker) : e("p", { className: "pf-tab-sub" }, "Theme picker is loading…") ); } // ────────────────────────────────────────────────────────── // ProfilePage — tab container // ────────────────────────────────────────────────────────── function ProfilePage() { const [tab, setTab] = React.useState("certs"); const user = window.AuthStore && window.AuthStore.user; // If not signed in, send to /login. /profile is only meaningful when logged in. if (!user) { return e("div", { className: "pf-page" }, e("div", { className: "pf-not-signed-in" }, e("h2", null, "Sign in to view your profile"), e("p", null, "Track certs you've earned, manage your career path, and configure your account."), e("button", { className: "btn btn-primary", onClick: () => window.Router && window.Router.navigate("login"), }, "Sign in") ) ); } return e("div", { className: "pf-page" }, e("div", { className: "pf-page-head" }, e("h1", { className: "pf-page-title" }, "Profile"), e("p", { className: "pf-page-sub" }, "Signed in as ", e("span", { className: "pf-mono" }, user.username)) ), e("div", { className: "pf-tabs" }, e("button", { className: `pf-tabbtn ${tab === "certs" ? "active" : ""}`, onClick: () => setTab("certs") }, "My Certifications"), e("button", { className: `pf-tabbtn ${tab === "plan" ? "active" : ""}`, onClick: () => setTab("plan") }, "My Plan"), e("button", { className: `pf-tabbtn ${tab === "account" ? "active" : ""}`, onClick: () => setTab("account") }, "Account"), e("button", { className: `pf-tabbtn ${tab === "appearance" ? "active" : ""}`, onClick: () => setTab("appearance") }, "Appearance") ), e("div", { className: "pf-tab-body" }, tab === "certs" && e(CertsTab), tab === "plan" && e(PlanTab), tab === "account" && e(AccountTab), tab === "appearance" && e(AppearanceTab) ) ); } window.ProfilePage = ProfilePage; })();