Jason Tudisco e630cca6c6 fix: message permalinks work on fresh page load and handle permissions
- Stash permalink hash in sessionStorage before login so it survives
  the auth flow and navigates after login completes
- Wait for DOM render (double rAF) before scrolling to target message
- Skip scrollToBottom when navigating via permalink
- Show error screen for 403 (no access) and 404 (room not found)
- Attach HTTP status code to API errors for proper error differentiation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:40:18 -06:00

132 lines
3.5 KiB
JavaScript

const API_BASE = '/api'
// Global callback for 401 responses (set by app component to trigger auto-logout)
let onUnauthorized = null
export function setOnUnauthorized(callback) {
onUnauthorized = callback
}
function getToken() {
return localStorage.getItem('token')
}
function authHeaders() {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function request(method, path, body) {
const opts = {
method,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
},
}
if (body) {
opts.body = JSON.stringify(body)
}
const res = await fetch(`${API_BASE}${path}`, opts)
if (!res.ok) {
// Auto-logout on 401 for any authenticated request (not login/register)
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register') {
if (onUnauthorized) onUnauthorized()
throw new Error('Session expired — please log in again')
}
const text = await res.text()
const err = new Error(text || `HTTP ${res.status}`)
err.status = res.status
throw err
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return null
}
return res.json()
}
export const api = {
// Auth
register: (data) => request('POST', '/auth/register', data),
login: (data) => request('POST', '/auth/login', data),
me: () => request('GET', '/auth/me'),
// Profile
updateProfile: (data) => request('PUT', '/auth/profile', data),
uploadAvatar: async (file) => {
const formData = new FormData()
formData.append('avatar', file)
const res = await fetch(`${API_BASE}/auth/avatar`, {
method: 'POST',
headers: authHeaders(),
body: formData,
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
deleteAvatar: () => request('DELETE', '/auth/avatar'),
// Rooms
listRooms: () => request('GET', '/rooms'),
createRoom: (data) => request('POST', '/rooms', data),
getRoom: (roomId) => request('GET', `/rooms/${roomId}`),
getMessages: (roomId, limit = 50, before) => {
const params = new URLSearchParams({ limit: String(limit) })
if (before) params.set('before', before)
return request('GET', `/rooms/${roomId}/messages?${params}`)
},
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
// Models
listModels: () => request('GET', '/models'),
// Upload (chat images)
uploadChatImage: async (file) => {
const formData = new FormData()
formData.append('image', file)
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: authHeaders(),
body: formData,
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
return res.json()
},
// Invites
createInvite: (data) => request('POST', '/invites', data),
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
}
export function saveAuth(token, user) {
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
}
export function getUser() {
const raw = localStorage.getItem('user')
return raw ? JSON.parse(raw) : null
}
export function clearAuth() {
localStorage.removeItem('token')
localStorage.removeItem('user')
}
export function isAuthenticated() {
return !!getToken()
}