HomePilot / frontend /src /ui /components /SecurityTab.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
23b413b
/**
* 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'
interface SessionRow {
id: string
created_at: string
expires_at: string
is_current: boolean
}
interface Props {
backendUrl: string
token: string
/** Called with a short, user-friendly success message so the modal can
* surface the same "Saved" toast it already uses for other tabs. */
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])
// ── 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<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
// 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<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
// "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: 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)
// 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: any) {
setFormErr(err?.message || 'Failed to update password')
} finally {
setSubmitting(false)
}
},
[base, token, current, next, signOutOthers, canSubmit, onSaved],
)
// ── Active sessions state ────────────────────────────────────────────
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
// ── Render ───────────────────────────────────────────────────────────
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>
)
}
// ─── Small presentational helpers ──────────────────────────────────────
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>
)
}