| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useCallback, useEffect, useMemo, useState } from 'react' |
| import { Eye, EyeOff, KeyRound, Shield, ShieldCheck, LogOut, Check } from 'lucide-react' |
|
|
| import { resolveBackendUrl } from '../lib/backendUrl' |
|
|
| interface SessionRow { |
| id: string |
| created_at: string |
| expires_at: string |
| is_current: boolean |
| } |
|
|
| interface Props { |
| backendUrl: string |
| token: string |
| |
| |
| onSaved?: (message: string) => void |
| } |
|
|
| type Strength = { score: 0 | 1 | 2 | 3 | 4; label: string; tone: string } |
|
|
| function scorePassword(value: string): Strength { |
| 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) as Strength['score'] |
| const meta: Strength[] = [ |
| { 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: string): string { |
| 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 }: Props) { |
| const base = useMemo(() => resolveBackendUrl(backendUrl), [backendUrl]) |
|
|
| |
| |
| |
| |
| |
| const [hasPassword, setHasPassword] = useState<boolean | null>(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<string | null>(null) |
| const [formOk, setFormOk] = useState<string | null>(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: React.FormEvent) => { |
| 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: any) { |
| setFormErr(err?.message || 'Failed to update password') |
| } finally { |
| setSubmitting(false) |
| } |
| }, |
| [base, token, current, next, signOutOthers, canSubmit, onSaved], |
| ) |
|
|
| |
| const [sessions, setSessions] = useState<SessionRow[]>([]) |
| const [sessionsLoading, setSessionsLoading] = useState(true) |
| const [sessionsErr, setSessionsErr] = useState<string | null>(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: any) { |
| 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: any) { |
| 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, |
| }: { |
| id: string |
| label: string |
| value: string |
| onChange: (value: string) => void |
| show: boolean |
| onToggleShow: () => void |
| autoComplete: string |
| disabled?: boolean |
| }) { |
| 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 }: { score: 0 | 1 | 2 | 3 | 4 }) { |
| 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> |
| ) |
| } |
|
|