/** * SecurityTab — account password + active sessions. * * Additive. Rendered inside ``ProfileSettingsModal`` under the * "Security" tab. Talks to the authenticated backend endpoints: * * POST /v1/auth/change-password * GET /v1/auth/sessions * POST /v1/auth/sessions/revoke-others * * Visual language intentionally matches the other tabs in the modal * (rounded inputs, white/10 borders, dark surfaces). */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Eye, EyeOff, KeyRound, Shield, ShieldCheck, LogOut, Check } from 'lucide-react'; import { resolveBackendUrl } from '../lib/backendUrl'; function scorePassword(value) { if (!value) return { score: 0, label: 'Empty', tone: 'text-white/30' }; let score = 0; if (value.length >= 8) score++; if (value.length >= 12) score++; if (/[A-Z]/.test(value) && /[a-z]/.test(value)) score++; if (/\d/.test(value) && /[^A-Za-z0-9]/.test(value)) score++; const s = Math.min(score, 4); const meta = [ { score: 0, label: 'Empty', tone: 'text-white/30' }, { score: 1, label: 'Weak', tone: 'text-red-400' }, { score: 2, label: 'Fair', tone: 'text-amber-400' }, { score: 3, label: 'Good', tone: 'text-emerald-400' }, { score: 4, label: 'Strong', tone: 'text-emerald-300' }, ]; return meta[s]; } function formatDate(value) { if (!value) return ''; try { const d = new Date(value.replace(' ', 'T') + 'Z'); return d.toLocaleString(); } catch { return value; } } const MIN_LEN = 8; export default function SecurityTab({ backendUrl, token, onSaved }) { const base = useMemo(() => resolveBackendUrl(backendUrl), [backendUrl]); // ── Has-password detection (first-time set vs change flow) ─────────── // Starts as ``null`` (unknown) until /v1/auth/me resolves. We render the // form only after we know the answer — this is what makes the "Set // password" flow just work on a fresh account (no current-password field // shown, no spurious validation). const [hasPassword, setHasPassword] = useState(null); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(`${base}/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` }, }); const payload = await res.json().catch(() => ({})); if (cancelled) return; const hp = payload?.user?.has_password; // Default to ``true`` (change-mode) when the backend is older and // doesn't return the flag — safer than silently hiding the current // field on an account that actually has one. setHasPassword(typeof hp === 'boolean' ? hp : true); } catch { if (!cancelled) setHasPassword(true); } })(); return () => { cancelled = true; }; }, [base, token]); // ── Password form state ─────────────────────────────────────────────── const [current, setCurrent] = useState(''); const [next, setNext] = useState(''); const [confirm, setConfirm] = useState(''); const [showCurrent, setShowCurrent] = useState(false); const [showNext, setShowNext] = useState(false); const [signOutOthers, setSignOutOthers] = useState(false); const [submitting, setSubmitting] = useState(false); const [formErr, setFormErr] = useState(null); const [formOk, setFormOk] = useState(null); const strength = useMemo(() => scorePassword(next), [next]); const matches = confirm.length === 0 || next === confirm; const tooShort = next.length > 0 && next.length < MIN_LEN; // "Must differ from current" only applies when an account already has // a password. On first-time set, current is empty by design. const sameAsCurrent = hasPassword === true && next.length > 0 && next === current; const canSubmit = !submitting && hasPassword !== null && next.length >= MIN_LEN && confirm.length >= MIN_LEN && matches && !sameAsCurrent; const submit = useCallback(async (event) => { event.preventDefault(); if (!canSubmit) return; setSubmitting(true); setFormErr(null); setFormOk(null); try { const res = await fetch(`${base}/v1/auth/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ current_password: current, new_password: next, sign_out_others: signOutOthers, }), }); const payload = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(payload?.detail || `HTTP ${res.status}`); } const revoked = payload?.sessions_revoked ?? 0; const wasSetting = hasPassword === false; setCurrent(''); setNext(''); setConfirm(''); setSignOutOthers(false); // Account now has a password — flip the UI into "Change" mode so // subsequent edits require the current value. setHasPassword(true); const baseMsg = wasSetting ? 'Password set successfully' : 'Password updated successfully'; const msg = revoked > 0 ? `${baseMsg} · ${revoked} other session${revoked === 1 ? '' : 's'} signed out` : baseMsg; setFormOk(msg); onSaved?.(msg); // Refresh sessions panel void loadSessions(); } catch (err) { setFormErr(err?.message || 'Failed to update password'); } finally { setSubmitting(false); } }, [base, token, current, next, signOutOthers, canSubmit, onSaved]); // ── Active sessions state ──────────────────────────────────────────── const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(true); const [sessionsErr, setSessionsErr] = useState(null); const [revoking, setRevoking] = useState(false); const loadSessions = useCallback(async () => { setSessionsLoading(true); setSessionsErr(null); try { const res = await fetch(`${base}/v1/auth/sessions`, { headers: { Authorization: `Bearer ${token}` }, }); const payload = await res.json(); if (!res.ok) throw new Error(payload?.detail || `HTTP ${res.status}`); setSessions(payload.sessions || []); } catch (err) { setSessionsErr(err?.message || 'Failed to load sessions'); } finally { setSessionsLoading(false); } }, [base, token]); useEffect(() => { void loadSessions(); }, [loadSessions]); const revokeOthers = useCallback(async () => { if (revoking) return; setRevoking(true); setSessionsErr(null); try { const res = await fetch(`${base}/v1/auth/sessions/revoke-others`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); const payload = await res.json().catch(() => ({})); if (!res.ok) throw new Error(payload?.detail || `HTTP ${res.status}`); await loadSessions(); const count = payload?.sessions_revoked ?? 0; onSaved?.(count > 0 ? `${count} other session${count === 1 ? '' : 's'} signed out` : 'No other sessions to revoke'); } catch (err) { setSessionsErr(err?.message || 'Failed to revoke sessions'); } finally { setRevoking(false); } }, [base, token, revoking, loadSessions, onSaved]); const otherSessionsCount = sessions.filter((s) => !s.is_current).length; // ── Render ─────────────────────────────────────────────────────────── return (
{hasPassword === false ? 'Your account does not have a password yet. Set one now to secure sign-in.' : 'Update the password you use to sign in to HomePilot.'}
You're currently signed in on {sessions.length || 0} device {sessions.length === 1 ? '' : 's'}. Sign out anywhere you don't recognise.
{sessionsLoading ? (