diff --git a/server/cmd/server/auth.go b/server/cmd/server/auth.go index cdcda83..a8800ab 100644 --- a/server/cmd/server/auth.go +++ b/server/cmd/server/auth.go @@ -166,6 +166,26 @@ func loginRedirect(w http.ResponseWriter, r *http.Request, code string) { http.Redirect(w, r, "/?auth_error="+code, http.StatusFound) } +// hcaLogoutHandler clears the session cookie and deletes the session from +// the DB, then sends the user back to the splash. +func hcaLogoutHandler(authSvc *auth.Service, secure bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(auth.CookieName); err == nil && c.Value != "" { + _ = authSvc.RevokeSession(r.Context(), c.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + http.Redirect(w, r, "/", http.StatusFound) + } +} + // nullStr lifts an empty-or-not string into a sql.NullString. We use this // so optional HCA fields end up as NULL in SQLite rather than empty // strings, which makes "did the user have a slack id?" queries less diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 7a66244..674dc54 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -127,6 +127,7 @@ func main() { sessionTTL := time.Duration(cfg.SessionTTL) * time.Second r.Get("/auth/login", hcaLoginHandler(hcaClient, cfg.IsProduction())) r.Get("/auth/callback", hcaCallbackHandler(hcaClient, q, authSvc, sessionTTL, cfg.IsProduction())) + r.Post("/auth/logout", hcaLogoutHandler(authSvc, cfg.IsProduction())) apiSrv := &web.Server{ Q: q, diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 6764ffa..2fce993 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -10,7 +10,6 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" - "errors" "net/http" "time" @@ -123,5 +122,9 @@ func hashToken(tok string) string { return hex.EncodeToString(sum[:]) } -// ErrNotFound is returned when a session lookup misses. -var ErrNotFound = errors.New("auth: session not found") +// RevokeSession deletes the session identified by the plaintext token. +// Used by the logout handler; silently succeeds if the session is already +// gone. +func (s *Service) RevokeSession(ctx context.Context, plaintextToken string) error { + return s.q.DeleteSession(ctx, hashToken(plaintextToken)) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fbbc8e6..9e0b860 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -56,6 +56,7 @@ export type User = { id: string; email: string; display_name: string; + slack_id: { String: string; Valid: boolean } | null; created_at: number; last_seen_at: number | null; }; @@ -120,3 +121,7 @@ export const listKeys = () => api.get('/api/keys'); export const createKey = (name: string) => api.post('/api/keys', { name }); export const revokeKey = (id: string) => api.del(`/api/keys/${id}`); + +export async function logout(): Promise { + await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); +} diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css index d84cfdb..db0a8e1 100644 --- a/web/src/lib/styles/tokens.css +++ b/web/src/lib/styles/tokens.css @@ -88,6 +88,10 @@ body { -webkit-font-smoothing: antialiased; } +#app { + display: contents; +} + a { color: var(--accent); text-decoration: none; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 19bc9b9..f7810ec 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -8,13 +8,14 @@ import { page } from '$app/state'; import { cycleTheme, currentTheme, type Theme } from '$lib/theme'; - import { me, type User } from '$lib/api'; + import { me, balance, type User } from '$lib/api'; import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); let theme = $state('auto'); let user = $state(null); + let bal = $state<{ balance_usd: string } | null>(null); let authChecked = $state(false); $effect(() => { @@ -25,6 +26,7 @@ (async () => { try { user = await me(); + bal = await balance(); } catch { user = null; } finally { @@ -135,11 +137,10 @@ // returns true when its href is the active page. type NavItem = { label: string; href: string; section: string }; const navItems: NavItem[] = [ - { label: 'Dashboard', href: '/dashboard', section: 'Pool' }, - { label: 'Usage', href: '/usage', section: 'Pool' }, - { label: 'Keys', href: '/keys', section: 'Pool' }, - { label: 'Conversations', href: '/chat', section: 'Account' }, - { label: 'Settings', href: '/settings', section: 'Account' } + { label: 'dashboard', href: '/dashboard', section: 'the pot' }, + { label: 'usage', href: '/usage', section: 'the pot' }, + { label: 'conversations', href: '/chat', section: 'yours' }, + { label: 'settings', href: '/settings', section: 'yours' } ]; let sections = $derived.by(() => { @@ -162,7 +163,7 @@