| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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]); |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| setHasPassword(typeof hp === 'boolean' ? hp : true); |
| } |
| catch { |
| if (!cancelled) |
| setHasPassword(true); |
| } |
| })(); |
| return () => { |
| cancelled = true; |
| }; |
| }, [base, token]); |
| |
| 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; |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| void loadSessions(); |
| } |
| catch (err) { |
| setFormErr(err?.message || 'Failed to update password'); |
| } |
| finally { |
| setSubmitting(false); |
| } |
| }, [base, token, current, next, signOutOthers, canSubmit, onSaved]); |
| |
| 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; |
| |
| return (<div className="space-y-8"> |
| {/* Password section */} |
| <section> |
| <div className="flex items-center gap-2 mb-3"> |
| <KeyRound size={14} className="text-white/60"/> |
| <h3 className="text-white font-semibold text-sm"> |
| {hasPassword === false ? 'Set a password' : 'Password'} |
| </h3> |
| </div> |
| <p className="text-xs text-white/50 mb-4"> |
| {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.'} |
| </p> |
| |
| <form onSubmit={submit} className="space-y-3"> |
| {/* 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 ? (<PasswordField id="security-current" label="Current password" value={current} onChange={setCurrent} show={showCurrent} onToggleShow={() => setShowCurrent((v) => !v)} autoComplete="current-password" disabled={submitting}/>) : null} |
| |
| <div> |
| <PasswordField id="security-new" label="New password" value={next} onChange={setNext} show={showNext} onToggleShow={() => setShowNext((v) => !v)} autoComplete="new-password" disabled={submitting}/> |
| {next.length > 0 ? (<div className="mt-1.5 flex items-center gap-2 text-[11px]"> |
| <StrengthBar score={strength.score}/> |
| <span className={strength.tone}>{strength.label}</span> |
| {tooShort ? (<span className="text-white/40">· min {MIN_LEN} characters</span>) : null} |
| </div>) : null} |
| </div> |
| |
| <div> |
| <PasswordField id="security-confirm" label="Confirm new password" value={confirm} onChange={setConfirm} show={showNext} onToggleShow={() => setShowNext((v) => !v)} autoComplete="new-password" disabled={submitting}/> |
| {!matches ? (<div className="mt-1.5 text-[11px] text-red-400"> |
| Passwords don't match |
| </div>) : null} |
| {sameAsCurrent ? (<div className="mt-1.5 text-[11px] text-red-400"> |
| New password must differ from the current one |
| </div>) : null} |
| </div> |
| |
| {otherSessionsCount > 0 ? (<label className="flex items-center gap-2 text-xs text-white/70 pt-1"> |
| <input type="checkbox" checked={signOutOthers} onChange={(e) => setSignOutOthers(e.target.checked)} disabled={submitting} className="accent-emerald-500"/> |
| Sign out {otherSessionsCount} other session |
| {otherSessionsCount === 1 ? '' : 's'} after updating |
| </label>) : null} |
| |
| {formErr ? (<div className="text-xs text-red-400/90" role="alert"> |
| {formErr} |
| </div>) : null} |
| {formOk ? (<div className="text-xs text-emerald-400/90 flex items-center gap-1.5"> |
| <Check size={12}/> {formOk} |
| </div>) : null} |
| |
| <div className="pt-2"> |
| <button type="submit" disabled={!canSubmit} className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 border border-white/10 text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed transition-colors"> |
| {submitting |
| ? (hasPassword === false ? 'Saving…' : 'Updating…') |
| : (hasPassword === false ? 'Set password' : 'Update password')} |
| </button> |
| {hasPassword === null ? (<span className="ml-2 text-[11px] text-white/40">checking…</span>) : null} |
| </div> |
| </form> |
| </section> |
| |
| {/* Divider */} |
| <div className="border-t border-white/10"/> |
| |
| {/* Active sessions */} |
| <section> |
| <div className="flex items-center gap-2 mb-3"> |
| <Shield size={14} className="text-white/60"/> |
| <h3 className="text-white font-semibold text-sm">Active sessions</h3> |
| </div> |
| <p className="text-xs text-white/50 mb-4"> |
| You're currently signed in on {sessions.length || 0} device |
| {sessions.length === 1 ? '' : 's'}. Sign out anywhere you don't |
| recognise. |
| </p> |
| |
| {sessionsLoading ? (<div className="text-xs text-white/50">Loading sessions…</div>) : sessionsErr ? (<div className="text-xs text-red-400/90">{sessionsErr}</div>) : (<ul className="space-y-2"> |
| {sessions.map((s) => (<li key={s.id} className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2"> |
| <div className="flex items-center gap-3"> |
| <ShieldCheck size={14} className={s.is_current ? 'text-emerald-400' : 'text-white/40'}/> |
| <div> |
| <div className="text-xs text-white font-medium"> |
| Session {s.id} |
| {s.is_current ? (<span className="ml-2 text-[10px] text-emerald-400 font-semibold uppercase tracking-wider"> |
| this device |
| </span>) : null} |
| </div> |
| <div className="text-[10px] text-white/40"> |
| signed in {formatDate(s.created_at)} · expires{' '} |
| {formatDate(s.expires_at)} |
| </div> |
| </div> |
| </div> |
| </li>))} |
| </ul>)} |
| |
| {otherSessionsCount > 0 ? (<div className="pt-3"> |
| <button type="button" onClick={revokeOthers} disabled={revoking} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-white/80 disabled:opacity-40"> |
| <LogOut size={12}/> |
| {revoking |
| ? 'Signing out…' |
| : `Sign out ${otherSessionsCount} other session${otherSessionsCount === 1 ? '' : 's'}`} |
| </button> |
| </div>) : null} |
| </section> |
| |
| {/* Hint for future 2FA */} |
| <div className="text-[10px] text-white/30 pt-2"> |
| Two-factor authentication coming soon. |
| </div> |
| </div>); |
| } |
| |
| function PasswordField({ id, label, value, onChange, show, onToggleShow, autoComplete, disabled, }) { |
| return (<div> |
| <label htmlFor={id} className="block text-[11px] font-medium text-white/60 mb-1"> |
| {label} |
| </label> |
| <div className="relative"> |
| <input id={id} type={show ? 'text' : 'password'} value={value} onChange={(e) => 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"/> |
| <button type="button" onClick={onToggleShow} tabIndex={-1} aria-label={show ? 'Hide password' : 'Show password'} className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded text-white/40 hover:text-white/70"> |
| {show ? <EyeOff size={14}/> : <Eye size={14}/>} |
| </button> |
| </div> |
| </div>); |
| } |
| function StrengthBar({ score }) { |
| const colors = ['bg-white/10', 'bg-red-500/70', 'bg-amber-500/70', 'bg-emerald-500/70', 'bg-emerald-400']; |
| return (<div className="flex gap-0.5 w-24"> |
| {[1, 2, 3, 4].map((i) => (<span key={i} className={`h-1 flex-1 rounded-full ${score >= i ? colors[score] : 'bg-white/10'}`}/>))} |
| </div>); |
| } |
|
|