Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon" placeholder) and the backend behind it. Still single-user; multi-user and SSO remain Phases 3-4. Backend: - New `sessions` table (id, user_id, user_agent, ip, created_at, last_seen_at) and `login_events` table (user_id, username, ip, user_agent, success, created_at). - Login and setup now mint a session row and embed its id as a `sid` claim in the JWT. The `authenticate` hook validates that the session still exists (and bumps last_seen_at), so revoking a session genuinely invalidates its token instead of relying on the JWT signature alone. Tokens minted before sessions existed have no `sid` and stay valid until expiry, for backward compatibility. - Every login attempt (success and failure) is recorded in login_events for the audit trail. - New endpoints: PUT /api/auth/password (verifies current via bcrypt, hashes new at cost 12, revokes all *other* sessions on success), GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke the current one), POST /api/auth/logout (revokes current session), GET /api/auth/login-events?limit. - AuthContext.logout() now calls POST /api/auth/logout best-effort so signing out revokes the server session, not just the local token. Frontend: - SecuritySection: change-password form (current/new/confirm with show/hide and client-side validation), active-sessions list (device description from user-agent, IP, last-seen relative time, per-session "Sign out" for non-current sessions), and a recent login-activity feed (success/failure dot, user, IP, relative time). - api.ts: changePassword/listSessions/revokeSession/logout/ listLoginEvents + AuthSession/LoginEvent types. Verified end-to-end against a throwaway backend instance: session creation, second-device session, failed-login logging, cross-session revocation invalidating the revoked token, password change keeping the current session alive while revoking others, and logout invalidating the current session. Frontend + backend both type-check clean. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
parent
993792e193
commit
2ccc7b82d7
7 changed files with 451 additions and 12 deletions
|
|
@ -85,6 +85,25 @@ db.exec(`
|
||||||
retry_interval_ms INTEGER NOT NULL DEFAULT 5000,
|
retry_interval_ms INTEGER NOT NULL DEFAULT 5000,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS login_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
username TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
success INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
export function logEvent(type: string, title: string, source?: string | null) {
|
export function logEvent(type: string, title: string, source?: string | null) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { db, logEvent } from '../db/index.js'
|
import { db, logEvent } from '../db/index.js'
|
||||||
|
|
||||||
|
|
@ -8,6 +9,29 @@ const credentialsSchema = z.object({
|
||||||
password: z.string().min(8).max(256),
|
password: z.string().min(8).max(256),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clientIp(req: FastifyRequest): string {
|
||||||
|
const fwd = req.headers['x-forwarded-for']
|
||||||
|
if (typeof fwd === 'string' && fwd.length > 0) return fwd.split(',')[0]!.trim()
|
||||||
|
return req.ip
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientUserAgent(req: FastifyRequest): string {
|
||||||
|
const ua = req.headers['user-agent']
|
||||||
|
return (typeof ua === 'string' ? ua : '').slice(0, 512)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a session row and returns a JWT carrying its id, so the session can be revoked. */
|
||||||
|
function createSession(app: FastifyInstance, req: FastifyRequest, userId: number, username: string): string {
|
||||||
|
const sid = randomUUID()
|
||||||
|
db.prepare('INSERT INTO sessions (id, user_id, user_agent, ip) VALUES (?, ?, ?, ?)').run(
|
||||||
|
sid,
|
||||||
|
userId,
|
||||||
|
clientUserAgent(req),
|
||||||
|
clientIp(req),
|
||||||
|
)
|
||||||
|
return app.jwt.sign({ sub: userId, username, sid })
|
||||||
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/system/setup-status', async () => {
|
app.get('/api/system/setup-status', async () => {
|
||||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
|
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
|
||||||
|
|
@ -28,7 +52,8 @@ export async function authRoutes(app: FastifyInstance) {
|
||||||
const result = db
|
const result = db
|
||||||
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||||
.run(username, passwordHash)
|
.run(username, passwordHash)
|
||||||
const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username })
|
const userId = Number(result.lastInsertRowid)
|
||||||
|
const token = createSession(app, req, userId, username)
|
||||||
logEvent('account_created', `Account created for ${username}`)
|
logEvent('account_created', `Account created for ${username}`)
|
||||||
return { token }
|
return { token }
|
||||||
})
|
})
|
||||||
|
|
@ -42,10 +67,18 @@ export async function authRoutes(app: FastifyInstance) {
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
|
||||||
| { id: number; username: string; password_hash: string }
|
| { id: number; username: string; password_hash: string }
|
||||||
| undefined
|
| undefined
|
||||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
const ok = !!user && (await bcrypt.compare(password, user.password_hash))
|
||||||
|
db.prepare('INSERT INTO login_events (user_id, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)').run(
|
||||||
|
user?.id ?? null,
|
||||||
|
username,
|
||||||
|
clientIp(req),
|
||||||
|
clientUserAgent(req),
|
||||||
|
ok ? 1 : 0,
|
||||||
|
)
|
||||||
|
if (!ok || !user) {
|
||||||
return reply.code(401).send({ error: 'Invalid username or password' })
|
return reply.code(401).send({ error: 'Invalid username or password' })
|
||||||
}
|
}
|
||||||
const token = app.jwt.sign({ sub: user.id, username: user.username })
|
const token = createSession(app, req, user.id, user.username)
|
||||||
logEvent('user_login', `${user.username} logged in`)
|
logEvent('user_login', `${user.username} logged in`)
|
||||||
return { token }
|
return { token }
|
||||||
})
|
})
|
||||||
|
|
@ -79,4 +112,86 @@ export async function authRoutes(app: FastifyInstance) {
|
||||||
.get(payload.sub)
|
.get(payload.sub)
|
||||||
return { user }
|
return { user }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const passwordChangeSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1).max(256),
|
||||||
|
newPassword: z.string().min(8).max(256),
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/auth/password', { onRequest: [app.authenticate] }, async (req, reply) => {
|
||||||
|
const payload = req.user as { sub: number; username: string; sid?: string }
|
||||||
|
const parsed = passwordChangeSchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data
|
||||||
|
const user = db.prepare('SELECT id, username, password_hash FROM users WHERE id = ?').get(payload.sub) as
|
||||||
|
| { id: number; username: string; password_hash: string }
|
||||||
|
| undefined
|
||||||
|
if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) {
|
||||||
|
return reply.code(401).send({ error: 'Current password is incorrect' })
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12)
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, user.id)
|
||||||
|
// Revoke every other session — a password change should sign out other devices,
|
||||||
|
// but keep the current session alive so the user isn't logged out of this tab.
|
||||||
|
if (payload.sid) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(user.id, payload.sid)
|
||||||
|
} else {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id)
|
||||||
|
}
|
||||||
|
logEvent('password_changed', `${user.username} changed their password`)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/auth/sessions', { onRequest: [app.authenticate] }, async (req) => {
|
||||||
|
const payload = req.user as { sub: number; sid?: string }
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT id, user_agent, ip, created_at, last_seen_at FROM sessions WHERE user_id = ? ORDER BY last_seen_at DESC')
|
||||||
|
.all(payload.sub) as Array<{ id: string; user_agent: string | null; ip: string | null; created_at: string; last_seen_at: string }>
|
||||||
|
return {
|
||||||
|
sessions: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userAgent: r.user_agent,
|
||||||
|
ip: r.ip,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
lastSeenAt: r.last_seen_at,
|
||||||
|
current: r.id === payload.sid,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/auth/sessions/:id', { onRequest: [app.authenticate] }, async (req, reply) => {
|
||||||
|
const payload = req.user as { sub: number; sid?: string }
|
||||||
|
const id = (req.params as { id: string }).id
|
||||||
|
if (id === payload.sid) {
|
||||||
|
return reply.code(400).send({ error: 'Cannot revoke the current session; use sign out instead' })
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?').run(id, payload.sub)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/auth/logout', { onRequest: [app.authenticate] }, async (req) => {
|
||||||
|
const payload = req.user as { sub: number; sid?: string }
|
||||||
|
if (payload.sid) db.prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?').run(payload.sid, payload.sub)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/auth/login-events', { onRequest: [app.authenticate] }, async (req) => {
|
||||||
|
const limitRaw = Number((req.query as { limit?: string }).limit)
|
||||||
|
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 100) : 20
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT id, username, ip, user_agent, success, created_at FROM login_events ORDER BY created_at DESC LIMIT ?')
|
||||||
|
.all(limit) as Array<{ id: number; username: string | null; ip: string | null; user_agent: string | null; success: number; created_at: string }>
|
||||||
|
return {
|
||||||
|
events: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
ip: r.ip,
|
||||||
|
userAgent: r.user_agent,
|
||||||
|
success: r.success === 1,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { metricsRoutes } from './routes/metrics.js'
|
||||||
import { transferRoutes } from './routes/transfer.js'
|
import { transferRoutes } from './routes/transfer.js'
|
||||||
import { dataRoutes } from './routes/data.js'
|
import { dataRoutes } from './routes/data.js'
|
||||||
import { startAutoStartTunnels } from './tunnels/manager.js'
|
import { startAutoStartTunnels } from './tunnels/manager.js'
|
||||||
|
import { db } from './db/index.js'
|
||||||
|
|
||||||
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
||||||
if (!JWT_SECRET) {
|
if (!JWT_SECRET) {
|
||||||
|
|
@ -35,6 +36,20 @@ app.decorate('authenticate', async function (req, reply) {
|
||||||
await req.jwtVerify()
|
await req.jwtVerify()
|
||||||
} catch {
|
} catch {
|
||||||
reply.code(401).send({ error: 'Unauthorized' })
|
reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Session-aware check: if the token carries a session id, that session must still
|
||||||
|
// exist (so "Sign out" / revoking a session genuinely invalidates the token, not
|
||||||
|
// just relies on the JWT signature). Tokens minted before sessions existed have no
|
||||||
|
// `sid` and remain valid until they expire, for backward compatibility.
|
||||||
|
const payload = req.user as { sub: number; sid?: string }
|
||||||
|
if (payload.sid) {
|
||||||
|
const session = db.prepare('SELECT id FROM sessions WHERE id = ? AND user_id = ?').get(payload.sid, payload.sub)
|
||||||
|
if (!session) {
|
||||||
|
reply.code(401).send({ error: 'Session expired' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE sessions SET last_seen_at = datetime('now') WHERE id = ?").run(payload.sid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
4
backend/src/types.d.ts
vendored
4
backend/src/types.d.ts
vendored
|
|
@ -8,7 +8,7 @@ declare module 'fastify' {
|
||||||
|
|
||||||
declare module '@fastify/jwt' {
|
declare module '@fastify/jwt' {
|
||||||
interface FastifyJWT {
|
interface FastifyJWT {
|
||||||
payload: { sub: number; username: string }
|
payload: { sub: number; username: string; sid?: string }
|
||||||
user: { sub: number; username: string }
|
user: { sub: number; username: string; sid?: string }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
// Best-effort server-side session revocation; clear client state regardless.
|
||||||
|
api.logout().catch(() => {})
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setStatus('logged-out')
|
setStatus('logged-out')
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ export const api = {
|
||||||
me: () => apiFetch<{ user: AuthUser }>('/auth/me'),
|
me: () => apiFetch<{ user: AuthUser }>('/auth/me'),
|
||||||
updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) =>
|
updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) =>
|
||||||
apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
|
apiFetch<{ ok: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }),
|
||||||
|
listSessions: () => apiFetch<{ sessions: AuthSession[] }>('/auth/sessions'),
|
||||||
|
revokeSession: (id: string) => apiFetch<{ ok: boolean }>(`/auth/sessions/${id}`, { method: 'DELETE' }),
|
||||||
|
logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
|
||||||
|
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
||||||
|
|
||||||
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
||||||
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
||||||
|
|
@ -179,6 +185,24 @@ export interface AuthUser {
|
||||||
avatar_data_url: string | null
|
avatar_data_url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
id: string
|
||||||
|
userAgent: string | null
|
||||||
|
ip: string | null
|
||||||
|
createdAt: string
|
||||||
|
lastSeenAt: string
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginEvent {
|
||||||
|
id: number
|
||||||
|
username: string | null
|
||||||
|
ip: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
success: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Integration {
|
export interface Integration {
|
||||||
id: number
|
id: number
|
||||||
type: string
|
type: string
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { api, ApiError, type Integration } from '../lib/api'
|
import { api, ApiError, type Integration, type AuthSession, type LoginEvent } from '../lib/api'
|
||||||
import { useAuth } from '../lib/AuthContext'
|
import { useAuth } from '../lib/AuthContext'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Shield,
|
Shield,
|
||||||
|
Monitor,
|
||||||
|
LogOut,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
|
|
@ -1257,13 +1259,275 @@ function AboutSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
// SQLite datetime('now') returns UTC without a timezone marker; treat it as UTC.
|
||||||
|
const ts = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z')
|
||||||
|
if (Number.isNaN(ts)) return iso
|
||||||
|
const diffMs = Date.now() - ts
|
||||||
|
const sec = Math.round(diffMs / 1000)
|
||||||
|
if (sec < 60) return 'just now'
|
||||||
|
const min = Math.round(sec / 60)
|
||||||
|
if (min < 60) return `${min}m ago`
|
||||||
|
const hr = Math.round(min / 60)
|
||||||
|
if (hr < 24) return `${hr}h ago`
|
||||||
|
const day = Math.round(hr / 24)
|
||||||
|
if (day < 30) return `${day}d ago`
|
||||||
|
return new Date(ts).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeUserAgent(ua: string | null): string {
|
||||||
|
if (!ua) return 'Unknown device'
|
||||||
|
let os = 'Unknown OS'
|
||||||
|
if (/Windows/i.test(ua)) os = 'Windows'
|
||||||
|
else if (/Macintosh|Mac OS/i.test(ua)) os = 'macOS'
|
||||||
|
else if (/Android/i.test(ua)) os = 'Android'
|
||||||
|
else if (/iPhone|iPad|iOS/i.test(ua)) os = 'iOS'
|
||||||
|
else if (/Linux/i.test(ua)) os = 'Linux'
|
||||||
|
let browser = ''
|
||||||
|
if (/Edg\//i.test(ua)) browser = 'Edge'
|
||||||
|
else if (/Chrome\//i.test(ua) && !/Chromium/i.test(ua)) browser = 'Chrome'
|
||||||
|
else if (/Firefox\//i.test(ua)) browser = 'Firefox'
|
||||||
|
else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari'
|
||||||
|
return browser ? `${browser} on ${os}` : os
|
||||||
|
}
|
||||||
|
|
||||||
function SecuritySection() {
|
function SecuritySection() {
|
||||||
|
// Change-password form
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showCurrent, setShowCurrent] = useState(false)
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [changing, setChanging] = useState(false)
|
||||||
|
const [pwMsg, setPwMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
|
||||||
|
// Sessions + login events
|
||||||
|
const [sessions, setSessions] = useState<AuthSession[]>([])
|
||||||
|
const [events, setEvents] = useState<LoginEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
async function loadActivity() {
|
||||||
|
try {
|
||||||
|
const [s, e] = await Promise.all([api.listSessions(), api.listLoginEvents(15)])
|
||||||
|
setSessions(s.sessions)
|
||||||
|
setEvents(e.events)
|
||||||
|
} catch {
|
||||||
|
// leave existing data on transient failure
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadActivity()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleChangePassword() {
|
||||||
|
setPwMsg(null)
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setPwMsg({ text: 'New password must be at least 8 characters', ok: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPwMsg({ text: 'New passwords do not match', ok: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChanging(true)
|
||||||
|
try {
|
||||||
|
await api.changePassword(currentPassword, newPassword)
|
||||||
|
setPwMsg({ text: 'Password changed. Other sessions were signed out.', ok: true })
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
loadActivity()
|
||||||
|
} catch (err) {
|
||||||
|
setPwMsg({ text: err instanceof ApiError ? err.message : 'Failed to change password', ok: false })
|
||||||
|
} finally {
|
||||||
|
setChanging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(id: string) {
|
||||||
|
try {
|
||||||
|
await api.revokeSession(id)
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||||
|
} catch {
|
||||||
|
loadActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwInputWrap: React.CSSProperties = { position: 'relative' }
|
||||||
|
const eyeBtn: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '10px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#7A7D85',
|
||||||
|
display: 'flex',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardBase}>
|
<div className="flex flex-col gap-5">
|
||||||
<h3 style={sectionTitle}>Security</h3>
|
{/* Change password */}
|
||||||
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
|
<div style={cardBase}>
|
||||||
Password changes, active sessions, login activity, and SSO are coming soon.
|
<h3 style={sectionTitle}>Change Password</h3>
|
||||||
</p>
|
<div className="flex flex-col gap-4" style={{ maxWidth: '420px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Current Password</label>
|
||||||
|
<div style={pwInputWrap}>
|
||||||
|
<input
|
||||||
|
style={{ ...inputStyle, paddingRight: '38px' }}
|
||||||
|
type={showCurrent ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button style={eyeBtn} onClick={() => setShowCurrent((v) => !v)} type="button" title={showCurrent ? 'Hide' : 'Show'}>
|
||||||
|
{showCurrent ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>New Password</label>
|
||||||
|
<div style={pwInputWrap}>
|
||||||
|
<input
|
||||||
|
style={{ ...inputStyle, paddingRight: '38px' }}
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button style={eyeBtn} onClick={() => setShowNew((v) => !v)} type="button" title={showNew ? 'Hide' : 'Show'}>
|
||||||
|
{showNew ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GoldButton
|
||||||
|
onClick={handleChangePassword}
|
||||||
|
disabled={changing || !currentPassword || !newPassword || !confirmPassword}
|
||||||
|
>
|
||||||
|
{changing ? 'Saving…' : 'Update Password'}
|
||||||
|
</GoldButton>
|
||||||
|
{pwMsg && (
|
||||||
|
<span style={{ fontSize: '12px', color: pwMsg.ok ? '#2ECC71' : '#E74C3C' }}>{pwMsg.text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active sessions */}
|
||||||
|
<div style={cardBase}>
|
||||||
|
<h3 style={sectionTitle}>Active Sessions</h3>
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading…</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No active sessions.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(200,164,52,0.08)',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Monitor size={18} color={s.current ? '#C8A434' : '#7A7D85'} style={{ flexShrink: 0 }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>
|
||||||
|
{describeUserAgent(s.userAgent)}
|
||||||
|
{s.current && (
|
||||||
|
<span style={{ fontSize: '10px', color: '#C8A434', marginLeft: '8px', fontWeight: 600 }}>THIS DEVICE</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#7A7D85' }}>
|
||||||
|
{s.ip ?? 'unknown IP'} · last active {relativeTime(s.lastSeenAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!s.current && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(s.id)}
|
||||||
|
className="flex items-center gap-1.5 cursor-pointer transition-colors whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#E74C3C',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(231,76,60,0.4)',
|
||||||
|
borderRadius: '7px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut size={13} /> Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login activity */}
|
||||||
|
<div style={cardBase}>
|
||||||
|
<h3 style={sectionTitle}>Recent Login Activity</h3>
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading…</p>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No login activity recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{events.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{
|
||||||
|
padding: '10px 0',
|
||||||
|
borderTop: i === 0 ? 'none' : '1px solid rgba(200,164,52,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: e.success ? '#2ECC71' : '#E74C3C',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>
|
||||||
|
{e.success ? 'Successful login' : 'Failed login'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '11px', color: '#7A7D85', marginLeft: '8px' }}>
|
||||||
|
{e.username ?? 'unknown'} · {e.ip ?? 'unknown IP'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{relativeTime(e.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p style={{ fontSize: '11px', color: '#7A7D85', marginTop: '14px' }}>
|
||||||
|
SSO (Authentik) and multi-user accounts are planned — see the project roadmap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue