Files
ds-chat/frontend/src/api/auth.ts
T
ksmithandClaude Sonnet 5 b26643527d Add per-device active sessions with revocation (#69)
Replaces the stateless signed-cookie session (bare user_id) with a
real server-side sessions table -- the cookie now just carries an
opaque session id, resolved against the DB on every request. Each
session records IP address (respects X-Forwarded-For), a parsed
device label, and last-seen time (throttled updates, not written on
every request).

New GET/DELETE /api/auth/sessions endpoints and an "Active sessions"
section in Profile settings let a user see every device they're
logged in from and revoke one they don't recognize -- including their
own current session, which just signs them out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:22:50 -06:00

123 lines
4.1 KiB
TypeScript

import { apiFetch, ApiError, NetworkError } from './client'
import type { User, UserSession } from '../types'
// No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not
// through a public endpoint.
export function login(usernameOrEmail: string, password: string): Promise<User> {
return apiFetch<User>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username_or_email: usernameOrEmail, password }),
})
}
export function logout(): Promise<void> {
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
}
// #69: every device/browser currently logged into this account, newest
// last-seen first -- see backend's app/schemas/session.py.
export function listSessions(): Promise<UserSession[]> {
return apiFetch<UserSession[]>('/api/auth/sessions')
}
export function revokeSession(sessionId: string): Promise<void> {
return apiFetch<void>(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' })
}
export function me(): Promise<User> {
return apiFetch<User>('/api/auth/me')
}
export function updateProfile(displayName: string | null): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ display_name: displayName }),
})
}
// Deliberately its own call sending only `theme` -- the backend only
// applies fields actually present in the request body, so this can't
// clobber display_name (and updateProfile above can't clobber theme).
// Presets only ('dark'/'light'/'midnight'/'sunset') -- activating a custom
// theme is POST /api/custom-themes/{id}/activate (see api/customThemes.ts),
// since that needs an id and an ownership check, not just a bare name.
export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ theme }),
})
}
export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
}
// Deliberately its own call, same reasoning as updateTheme above -- a
// manual override of the presence indicator, global (every room, not
// per-room), independent of display_name/theme.
export function updateAppearOffline(appearOffline: boolean): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ appear_offline: appearOffline }),
})
}
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return apiFetch<void>('/api/auth/password', {
method: 'PATCH',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
})
}
export function requestPasswordReset(email: string): Promise<void> {
return apiFetch<void>('/api/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
export function validateResetToken(token: string): Promise<void> {
return apiFetch<void>(`/api/auth/reset-password/validate?token=${encodeURIComponent(token)}`)
}
export function completePasswordReset(token: string, newPassword: string): Promise<User> {
return apiFetch<User>('/api/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, new_password: newPassword }),
})
}
// Not apiFetch: that wrapper always sets Content-Type: application/json,
// which would stomp the multipart boundary the browser needs to set itself
// for a file upload. Mirrors api/rooms.ts's uploadRoomImage.
export async function uploadAvatar(file: File): Promise<User> {
const formData = new FormData()
formData.append('file', file)
let response: Response
try {
response = await fetch('/api/auth/me/avatar', {
method: 'POST',
credentials: 'include',
body: formData,
})
} catch {
throw new NetworkError()
}
if (!response.ok) {
let detail = response.statusText
try {
const body = await response.json()
detail = body.detail ?? detail
} catch {
// response had no JSON body
}
throw new ApiError(response.status, detail)
}
return (await response.json()) as User
}