Private
Public Access
Phase 1: auth, room CRUD, WebSocket chat, PWA frontend
Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat) and a React + Vite PWA frontend (login, room list, chat view). Backend tests pass against a local Postgres DB. See README.md and backend/README.md for setup, and ARCHITECTURE.md for the full phased design. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { RoomListPage } from './pages/RoomListPage'
|
||||
import { ChatRoomPage } from './pages/ChatRoomPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/rooms"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RoomListPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rooms/:roomId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ChatRoomPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/rooms" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { User } 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' })
|
||||
}
|
||||
|
||||
export function me(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { Message, Room, RoomListItem } from '../types'
|
||||
|
||||
export function listRooms(): Promise<RoomListItem[]> {
|
||||
return apiFetch<RoomListItem[]>('/api/rooms')
|
||||
}
|
||||
|
||||
export function createRoom(name: string, description?: string): Promise<Room> {
|
||||
return apiFetch<Room>('/api/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description: description || null }),
|
||||
})
|
||||
}
|
||||
|
||||
export function joinRoom(roomId: string): Promise<Room> {
|
||||
return apiFetch<Room>(`/api/rooms/${roomId}/join`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getRoomMessages(roomId: string): Promise<Message[]> {
|
||||
return apiFetch<Message[]>(`/api/rooms/${roomId}/messages`)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
interface MessageInputProps {
|
||||
disabled?: boolean
|
||||
onSend: (content: string) => void
|
||||
}
|
||||
|
||||
export function MessageInput({ disabled, onSend }: MessageInputProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
onSend(trimmed)
|
||||
setValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Message..."
|
||||
/>
|
||||
<button type="submit" disabled={disabled || !value.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChatMessageEnvelope, Message } from '../types'
|
||||
|
||||
interface DisplayMessage {
|
||||
id: string
|
||||
username: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: (Message | ChatMessageEnvelope)[]
|
||||
usernames: Record<string, string>
|
||||
}
|
||||
|
||||
export function MessageList({ messages, usernames }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: 'end' })
|
||||
}, [messages.length])
|
||||
|
||||
const display: DisplayMessage[] = messages.map((m) => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
username: 'username' in m ? m.username : usernames[m.user_id] ?? m.user_id,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
|
||||
{display.map((m) => (
|
||||
<div key={m.id} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>{m.username}</strong>{' '}
|
||||
<span style={{ color: '#888', fontSize: '0.8em' }}>
|
||||
{new Date(m.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
<div>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) return <p>Loading...</p>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { RoomListItem as RoomListItemType } from '../types'
|
||||
|
||||
interface RoomListItemProps {
|
||||
room: RoomListItemType
|
||||
onJoin: (roomId: string) => void
|
||||
joining: boolean
|
||||
}
|
||||
|
||||
export function RoomListItem({ room, onJoin, joining }: RoomListItemProps) {
|
||||
return (
|
||||
<li style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 0' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong>{room.name}</strong>
|
||||
{room.description && <div style={{ color: '#666' }}>{room.description}</div>}
|
||||
</div>
|
||||
{room.is_member ? (
|
||||
<Link to={`/rooms/${room.id}`}>Open</Link>
|
||||
) : (
|
||||
<button disabled={joining} onClick={() => onJoin(room.id)}>
|
||||
Join
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import * as authApi from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (usernameOrEmail: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch((err) => {
|
||||
if (!(err instanceof ApiError && err.status === 401)) {
|
||||
console.error('Failed to load current user', err)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function login(usernameOrEmail: string, password: string) {
|
||||
setUser(await authApi.login(usernameOrEmail, password))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authApi.logout()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within an AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
:root {
|
||||
font-family: system-ui, sans-serif;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { getRoomMessages } from '../api/rooms'
|
||||
import { MessageInput } from '../components/MessageInput'
|
||||
import { MessageList } from '../components/MessageList'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useChatSocket } from '../ws/useChatSocket'
|
||||
import type { ChatMessageEnvelope, Message, ServerEnvelope } from '../types'
|
||||
|
||||
export function ChatRoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>()
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [history, setHistory] = useState<Message[]>([])
|
||||
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
|
||||
const [wsError, setWsError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return
|
||||
setHistory([])
|
||||
setLive([])
|
||||
getRoomMessages(roomId).then(setHistory).catch((err) => setWsError(String(err)))
|
||||
}, [roomId])
|
||||
|
||||
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
||||
if (envelope.type === 'message') {
|
||||
setLive((prev) => [...prev, envelope])
|
||||
} else if (envelope.type === 'error') {
|
||||
setWsError(envelope.detail)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
|
||||
|
||||
const { connected, send } = useChatSocket({
|
||||
roomId: roomId ?? '',
|
||||
onMessage,
|
||||
onUnauthenticated,
|
||||
})
|
||||
|
||||
if (!roomId) return null
|
||||
|
||||
const usernames = user ? { [user.id]: user.username } : {}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: 640, margin: '0 auto' }}>
|
||||
<header style={{ padding: '0.5rem', borderBottom: '1px solid #ddd' }}>
|
||||
<button onClick={() => navigate('/rooms')}>← Rooms</button>
|
||||
{!connected && <span style={{ marginLeft: '1rem', color: '#888' }}>Connecting...</span>}
|
||||
</header>
|
||||
|
||||
{wsError && <p style={{ color: 'red', padding: '0 0.5rem' }}>{wsError}</p>}
|
||||
|
||||
<MessageList messages={[...history, ...live]} usernames={usernames} />
|
||||
<MessageInput disabled={!connected} onSend={send} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (user) return <Navigate to="/rooms" replace />
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/rooms')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 360, margin: '4rem auto' }}>
|
||||
<h1>KeepItTalking</h1>
|
||||
<p style={{ color: '#888' }}>This is an invite-only site. Ask an admin for an account.</p>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<label>
|
||||
Username or email
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting}>
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createRoom, joinRoom, listRooms } from '../api/rooms'
|
||||
import { RoomListItem } from '../components/RoomListItem'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { RoomListItem as RoomListItemType } from '../types'
|
||||
|
||||
export function RoomListPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [rooms, setRooms] = useState<RoomListItemType[]>([])
|
||||
const [newRoomName, setNewRoomName] = useState('')
|
||||
const [joiningId, setJoiningId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
setRooms(await listRooms())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err) => setError(String(err)))
|
||||
}, [])
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = newRoomName.trim()
|
||||
if (!name) return
|
||||
setError(null)
|
||||
try {
|
||||
await createRoom(name)
|
||||
setNewRoomName('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin(roomId: string) {
|
||||
setJoiningId(roomId)
|
||||
setError(null)
|
||||
try {
|
||||
await joinRoom(roomId)
|
||||
await refresh()
|
||||
navigate(`/rooms/${roomId}`)
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setJoiningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480, margin: '2rem auto' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1>Rooms</h1>
|
||||
<div>
|
||||
<span style={{ marginRight: '1rem' }}>{user?.username}</span>
|
||||
<button onClick={() => logout()}>Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleCreate} style={{ display: 'flex', gap: '0.5rem', margin: '1rem 0' }}>
|
||||
<input
|
||||
value={newRoomName}
|
||||
onChange={(e) => setNewRoomName(e.target.value)}
|
||||
placeholder="New room name"
|
||||
/>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{rooms.map((room) => (
|
||||
<RoomListItem
|
||||
key={room.id}
|
||||
room={room}
|
||||
onJoin={handleJoin}
|
||||
joining={joiningId === room.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
is_bot: boolean
|
||||
is_site_admin: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
is_private: boolean
|
||||
owner_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RoomListItem extends Room {
|
||||
is_member: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
room_id: string
|
||||
user_id: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatMessageEnvelope {
|
||||
type: 'message'
|
||||
id: string
|
||||
room_id: string
|
||||
user_id: string
|
||||
username: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatJoinedEnvelope {
|
||||
type: 'joined'
|
||||
room_id: string
|
||||
}
|
||||
|
||||
export interface ChatErrorEnvelope {
|
||||
type: 'error'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ServerEnvelope } from '../types'
|
||||
|
||||
interface UseChatSocketOptions {
|
||||
roomId: string
|
||||
onMessage: (envelope: ServerEnvelope) => void
|
||||
onUnauthenticated: () => void
|
||||
}
|
||||
|
||||
export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatSocketOptions) {
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const onMessageRef = useRef(onMessage)
|
||||
onMessageRef.current = onMessage
|
||||
const onUnauthenticatedRef = useRef(onUnauthenticated)
|
||||
onUnauthenticatedRef.current = onUnauthenticated
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/chat`)
|
||||
socketRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true)
|
||||
ws.send(JSON.stringify({ type: 'join', room_id: roomId }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
onMessageRef.current(JSON.parse(event.data) as ServerEnvelope)
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnected(false)
|
||||
if (event.code === 4401) {
|
||||
onUnauthenticatedRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
const send = useCallback((content: string) => {
|
||||
const ws = socketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
|
||||
}, [roomId])
|
||||
|
||||
return { connected, send }
|
||||
}
|
||||
Reference in New Issue
Block a user