// Account settings page — DynamoDB-backed via API Gateway // ── API layer ───────────────────────────────────────────────────────────────── const API_BASE = 'https://r5vefiej3l.execute-api.eu-central-1.amazonaws.com/prod'; const COGNITO_CLIENT = '7nffjheavp985k7b4bgug45jf4'; function getToken() { const last = localStorage.getItem(`CognitoIdentityServiceProvider.${COGNITO_CLIENT}.LastAuthUser`); if (!last) return null; return localStorage.getItem(`CognitoIdentityServiceProvider.${COGNITO_CLIENT}.${last}.idToken`); } function _isCognitoToken(token) { try { const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); return payload.token_use === 'access' && String(payload.iss || '').includes('cognito-idp'); } catch { return false; } } // Returns true for email/password Cognito users; false for GitHub OAuth users function isEmailPasswordUser() { const t = localStorage.getItem('fossyl_access_token'); return !!t && _isCognitoToken(t); } // Use the amazon-cognito-identity-js SDK (loaded via CDN) for all Cognito operations. // Raw fetch to the Cognito endpoint fails because the SDK handles session refresh, // secret hash computation, and other auth details that plain fetch misses. function _getCognitoSDKUser() { if (!window.AmazonCognitoIdentity) return null; const pool = new window.AmazonCognitoIdentity.CognitoUserPool({ UserPoolId: 'eu-central-1_IDzeVSllu', ClientId: COGNITO_CLIENT, }); return pool.getCurrentUser(); } function withCognitoSession(fn) { return new Promise((resolve, reject) => { const user = _getCognitoSDKUser(); if (!user) return reject(new Error('Not signed in — please sign out and back in')); user.getSession((err, session) => { if (err || !session?.isValid()) { return reject(err || new Error('Session expired — please sign in again')); } fn(user, resolve, reject); }); }); } async function api(method, path, body) { const token = getToken(); const res = await fetch(`${API_BASE}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } // ── localStorage helpers (sidebar cache only) ───────────────────────────────── function storageGet(key, fallback) { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; } } function storageSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch {} } function initials(name) { return (name || '?').split(' ').filter(Boolean).map(w => w[0]).join('').slice(0, 2).toUpperCase(); } // ── Shared UI primitives ────────────────────────────────────────────────────── function Setting({ label, help, children }) { return (
{label}
{help &&
{help}
}
{children}
); } function SegmentControl({ options, value, onChange }) { return (
{options.map((opt, i) => ( ))}
); } function Modal({ children, onClose, width = 440 }) { useEffect(() => { const fn = e => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', fn); return () => window.removeEventListener('keydown', fn); }, [onClose]); return (
e.stopPropagation()} style={{ width, background: 'var(--bg)', border: '1px solid var(--line-2)', borderRadius: 3, padding: 28, boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
{children}
); } function SaveBar({ onSave, onDiscard, saving, dirty }) { return (
); } // ── AccountPage ─────────────────────────────────────────────────────────────── function AccountPage() { const { go } = useNav(); const { toast } = useToast(); const [tab, setTab] = useState('profile'); const [profile, setProfile] = useState(null); const [workspace,setWorkspace] = useState(null); const [members, setMembers] = useState(null); const [loading, setLoading] = useState(true); const [apiError, setApiError] = useState(false); useEffect(() => { // Profile must come first — it bootstraps the workspace on first login api('GET', '/account/profile') .then(p => { setProfile(p); _sidebarProfile = p; return Promise.all([ api('GET', '/account/workspace'), api('GET', '/account/members'), ]); }) .then(([ws, mem]) => { const memberList = mem.members || []; setWorkspace(ws); _sidebarWorkspace = ws; setMembers(memberList); _sbMembers = memberList; setLoading(false); }) .catch(err => { console.warn('Account API unavailable:', err.message); setProfile({ display_name: '', email: '', role_title: '', timezone: 'Europe/Berlin (CET · UTC+01:00)', member_since: '', workspace_id: '' }); setWorkspace({ org_name: '', workspace_slug: '', industry: 'Other', framework: 'Plone 4', seat_limit: 5, plan: 'field' }); setMembers([]); setApiError(true); setLoading(false); }); }, []); if (loading) return (
loading account…
); const TABS = [ ['profile','Profile'],['team','Team & seats'],['plan','Plan'],['org','Organisation'], ['security','Security'],['data','Data handling'],['api','API & tokens'], ['notifications','Notifications'],['danger','Danger zone'], ]; return (
{apiError && (
⚠ API unreachable — changes will not persist until the backend is available.
)}
{TABS.map(([id, label]) => ( ))}
{tab === 'profile' && { setProfile(p); _sidebarProfile = p; }}/>} {tab === 'team' && { setMembers(m); _sbMembers = m; }}/>} {tab === 'plan' && } {tab === 'org' && { setWorkspace(ws); _sidebarWorkspace = ws; }}/>} {tab === 'security' && } {tab === 'data' && } {tab === 'api' && } {tab === 'notifications' && } {tab === 'danger' && }
); } // ── Profile tab ─────────────────────────────────────────────────────────────── function TabProfile({ initialProfile, onProfileSaved }) { const { toast } = useToast(); const [saved, setSaved] = useState(initialProfile); const [form, setForm] = useState({ ...initialProfile }); const [saving, setSaving] = useState(false); const dirty = JSON.stringify(form) !== JSON.stringify(saved); const set = k => e => setForm(f => ({ ...f, [k]: e.target.value })); const onSave = async () => { setSaving(true); try { await api('PUT', '/account/profile', { display_name: form.display_name, role_title: form.role_title, timezone: form.timezone }); const next = { ...form }; setSaved(next); _sidebarProfile = next; onProfileSaved?.(next); toast('Profile saved', { tone: 'success' }); } catch (e) { toast(e.message || 'Save failed', { tone: 'error' }); } finally { setSaving(false); } }; const onDiscard = () => { setForm({ ...saved }); toast('Changes discarded'); }; const av = initials(form.display_name); return (
{av}
{form.display_name || '—'}
{form.email} · member since {form.member_since?.slice(0,10) || '—'}
Email is managed by Cognito — contact support to change it.
); } // ── Team tab ────────────────────────────────────────────────────────────────── function TabTeam({ initialMembers, workspace, onMembersChange }) { const { toast } = useToast(); const { role: myRole } = useAuth(); const canManage = myRole === 'Owner' || myRole === 'Admin'; const [list, setList] = useState(initialMembers || []); const [managing, setManaging] = useState(null); const [inviteOpen, setInviteOpen] = useState(false); const [defaultRole,setDefaultRole]= useState('Member'); const [busy, setBusy] = useState(false); const totalSeats = workspace?.seat_limit || 5; const activeCount = list.filter(m => m.status === 'active').length; const invitedCount = list.filter(m => m.status === 'invited').length; const remaining = Math.max(0, totalSeats - list.length); const sync = updated => { setList(updated); onMembersChange?.(updated); }; const updateRole = async (email, role) => { setBusy(true); try { await api('PATCH', `/account/members/${encodeURIComponent(email)}`, { role }); sync(list.map(m => m.email === email ? { ...m, role } : m)); setManaging(prev => prev ? { ...prev, role } : null); toast(`Role updated to ${role}`, { tone: 'success' }); } catch (e) { toast(e.message, { tone: 'error' }); } finally { setBusy(false); } }; const remove = async email => { setBusy(true); try { await api('DELETE', `/account/members/${encodeURIComponent(email)}`); sync(list.filter(m => m.email !== email)); toast('Member removed', { tone: 'success' }); setManaging(null); } catch (e) { toast(e.message, { tone: 'error' }); } finally { setBusy(false); } }; const invite = async (email, role) => { setBusy(true); try { const res = await api('POST', '/account/members', { email, role }); sync([...list, { email, display_name: res.display_name || email.split('@')[0], role, status: 'invited', invited_at: new Date().toISOString() }]); toast(`Invite sent to ${email}`, { tone: 'success' }); setInviteOpen(false); } catch (e) { toast(e.message, { tone: 'error' }); } finally { setBusy(false); } }; return (
Team · {activeCount} active{invitedCount > 0 ? `, ${invitedCount} invited` : ''}
{totalSeats} seats included · {remaining} remaining
{canManage && (
)}
{list.map(m => ( ))}
NameEmailRoleStatusSince
{initials(m.display_name)}
{m.display_name} {m.email} {m.role} {m.status} {(m.joined_at || m.invited_at || '').slice(0,10) || '—'} {canManage && m.role !== 'Owner' && setManaging(m)}>manage →}
{managing && ( setManaging(null)}>
Manage member
{initials(managing.display_name)}
{managing.display_name}
{managing.email}
Role
updateRole(managing.email, r)}/>
{managing.status === 'invited' && }
)} {inviteOpen && setInviteOpen(false)} onInvite={invite} busy={busy}/>}
); } function InviteModal({ defaultRole, onClose, onInvite, busy }) { const [email, setEmail] = useState(''); const [role, setRole] = useState(defaultRole); const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return (
Invite member
Email address setEmail(e.target.value)} placeholder="colleague@yourdomain.com" autoFocus onKeyDown={e => e.key === 'Enter' && valid && !busy && onInvite(email, role)}/>
Role
); } // ── Organisation tab ────────────────────────────────────────────────────────── function TabOrg({ initialWorkspace, onWorkspaceSaved }) { const { toast } = useToast(); const [saved, setSaved] = useState(initialWorkspace); const [form, setForm] = useState({ ...initialWorkspace }); const [saving, setSaving] = useState(false); const dirty = JSON.stringify(form) !== JSON.stringify(saved); const set = k => e => setForm(f => ({ ...f, [k]: e.target.value })); const onSave = async () => { setSaving(true); try { await api('PUT', '/account/workspace', { org_name: form.org_name, workspace_slug: form.workspace_slug, industry: form.industry, framework: form.framework }); const next = { ...form }; setSaved(next); onWorkspaceSaved?.(next); toast('Organisation saved', { tone: 'success' }); } catch (e) { toast(e.message || 'Save failed', { tone: 'error' }); } finally { setSaving(false); } }; const onDiscard = () => { setForm({ ...saved }); toast('Changes discarded'); }; return (
.getfossyl.dev
setForm(f => ({ ...f, framework: fw }))}/>
); } // ── Plan tab ────────────────────────────────────────────────────────────────── function TabPlan({ go, workspace }) { const { paid, plan, role, setRole, setPaid, setPlan } = useAuth(); const { toast } = useToast(); const [leaving, setLeaving] = useState(false); const [confirmLeave, setConfirmLeave] = useState(false); const ws = workspace || {}; const wsSlug = ws.workspace_slug || '—'; const planLabel = { specimen:'Specimen', field:'Field', team:'Team', agency:'Agency' }[plan] || plan || '—'; const seats = { specimen:1, field:2, team:5, agency:null }[plan] || ws.seat_limit || 1; const isOwner = role === 'Owner'; const handleLeave = async () => { setLeaving(true); try { await api('POST', '/account/workspace/leave'); // Reset to unpaid solo state setPaid(false); setPlan('field'); setRole('Owner'); localStorage.setItem('fossyl_role', 'Owner'); setConfirmLeave(false); toast('You have left the workspace — choose a plan to start excavating.', { tone: 'success', duration: 4000 }); go('billing'); } catch (e) { toast(e.message || 'Failed to leave workspace', { tone: 'error' }); } finally { setLeaving(false); } }; return (
Current planworkspace · {wsSlug}
{!paid ? (
No active subscription — choose a plan to start excavating.
) : (
Tier
{planLabel}
{seats} seats · active plan
{isOwner && } {isOwner && }
)}
{!isOwner && (
Workspace membership
You are a {role} in this workspace.
Leaving will remove your access and return you to a solo account.
)} {confirmLeave && (
setConfirmLeave(false)}>
e.stopPropagation()} style={{ width: 480, background: 'var(--bg)', border: '1px solid var(--rust-dim)', borderRadius: 3, padding: 28 }}>
Leave workspace

You will lose access to all shared excavations and reports in this workspace. Your account will be reset to an unpaid solo plan — you can subscribe again at any time.

)}
); } // ── Security tab ────────────────────────────────────────────────────────────── function TabSecurity() { const { toast } = useToast(); const { signOut } = useAuth(); const { go } = useNav(); // Password change const [showPwModal, setShowPwModal] = useState(false); const [pwForm, setPwForm] = useState({ current: '', newPw: '', confirm: '' }); const [pwBusy, setPwBusy] = useState(false); // TOTP / MFA const [mfaEnabled, setMfaEnabled] = useState(false); const [showMfaSetup, setShowMfaSetup] = useState(false); const [totpSecret, setTotpSecret] = useState(null); const [totpCode, setTotpCode] = useState(''); const [mfaBusy, setMfaBusy] = useState(false); // Current session info (decoded from JWT) const [session, setSession] = useState(null); const emailUser = isEmailPasswordUser(); useEffect(() => { const id = getToken(); if (id) { try { const { iat, email } = JSON.parse(atob(id.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); const ua = navigator.userAgent; const browser = /Firefox/i.test(ua) ? 'Firefox' : /Edg/i.test(ua) ? 'Edge' : /Chrome/i.test(ua) ? 'Chrome' : /Safari/i.test(ua) ? 'Safari' : 'Browser'; setSession({ browser, email, when: new Date(iat * 1000).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) }); } catch {} } }, []); const handleChangePassword = async () => { if (pwForm.newPw !== pwForm.confirm) { toast('New passwords do not match', { tone: 'error' }); return; } if (pwForm.newPw.length < 8) { toast('Password must be at least 8 characters', { tone: 'error' }); return; } setPwBusy(true); try { await withCognitoSession((user, resolve, reject) => { user.changePassword(pwForm.current, pwForm.newPw, (err, result) => { if (err) reject(err); else resolve(result); }); }); setShowPwModal(false); setPwForm({ current: '', newPw: '', confirm: '' }); toast('Password changed', { tone: 'success' }); } catch (e) { toast(e.message || 'Password change failed', { tone: 'error' }); } setPwBusy(false); }; const handleMfaSetup = async () => { setMfaBusy(true); try { const secret = await withCognitoSession((user, resolve, reject) => { user.associateSoftwareToken({ associateSecretCode: resolve, onFailure: reject, }); }); setTotpSecret(secret); setShowMfaSetup(true); } catch (e) { toast(`Authenticator setup failed: ${e.message}`, { tone: 'error', duration: 5000 }); } setMfaBusy(false); }; const handleMfaVerify = async () => { if (totpCode.length !== 6) { toast('Enter the 6-digit code from your app', { tone: 'error' }); return; } setMfaBusy(true); try { await withCognitoSession((user, resolve, reject) => { user.verifySoftwareToken(totpCode, 'Authenticator app', { onSuccess: resolve, onFailure: reject, }); }); await withCognitoSession((user, resolve, reject) => { user.setUserMfaPreference( null, { Enabled: true, PreferredMfa: true }, (err, r) => err ? reject(err) : resolve(r), ); }); setMfaEnabled(true); setShowMfaSetup(false); setTotpSecret(null); setTotpCode(''); toast('Two-factor authentication enabled', { tone: 'success' }); } catch (e) { toast(e.message, { tone: 'error' }); } setMfaBusy(false); }; const handleMfaDisable = async () => { setMfaBusy(true); try { await withCognitoSession((user, resolve, reject) => { user.setUserMfaPreference( null, { Enabled: false, PreferredMfa: false }, (err, r) => err ? reject(err) : resolve(r), ); }); setMfaEnabled(false); toast('Two-factor authentication disabled'); } catch (e) { toast(e.message, { tone: 'error' }); } setMfaBusy(false); }; const handleSignOutEverywhere = async () => { try { await withCognitoSession((user, resolve, reject) => { user.globalSignOut({ onSuccess: resolve, onFailure: reject }); }); } catch {} signOut(); toast('Signed out of all sessions', { tone: 'success' }); go('login'); }; const totpUri = totpSecret && session?.email ? `otpauth://totp/Fossyl:${encodeURIComponent(session.email)}?secret=${totpSecret}&issuer=Fossyl` : ''; const qrUrl = totpUri ? `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}` : ''; return (
{emailUser ? ( <> {mfaEnabled ? 'enabled' : 'disabled'} {mfaEnabled ? : } ) : ( managed by GitHub )}
{session && (
{session.browser} · {navigator.platform || 'Unknown OS'}
signed in {session.when}
current
)}
{/* Change password modal */} {showPwModal && ( { setShowPwModal(false); setPwForm({ current: '', newPw: '', confirm: '' }); }}>
Change password
Current password setPwForm(f => ({ ...f, current: e.target.value }))}/>
New password setPwForm(f => ({ ...f, newPw: e.target.value }))}/>
Confirm new password setPwForm(f => ({ ...f, confirm: e.target.value }))} onKeyDown={e => e.key === 'Enter' && !pwBusy && handleChangePassword()}/>
)} {/* TOTP setup modal */} {showMfaSetup && totpSecret && ( { setShowMfaSetup(false); setTotpSecret(null); setTotpCode(''); }} width={480}>
Set up authenticator

Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.

TOTP QR code
Or enter the secret key manually
{totpSecret}
Verification code from your app setTotpCode(e.target.value.replace(/\D/g, ''))} onKeyDown={e => e.key === 'Enter' && totpCode.length === 6 && !mfaBusy && handleMfaVerify()}/>
)}
); } // ── Data handling tab ───────────────────────────────────────────────────────── function TabData({ profile }) { const { toast } = useToast(); const { paid, plan } = useAuth(); const [exporting, setExporting] = useState(false); const [lastExport, setLastExport] = useState(profile?.last_export_at || ''); const PLAN_RETENTION = { specimen: '30 days', field: '90 days', team: '1 year', agency: 'Until deleted' }; const retentionLabel = paid ? (PLAN_RETENTION[plan] || plan || '—') : null; const handleExport = async () => { setExporting(true); try { const res = await api('POST', '/account/export'); setLastExport(res.exported_at || ''); toast('Export sent — check your email inbox.', { tone: 'success', duration: 4000 }); } catch (e) { toast(e.message || 'Export failed', { tone: 'error' }); } setExporting(false); }; return (
{paid ? (
{retentionLabel} set by your {plan} plan · cannot be shortened
) : ( No active subscription — retention follows your plan after subscribing. )}
{lastExport && ( last export: {lastExport.slice(0, 10)} )}
); } // ── API tokens tab ──────────────────────────────────────────────────────────── function TabApi() { const { toast } = useToast(); const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [newToken, setNewToken] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { api('GET', '/account/tokens') .then(d => { setTokens(d.tokens || []); setLoading(false); }) .catch(() => { setTokens([]); setLoading(false); }); }, []); const revoke = async prefix => { setBusy(true); try { await api('DELETE', `/account/tokens/${prefix}`); setTokens(ts => ts.filter(t => t.prefix !== prefix)); toast('Token revoked', { tone: 'success' }); } catch (e) { toast(e.message, { tone: 'error' }); } finally { setBusy(false); } }; const generate = async () => { setBusy(true); try { const res = await api('POST', '/account/tokens', { label: 'New token' }); setTokens(ts => [...ts, { prefix: res.prefix, label: res.label, created_at: res.created_at, last_used: 'never' }]); setNewToken(res.token); toast('Copy the token now — it won\'t be shown again', { tone: 'success', duration: 4000 }); } catch (e) { toast(e.message, { tone: 'error' }); } finally { setBusy(false); } }; const copy = () => { navigator.clipboard?.writeText(newToken).catch(() => {}); toast('Copied', { tone: 'success' }); setNewToken(null); }; return (
{loading &&
loading tokens…
} {newToken && (
New token — copy now, shown once only
{newToken}
)} {tokens.map(t => (
{t.prefix}••••
{t.label} · created {t.created_at?.slice(0,10)} · last used {t.last_used}
))}
{`$ npm i -g @fossyl/cli\n$ fossyl auth pat_live_F0a3f...\n$ fossyl scan ./plone-site.zip --zodb ./Data.fs.gz\n  → scan queued · F-8a41\n  → report ready in 48s\n  → https://charite.getfossyl.dev/r/F-8a41`}
); } // ── Notifications tab ───────────────────────────────────────────────────────── const NOTIF_DEFAULTS = { scanEmail: true, scanApp: true, riskEmail: true, riskApp: true, digestEmail: true,digestApp: false, teamEmail: false, teamApp: false, billingEmail: true,billingApp: false, }; function TabNotifications() { const { toast } = useToast(); const [prefs, setPrefs] = useState(() => storageGet('fossyl_notifs', NOTIF_DEFAULTS)); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { api('GET', '/account/notifications') .then(d => { const p = { ...NOTIF_DEFAULTS, ...(d.prefs || {}) }; setPrefs(p); storageSet('fossyl_notifs', p); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); const set = k => e => { const next = { ...prefs, [k]: e.target.checked }; setPrefs(next); storageSet('fossyl_notifs', next); setSaving(true); api('PUT', '/account/notifications', { prefs: next }) .catch(() => toast('Could not save preferences', { tone: 'error' })) .finally(() => setSaving(false)); }; const Check = ({ k, label }) => ( ); const Row = ({ prefix }) => (
); if (loading) return
loading preferences…
; return (
{saving &&
saving…
}
); } // ── Danger zone ─────────────────────────────────────────────────────────────── function TabDanger({ go, members }) { const { signOut, role } = useAuth(); const { toast } = useToast(); const [confirmDelete, setConfirmDelete] = useState(false); const [confirmText, setConfirmText] = useState(''); const [showTransfer, setShowTransfer] = useState(false); const [transferEmail, setTransferEmail] = useState(''); const [transferBusy, setTransferBusy] = useState(false); const [disbandConfirm, setDisbandConfirm] = useState(false); const wsSlug = (_sidebarWorkspace || {}).workspace_slug || 'workspace'; const deletePhrase = `DELETE ${wsSlug}`; const isOwner = role === 'Owner'; const targets = (members || []).filter(m => m.status === 'active' && m.role !== 'Owner'); const handleSignOut = () => { const user = _getCognitoSDKUser?.(); if (user) { try { user.signOut(); } catch {} } signOut(); toast('Signed out', { tone: 'success' }); go('landing'); }; const handleSignOutEverywhere = async () => { try { await withCognitoSession((user, resolve, reject) => { user.globalSignOut({ onSuccess: resolve, onFailure: reject }); }); } catch {} const user = _getCognitoSDKUser?.(); if (user) { try { user.signOut(); } catch {} } signOut(); toast('Signed out of all sessions', { tone: 'success' }); go('login'); }; const handleTransfer = async () => { if (!transferEmail) { toast('Select a member first', { tone: 'error' }); return; } setTransferBusy(true); try { await api('POST', '/account/workspace/transfer', { target_email: transferEmail }); toast(`Transfer request sent to ${transferEmail}`, { tone: 'success', duration: 4000 }); setShowTransfer(false); setTransferEmail(''); } catch (e) { toast(e.message || 'Transfer failed', { tone: 'error' }); } setTransferBusy(false); }; const handleDisband = async () => { setTransferBusy(true); try { await api('POST', '/account/workspace/disband'); toast('Team disbanded — all members removed and notified.', { tone: 'success', duration: 5000 }); setShowTransfer(false); setDisbandConfirm(false); } catch (e) { toast(e.message || 'Disband failed', { tone: 'error' }); } setTransferBusy(false); }; return (
⚠ Irreversible actions

Sign-out is safe. Account deletion permanently destroys all reports, audit logs, and team data within seven days.

{isOwner && ( )} {/* Transfer ownership modal */} {showTransfer && (
{ setShowTransfer(false); setDisbandConfirm(false); }}>
e.stopPropagation()} style={{ width: 520, background: 'var(--bg)', border: '1px solid var(--line-2)', borderRadius: 3, padding: 28 }}> {!disbandConfirm ? ( <>
Transfer ownership

The selected member will receive an in-app request. Once they accept, you become a Member.

{targets.length > 0 ? (
{targets.map(m => ( ))}
) : (
No other active members — invite members first.
)}
) : ( <>
Delete team

This will remove all {targets.length} member{targets.length !== 1 ? 's' : ''} from the workspace. Each will be notified by email and moved to a solo account. You remain as the sole owner of your workspace.

)}
)} {/* Delete account modal */} {confirmDelete && (
{ setConfirmDelete(false); setConfirmText(''); }}>
e.stopPropagation()} style={{ width: 520, background: 'var(--bg)', border: '1px solid var(--rust-dim)', borderRadius: 3, padding: 28 }}>
Confirm — delete account

This will erase everything.

You are about to permanently delete the {wsSlug} workspace and all associated reports, team members, API tokens, and audit logs. There is no undo.

setConfirmText(e.target.value)} placeholder={deletePhrase} autoFocus/>
)}
); } // ── Sidebar + top bar ───────────────────────────────────────────────────────── let _sidebarProfile = null; let _sidebarWorkspace = null; let _sbMembers = null; function AccountSidebar({ current, go }) { // Read from module cache (set by AccountPage on load) or fall back to blank const p = _sidebarProfile || {}; const ws = _sidebarWorkspace || {}; const name = p.display_name || ''; const wsSlug = ws.workspace_slug || ''; const wsPlan = ws.plan || 'team'; const wsSeats = ws.seat_limit || 5; const activeSeats = (_sbMembers || []).filter(m => m.status === 'active').length; const av = initials(name || p.email || '?'); const short = name ? name.split(' ').filter(Boolean).map((w,i) => i===0 ? w[0]+'.' : w).join(' ') : '—'; const planLabel = { specimen:'Specimen', field:'Field', team:'Team', agency:'Agency' }[wsPlan] || wsPlan; const items = [ { id:'upload', num:'§X', label:'New excavation' }, { id:'reports', num:'§R', label:'Report' }, { id:'account', num:'§A', label:'Account' }, { id:'billing', num:'§B', label:'Billing' }, { id:'landing', num:'↩', label:'Back to landing'}, ]; return ( ); } function AccountTopBar({ crumb }) { const { go } = useNav(); const { signOut } = useAuth(); const { toast } = useToast(); const ws = _sidebarWorkspace || {}; const wsSlug = ws.workspace_slug || ''; return (
{crumb.map((c,i) => ( {i>0 && /} {c} ))}
{wsSlug && workspace · {wsSlug}} {wsSlug && ·} go('account')}>Account { signOut(); toast('Signed out', { tone:'success' }); go('landing'); }}>Sign out
); } Object.assign(window, { AccountPage, AccountSidebar, AccountTopBar });