// 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 (
);
}
function SegmentControl({ options, value, onChange }) {
return (
{options.map((opt, i) => (
onChange(opt)} style={{
padding: '8px 18px', background: value === opt ? 'var(--bg-2)' : 'transparent',
border: 0, borderRight: i < options.length - 1 ? '1px solid var(--line-2)' : 0,
color: value === opt ? 'var(--ink)' : 'var(--muted)',
fontFamily: 'var(--mono)', fontSize: 11, cursor: 'pointer', transition: 'background 120ms, color 120ms',
}}>{opt}
))}
);
}
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 (
Discard
{saving ? 'Saving…' : 'Save changes'}
);
}
// ── 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 (
);
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]) => (
setTab(id)} style={{
background: 'transparent', border: 0, padding: '12px 18px',
color: id === 'danger' ? (tab===id ? 'var(--rust)' : 'var(--rust-dim)') : (tab===id ? 'var(--ink)' : 'var(--muted)'),
borderBottom: tab === id ? `1px solid ${id==='danger' ? 'var(--rust)' : 'var(--bone)'}` : '1px solid transparent',
marginBottom: -1, fontSize: 13, fontFamily: 'var(--sans)', fontWeight: 500,
cursor: 'pointer', letterSpacing: '-0.005em',
}}>{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 (
);
}
// ── 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 && (
toast('Seat added — +€ 60 / mo on next invoice', { tone: 'success' })}>Add seat (€ 60)
setInviteOpen(true)}>Invite member
)}
Name Email Role Status Since
{list.map(m => (
{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' && { toast(`Invite resent to ${managing.email}`, { tone: 'success' }); setManaging(null); }}>Resend invite → }
remove(managing.email)}>
{managing.status === 'invited' ? 'Cancel invite' : 'Remove member'}
)}
{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
Cancel
onInvite(email, role)}>
{busy ? 'Sending…' : 'Send invite →'}
);
}
// ── 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 (
);
}
// ── 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 plan workspace · {wsSlug}
{!paid ? (
◈
No active subscription — choose a plan to start excavating.
go('billing')}>Choose a plan →
) : (
Tier
{planLabel}
{seats} seats · active plan
{isOwner && go('billing')}>Change plan → }
{isOwner && go('billing')}>Update payment method }
)}
{!isOwner && (
Workspace membership
You are a {role} in this workspace.
Leaving will remove your access and return you to a solo account.
setConfirmLeave(true)}
>
Leave workspace
)}
{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.
setConfirmLeave(false)}>Cancel
{leaving ? 'Leaving…' : 'Leave workspace'}
)}
);
}
// ── 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 (
setShowPwModal(true)}>Change password
{emailUser ? (
<>
{mfaEnabled ? 'enabled' : 'disabled'}
{mfaEnabled
? Disable
: {mfaBusy ? 'Loading…' : 'Set up authenticator →'}
}
>
) : (
managed by GitHub
)}
toast('SSO is available on Agency plan')}>Upgrade to enable
{session && (
{session.browser} · {navigator.platform || 'Unknown OS'}
signed in {session.when}
current
)}
Sign out everywhere
{/* Change password modal */}
{showPwModal && (
{ setShowPwModal(false); setPwForm({ current: '', newPw: '', confirm: '' }); }}>
Change password
)}
{/* 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.
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()}/>
{ setShowMfaSetup(false); setTotpSecret(null); setTotpCode(''); }}>Cancel
{mfaBusy ? 'Verifying…' : 'Verify & enable →'}
)}
);
}
// ── 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.
)}
{exporting ? 'Exporting…' : 'Export data'}
{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}
Copy →
)}
{tokens.map(t => (
{t.prefix}••••
{t.label} · created {t.created_at?.slice(0,10)} · last used {t.last_used}
revoke(t.prefix)}>Revoke
))}
Generate new token
{`$ 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 }) => (
{label}
);
const Row = ({ prefix }) => (
);
if (loading) return loading preferences…
;
return (
);
}
// ── 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.
Sign out
Sign out everywhere
{isOwner && (
{ setShowTransfer(true); setDisbandConfirm(false); setTransferEmail(''); }}>Transfer ownership…
)}
setConfirmDelete(true)}>Delete account…
{/* 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 => (
setTransferEmail(m.email)} style={{ accentColor: 'var(--bone)', flexShrink: 0 }}/>
{m.display_name || m.email.split('@')[0]}
{m.email} · {m.role}
))}
) : (
No other active members — invite members first.
)}
setDisbandConfirm(true)}>Delete team →
setShowTransfer(false)}>Cancel
{transferBusy ? 'Sending…' : 'Send transfer request →'}
>
) : (
<>
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.
setDisbandConfirm(false)}>Go back
{transferBusy ? 'Disbanding…' : 'Disband team'}
>
)}
)}
{/* 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.
Type {deletePhrase} to confirm
setConfirmText(e.target.value)} placeholder={deletePhrase} autoFocus/>
{ setConfirmDelete(false); setConfirmText(''); }}>Cancel
{ setConfirmDelete(false); setConfirmText(''); toast('Account scheduled for deletion.', { tone: 'error', duration: 4000 }); setTimeout(() => { signOut(); go('landing'); }, 600); }}>
Delete permanently
)}
);
}
// ── 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}
))}
);
}
Object.assign(window, { AccountPage, AccountSidebar, AccountTopBar });