feat: add auth, real-time collaboration, sharing, font control, and UI fixes

Phase 1 - Bug fixes:
- Fix color labels not showing on active line in format preview
- Replace eye emoji with SVG icon showing clear preview/raw state
- Replace // button with comment icon + better tooltip
- Fix ThemePicker accent colors when using system theme

Phase 2 - Font:
- Load JetBrains Mono via Google Fonts with offline fallback
- Add font size control (A-/A+) with keyboard shortcuts
- Persist font size preference in localStorage

Phase 3 - Auth:
- Supabase-based email/password authentication
- Device session management with configurable password renewal TTL
- AuthModal, UserMenu, SecuritySettings components

Phase 4 - Cloud sync:
- Document metadata sync to Supabase PostgreSQL
- Legacy localStorage migration on first login
- IndexedDB persistence via y-indexeddb

Phase 5 - Real-time collaboration:
- Y.js CRDT integration with CodeMirror 6
- Hocuspocus WebSocket server with JWT auth
- Collaborative cursor awareness
- CollabIndicator component

Phase 6 - Sharing:
- Share links with view/edit permissions
- ShareDialog component with copy-to-clipboard
- Minimal client-side router for /s/{token} URLs

Infrastructure:
- Docker Compose with PostgreSQL, GoTrue, PostgREST, Hocuspocus
- Nginx reverse proxy for all backend services
- SQL migrations with RLS policies
- Production-ready Dockerfile with build args

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 16:21:04 -04:00
parent 42d88fd7b4
commit ef302ebda9
49 changed files with 2499 additions and 36 deletions

24
collab-server/src/auth.ts Normal file
View File

@@ -0,0 +1,24 @@
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-jwt-token-for-calctext-local-dev-only'
interface JWTPayload {
sub: string
email?: string
role?: string
exp?: number
iat?: number
}
/**
* Verify a Supabase JWT token.
* Returns the decoded payload or null if invalid.
*/
export function verifyToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
return decoded
} catch {
return null
}
}

View File

@@ -0,0 +1,50 @@
import { Hocuspocus } from '@hocuspocus/server'
import { Database } from '@hocuspocus/extension-database'
import { verifyToken } from './auth.js'
import { fetchDocument, storeDocument } from './storage.js'
const PORT = parseInt(process.env.PORT || '4000', 10)
const server = new Hocuspocus({
port: PORT,
async onAuthenticate({ token, documentName }) {
// Verify JWT from Supabase
const user = verifyToken(token)
if (!user) {
throw new Error('Unauthorized')
}
// Extract document ID from room name (format: doc:{uuid})
const docId = documentName.replace('doc:', '')
// TODO: Check if user has access to this document
// For now, any authenticated user can access any document
// In production, check documents table + document_collaborators
return {
user: {
id: user.sub,
email: user.email,
},
}
},
extensions: [
new Database({
async fetch({ documentName }) {
const docId = documentName.replace('doc:', '')
return fetchDocument(docId)
},
async store({ documentName, state }) {
const docId = documentName.replace('doc:', '')
await storeDocument(docId, state)
},
}),
],
})
server.listen().then(() => {
console.log(`Hocuspocus collaboration server running on port ${PORT}`)
})

View File

@@ -0,0 +1,51 @@
import pg from 'pg'
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/calctext'
const pool = new pg.Pool({ connectionString: DATABASE_URL })
/**
* Fetch a Y.Doc snapshot from the database.
* Returns the binary state or null if not found.
*/
export async function fetchDocument(documentId: string): Promise<Uint8Array | null> {
try {
const result = await pool.query(
'SELECT state FROM ydoc_snapshots WHERE document_id = $1',
[documentId],
)
if (result.rows.length === 0) return null
const state = result.rows[0].state
// state is stored as BYTEA in PostgreSQL, pg returns it as a Buffer
if (Buffer.isBuffer(state)) {
return new Uint8Array(state)
}
// If stored as integer array (from JSON), convert
if (Array.isArray(state)) {
return new Uint8Array(state)
}
return null
} catch (error) {
console.error('Failed to fetch document:', documentId, error)
return null
}
}
/**
* Store a Y.Doc snapshot to the database.
*/
export async function storeDocument(documentId: string, state: Uint8Array): Promise<void> {
try {
await pool.query(
`INSERT INTO ydoc_snapshots (document_id, state, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (document_id) DO UPDATE SET state = $2, updated_at = NOW()`,
[documentId, Buffer.from(state)],
)
} catch (error) {
console.error('Failed to store document:', documentId, error)
}
}