/** * 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 (
{/* Password section */}

{hasPassword === false ? 'Set a password' : 'Password'}

{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.'}

{/* Current password — only shown when the account has one. On first-time setup we hide it entirely to avoid the "field is empty, can't submit" confusion. */} {hasPassword === true ? ( setShowCurrent((v) => !v)} autoComplete="current-password" disabled={submitting}/>) : null}
setShowNext((v) => !v)} autoComplete="new-password" disabled={submitting}/> {next.length > 0 ? (
{strength.label} {tooShort ? (· min {MIN_LEN} characters) : null}
) : null}
setShowNext((v) => !v)} autoComplete="new-password" disabled={submitting}/> {!matches ? (
Passwords don't match
) : null} {sameAsCurrent ? (
New password must differ from the current one
) : null}
{otherSessionsCount > 0 ? () : null} {formErr ? (
{formErr}
) : null} {formOk ? (
{formOk}
) : null}
{hasPassword === null ? (checking…) : null}
{/* Divider */}
{/* Active sessions */}

Active sessions

You're currently signed in on {sessions.length || 0} device {sessions.length === 1 ? '' : 's'}. Sign out anywhere you don't recognise.

{sessionsLoading ? (
Loading sessions…
) : sessionsErr ? (
{sessionsErr}
) : (
    {sessions.map((s) => (
  • Session {s.id} {s.is_current ? ( this device ) : null}
    signed in {formatDate(s.created_at)} · expires{' '} {formatDate(s.expires_at)}
  • ))}
)} {otherSessionsCount > 0 ? (
) : null}
{/* Hint for future 2FA */}
Two-factor authentication coming soon.
); } // ─── Small presentational helpers ────────────────────────────────────── function PasswordField({ id, label, value, onChange, show, onToggleShow, autoComplete, disabled, }) { return (
onChange(e.target.value)} autoComplete={autoComplete} disabled={disabled} className="w-full h-10 rounded-xl bg-white/5 border border-white/10 px-3 pr-10 text-sm text-white placeholder:text-white/30 focus:border-white/25 focus:outline-none disabled:opacity-50"/>
); } function StrengthBar({ score }) { const colors = ['bg-white/10', 'bg-red-500/70', 'bg-amber-500/70', 'bg-emerald-500/70', 'bg-emerald-400']; return (
{[1, 2, 3, 4].map((i) => (= i ? colors[score] : 'bg-white/10'}`}/>))}
); }