Compare commits

...

10 commits

51 changed files with 6722 additions and 265 deletions

39
.env.example Normal file
View file

@ -0,0 +1,39 @@
# ==============================================================================
# REQUIRED - Generate with: openssl rand -base64 36 | tr -d '\n'
# ==============================================================================
PG_PASS=
AUTHENTIK_SECRET_KEY=
# ==============================================================================
# OPTIONAL - Authentik Database
# ==============================================================================
PG_USER=authentik
PG_DB=authentik
# ==============================================================================
# OPTIONAL - Ports
# ==============================================================================
AUTHENTIK_PORT_HTTP=9000
AUTHENTIK_PORT_HTTPS=9443
KABOOT_BACKEND_PORT=3001
# ==============================================================================
# OPTIONAL - Authentik Settings
# ==============================================================================
AUTHENTIK_ERROR_REPORTING=false
# ==============================================================================
# OPTIONAL - OIDC (Override if using custom domain)
# ==============================================================================
OIDC_ISSUER=http://localhost:9000/application/o/kaboot/
OIDC_JWKS_URI=http://localhost:9000/application/o/kaboot/jwks/
# ==============================================================================
# OPTIONAL - CORS (Frontend origin for backend API)
# ==============================================================================
CORS_ORIGIN=http://localhost:5173
# ==============================================================================
# OPTIONAL - Logging
# ==============================================================================
LOG_REQUESTS=false

16
.gitignore vendored
View file

@ -22,3 +22,19 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Environment secrets
.env
.env.local
.env.*.local
.env.test
# Authentik volumes (keep structure, ignore data)
authentik/media/*
!authentik/media/.gitkeep
authentik/certs/*
!authentik/certs/.gitkeep
# Backend data
server/data/
*.db

45
App.tsx
View file

@ -1,5 +1,7 @@
import React from 'react';
import { useAuth } from 'react-oidc-context';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
import { Landing } from './components/Landing';
import { Lobby } from './components/Lobby';
import { GameScreen } from './components/GameScreen';
@ -7,6 +9,7 @@ import { Scoreboard } from './components/Scoreboard';
import { Podium } from './components/Podium';
import { QuizCreator } from './components/QuizCreator';
import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9999) * 10000;
@ -34,6 +37,8 @@ const FloatingShapes = React.memo(() => {
});
function App() {
const auth = useAuth();
const { saveQuiz } = useQuizLibrary();
const {
role,
gameState,
@ -46,19 +51,31 @@ function App() {
startQuizGen,
startManualCreation,
finalizeManualQuiz,
loadSavedQuiz,
joinGame,
startGame,
handleAnswer,
hasAnswered,
lastPointsEarned,
nextQuestion,
showScoreboard,
currentCorrectShape,
selectedOption,
currentPlayerScore,
currentStreak,
currentPlayerId
currentPlayerId,
pendingQuizToSave,
dismissSavePrompt
} = useGame();
const handleSaveQuiz = async () => {
if (!pendingQuizToSave) return;
const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual';
const topic = pendingQuizToSave.topic || undefined;
await saveQuiz(pendingQuizToSave.quiz, source, topic);
dismissSavePrompt();
};
const currentQ = quiz?.questions[currentQuestionIndex];
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
@ -76,6 +93,7 @@ function App() {
<Landing
onGenerate={startQuizGen}
onCreateManual={startManualCreation}
onLoadQuiz={loadSavedQuiz}
onJoin={joinGame}
isLoading={gameState === 'GENERATING'}
error={error}
@ -90,13 +108,23 @@ function App() {
) : null}
{gameState === 'LOBBY' ? (
<Lobby
quizTitle={quiz?.title || 'Kaboot'}
players={players}
gamePin={gamePin}
role={role}
onStart={startGame}
/>
<>
<Lobby
quizTitle={quiz?.title || 'Kaboot'}
players={players}
gamePin={gamePin}
role={role}
onStart={startGame}
/>
{auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt
isOpen={true}
quizTitle={pendingQuizToSave.quiz.title}
onSave={handleSaveQuiz}
onSkip={dismissSavePrompt}
/>
)}
</>
) : null}
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (
@ -131,6 +159,7 @@ function App() {
correctOption={correctOpt}
selectedOption={selectedOption}
role={role}
onNext={showScoreboard}
/>
) : null}

20
Caddyfile.example Normal file
View file

@ -0,0 +1,20 @@
# Kaboot Production Caddyfile
# Copy this file to Caddyfile and update the domain names
kaboot.example.com {
root * /srv/frontend
file_server
try_files {path} /index.html
handle /api/* {
reverse_proxy kaboot-backend:3001
}
handle /health {
reverse_proxy kaboot-backend:3001
}
}
auth.example.com {
reverse_proxy authentik-server:9000
}

View file

@ -31,47 +31,47 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
## Phase 1: Infrastructure Setup
### 1.1 Docker Compose Configuration
- [ ] Create `docker-compose.yml` with all services:
- [ ] PostgreSQL (Authentik database)
- [ ] Redis (Authentik cache)
- [ ] Authentik server
- [ ] Authentik worker
- [ ] Kaboot backend service
- [ ] Create `.env.example` with required variables:
- [ ] `PG_PASS` - PostgreSQL password
- [ ] `AUTHENTIK_SECRET_KEY` - Authentik secret
- [ ] `PG_USER`, `PG_DB` - Optional customization
- [ ] Create setup script to generate secrets (`scripts/setup.sh`)
- [ ] Add `authentik/` directory structure for volumes:
- [ ] `authentik/media/`
- [ ] `authentik/certs/`
- [ ] `authentik/custom-templates/`
- [ ] Update `.gitignore` for new files:
- [ ] `.env`
- [ ] `authentik/media/*`
- [ ] `authentik/certs/*`
- [ ] `server/data/`
- [x] Create `docker-compose.yml` with all services:
- [x] PostgreSQL (Authentik database)
- [x] Redis (Authentik cache)
- [x] Authentik server
- [x] Authentik worker
- [x] Kaboot backend service
- [x] Create `.env.example` with required variables:
- [x] `PG_PASS` - PostgreSQL password
- [x] `AUTHENTIK_SECRET_KEY` - Authentik secret
- [x] `PG_USER`, `PG_DB` - Optional customization
- [x] Create setup script to generate secrets (`scripts/setup.sh`)
- [x] Add `authentik/` directory structure for volumes:
- [x] `authentik/media/`
- [x] `authentik/certs/`
- [x] `authentik/custom-templates/`
- [x] Update `.gitignore` for new files:
- [x] `.env`
- [x] `authentik/media/*`
- [x] `authentik/certs/*`
- [x] `server/data/`
### 1.2 Authentik Configuration Documentation
- [ ] Document initial setup steps in `docs/AUTHENTIK_SETUP.md`:
- [ ] Navigate to `http://localhost:9000/if/flow/initial-setup/`
- [ ] Create admin account
- [ ] Create OAuth2/OIDC Application + Provider:
- [ ] Application name: `Kaboot`
- [ ] Application slug: `kaboot`
- [ ] Provider type: `OAuth2/OIDC`
- [ ] Client type: `Public` (SPA with PKCE)
- [ ] Client ID: `kaboot-spa`
- [ ] Redirect URIs: `http://localhost:5173/callback`, `http://localhost:5173/silent-renew.html`
- [ ] Scopes: `openid`, `profile`, `email`, `offline_access`
- [ ] Note down OIDC endpoints (issuer, JWKS URI, etc.)
- [x] Document initial setup steps in `docs/AUTHENTIK_SETUP.md`:
- [x] Navigate to `http://localhost:9000/if/flow/initial-setup/`
- [x] Create admin account
- [x] Create OAuth2/OIDC Application + Provider:
- [x] Application name: `Kaboot`
- [x] Application slug: `kaboot`
- [x] Provider type: `OAuth2/OIDC`
- [x] Client type: `Public` (SPA with PKCE)
- [x] Client ID: `kaboot-spa`
- [x] Redirect URIs: `http://localhost:5173/callback`, `http://localhost:5173/silent-renew.html`
- [x] Scopes: `openid`, `profile`, `email`, `offline_access`
- [x] Note down OIDC endpoints (issuer, JWKS URI, etc.)
---
## Phase 2: Backend API Development
### 2.1 Project Setup
- [ ] Create `server/` directory structure:
- [x] Create `server/` directory structure:
```
server/
├── Dockerfile
@ -84,233 +84,236 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
├── routes/
└── services/
```
- [ ] Initialize `package.json` with dependencies:
- [ ] `express` - Web framework
- [ ] `better-sqlite3` - SQLite driver
- [ ] `jsonwebtoken` - JWT verification
- [ ] `jwks-rsa` - JWKS client for Authentik
- [ ] `uuid` - ID generation
- [ ] `cors` - CORS middleware
- [ ] Dev deps: `typescript`, `@types/*`, `tsx`
- [ ] Create `tsconfig.json` for Node.js
- [ ] Create `Dockerfile` for backend container
- [x] Initialize `package.json` with dependencies:
- [x] `express` - Web framework
- [x] `better-sqlite3` - SQLite driver
- [x] `jsonwebtoken` - JWT verification
- [x] `jwks-rsa` - JWKS client for Authentik
- [x] `uuid` - ID generation
- [x] `cors` - CORS middleware
- [x] Dev deps: `typescript`, `@types/*`, `tsx`
- [x] Create `tsconfig.json` for Node.js
- [x] Create `Dockerfile` for backend container
### 2.2 Database Layer
- [ ] Create `server/src/db/schema.sql`:
- [ ] `users` table (synced from OIDC claims)
- [ ] `quizzes` table (with `user_id` foreign key)
- [ ] `questions` table (with `quiz_id` foreign key)
- [ ] `answer_options` table (with `question_id` foreign key)
- [ ] Indexes for foreign keys
- [ ] Create `server/src/db/connection.ts`:
- [ ] Initialize better-sqlite3 connection
- [ ] Run schema on startup
- [ ] Export db instance
- [x] Create `server/src/db/schema.sql`:
- [x] `users` table (synced from OIDC claims)
- [x] `quizzes` table (with `user_id` foreign key)
- [x] `questions` table (with `quiz_id` foreign key)
- [x] `answer_options` table (with `question_id` foreign key)
- [x] Indexes for foreign keys
- [x] Create `server/src/db/connection.ts`:
- [x] Initialize better-sqlite3 connection
- [x] Run schema on startup
- [x] Export db instance
### 2.3 Authentication Middleware
- [ ] Create `server/src/middleware/auth.ts`:
- [ ] JWKS client setup pointing to Authentik
- [ ] `requireAuth` middleware function:
- [ ] Extract Bearer token from Authorization header
- [ ] Verify JWT signature against JWKS
- [ ] Validate issuer matches Authentik
- [ ] Attach decoded user to request
- [ ] Define `AuthenticatedRequest` interface
- [x] Create `server/src/middleware/auth.ts`:
- [x] JWKS client setup pointing to Authentik
- [x] `requireAuth` middleware function:
- [x] Extract Bearer token from Authorization header
- [x] Verify JWT signature against JWKS
- [x] Validate issuer matches Authentik
- [x] Attach decoded user to request
- [x] Define `AuthenticatedRequest` interface
### 2.4 API Routes
- [ ] Create `server/src/routes/quizzes.ts`:
- [ ] `GET /api/quizzes` - List user's quizzes (with question count)
- [ ] `GET /api/quizzes/:id` - Get full quiz with questions and options
- [ ] `POST /api/quizzes` - Save new quiz (upsert user from token)
- [ ] `PUT /api/quizzes/:id` - Update existing quiz
- [ ] `DELETE /api/quizzes/:id` - Delete quiz (verify ownership)
- [ ] Create `server/src/routes/users.ts`:
- [ ] `GET /api/users/me` - Get current user profile
- [ ] Create `server/src/index.ts`:
- [ ] Express app setup
- [ ] CORS configuration (allow frontend origin)
- [ ] JSON body parser
- [ ] Mount routes
- [ ] Error handling middleware
- [ ] Start server on port 3001
- [x] Create `server/src/routes/quizzes.ts`:
- [x] `GET /api/quizzes` - List user's quizzes (with question count)
- [x] `GET /api/quizzes/:id` - Get full quiz with questions and options
- [x] `POST /api/quizzes` - Save new quiz (upsert user from token)
- [x] `PUT /api/quizzes/:id` - Update existing quiz
- [x] `DELETE /api/quizzes/:id` - Delete quiz (verify ownership)
- [x] Create `server/src/routes/users.ts`:
- [x] `GET /api/users/me` - Get current user profile
- [x] Create `server/src/index.ts`:
- [x] Express app setup
- [x] CORS configuration (allow frontend origin)
- [x] JSON body parser
- [x] Mount routes
- [x] Error handling middleware
- [x] Start server on port 3001
### 2.5 Backend Testing
- [ ] Test API manually with curl/Postman:
- [ ] Verify 401 without token
- [ ] Verify endpoints work with valid Authentik token
- [ ] Verify quiz CRUD operations
- [ ] Verify user sync from token claims
- [x] Test API with automated test suite:
- [x] Verify 401 without token
- [x] Verify endpoints work with valid Authentik token
- [x] Verify quiz CRUD operations
- [x] Verify user sync from token claims
---
## Phase 3: Frontend Authentication
### 3.1 Dependencies
- [ ] Add to `package.json`:
- [ ] `react-oidc-context` - React OIDC hooks
- [ ] `oidc-client-ts` - Underlying OIDC client
- [x] Add to `package.json`:
- [x] `react-oidc-context` - React OIDC hooks
- [x] `oidc-client-ts` - Underlying OIDC client
- [ ] Run `npm install`
### 3.2 OIDC Configuration
- [ ] Create `src/config/oidc.ts`:
- [ ] Define `oidcConfig` object:
- [ ] `authority` - Authentik issuer URL
- [ ] `client_id` - `kaboot-spa`
- [ ] `redirect_uri` - `${origin}/callback`
- [ ] `post_logout_redirect_uri` - `${origin}`
- [ ] `response_type` - `code` (PKCE)
- [ ] `scope` - `openid profile email offline_access`
- [ ] `automaticSilentRenew` - `true`
- [ ] `userStore` - `WebStorageStateStore` with localStorage
- [ ] `onSigninCallback` - Clean URL after redirect
- [x] Create `src/config/oidc.ts`:
- [x] Define `oidcConfig` object:
- [x] `authority` - Authentik issuer URL
- [x] `client_id` - `kaboot-spa`
- [x] `redirect_uri` - `${origin}/callback`
- [x] `post_logout_redirect_uri` - `${origin}`
- [x] `response_type` - `code` (PKCE)
- [x] `scope` - `openid profile email offline_access`
- [x] `automaticSilentRenew` - `true`
- [x] `userStore` - `WebStorageStateStore` with localStorage
- [x] `onSigninCallback` - Clean URL after redirect
### 3.3 Auth Provider Setup
- [ ] Modify `src/main.tsx`:
- [ ] Import `AuthProvider` from `react-oidc-context`
- [ ] Import `oidcConfig`
- [ ] Wrap `<App />` with `<AuthProvider {...oidcConfig}>`
- [x] Modify `index.tsx`:
- [x] Import `AuthProvider` from `react-oidc-context`
- [x] Import `oidcConfig`
- [x] Wrap `<App />` with `<AuthProvider {...oidcConfig}>`
### 3.4 Auth UI Components
- [ ] Create `src/components/AuthButton.tsx`:
- [ ] Use `useAuth()` hook
- [ ] Show loading state while auth initializing
- [ ] Show "Sign In" button when unauthenticated
- [ ] Show username + "Sign Out" button when authenticated
- [ ] Modify `src/components/Landing.tsx`:
- [ ] Add `<AuthButton />` to top-right corner
- [ ] Style consistently with existing design
- [x] Create `components/AuthButton.tsx`:
- [x] Use `useAuth()` hook
- [x] Show loading state while auth initializing
- [x] Show "Sign In" button when unauthenticated
- [x] Show username + "Sign Out" button when authenticated
- [x] Modify `components/Landing.tsx`:
- [x] Add `<AuthButton />` to top-right corner
- [x] Style consistently with existing design
### 3.5 Authenticated Fetch Hook
- [ ] Create `src/hooks/useAuthenticatedFetch.ts`:
- [ ] Use `useAuth()` to get access token
- [ ] Create `authFetch` wrapper that:
- [ ] Adds `Authorization: Bearer <token>` header
- [ ] Adds `Content-Type: application/json`
- [ ] Handles 401 by triggering silent renew
- [ ] Export `{ authFetch, isAuthenticated }`
- [x] Create `hooks/useAuthenticatedFetch.ts`:
- [x] Use `useAuth()` to get access token
- [x] Create `authFetch` wrapper that:
- [x] Adds `Authorization: Bearer <token>` header
- [x] Adds `Content-Type: application/json`
- [x] Handles 401 by triggering silent renew
- [x] Export `{ authFetch, isAuthenticated }`
---
## Phase 4: Quiz Library Feature
### 4.1 Quiz Library Hook
- [ ] Create `src/hooks/useQuizLibrary.ts`:
- [ ] State: `quizzes`, `loading`, `error`
- [ ] `fetchQuizzes()` - GET /api/quizzes
- [ ] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
- [ ] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
- [ ] `deleteQuiz(id)` - DELETE /api/quizzes/:id
- [ ] Handle loading and error states
- [x] Create `hooks/useQuizLibrary.ts`:
- [x] State: `quizzes`, `loading`, `error`
- [x] `fetchQuizzes()` - GET /api/quizzes
- [x] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
- [x] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
- [x] `deleteQuiz(id)` - DELETE /api/quizzes/:id
- [x] Handle loading and error states
### 4.2 Quiz Library UI
- [ ] Create `src/components/QuizLibrary.tsx`:
- [ ] Modal overlay design (consistent with app style)
- [ ] Header: "My Quizzes" with close button
- [ ] Search/filter input (optional, future enhancement)
- [ ] Quiz list:
- [ ] Show title, question count, source badge (AI/Manual), date
- [ ] Click to select
- [ ] Delete button with confirmation
- [ ] Footer: "Load Selected" and "Cancel" buttons
- [ ] Empty state: "No saved quizzes yet"
- [ ] Loading state: Skeleton/spinner
- [x] Create `components/QuizLibrary.tsx`:
- [x] Modal overlay design (consistent with app style)
- [x] Header: "My Library" with close button
- [x] Quiz list:
- [x] Show title, question count, source badge (AI/Manual), date
- [x] Click to load quiz
- [x] Delete button with confirmation
- [x] Empty state: "No saved quizzes yet"
- [x] Loading state: Spinner
### 4.3 Landing Page Integration
- [ ] Modify `src/components/Landing.tsx`:
- [ ] Add "My Quizzes" button (only visible when authenticated)
- [ ] Add state for quiz library modal visibility
- [ ] Render `<QuizLibrary />` modal when open
- [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
- [x] Modify `components/Landing.tsx`:
- [x] Add "My Quizzes" button (only visible when authenticated)
- [x] Add state for quiz library modal visibility
- [x] Render `<QuizLibrary />` modal when open
- [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
### 4.4 Types Update
- [ ] Modify `src/types.ts`:
- [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
- [ ] Add `QuizListItem` interface (for list view)
- [ ] Add `QuizSource` type: `'manual' | 'ai_generated'`
- [x] Modify `types.ts`:
- [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
- [x] Add `QuizListItem` interface (for list view)
- [x] Add `QuizSource` type: `'manual' | 'ai_generated'`
### 4.5 Game Hook Integration
- [x] Modify `hooks/useGame.ts`:
- [x] Add `loadSavedQuiz(quiz)` function
- [x] Export for App.tsx to consume
---
## Phase 5: Save Integration
### 5.1 Save After AI Generation
- [ ] Modify `src/hooks/useGame.ts`:
- [ ] Add `pendingQuizToSave` state
- [ ] After successful AI generation, set `pendingQuizToSave`
- [ ] Add `savePendingQuiz()` and `dismissSavePrompt()` functions
- [ ] Export these for UI to consume
- [ ] Create `src/components/SaveQuizPrompt.tsx`:
- [ ] Modal asking "Save this quiz to your library?"
- [ ] Show quiz title
- [ ] "Save" and "Skip" buttons
- [ ] Only show when authenticated
- [x] Modify `hooks/useGame.ts`:
- [x] Add `pendingQuizToSave` state
- [x] After successful AI generation, set `pendingQuizToSave`
- [x] Add `dismissSavePrompt()` function
- [x] Export these for UI to consume
- [x] Create `components/SaveQuizPrompt.tsx`:
- [x] Modal asking "Save this quiz to your library?"
- [x] Show quiz title
- [x] "Save" and "Skip" buttons
- [x] Loading state while saving
- [x] Wire up in `App.tsx`:
- [x] Show SaveQuizPrompt in LOBBY state when authenticated and pendingQuizToSave exists
- [x] Handle save via useQuizLibrary hook
### 5.2 Save in Quiz Creator
- [ ] Modify `src/components/QuizCreator.tsx`:
- [ ] Add checkbox or toggle: "Save to my library"
- [ ] Pass `shouldSave` flag to `onFinalize`
- [ ] Modify `src/hooks/useGame.ts`:
- [ ] Update `finalizeManualQuiz` to accept save preference
- [ ] If save requested + authenticated, call `saveQuiz`
- [x] Modify `components/QuizCreator.tsx`:
- [x] Add "Save to my library" checkbox (only shown when authenticated)
- [x] Pass `saveToLibrary` flag to `onFinalize`
- [x] Modify `hooks/useGame.ts`:
- [x] Update `finalizeManualQuiz` to accept save preference
- [x] If save requested, set `pendingQuizToSave`
### 5.3 Load Quiz Flow
- [ ] Modify `src/hooks/useGame.ts`:
- [ ] Add `loadSavedQuiz(quiz: Quiz)` function
- [ ] Initialize game state with loaded quiz
- [ ] Transition to LOBBY state
- [ ] Wire up from Landing → QuizLibrary → useGame
### 5.3 Load Quiz Flow (Already done in Phase 4)
- [x] `loadSavedQuiz(quiz: Quiz)` function in useGame.ts
- [x] Wire up from Landing → QuizLibrary → useGame
---
## Phase 6: Polish & Error Handling
### 6.1 Loading States
- [ ] Add loading indicators:
- [ ] Quiz library list loading
- [ ] Quiz loading when selected
- [ ] Save operation in progress
- [ ] Disable buttons during async operations
- [x] Add loading indicators:
- [x] Quiz library list loading
- [x] Quiz loading when selected
- [x] Save operation in progress
- [x] Disable buttons during async operations
### 6.2 Error Handling
- [ ] Display user-friendly error messages:
- [ ] Failed to load quiz library
- [ ] Failed to save quiz
- [ ] Failed to delete quiz
- [ ] Network/auth errors
- [ ] Add retry mechanisms where appropriate
- [x] Display user-friendly error messages:
- [x] Failed to load quiz library
- [x] Failed to save quiz
- [x] Failed to delete quiz
- [x] Network/auth errors
- [x] Add retry mechanisms where appropriate
### 6.3 Toast Notifications (Optional)
- [ ] Add `react-hot-toast` or similar
- [ ] Show success toasts:
- [ ] "Quiz saved successfully"
- [ ] "Quiz deleted"
- [ ] Show error toasts for failures
- [x] Add `react-hot-toast` or similar
- [x] Show success toasts:
- [x] "Quiz saved successfully"
- [x] "Quiz deleted"
- [x] Show error toasts for failures
### 6.4 Edge Cases
- [ ] Handle auth token expiry gracefully
- [ ] Handle offline state
- [ ] Handle concurrent save attempts
- [ ] Validate quiz data before save
- [x] Handle auth token expiry gracefully
- [x] Handle offline state
- [x] Handle concurrent save attempts
- [x] Validate quiz data before save
---
## Phase 7: Documentation & Deployment
### 7.1 Documentation
- [ ] Update main `README.md`:
- [ ] Add Docker Compose setup instructions
- [ ] Document environment variables
- [ ] Add Authentik configuration steps
- [ ] Create `docs/AUTHENTIK_SETUP.md` (detailed IdP setup)
- [ ] Create `docs/API.md` (backend API documentation)
- [x] Update main `README.md`:
- [x] Add Docker Compose setup instructions
- [x] Document environment variables
- [x] Add Authentik configuration steps
- [x] Create `docs/AUTHENTIK_SETUP.md` (detailed IdP setup)
- [x] Create `docs/API.md` (backend API documentation)
### 7.2 Production Considerations
- [ ] Document production deployment:
- [ ] HTTPS setup (reverse proxy)
- [ ] Update redirect URIs for production domain
- [ ] Database backup strategy
- [ ] Authentik email configuration
- [ ] Add health check endpoints
- [ ] Add logging configuration
- [x] Document production deployment:
- [x] HTTPS setup (reverse proxy)
- [x] Update redirect URIs for production domain
- [x] Database backup strategy
- [x] Authentik email configuration
- [x] Add health check endpoints
- [x] Add logging configuration
---
@ -396,14 +399,14 @@ kaboot/
## Progress Tracking
**Last Updated**: 2026-01-13
**Last Updated**: 2026-01-14
| Phase | Status | Notes |
|-------|--------|-------|
| Phase 1 | Not Started | |
| Phase 2 | Not Started | |
| Phase 3 | Not Started | |
| Phase 4 | Not Started | |
| Phase 5 | Not Started | |
| Phase 6 | Not Started | |
| Phase 7 | Not Started | |
| Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs |
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration |
| Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration |
| Phase 6 | **COMPLETE** | Toast notifications, loading states, error handling, edge cases |
| Phase 7 | **COMPLETE** | README, API docs, PRODUCTION docs, health checks, logging |

124
README.md
View file

@ -2,19 +2,123 @@
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
# Kaboot
This contains everything you need to run your app locally.
Kaboot is an AI-powered quiz party game inspired by Kahoot. It leverages the Google Gemini API to instantly generate engaging quizzes on any topic, allowing users to host and join multiplayer games with ease.
View your app in AI Studio: https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8
## Features
## Run Locally
- **AI Quiz Generation**: Create full quizzes in seconds by simply providing a topic or prompt using Google Gemini.
- **Real-time Multiplayer**: Host games and have players join via game pins using Peer-to-Peer technology.
- **Single-player Arcade Mode**: Play against AI bots to sharpen your skills.
- **Secure Authentication**: Integrated with Authentik for robust OIDC-based user management.
- **Quiz Library**: Save, manage, and reuse your AI-generated quizzes.
- **Dynamic UI**: A premium, responsive interface built with React, Framer Motion, and Lucide.
**Prerequisites:** Node.js
## Architecture
Kaboot is built with a modern decoupled architecture:
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
- **Frontend**: React (Vite) with PeerJS for real-time communication.
- **Backend**: Node.js Express server managing the quiz database and session state.
- **Database**: SQLite (Better-SQLite3) for lightweight, reliable data storage.
- **Identity Provider**: Authentik (running in Docker) providing OIDC authentication.
- **Infrastructure**: Redis and PostgreSQL (supporting Authentik).
- **AI Engine**: Google Gemini API for intelligent content generation.
## Prerequisites
Before you begin, ensure you have the following installed:
- **Docker & Docker Compose**: For running the backend services and authentication.
- **Node.js (v18+)**: For local frontend development.
- **Google Gemini API Key**: Required for AI quiz generation.
## Quick Start
The fastest way to get Kaboot running is using Docker Compose.
### 1. Initialize Environment
Run the setup script to generate necessary secrets and create your `.env` file:
```bash
chmod +x scripts/setup.sh
./scripts/setup.sh
```
### 2. Configure Gemini API
Open the newly created `.env` file and add your Gemini API key:
```env
GEMINI_API_KEY=your_api_key_here
```
### 3. Start Services
Launch the entire stack using Docker Compose:
```bash
docker compose up -d
```
This will start:
- **Authentik** (Port 9000)
- **PostgreSQL** & **Redis** (Internal)
- **Kaboot Backend** (Port 3001)
### 4. Setup Authentication
Follow the [Authentik Setup Guide](docs/AUTHENTIK_SETUP.md) to configure the OIDC provider.
## Development Setup
If you want to run the frontend in development mode with hot-reloading:
1. **Install Dependencies**:
```bash
npm install
```
2. **Run Development Server**:
```bash
npm run dev
```
The frontend will be available at `http://localhost:5173`.
## Configuration
Kaboot uses environment variables for configuration. Refer to `.env.example` for a complete list.
| Variable | Description | Default |
|----------|-------------|---------|
| `KABOOT_BACKEND_PORT` | Port for the Express backend | `3001` |
| `AUTHENTIK_PORT_HTTP` | Port for Authentik web interface | `9000` |
| `GEMINI_API_KEY` | Your Google Gemini API key | (Required) |
| `CORS_ORIGIN` | Allowed origin for API requests | `http://localhost:5173` |
| `PG_PASS` | PostgreSQL password for Authentik | (Generated) |
| `AUTHENTIK_SECRET_KEY` | Secret key for Authentik | (Generated) |
## Testing
### Backend Tests
To run the backend test suite:
```bash
cd server
npm install
npm test
```
## Documentation
- [Authentik Configuration](docs/AUTHENTIK_SETUP.md)
- [API Reference](docs/API.md)
## Troubleshooting
- **Authentik Initial Setup**: If you can't access the setup page, ensure `authentik-server` container is healthy using `docker compose ps`.
- **CORS Errors**: Verify that `CORS_ORIGIN` in your `.env` matches your frontend URL.
- **Gemini API Issues**: Ensure your API key is valid and has sufficient quota. Check the browser console for specific error messages from the GenAI SDK.
- **Database Locked**: If you encounter SQLite locking issues, ensure only one instance of the backend is writing to the database volume.
---
View your app in AI Studio: [https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8](https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8)

3
ai-todo.md Normal file
View file

@ -0,0 +1,3 @@
# Kaboot Documentation Tasks
- [x] Create /Users/joey/Downloads/kaboot/docs/PRODUCTION.md with production deployment guide

0
authentik/certs/.gitkeep Normal file
View file

View file

0
authentik/media/.gitkeep Normal file
View file

53
components/AuthButton.tsx Normal file
View file

@ -0,0 +1,53 @@
import React from 'react';
import { useAuth } from 'react-oidc-context';
import { LogIn, LogOut, User, Loader2 } from 'lucide-react';
export const AuthButton: React.FC = () => {
const auth = useAuth();
if (auth.isLoading) {
return (
<div className="flex items-center gap-2 bg-white/10 px-4 py-2 rounded-xl">
<Loader2 className="animate-spin" size={20} />
</div>
);
}
if (auth.error) {
return (
<div className="flex items-center gap-2 bg-red-500/20 px-4 py-2 rounded-xl text-sm">
<span>Auth Error</span>
</div>
);
}
if (auth.isAuthenticated) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-white/10 px-3 py-2 rounded-xl">
<User size={18} />
<span className="font-bold text-sm">
{auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'}
</span>
</div>
<button
onClick={() => auth.signoutRedirect()}
className="p-2 bg-white/10 rounded-xl hover:bg-white/20 transition"
title="Sign out"
>
<LogOut size={20} />
</button>
</div>
);
}
return (
<button
onClick={() => auth.signinRedirect()}
className="flex items-center gap-2 bg-white/10 px-4 py-2 rounded-xl hover:bg-white/20 transition font-bold"
>
<LogIn size={20} />
Sign In
</button>
);
};

View file

@ -27,9 +27,9 @@ export const GameScreen: React.FC<GameScreenProps> = ({
}) => {
const isClient = role === 'CLIENT';
const displayOptions = question?.options || [];
const timeLeftSeconds = Math.ceil(timeLeft / 1000);
// Timer styling logic
const isUrgent = timeLeft < 5 && timeLeft > 0;
const isUrgent = timeLeftSeconds <= 5 && timeLeftSeconds > 0;
const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white';
const timerTextColor = isUrgent ? 'text-red-500' : 'text-theme-primary';
const timerAnimation = isUrgent ? 'animate-ping' : '';
@ -45,9 +45,9 @@ export const GameScreen: React.FC<GameScreenProps> = ({
{/* Whimsical Timer */}
<div className="relative">
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
<div className={`bg-white ${timerTextColor} rounded-full w-20 h-20 flex items-center justify-center text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
{timeLeft}
</div>
<div className={`bg-white ${timerTextColor} rounded-full w-20 h-20 flex items-center justify-center text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
{timeLeftSeconds}
</div>
</div>
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">

View file

@ -1,20 +1,46 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react';
import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary';
import { useQuizLibrary } from '../hooks/useQuizLibrary';
import type { Quiz } from '../types';
interface LandingProps {
onGenerate: (topic: string) => void;
onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz) => void;
onJoin: (pin: string, name: string) => void;
isLoading: boolean;
error: string | null;
}
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => {
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
const auth = useAuth();
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
const [topic, setTopic] = useState('');
const [pin, setPin] = useState('');
const [name, setName] = useState('');
const [libraryOpen, setLibraryOpen] = useState(false);
const {
quizzes,
loading: libraryLoading,
loadingQuizId,
deletingQuizId,
error: libraryError,
fetchQuizzes,
loadQuiz,
deleteQuiz,
retry: retryLibrary
} = useQuizLibrary();
useEffect(() => {
if (libraryOpen && auth.isAuthenticated) {
fetchQuizzes();
}
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -26,8 +52,24 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
if (pin.trim() && name.trim()) onJoin(pin, name);
};
const handleLoadQuiz = async (id: string) => {
try {
const quiz = await loadQuiz(id);
setLibraryOpen(false);
onLoadQuiz(quiz);
} catch (err) {
if (err instanceof Error && err.message.includes('redirecting')) {
return;
}
console.error('Failed to load quiz:', err);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
<div className="absolute top-4 right-4">
<AuthButton />
</div>
<motion.div
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
@ -89,6 +131,15 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
>
<PenTool size={20} /> Create Manually
</button>
{auth.isAuthenticated && (
<button
onClick={() => setLibraryOpen(true)}
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
>
<BookOpen size={20} /> My Quizzes
</button>
)}
</div>
) : (
<form onSubmit={handleJoinSubmit} className="space-y-4">
@ -122,6 +173,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
</motion.div>
)}
</motion.div>
<QuizLibrary
isOpen={libraryOpen}
onClose={() => setLibraryOpen(false)}
quizzes={quizzes}
loading={libraryLoading}
loadingQuizId={loadingQuizId}
deletingQuizId={deletingQuizId}
error={libraryError}
onLoadQuiz={handleLoadQuiz}
onDeleteQuiz={deleteQuiz}
onRetry={retryLibrary}
/>
</div>
);
};

View file

@ -1,21 +1,24 @@
import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { Quiz, Question, AnswerOption } from '../types';
import { v4 as uuidv4 } from 'uuid';
import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react';
import { Plus, Save, Trash2, CheckCircle, Circle, X, BookOpen } from 'lucide-react';
import { COLORS, SHAPES } from '../constants';
interface QuizCreatorProps {
onFinalize: (quiz: Quiz) => void;
onFinalize: (quiz: Quiz, saveToLibrary: boolean) => void;
onCancel: () => void;
}
export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }) => {
const auth = useAuth();
const [title, setTitle] = useState('');
const [questions, setQuestions] = useState<Question[]>([]);
const [qText, setQText] = useState('');
const [options, setOptions] = useState<string[]>(['', '', '', '']);
const [reasons, setReasons] = useState<string[]>(['', '', '', '']);
const [correctIdx, setCorrectIdx] = useState<number>(0);
const [saveToLibrary, setSaveToLibrary] = useState(false);
const handleAddQuestion = () => {
if (!qText.trim() || options.some(o => !o.trim())) {
@ -53,7 +56,7 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
const handleFinalize = () => {
if (!title.trim() || questions.length === 0) return;
onFinalize({ title, questions });
onFinalize({ title, questions }, saveToLibrary);
};
return (
@ -176,7 +179,25 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
</div>
</div>
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-end">
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-between items-center">
{auth.isAuthenticated ? (
<label className="flex items-center gap-3 cursor-pointer select-none group">
<input
type="checkbox"
checked={saveToLibrary}
onChange={(e) => setSaveToLibrary(e.target.checked)}
className="sr-only peer"
/>
<div className="w-6 h-6 border-2 border-gray-300 rounded-lg flex items-center justify-center peer-checked:bg-theme-primary peer-checked:border-theme-primary transition-all group-hover:border-gray-400">
{saveToLibrary && <CheckCircle size={16} className="text-white" />}
</div>
<span className="text-gray-600 font-bold flex items-center gap-2">
<BookOpen size={18} /> Save to my library
</span>
</label>
) : (
<div />
)}
<button
onClick={handleFinalize}
className="flex items-center gap-2 bg-green-500 text-white px-10 py-4 rounded-2xl text-xl font-black hover:bg-green-600 shadow-[0_6px_0_#15803d] active:shadow-none active:translate-y-[6px] transition-all"

218
components/QuizLibrary.tsx Normal file
View file

@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react';
import { QuizListItem } from '../types';
interface QuizLibraryProps {
isOpen: boolean;
onClose: () => void;
quizzes: QuizListItem[];
loading: boolean;
loadingQuizId: string | null;
deletingQuizId: string | null;
error: string | null;
onLoadQuiz: (id: string) => void;
onDeleteQuiz: (id: string) => void;
onRetry: () => void;
}
export const QuizLibrary: React.FC<QuizLibraryProps> = ({
isOpen,
onClose,
quizzes,
loading,
loadingQuizId,
deletingQuizId,
error,
onLoadQuiz,
onDeleteQuiz,
onRetry,
}) => {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId;
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setConfirmDeleteId(id);
};
const confirmDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (confirmDeleteId) {
try {
await onDeleteQuiz(confirmDeleteId);
setConfirmDeleteId(null);
} catch {
setConfirmDeleteId(null);
}
}
};
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setConfirmDeleteId(null);
};
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch (e) {
return dateString;
}
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: "spring", bounce: 0.4 }}
onClick={(e) => e.stopPropagation()}
className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden"
>
<div className="p-6 border-b-2 border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
<div>
<h2 className="text-3xl font-black text-gray-900 tracking-tight">My Library</h2>
<p className="text-gray-500 font-bold text-sm">Select a quiz to play</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600"
>
<X size={24} strokeWidth={3} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50">
{loading && (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<Loader2 size={48} className="animate-spin mb-4 text-theme-primary" />
<p className="font-bold">Loading your quizzes...</p>
</div>
)}
{!loading && error && (
<div className="bg-red-50 border-2 border-red-100 p-4 rounded-2xl text-center">
<p className="text-red-500 font-bold mb-3">{error}</p>
<button
onClick={onRetry}
className="bg-red-500 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-red-600 transition-colors"
>
Try Again
</button>
</div>
)}
{!loading && !error && quizzes.length === 0 && (
<div className="text-center py-12 space-y-4">
<div className="bg-gray-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4">
<BrainCircuit size={40} className="text-gray-300" />
</div>
<h3 className="text-xl font-black text-gray-400">No saved quizzes yet</h3>
<p className="text-gray-400 font-medium">Create or generate a quiz to save it here!</p>
</div>
)}
{!loading && !error && quizzes.map((quiz) => (
<motion.div
key={quiz.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all relative overflow-hidden ${isAnyOperationInProgress ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`}
onClick={() => !isAnyOperationInProgress && onLoadQuiz(quiz.id)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{quiz.source === 'ai_generated' ? (
<span className="bg-purple-100 text-purple-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
<BrainCircuit size={12} /> AI
</span>
) : (
<span className="bg-blue-100 text-blue-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
<PenTool size={12} /> Manual
</span>
)}
<span className="text-gray-400 text-xs font-bold flex items-center gap-1">
<Calendar size={12} /> {formatDate(quiz.createdAt)}
</span>
</div>
<h3 className="text-xl font-black text-gray-800 mb-1 group-hover:text-theme-primary transition-colors">
{quiz.title}
</h3>
<p className="text-gray-500 font-medium text-sm">
{quiz.questionCount} question{quiz.questionCount !== 1 ? 's' : ''}
{quiz.aiTopic && <span className="text-gray-400"> • Topic: {quiz.aiTopic}</span>}
</p>
</div>
<div className="flex items-center gap-3 pl-4">
{loadingQuizId === quiz.id ? (
<div className="p-3">
<Loader2 size={24} className="animate-spin text-theme-primary" />
</div>
) : deletingQuizId === quiz.id ? (
<div className="p-3">
<Loader2 size={24} className="animate-spin text-red-500" />
</div>
) : confirmDeleteId === quiz.id ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={confirmDelete}
disabled={isAnyOperationInProgress}
className="bg-red-500 text-white px-3 py-2 rounded-xl text-sm font-bold shadow-[0_3px_0_#991b1b] active:shadow-none active:translate-y-[3px] transition-all disabled:opacity-50"
>
Confirm
</button>
<button
onClick={cancelDelete}
disabled={isAnyOperationInProgress}
className="bg-gray-200 text-gray-600 px-3 py-2 rounded-xl text-sm font-bold hover:bg-gray-300 transition-all disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<>
<button
onClick={(e) => handleDeleteClick(e, quiz.id)}
disabled={isAnyOperationInProgress}
className="p-3 rounded-xl text-gray-300 hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete quiz"
>
<Trash2 size={20} />
</button>
<div className="bg-theme-primary/10 p-3 rounded-xl text-theme-primary group-hover:bg-theme-primary group-hover:text-white transition-all">
<Play size={24} fill="currentColor" />
</div>
</>
)}
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { motion, useSpring, useTransform } from 'framer-motion';
import { Check, X, Flame, Trophy } from 'lucide-react';
import { Check, X, Flame, ChevronRight } from 'lucide-react';
import { AnswerOption, Player, GameRole } from '../types';
import { SHAPES, COLORS } from '../constants';
import confetti from 'canvas-confetti';
@ -34,6 +34,7 @@ interface RevealScreenProps {
correctOption: AnswerOption;
selectedOption?: AnswerOption | null;
role: GameRole;
onNext?: () => void;
}
export const RevealScreen: React.FC<RevealScreenProps> = ({
@ -43,7 +44,8 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
streak,
correctOption,
selectedOption,
role
role,
onNext
}) => {
const isHost = role === 'HOST';
@ -59,19 +61,18 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
}
}, [isCorrect, isHost]);
// -- HOST VIEW --
if (isHost) {
const ShapeIcon = SHAPES[correctOption.shape];
const colorClass = COLORS[correctOption.color];
return (
<div className="flex flex-col items-center justify-center h-screen bg-gray-900 text-white p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10 pointer-events-none"></div>
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold uppercase tracking-widest mb-12 opacity-80"
className="text-4xl font-bold uppercase tracking-widest mb-12 opacity-80 relative z-10"
>
The correct answer is
</motion.div>
@ -80,7 +81,7 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", bounce: 0.5 }}
className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20`}
className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20 relative z-10`}
>
<div className="bg-black/20 p-6 rounded-full mb-6">
<ShapeIcon size={80} fill="currentColor" />
@ -99,6 +100,19 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
</motion.p>
)}
</motion.div>
{onNext && (
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
onClick={onNext}
className="mt-12 bg-white text-gray-900 px-8 py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_rgba(0,0,0,0.3)] active:shadow-none active:translate-y-[6px] transition-all flex items-center gap-2 hover:bg-gray-100 relative z-10 cursor-pointer"
>
Continue to Scoreboard
<ChevronRight size={28} strokeWidth={3} />
</motion.button>
)}
</div>
);
}

View file

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Save, X, Loader2, BrainCircuit } from 'lucide-react';
interface SaveQuizPromptProps {
isOpen: boolean;
quizTitle: string;
onSave: () => Promise<void>;
onSkip: () => void;
}
export const SaveQuizPrompt: React.FC<SaveQuizPromptProps> = ({
isOpen,
quizTitle,
onSave,
onSkip
}) => {
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave();
} finally {
setIsSaving(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={!isSaving ? onSkip : undefined}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: "spring", bounce: 0.4 }}
onClick={(e) => e.stopPropagation()}
className="bg-white w-full max-w-md flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden"
>
<div className="p-8 text-center">
<div className="bg-theme-primary/10 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6">
<BrainCircuit size={40} className="text-theme-primary" />
</div>
<h2 className="text-3xl font-black text-gray-900 mb-2 tracking-tight">Save this Quiz?</h2>
<p className="text-gray-500 font-bold mb-6">
"{quizTitle}" generated successfully! Would you like to save it to your library?
</p>
<div className="space-y-3">
<button
onClick={handleSave}
disabled={isSaving}
className="w-full bg-theme-primary text-white py-4 rounded-2xl text-xl font-black shadow-[0_4px_0_#1e40af] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2 hover:bg-theme-primary-dark disabled:opacity-70 disabled:active:shadow-[0_4px_0_#1e40af] disabled:active:translate-y-0 disabled:cursor-not-allowed"
>
{isSaving ? (
<>
<Loader2 className="animate-spin" size={24} />
Saving...
</>
) : (
<>
<Save size={24} strokeWidth={3} />
Save to Library
</>
)}
</button>
<button
onClick={onSkip}
disabled={isSaving}
className="w-full bg-gray-100 text-gray-500 py-3 rounded-2xl text-lg font-bold hover:bg-gray-200 hover:text-gray-700 transition-colors disabled:opacity-50"
>
Skip & Don't Save
</button>
</div>
</div>
<div className="absolute top-4 right-4">
<button
onClick={onSkip}
disabled={isSaving}
className="p-2 rounded-xl hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600 disabled:opacity-0"
>
<X size={24} strokeWidth={3} />
</button>
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

View file

@ -22,6 +22,7 @@ export const BOT_NAMES = [
];
export const QUESTION_TIME = 20; // seconds
export const QUESTION_TIME_MS = 20000; // milliseconds
export const POINTS_PER_QUESTION = 1000;
export const PLAYER_COLORS = [

BIN
data/kaboot.db-shm Normal file

Binary file not shown.

BIN
data/kaboot.db-wal Normal file

Binary file not shown.

35
docker-compose.caddy.yml Normal file
View file

@ -0,0 +1,35 @@
# Caddy Reverse Proxy for Kaboot Production
#
# This compose file adds Caddy as a reverse proxy with automatic HTTPS.
# Use with the main docker-compose.yml using the -f flag.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
#
# Prerequisites:
# 1. Create a Caddyfile in the project root (see docs/PRODUCTION.md)
# 2. Build the frontend: npm run build
# 3. Update your domain DNS to point to your server
services:
caddy:
image: caddy:2-alpine
container_name: kaboot-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./dist:/srv/frontend:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- kaboot-backend
- authentik-server
networks:
- kaboot-network
volumes:
caddy-data:
caddy-config:

128
docker-compose.yml Normal file
View file

@ -0,0 +1,128 @@
services:
# ═══════════════════════════════════════════════════════════════════════════
# AUTHENTIK - Identity Provider
# ═══════════════════════════════════════════════════════════════════════════
postgresql:
image: docker.io/library/postgres:16-alpine
container_name: kaboot-postgresql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
volumes:
- postgresql-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
POSTGRES_DB: ${PG_DB:-authentik}
networks:
- kaboot-network
redis:
image: docker.io/library/redis:alpine
container_name: kaboot-redis
command: --save 60 1 --loglevel warning
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
start_period: 20s
interval: 30s
retries: 5
timeout: 3s
volumes:
- redis-data:/data
networks:
- kaboot-network
authentik-server:
image: ghcr.io/goauthentik/server:2025.2
container_name: kaboot-authentik-server
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?authentik secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING:-false}
volumes:
- ./authentik/media:/media
- ./authentik/custom-templates:/templates
ports:
- "${AUTHENTIK_PORT_HTTP:-9000}:9000"
- "${AUTHENTIK_PORT_HTTPS:-9443}:9443"
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- kaboot-network
authentik-worker:
image: ghcr.io/goauthentik/server:2025.2
container_name: kaboot-authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./authentik/media:/media
- ./authentik/certs:/certs
- ./authentik/custom-templates:/templates
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- kaboot-network
# ═══════════════════════════════════════════════════════════════════════════
# KABOOT - Application Backend
# ═══════════════════════════════════════════════════════════════════════════
kaboot-backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: kaboot-backend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3001
DATABASE_PATH: /data/kaboot.db
OIDC_ISSUER: http://localhost:9000/application/o/kaboot/
OIDC_JWKS_URI: http://localhost:9000/application/o/kaboot/jwks/
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
volumes:
- kaboot-data:/data
ports:
- "${KABOOT_BACKEND_PORT:-3001}:3001"
depends_on:
- authentik-server
networks:
- kaboot-network
volumes:
postgresql-data:
redis-data:
kaboot-data:
networks:
kaboot-network:
driver: bridge

239
docs/API.md Normal file
View file

@ -0,0 +1,239 @@
# Kaboot Backend API Documentation
The Kaboot backend provides a RESTful API for managing quizzes and user profiles.
## Base URL
The backend server runs at:
`http://localhost:3001`
## Authentication
All routes under `/api/*` require authentication using an OIDC Bearer token in the `Authorization` header.
```http
Authorization: Bearer <your_access_token>
```
Tokens are issued by the Authentik Identity Provider.
---
## Endpoints
### Health Check
#### GET /health
Check the operational status of the backend server.
- **Authentication**: None
- **Response**: `200 OK`
- **Example Response**:
```json
{
"status": "ok",
"timestamp": "2026-01-13T10:00:00.000Z"
}
```
- **Curl Example**:
```bash
curl http://localhost:3001/health
```
---
### User Profile
#### GET /api/users/me
Retrieve the profile of the currently authenticated user.
- **Authentication**: Required
- **Response**: `200 OK`
- **Example Response**:
```json
{
"id": "user_123",
"username": "jdoe",
"email": "jdoe@example.com",
"displayName": "John Doe",
"createdAt": "2026-01-13T10:00:00.000Z",
"lastLogin": "2026-01-13T10:00:00.000Z",
"isNew": false
}
```
- **Curl Example**:
```bash
curl -H "Authorization: Bearer <token>" http://localhost:3001/api/users/me
```
---
### Quizzes
#### GET /api/quizzes
List all quizzes created by the authenticated user.
- **Authentication**: Required
- **Response**: `200 OK`
- **Example Response**:
```json
[
{
"id": "quiz_8b3f...",
"title": "World Capitals",
"source": "manual",
"aiTopic": null,
"createdAt": "2026-01-13T10:00:00.000Z",
"updatedAt": "2026-01-13T10:00:00.000Z",
"questionCount": 5
}
]
```
- **Curl Example**:
```bash
curl -H "Authorization: Bearer <token>" http://localhost:3001/api/quizzes
```
#### GET /api/quizzes/:id
Retrieve full details for a specific quiz, including all questions and answer options.
- **Authentication**: Required
- **Response**: `200 OK` or `404 Not Found`
- **Example Response**:
```json
{
"id": "quiz_8b3f...",
"title": "World Capitals",
"source": "manual",
"aiTopic": null,
"createdAt": "2026-01-13T10:00:00.000Z",
"updatedAt": "2026-01-13T10:00:00.000Z",
"questions": [
{
"id": "question_456",
"text": "What is the capital of Japan?",
"timeLimit": 20,
"orderIndex": 0,
"options": [
{
"id": "option_789",
"text": "Tokyo",
"isCorrect": true,
"shape": "triangle",
"color": "red",
"reason": "Tokyo is the political and economic center of Japan.",
"orderIndex": 0
}
]
}
]
}
```
- **Curl Example**:
```bash
curl -H "Authorization: Bearer <token>" http://localhost:3001/api/quizzes/quiz_8b3f...
```
#### POST /api/quizzes
Create a new quiz.
- **Authentication**: Required
- **Validation Rules**:
- `title`: Required, non-empty string.
- `source`: Required, must be `'manual'` or `'ai_generated'`.
- `questions`: Required, array with at least 1 question.
- **Question validation**:
- `text`: Required, non-empty string.
- `options`: Required, array with at least 2 options.
- Each option:
- `text`: Required, non-empty string.
- `isCorrect`: Required, boolean.
- `shape`: Required, one of `'triangle'`, `'diamond'`, `'circle'`, `'square'`.
- `color`: Required, one of `'red'`, `'blue'`, `'yellow'`, `'green'`.
- `reason`: Optional, string explaining the answer.
- At least one option must be marked as correct (`isCorrect: true`).
- **Request Body**:
```json
{
"title": "Space Exploration",
"source": "manual",
"questions": [
{
"text": "Which planet is known as the Red Planet?",
"timeLimit": 20,
"options": [
{ "text": "Mars", "isCorrect": true, "shape": "triangle", "color": "red" },
{ "text": "Venus", "isCorrect": false, "shape": "diamond", "color": "blue" }
]
}
]
}
```
- **Response**: `201 Created`
- **Example Response**:
```json
{
"id": "new_quiz_uuid"
}
```
- **Curl Example**:
```bash
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"title":"Space Quiz","source":"manual","questions":[{"text":"Which planet is known as the Red Planet?","options":[{"text":"Mars","isCorrect":true,"shape":"triangle","color":"red"},{"text":"Venus","isCorrect":false,"shape":"diamond","color":"blue"}]}]}' \
http://localhost:3001/api/quizzes
```
#### PUT /api/quizzes/:id
Update an existing quiz. This operation replaces the existing questions and options.
- **Authentication**: Required
- **Validation Rules**: Same as `POST /api/quizzes`.
- **Response**: `200 OK` or `404 Not Found`
- **Curl Example**:
```bash
curl -X PUT -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"title":"Updated Space Quiz","questions":[{"text":"Which planet is red?","options":[{"text":"Mars","isCorrect":true,"shape":"triangle","color":"red"},{"text":"Venus","isCorrect":false,"shape":"diamond","color":"blue"}]}]}' \
http://localhost:3001/api/quizzes/quiz_uuid
```
#### DELETE /api/quizzes/:id
Permanently delete a quiz.
- **Authentication**: Required
- **Response**: `204 No Content` or `404 Not Found`
- **Curl Example**:
```bash
curl -X DELETE -H "Authorization: Bearer <token>" http://localhost:3001/api/quizzes/quiz_uuid
```
---
## Error Responses
The API returns standard HTTP status codes along with a JSON error object.
- **400 Bad Request**
Returned when the request body is invalid or validation fails.
```json
{ "error": "Title is required and cannot be empty" }
```
- **401 Unauthorized**
Returned when the Bearer token is missing, expired, or invalid.
```json
{ "error": "Missing or invalid authorization header" }
```
or
```json
{ "error": "Invalid token", "details": "..." }
```
- **404 Not Found**
Returned when the requested quiz does not exist or does not belong to the user.
```json
{ "error": "Quiz not found" }
```
- **500 Internal Server Error**
Returned when an unexpected error occurs on the server.
```json
{ "error": "Internal server error" }
```

373
docs/AUTHENTIK_SETUP.md Normal file
View file

@ -0,0 +1,373 @@
# Authentik Setup Guide for Kaboot
This guide walks through configuring Authentik as the OAuth2/OIDC identity provider for Kaboot.
## Prerequisites
- Docker and Docker Compose installed
- Kaboot stack running (`docker compose up -d`)
- Access to `http://localhost:9000`
## Step 1: Initial Authentik Setup
1. Navigate to `http://localhost:9000/if/flow/initial-setup/`
- **Important**: Include the trailing slash `/`
2. Create the admin account:
- Email: Your email address
- Password: Choose a strong password
3. Log in with the credentials you just created
## Step 2: Create the Kaboot Application
1. In the Authentik admin interface, go to **Applications** > **Applications**
2. Click **Create with provider**
3. **Application Settings**:
| Field | Value |
|-------|-------|
| Name | `Kaboot` |
| Slug | `kaboot` |
| Launch URL | `http://localhost:5173` |
4. Click **Next**
## Step 3: Configure OAuth2/OIDC Provider
1. Select **OAuth2/OIDC** as the Provider Type
2. Click **Next**
3. **Provider Configuration**:
| Field | Value |
|-------|-------|
| Name | `Kaboot OAuth2` |
| Authorization flow | `default-provider-authorization-implicit-consent` |
| Client type | `Public` |
| Client ID | `kaboot-spa` |
4. **Redirect URIs** (one per line):
```
http://localhost:5173/callback
http://localhost:5173/silent-renew.html
http://localhost:5173
```
5. **Advanced Settings**:
| Field | Value |
|-------|-------|
| Subject mode | `Based on the User's hashed ID` |
| Include claims in id_token | `Yes` |
| Issuer mode | `Each provider has a different issuer` |
6. **Scopes** - Ensure these are selected:
- `openid`
- `profile`
- `email`
- `offline_access` (for refresh tokens)
7. Click **Submit**
## Step 4: Enable User Registration (Sign Up)
By default, Authentik only shows a login form. To allow users to sign up, you need to create an enrollment flow and link it.
### Step 4.1: Create the Enrollment Prompt Stage
1. Go to **Flows and Stages** > **Stages**
2. Click **Create**
3. Select **Prompt Stage** and click **Next**
4. Configure:
| Field | Value |
|-------|-------|
| Name | `enrollment-prompt` |
5. In the **Fields** section, move these to the **Selected** side:
- `default-source-enrollment-field-username` (username)
- `default-user-settings-field-email` (email)
- `default-password-change-field-password` (password)
- `default-password-change-field-password-repeat` (password_repeat)
6. (Optional) In **Validation policies**, select `password-complexity` if you created it in Step 4.2
7. Click **Finish**
### Step 4.2: (Optional) Create Password Complexity Policy
1. Go to **Customisation** > **Policies**
2. Click **Create** and select **Password Policy**
3. Configure:
| Field | Value |
|-------|-------|
| Name | `password-complexity` |
| Password field | `password` |
| Minimum length | `8` |
| Amount of uppercase characters | `1` |
| Amount of lowercase characters | `1` |
| Amount of digits | `1` |
4. Click **Finish**
You'll add this to the enrollment prompt stage later.
### Step 4.3: Create a Group for Kaboot Users
1. Go to **Directory** > **Groups**
2. Click **Create**
3. Configure:
| Field | Value |
|-------|-------|
| Name | `kaboot-users` |
4. Click **Create**
### Step 4.4: Create the User Write Stage
1. Go to **Flows and Stages** > **Stages**
2. Click **Create**
3. Select **User Write Stage** and click **Next**
4. Configure:
| Field | Value |
|-------|-------|
| Name | `enrollment-user-write` |
| User creation mode | `Create users when required` |
| Create users as inactive | Unchecked |
| Group | `kaboot-users` |
5. Click **Finish**
### Step 4.5: Create the User Login Stage
1. Go to **Flows and Stages** > **Stages**
2. Click **Create**
3. Select **User Login Stage** and click **Next**
4. Configure:
| Field | Value |
|-------|-------|
| Name | `enrollment-user-login` |
| Session duration | `hours=24` |
| Stay signed in offset | `days=30` |
| Network binding | `No binding` |
| GeoIP binding | `No binding` |
5. Click **Finish**
### Step 4.6: Create the Enrollment Flow
1. Go to **Flows and Stages** > **Flows**
2. Click **Create**
3. Configure:
| Field | Value |
|-------|-------|
| Name | `Enrollment Flow` |
| Title | `Sign Up` |
| Slug | `enrollment-flow` |
| Designation | `Enrollment` |
| Authentication | `No requirement` |
4. Click **Create**
5. Click on the newly created `enrollment-flow`
6. Go to the **Stage Bindings** tab
7. Click **Bind existing stage** and add stages in this order:
| Stage | Order |
|-------|-------|
| `enrollment-prompt` | 10 |
| `enrollment-user-write` | 20 |
| `enrollment-user-login` | 30 |
### Step 4.7: Bind the Group to the Kaboot Application
1. Go to **Applications** > **Applications** > **Kaboot**
2. Go to the **Policy / Group / User Bindings** tab
3. Click **Bind existing group**
4. Select `kaboot-users`
5. Click **Bind**
Now users in the `kaboot-users` group (which includes all users who sign up) will have access to Kaboot.
### Step 4.8: Link Enrollment Flow to Login
1. Go to **Flows and Stages** > **Stages**
2. Find and click on `default-authentication-identification`
3. Scroll down to **Flow settings**
4. In the **Enrollment flow** dropdown, select `enrollment-flow`
5. Click **Update**
Now when users visit the login page, they'll see a "Need an account? Sign up." link.
### Optional: Add Password Recovery
1. In **Flows and Stages** > **Stages** > `default-authentication-identification`
2. Set **Recovery flow** to `default-recovery-flow` (if it exists)
3. Click **Update**
## Step 5: Verify OIDC Endpoints
After creation, go to **Applications** > **Providers** > **Kaboot OAuth2**
Note these endpoints (you'll need them for frontend configuration):
| Endpoint | URL |
|----------|-----|
| Issuer | `http://localhost:9000/application/o/kaboot/` |
| Authorization | `http://localhost:9000/application/o/authorize/` |
| Token | `http://localhost:9000/application/o/token/` |
| UserInfo | `http://localhost:9000/application/o/userinfo/` |
| JWKS | `http://localhost:9000/application/o/kaboot/jwks/` |
## Step 5: Test the Configuration
1. Open the OpenID Configuration URL in your browser:
```
http://localhost:9000/application/o/kaboot/.well-known/openid-configuration
```
2. You should see a JSON response with all OIDC endpoints
## Step 6: Create a Test User
Create a regular user for manual browser testing.
1. Go to **Directory** > **Users**
2. Click **Create**
3. Fill in user details:
| Field | Value |
|-------|-------|
| Username | `kaboottest` |
| Name | `Kaboot Test` |
| Email | `kaboottest@test.com` |
4. After creation, click on the user and go to the **Credentials** tab
5. Click **Set password** and set it to `kaboottest`
6. **Bind the user to the Kaboot application**:
- Go to **Applications** > **Applications** > **Kaboot**
- Click the **Policy / Group / User Bindings** tab
- Click **Bind existing user**
- Select `kaboottest` and click **Bind**
## Step 7: Create a Service Account for API Testing
Create a service account that can obtain tokens programmatically for automated tests.
1. Go to **Directory** > **Users**
2. Click **Create Service Account**
3. Fill in details:
| Field | Value |
|-------|-------|
| Username | `kaboot-test-service` |
| Create group | Unchecked |
4. Click **Create**
5. **Create an App Password** for the service account:
- Click on the newly created `kaboot-test-service` user
- Go to the **App passwords** tab
- Click **Create App Password**
- Name it `api-tests`
- Copy the generated password (you won't see it again!)
6. **Bind the service account to the Kaboot application**:
- Go to **Applications** > **Applications** > **Kaboot**
- Click the **Policy / Group / User Bindings** tab
- Click **Bind existing user**
- Select `kaboot-test-service` and click **Bind**
7. **Save credentials to `server/.env.test`**:
```bash
TEST_USERNAME=kaboot-test-service
TEST_PASSWORD=<paste-app-password-here>
```
8. **Verify token generation works**:
```bash
cd server
npm run test:get-token
```
You should see "Token obtained successfully" and the access token printed.
## Environment Variables
Ensure your `.env` file has the correct OIDC configuration:
```bash
OIDC_ISSUER=http://localhost:9000/application/o/kaboot/
OIDC_JWKS_URI=http://localhost:9000/application/o/kaboot/jwks/
```
For the frontend OIDC config (`src/config/oidc.ts`):
```typescript
export const oidcConfig = {
authority: 'http://localhost:9000/application/o/kaboot/',
client_id: 'kaboot-spa',
redirect_uri: `${window.location.origin}/callback`,
// ... rest of config
};
```
## Troubleshooting
### "Invalid redirect URI" error
- Ensure all redirect URIs are added exactly as configured in the provider
- Check for trailing slashes - they must match exactly
### "Client not found" error
- Verify the Client ID matches `kaboot-spa`
- Ensure the application is enabled (not archived)
### CORS errors
- Authentik handles CORS automatically for configured redirect URIs
- Ensure your frontend origin (`http://localhost:5173`) is in the redirect URIs
### Token validation fails on backend
- Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct
- The backend must be able to reach Authentik at `http://authentik-server:9000` (Docker network)
## Production Notes
For production deployment:
1. Use HTTPS everywhere
2. Update all URLs from `localhost` to your domain
3. Update redirect URIs in Authentik
4. Update frontend OIDC config with production URLs
5. Update `.env` with production OIDC endpoints
6. Consider enabling Authentik error reporting
7. Configure email settings in Authentik for password recovery

326
docs/PRODUCTION.md Normal file
View file

@ -0,0 +1,326 @@
# Production Deployment Guide
This guide provides instructions for deploying Kaboot to a production environment. It covers security, persistence, and configuration for a robust setup.
## Prerequisites
- A Linux server with Docker and Docker Compose installed.
- A registered domain name (e.g., `kaboot.example.com`).
- SSL certificates (e.g., from Let's Encrypt).
- A Google Gemini API key.
## Architecture Overview
In production, the stack consists of:
- **Nginx**: Reverse proxy handling HTTPS and routing.
- **Authentik**: Identity Provider for authentication.
- **Kaboot Backend**: Express server for quiz logic and SQLite storage.
- **Kaboot Frontend**: Static assets served via Nginx or a dedicated service.
- **PostgreSQL**: Database for Authentik.
- **Redis**: Cache and task queue for Authentik.
## Environment Variables
Create a production `.env` file. Do not commit this file to version control.
### Backend & Authentik Configuration
```env
# Database Passwords (Generate strong secrets)
PG_PASS=your_strong_postgres_password
AUTHENTIK_SECRET_KEY=your_strong_authentik_secret
# Infrastructure
AUTHENTIK_PORT_HTTP=9000
KABOOT_BACKEND_PORT=3001
# AI Configuration
GEMINI_API_KEY=your_gemini_api_key
# OIDC Production Settings
OIDC_ISSUER=https://auth.example.com/application/o/kaboot/
OIDC_JWKS_URI=https://auth.example.com/application/o/kaboot/jwks/
# Security
CORS_ORIGIN=https://kaboot.example.com
LOG_REQUESTS=true
```
### Frontend Configuration
The frontend requires environment variables at build time:
- `VITE_API_URL`: `https://kaboot.example.com/api`
- `VITE_OIDC_AUTHORITY`: `https://auth.example.com/application/o/kaboot/`
## Docker Compose Production Example
Create a `docker-compose.prod.yml` for your production environment:
```yaml
services:
postgresql:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
volumes:
- postgresql-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${PG_PASS}
POSTGRES_USER: ${PG_USER:-authentik}
POSTGRES_DB: ${PG_DB:-authentik}
networks:
- kaboot-network
redis:
image: docker.io/library/redis:alpine
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- redis-data:/data
networks:
- kaboot-network
authentik-server:
image: ghcr.io/goauthentik/server:2025.2
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
volumes:
- ./authentik/media:/media
- ./authentik/custom-templates:/templates
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- kaboot-network
authentik-worker:
image: ghcr.io/goauthentik/server:2025.2
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./authentik/media:/media
- ./authentik/certs:/certs
- ./authentik/custom-templates:/templates
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- kaboot-network
kaboot-backend:
build:
context: ./server
dockerfile: Dockerfile
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3001
DATABASE_PATH: /data/kaboot.db
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_JWKS_URI: ${OIDC_JWKS_URI}
CORS_ORIGIN: ${CORS_ORIGIN}
LOG_REQUESTS: ${LOG_REQUESTS}
volumes:
- kaboot-data:/data
networks:
- kaboot-network
volumes:
postgresql-data:
redis-data:
kaboot-data:
networks:
kaboot-network:
driver: bridge
```
## HTTPS and Reverse Proxy
Choose one of the following reverse proxy options. Caddy is recommended for its simplicity and automatic HTTPS.
### Option 1: Caddy (Recommended)
Caddy automatically obtains and renews SSL certificates from Let's Encrypt.
A separate `docker-compose.caddy.yml` is provided to add Caddy to your stack.
**Step 1: Create the Caddyfile**
Copy and customize the example Caddyfile:
```bash
cp Caddyfile.example Caddyfile
```
Edit `Caddyfile` and replace `kaboot.example.com` and `auth.example.com` with your actual domains:
```caddyfile
kaboot.example.com {
root * /srv/frontend
file_server
try_files {path} /index.html
handle /api/* {
reverse_proxy kaboot-backend:3001
}
handle /health {
reverse_proxy kaboot-backend:3001
}
}
auth.example.com {
reverse_proxy authentik-server:9000
}
```
**Step 2: Build the Frontend**
```bash
npm run build
```
This creates the `dist/` directory with production assets.
**Step 3: Start with Caddy**
Use both compose files together:
```bash
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
```
This will:
- Start all Kaboot services (backend, Authentik, PostgreSQL, Redis)
- Start Caddy as a reverse proxy on ports 80 and 443
- Automatically obtain SSL certificates from Let's Encrypt
**Step 4: Verify**
Check that all services are running:
```bash
docker compose -f docker-compose.yml -f docker-compose.caddy.yml ps
```
View Caddy logs:
```bash
docker logs kaboot-caddy
```
**Stopping the Stack**
```bash
docker compose -f docker-compose.yml -f docker-compose.caddy.yml down
```
### Option 2: Nginx
Use Nginx as a reverse proxy with manual SSL certificate management.
```nginx
server {
listen 443 ssl;
server_name kaboot.example.com;
ssl_certificate /etc/letsencrypt/live/kaboot.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kaboot.example.com/privkey.pem;
location / {
root /var/www/kaboot/frontend;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /health {
proxy_pass http://localhost:3001/health;
}
}
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
location / {
proxy_pass http://localhost:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## Authentik Configuration for Production
1. **Update Redirect URIs**: In the Authentik Admin interface, go to **Applications** > **Providers** > **Kaboot OAuth2**. Update the **Redirect URIs** to use your production domain:
- `https://kaboot.example.com/callback`
- `https://kaboot.example.com/silent-renew.html`
- `https://kaboot.example.com`
2. **Email Configuration**: To enable password recovery, configure SMTP settings in Authentik. In the Admin interface, go to **System** > **Settings** and update the Email section.
- Host: your SMTP server
- Port: 587 or 465
- Username/Password: your credentials
- Use TLS/SSL: Enabled
## Database Backup Strategy
Kaboot uses SQLite, making backups straightforward.
### SQLite (Kaboot Data)
The database file is located in the `kaboot-data` volume at `/data/kaboot.db`. To back it up:
```bash
docker exec kaboot-backend sqlite3 /data/kaboot.db ".backup '/data/backup_$(date +%F).db'"
```
Then, copy the backup file from the volume to a secure location.
### PostgreSQL (Authentik Data)
For Authentik's metadata:
```bash
docker exec kaboot-postgresql pg_dump -U authentik authentik > authentik_backup_$(date +%F).sql
```
## Security Checklist
- [ ] Change all default passwords (`PG_PASS`, `AUTHENTIK_SECRET_KEY`).
- [ ] Ensure `NODE_ENV` is set to `production`.
- [ ] Use HTTPS for all connections.
- [ ] Set `CORS_ORIGIN` to your specific frontend domain.
- [ ] Regularly back up the `kaboot.db` and PostgreSQL data.
- [ ] Monitor logs by setting `LOG_REQUESTS=true`.
- [ ] Keep Docker images updated to the latest stable versions.

4
features.md Normal file
View file

@ -0,0 +1,4 @@
- [ ] All data stored in sqlite db.
- [ ] AI generated content based on document upload
- [ ] Moderation (kick player, lock game, filter names)
- [ ] Persistent game urls while game is active

View file

@ -0,0 +1,97 @@
import { useAuth } from 'react-oidc-context';
import { useCallback } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export const useAuthenticatedFetch = () => {
const auth = useAuth();
const isTokenExpired = useCallback(() => {
if (!auth.user?.expires_at) return true;
const expiresAt = auth.user.expires_at * 1000;
const now = Date.now();
const bufferMs = 60 * 1000;
return now >= expiresAt - bufferMs;
}, [auth.user?.expires_at]);
const ensureValidToken = useCallback(async (): Promise<string> => {
if (!auth.user?.access_token) {
throw new Error('Not authenticated');
}
if (isTokenExpired()) {
try {
const user = await auth.signinSilent();
if (user?.access_token) {
return user.access_token;
}
} catch {
auth.signinRedirect();
throw new Error('Session expired, redirecting to login');
}
}
return auth.user.access_token;
}, [auth, isTokenExpired]);
const authFetch = useCallback(
async (path: string, options: RequestInit = {}): Promise<Response> => {
if (!navigator.onLine) {
throw new Error('You appear to be offline. Please check your connection.');
}
const token = await ensureValidToken();
const url = path.startsWith('http') ? path : `${API_URL}${path}`;
let response: Response;
try {
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
} catch (err) {
if (!navigator.onLine) {
throw new Error('You appear to be offline. Please check your connection.');
}
throw new Error('Network error. Please try again.');
}
if (response.status === 401) {
try {
const user = await auth.signinSilent();
if (user?.access_token) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${user.access_token}`,
'Content-Type': 'application/json',
},
});
}
} catch {
auth.signinRedirect();
}
throw new Error('Session expired, redirecting to login');
}
if (response.status >= 500) {
throw new Error('Server error. Please try again later.');
}
return response;
},
[auth, ensureValidToken]
);
return {
authFetch,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
user: auth.user,
};
};

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question } from '../types';
import { generateQuiz } from '../services/geminiService';
import { POINTS_PER_QUESTION, QUESTION_TIME, PLAYER_COLORS } from '../constants';
import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants';
import { Peer, DataConnection } from 'peerjs';
export const useGame = () => {
@ -21,6 +21,7 @@ export const useGame = () => {
const [currentStreak, setCurrentStreak] = useState(0);
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peerRef = useRef<Peer | null>(null);
@ -48,6 +49,7 @@ export const useGame = () => {
setError(null);
setRole('HOST');
const generatedQuiz = await generateQuiz(topic);
setPendingQuizToSave({ quiz: generatedQuiz, topic });
initializeHostGame(generatedQuiz);
} catch (e) {
setError("Failed to generate quiz.");
@ -55,15 +57,26 @@ export const useGame = () => {
}
};
const dismissSavePrompt = () => {
setPendingQuizToSave(null);
};
const startManualCreation = () => {
setRole('HOST');
setGameState('CREATING');
};
const finalizeManualQuiz = (manualQuiz: Quiz) => {
const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => {
if (saveToLibrary) {
setPendingQuizToSave({ quiz: manualQuiz, topic: '' });
}
initializeHostGame(manualQuiz);
};
const loadSavedQuiz = (savedQuiz: Quiz) => {
initializeHostGame(savedQuiz);
};
// We use a ref to hold the current handleHostData function
// This prevents stale closures in the PeerJS event listeners
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
@ -134,7 +147,7 @@ export const useGame = () => {
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0;
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
const newScore = currentPlayer.score + points;
setPlayers(prev => prev.map(p => {
@ -184,16 +197,14 @@ export const useGame = () => {
setHasAnswered(false);
setLastPointsEarned(null);
setSelectedOption(null);
setTimeLeft(QUESTION_TIME);
setTimeLeft(QUESTION_TIME_MS);
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
// Use refs to get the latest state inside this async callback
const currentQuiz = quizRef.current;
const currentIndex = currentQuestionIndexRef.current;
if (currentQuiz) {
const currentQ = currentQuiz.questions[currentIndex];
// Ensure options exist
const options = currentQ.options || [];
const correctOpt = options.find(o => o.isCorrect);
const correctShape = correctOpt?.shape || 'triangle';
@ -201,7 +212,7 @@ export const useGame = () => {
const optionsForClient = options.map(o => ({
...o,
isCorrect: false // Masked
isCorrect: false
}));
broadcast({
@ -220,17 +231,21 @@ export const useGame = () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) { endQuestion(); return 0; }
return prev - 1;
if (prev <= 100) { endQuestion(); return 0; }
return prev - 100;
});
}, 1000);
}, 100);
};
const endQuestion = () => {
if (timerRef.current) clearInterval(timerRef.current);
setGameState('REVEAL');
broadcast({ type: 'TIME_UP', payload: {} });
setTimeout(() => setGameState('SCOREBOARD'), 4000);
};
const showScoreboard = () => {
setGameState('SCOREBOARD');
broadcast({ type: 'SHOW_SCOREBOARD', payload: { players: playersRef.current } });
};
const nextQuestion = () => {
@ -290,7 +305,7 @@ export const useGame = () => {
setLastPointsEarned(null);
setSelectedOption(null);
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
setTimeLeft(data.payload.timeLimit);
setTimeLeft(data.payload.timeLimit * 1000);
setCurrentCorrectShape(data.payload.correctShape);
setQuiz(prev => {
@ -308,7 +323,7 @@ export const useGame = () => {
});
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 1)), 1000);
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
}
if (data.type === 'RESULT') {
@ -343,7 +358,7 @@ export const useGame = () => {
const option = arg as AnswerOption;
const isCorrect = option.isCorrect;
setSelectedOption(option);
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0;
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
setLastPointsEarned(points);
const hostPlayer = playersRef.current.find(p => p.id === 'host');
@ -379,6 +394,7 @@ export const useGame = () => {
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
pendingQuizToSave, dismissSavePrompt,
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard
};
};

216
hooks/useQuizLibrary.ts Normal file
View file

@ -0,0 +1,216 @@
import { useState, useCallback, useRef } from 'react';
import toast from 'react-hot-toast';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types';
interface UseQuizLibraryReturn {
quizzes: QuizListItem[];
loading: boolean;
loadingQuizId: string | null;
deletingQuizId: string | null;
saving: boolean;
error: string | null;
fetchQuizzes: () => Promise<void>;
loadQuiz: (id: string) => Promise<SavedQuiz>;
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
deleteQuiz: (id: string) => Promise<void>;
retry: () => Promise<void>;
clearError: () => void;
}
export const useQuizLibrary = (): UseQuizLibraryReturn => {
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
const [quizzes, setQuizzes] = useState<QuizListItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadingQuizId, setLoadingQuizId] = useState<string | null>(null);
const [deletingQuizId, setDeletingQuizId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastOperationRef = useRef<(() => Promise<void>) | null>(null);
const fetchQuizzes = useCallback(async () => {
if (!isAuthenticated) return;
setLoading(true);
setError(null);
lastOperationRef.current = fetchQuizzes;
try {
const response = await authFetch('/api/quizzes');
if (!response.ok) {
const errorText = response.status === 500
? 'Server error. Please try again.'
: 'Failed to load your quizzes.';
throw new Error(errorText);
}
const data = await response.json();
setQuizzes(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load quizzes';
setError(message);
if (!message.includes('redirecting')) {
toast.error(message);
}
} finally {
setLoading(false);
}
}, [authFetch, isAuthenticated]);
const loadQuiz = useCallback(async (id: string): Promise<SavedQuiz> => {
setLoadingQuizId(id);
setError(null);
try {
const response = await authFetch(`/api/quizzes/${id}`);
if (!response.ok) {
const errorText = response.status === 404
? 'Quiz not found. It may have been deleted.'
: 'Failed to load quiz.';
throw new Error(errorText);
}
toast.success('Quiz loaded!');
return response.json();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load quiz';
if (!message.includes('redirecting')) {
toast.error(message);
}
throw err;
} finally {
setLoadingQuizId(null);
}
}, [authFetch]);
const saveQuiz = useCallback(async (
quiz: Quiz,
source: QuizSource,
aiTopic?: string
): Promise<string> => {
if (saving) {
toast.error('Save already in progress');
throw new Error('Save already in progress');
}
if (!quiz.title?.trim()) {
toast.error('Quiz must have a title');
throw new Error('Quiz must have a title');
}
if (!quiz.questions || quiz.questions.length === 0) {
toast.error('Quiz must have at least one question');
throw new Error('Quiz must have at least one question');
}
for (const q of quiz.questions) {
if (!q.text?.trim()) {
toast.error('All questions must have text');
throw new Error('All questions must have text');
}
if (!q.options || q.options.length < 2) {
toast.error('Each question must have at least 2 options');
throw new Error('Each question must have at least 2 options');
}
const hasCorrect = q.options.some(o => o.isCorrect);
if (!hasCorrect) {
toast.error('Each question must have a correct answer');
throw new Error('Each question must have a correct answer');
}
}
setSaving(true);
setError(null);
try {
const response = await authFetch('/api/quizzes', {
method: 'POST',
body: JSON.stringify({
title: quiz.title,
source,
aiTopic,
questions: quiz.questions.map(q => ({
text: q.text,
timeLimit: q.timeLimit,
options: q.options.map(o => ({
text: o.text,
isCorrect: o.isCorrect,
shape: o.shape,
color: o.color,
reason: o.reason,
})),
})),
}),
});
if (!response.ok) {
const errorText = response.status === 400
? 'Invalid quiz data. Please check and try again.'
: 'Failed to save quiz.';
throw new Error(errorText);
}
const data = await response.json();
toast.success('Quiz saved to your library!');
return data.id;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save quiz';
if (!message.includes('redirecting')) {
toast.error(message);
}
throw err;
} finally {
setSaving(false);
}
}, [authFetch]);
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
setDeletingQuizId(id);
setError(null);
try {
const response = await authFetch(`/api/quizzes/${id}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
const errorText = response.status === 404
? 'Quiz not found.'
: 'Failed to delete quiz.';
throw new Error(errorText);
}
setQuizzes(prev => prev.filter(q => q.id !== id));
toast.success('Quiz deleted');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete quiz';
if (!message.includes('redirecting')) {
toast.error(message);
}
throw err;
} finally {
setDeletingQuizId(null);
}
}, [authFetch]);
const retry = useCallback(async () => {
if (lastOperationRef.current) {
await lastOperationRef.current();
}
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return {
quizzes,
loading,
loadingQuizId,
deletingQuizId,
saving,
error,
fetchQuizzes,
loadQuiz,
saveQuiz,
deleteQuiz,
retry,
clearError,
};
};

View file

@ -1,15 +1,55 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AuthProvider } from 'react-oidc-context';
import { Toaster } from 'react-hot-toast';
import App from './App';
import { oidcConfig } from './src/config/oidc';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const onSigninCallback = () => {
window.history.replaceState({}, document.title, window.location.pathname);
};
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
<AuthProvider
{...oidcConfig}
onSigninCallback={onSigninCallback}
onRemoveUser={() => {
window.localStorage.clear();
}}
>
<Toaster
position="top-center"
toastOptions={{
duration: 4000,
style: {
background: '#333',
color: '#fff',
fontWeight: 'bold',
borderRadius: '1rem',
padding: '12px 20px',
},
success: {
iconTheme: {
primary: '#22c55e',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
<App />
</AuthProvider>
</React.StrictMode>
);

71
package-lock.json generated
View file

@ -12,9 +12,12 @@
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.26.1",
"lucide-react": "^0.562.0",
"oidc-client-ts": "^3.1.0",
"peerjs": "^1.5.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
"react-oidc-context": "^3.2.0",
"recharts": "^3.6.0",
"uuid": "^13.0.0"
},
@ -1622,6 +1625,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@ -2047,6 +2057,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
@ -2212,6 +2231,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -2340,6 +2368,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/oidc-client-ts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -2482,6 +2523,23 @@
"react": "^19.2.3"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
@ -2489,6 +2547,19 @@
"license": "MIT",
"peer": true
},
"node_modules/react-oidc-context": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz",
"integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"oidc-client-ts": "^3.1.0",
"react": ">=16.14.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",

View file

@ -9,15 +9,18 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"@google/genai": "^1.35.0",
"lucide-react": "^0.562.0",
"react-dom": "^19.2.3",
"uuid": "^13.0.0",
"recharts": "^3.6.0",
"framer-motion": "^12.26.1",
"canvas-confetti": "^1.9.4",
"peerjs": "^1.5.2"
"framer-motion": "^12.26.1",
"lucide-react": "^0.562.0",
"oidc-client-ts": "^3.1.0",
"peerjs": "^1.5.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
"react-oidc-context": "^3.2.0",
"recharts": "^3.6.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^22.14.0",

48
scripts/setup.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
set -e
ENV_FILE=".env"
ENV_EXAMPLE=".env.example"
echo "Kaboot Setup Script"
echo "==================="
echo ""
if [ -f "$ENV_FILE" ]; then
read -p ".env file already exists. Overwrite? (y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborting. Existing .env file preserved."
exit 0
fi
fi
if [ ! -f "$ENV_EXAMPLE" ]; then
echo "Error: .env.example not found. Run this script from the project root."
exit 1
fi
echo "Generating secrets..."
PG_PASS=$(openssl rand -base64 36 | tr -d '\n')
AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')
cp "$ENV_EXAMPLE" "$ENV_FILE"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE"
sed -i '' "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE"
else
sed -i "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE"
sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE"
fi
echo ""
echo "Created .env file with generated secrets."
echo ""
echo "Next steps:"
echo " 1. Review .env and adjust settings if needed"
echo " 2. Run: docker compose up -d"
echo " 3. Open: http://localhost:9000/if/flow/initial-setup/"
echo " 4. Follow docs/AUTHENTIK_SETUP.md to configure the OAuth2 provider"
echo ""

4
server/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
*.log
.env*

17
server/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build && cp src/db/schema.sql dist/db/
RUN mkdir -p /data
EXPOSE 3001
CMD ["npm", "start"]

2241
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
server/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "kaboot-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "tsx --env-file=.env.test tests/run-tests.ts",
"test:only": "tsx --env-file=.env.test tests/api.test.ts",
"test:get-token": "tsx --env-file=.env.test tests/get-token.ts"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.7",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,19 @@
import Database, { Database as DatabaseType } from 'better-sqlite3';
import { readFileSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DATABASE_PATH || join(__dirname, '../../../data/kaboot.db');
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db: DatabaseType = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
db.exec(schema);
console.log(`Database initialized at ${DB_PATH}`);

44
server/src/db/schema.sql Normal file
View file

@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
);
CREATE TABLE IF NOT EXISTS quizzes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
ai_topic TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
quiz_id TEXT NOT NULL,
text TEXT NOT NULL,
time_limit INTEGER DEFAULT 20,
order_index INTEGER NOT NULL,
FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS answer_options (
id TEXT PRIMARY KEY,
question_id TEXT NOT NULL,
text TEXT NOT NULL,
is_correct INTEGER NOT NULL,
shape TEXT NOT NULL CHECK(shape IN ('triangle', 'diamond', 'circle', 'square')),
color TEXT NOT NULL CHECK(color IN ('red', 'blue', 'yellow', 'green')),
reason TEXT,
order_index INTEGER NOT NULL,
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);

77
server/src/index.ts Normal file
View file

@ -0,0 +1,77 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { db } from './db/connection.js';
import quizzesRouter from './routes/quizzes.js';
import usersRouter from './routes/users.js';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
}));
const LOG_REQUESTS = process.env.LOG_REQUESTS === 'true';
app.use((req: Request, res: Response, next: NextFunction) => {
if (LOG_REQUESTS && req.path !== '/health') {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
}
next();
});
app.use((req: Request, res: Response, next: NextFunction) => {
express.json({ limit: '10mb' })(req, res, (err) => {
if (err instanceof SyntaxError && 'body' in err) {
res.status(400).json({ error: 'Invalid JSON' });
return;
}
if (err) {
next(err);
return;
}
next();
});
});
app.get('/health', (_req: Request, res: Response) => {
try {
db.prepare('SELECT 1').get();
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
database: 'connected'
});
} catch {
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString(),
database: 'disconnected'
});
}
});
app.use('/api/quizzes', quizzesRouter);
app.use('/api/users', usersRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`Kaboot backend running on port ${PORT}`);
console.log(`Database: ${process.env.DATABASE_PATH || 'default location'}`);
console.log(`CORS origin: ${process.env.CORS_ORIGIN || 'http://localhost:5173'}`);
});
process.on('SIGTERM', () => {
console.log('Shutting down...');
db.close();
process.exit(0);
});

View file

@ -0,0 +1,82 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'http://localhost:9000/application/o/kaboot/';
const OIDC_JWKS_URI = process.env.OIDC_JWKS_URI || 'http://localhost:9000/application/o/kaboot/jwks/';
const OIDC_INTERNAL_JWKS_URI = process.env.OIDC_INTERNAL_JWKS_URI || OIDC_JWKS_URI;
const client = jwksClient({
jwksUri: OIDC_INTERNAL_JWKS_URI,
cache: true,
cacheMaxAge: 600000,
rateLimit: true,
jwksRequestsPerMinute: 10,
});
function getSigningKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void {
if (!header.kid) {
callback(new Error('No kid in token header'));
return;
}
client.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err);
return;
}
const signingKey = key?.getPublicKey();
callback(null, signingKey);
});
}
export interface AuthenticatedUser {
sub: string;
preferred_username: string;
email?: string;
name?: string;
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
export function requireAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const token = authHeader.slice(7);
jwt.verify(
token,
getSigningKey,
{
issuer: OIDC_ISSUER,
algorithms: ['RS256'],
},
(err, decoded) => {
if (err) {
console.error('Token verification failed:', err.message);
res.status(401).json({ error: 'Invalid token', details: err.message });
return;
}
const payload = decoded as jwt.JwtPayload;
req.user = {
sub: payload.sub!,
preferred_username: payload.preferred_username || payload.sub!,
email: payload.email,
name: payload.name,
};
next();
}
);
}

View file

@ -0,0 +1,285 @@
import { Router, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
interface QuizBody {
title: string;
source: 'manual' | 'ai_generated';
aiTopic?: string;
questions: {
text: string;
timeLimit?: number;
options: {
text: string;
isCorrect: boolean;
shape: string;
color: string;
reason?: string;
}[];
}[];
}
router.get('/', (req: AuthenticatedRequest, res: Response) => {
const quizzes = db.prepare(`
SELECT
q.id,
q.title,
q.source,
q.ai_topic as aiTopic,
q.created_at as createdAt,
q.updated_at as updatedAt,
(SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount
FROM quizzes q
WHERE q.user_id = ?
ORDER BY q.updated_at DESC
`).all(req.user!.sub);
res.json(quizzes);
});
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
const quiz = db.prepare(`
SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt
FROM quizzes
WHERE id = ? AND user_id = ?
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
if (!quiz) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
const questions = db.prepare(`
SELECT id, text, time_limit as timeLimit, order_index as orderIndex
FROM questions
WHERE quiz_id = ?
ORDER BY order_index
`).all(quiz.id) as Record<string, unknown>[];
const questionsWithOptions = questions.map((q) => {
const options = db.prepare(`
SELECT id, text, is_correct as isCorrect, shape, color, reason, order_index as orderIndex
FROM answer_options
WHERE question_id = ?
ORDER BY order_index
`).all(q.id) as Record<string, unknown>[];
return {
...q,
options: options.map((o) => ({
...o,
isCorrect: Boolean(o.isCorrect),
})),
};
});
res.json({
...quiz,
questions: questionsWithOptions,
});
});
function validateQuizBody(body: QuizBody): string | null {
const { title, source, questions } = body;
if (!title?.trim()) {
return 'Title is required and cannot be empty';
}
if (!source || !['manual', 'ai_generated'].includes(source)) {
return 'Source must be "manual" or "ai_generated"';
}
if (!questions || !Array.isArray(questions) || questions.length === 0) {
return 'At least one question is required';
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.text?.trim()) {
return `Question ${i + 1} text is required`;
}
if (!q.options || !Array.isArray(q.options) || q.options.length < 2) {
return `Question ${i + 1} must have at least 2 options`;
}
const hasCorrect = q.options.some(o => o.isCorrect);
if (!hasCorrect) {
return `Question ${i + 1} must have at least one correct answer`;
}
}
return null;
}
router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, questions } = body;
const validationError = validateQuizBody(body);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const quizId = uuidv4();
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, last_login)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
last_login = CURRENT_TIMESTAMP,
email = COALESCE(excluded.email, users.email),
display_name = COALESCE(excluded.display_name, users.display_name)
`);
const insertQuiz = db.prepare(`
INSERT INTO quizzes (id, user_id, title, source, ai_topic)
VALUES (?, ?, ?, ?, ?)
`);
const insertQuestion = db.prepare(`
INSERT INTO questions (id, quiz_id, text, time_limit, order_index)
VALUES (?, ?, ?, ?, ?)
`);
const insertOption = db.prepare(`
INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const transaction = db.transaction(() => {
upsertUser.run(
req.user!.sub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null
);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null);
questions.forEach((q, qIdx) => {
const questionId = uuidv4();
insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx);
q.options.forEach((o, oIdx) => {
insertOption.run(
uuidv4(),
questionId,
o.text,
o.isCorrect ? 1 : 0,
o.shape,
o.color,
o.reason || null,
oIdx
);
});
});
});
transaction();
res.status(201).json({ id: quizId });
});
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, questions } = body;
const quizId = req.params.id;
if (!title?.trim()) {
res.status(400).json({ error: 'Title is required and cannot be empty' });
return;
}
if (!questions || !Array.isArray(questions) || questions.length === 0) {
res.status(400).json({ error: 'At least one question is required' });
return;
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.text?.trim()) {
res.status(400).json({ error: `Question ${i + 1} text is required` });
return;
}
if (!q.options || !Array.isArray(q.options) || q.options.length < 2) {
res.status(400).json({ error: `Question ${i + 1} must have at least 2 options` });
return;
}
const hasCorrect = q.options.some(o => o.isCorrect);
if (!hasCorrect) {
res.status(400).json({ error: `Question ${i + 1} must have at least one correct answer` });
return;
}
}
const existing = db.prepare(`
SELECT id FROM quizzes WHERE id = ? AND user_id = ?
`).get(quizId, req.user!.sub);
if (!existing) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
const updateQuiz = db.prepare(`
UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`);
const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`);
const insertQuestion = db.prepare(`
INSERT INTO questions (id, quiz_id, text, time_limit, order_index)
VALUES (?, ?, ?, ?, ?)
`);
const insertOption = db.prepare(`
INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const transaction = db.transaction(() => {
updateQuiz.run(title, quizId);
deleteQuestions.run(quizId);
questions.forEach((q, qIdx) => {
const questionId = uuidv4();
insertQuestion.run(questionId, quizId, q.text, q.timeLimit || 20, qIdx);
q.options.forEach((o, oIdx) => {
insertOption.run(
uuidv4(),
questionId,
o.text,
o.isCorrect ? 1 : 0,
o.shape,
o.color,
o.reason || null,
oIdx
);
});
});
});
transaction();
res.json({ id: quizId });
});
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
const result = db.prepare(`
DELETE FROM quizzes WHERE id = ? AND user_id = ?
`).run(req.params.id, req.user!.sub);
if (result.changes === 0) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
res.status(204).send();
});
export default router;

View file

@ -0,0 +1,32 @@
import { Router, Response } from 'express';
import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(`
SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined;
if (!user) {
res.json({
id: req.user!.sub,
username: req.user!.preferred_username,
email: req.user!.email,
displayName: req.user!.name,
createdAt: null,
lastLogin: null,
isNew: true,
});
return;
}
res.json({ ...user, isNew: false });
});
export default router;

68
server/tests/README.md Normal file
View file

@ -0,0 +1,68 @@
# Kaboot Backend API Tests
## Getting a Test Token
Since Authentik uses OAuth2 flows that require browser interaction, you need to obtain a token manually.
### Method 1: Browser DevTools (Easiest)
1. Start the Kaboot frontend: `npm run dev` (in root directory)
2. Open `http://localhost:5173`
3. Click "Sign In" and log in with Authentik
4. Open browser DevTools (F12)
5. Go to **Application** > **Local Storage** > `http://localhost:5173`
6. Find the key starting with `oidc.user:`
7. Click on it and find `"access_token"` in the JSON value
8. Copy the token value (without quotes)
### Method 2: Service Account
1. Go to Authentik Admin: `http://localhost:9000/if/admin/`
2. Navigate to **Directory** > **Users**
3. Click **Create Service Account**
4. Enter a name (e.g., `kaboot-test-service`)
5. Note the generated username and token
6. Use these credentials:
```bash
TEST_USERNAME=<service-account-username> \
TEST_PASSWORD=<generated-token> \
npm run test:get-token
```
## Running Tests
```bash
cd server
npm install
# Set the token you obtained
export TEST_TOKEN="your-access-token-here"
# Run tests
npm run test
```
## Test Coverage
The test suite covers:
- **Health Check**: Basic server availability
- **Authentication**: 401 without token, 401 with invalid token
- **User API**: GET /api/users/me
- **Quiz CRUD**:
- GET /api/quizzes (list)
- POST /api/quizzes (create)
- GET /api/quizzes/:id (read)
- PUT /api/quizzes/:id (update)
- DELETE /api/quizzes/:id (delete)
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `API_URL` | `http://localhost:3001` | Backend API URL |
| `TEST_TOKEN` | (required) | JWT access token from Authentik |
| `AUTHENTIK_URL` | `http://localhost:9000` | Authentik server URL |
| `CLIENT_ID` | `kaboot-spa` | OAuth2 client ID |
| `TEST_USERNAME` | `kaboottest` | Username for token request |
| `TEST_PASSWORD` | `kaboottest` | Password for token request |

1015
server/tests/api.test.ts Normal file

File diff suppressed because it is too large Load diff

122
server/tests/get-token.ts Normal file
View file

@ -0,0 +1,122 @@
const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000';
const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa';
const CLIENT_SECRET = process.env.CLIENT_SECRET || '';
const USERNAME = process.env.TEST_USERNAME || '';
const PASSWORD = process.env.TEST_PASSWORD || '';
async function getTokenWithClientSecret(): Promise<string> {
if (!CLIENT_SECRET) throw new Error('CLIENT_SECRET not set');
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'openid profile email',
});
console.log(` Trying client_credentials with client_secret...`);
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} - ${error}`);
}
const data = await response.json();
return data.access_token;
}
async function getTokenWithServiceAccount(): Promise<string> {
if (!USERNAME || !PASSWORD) throw new Error('USERNAME and PASSWORD not set');
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
username: USERNAME,
password: PASSWORD,
scope: 'openid profile email',
});
console.log(` Trying client_credentials with username/password...`);
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} - ${error}`);
}
const data = await response.json();
return data.access_token;
}
async function main() {
console.log('Kaboot API Token Generator');
console.log('==========================\n');
console.log(`Authentik URL: ${AUTHENTIK_URL}`);
console.log(`Client ID: ${CLIENT_ID}`);
console.log('');
let token: string | null = null;
if (CLIENT_SECRET) {
try {
token = await getTokenWithClientSecret();
console.log(' ✓ Success!\n');
} catch (error) {
console.log(` ✗ Failed: ${error instanceof Error ? error.message : error}\n`);
}
}
if (!token && USERNAME && PASSWORD) {
try {
token = await getTokenWithServiceAccount();
console.log(' ✓ Success!\n');
} catch (error) {
console.log(` ✗ Failed: ${error instanceof Error ? error.message : error}\n`);
}
}
if (token) {
console.log('=== ACCESS TOKEN ===');
console.log(token);
console.log('\n=== FOR .env.test ===');
console.log(`TEST_TOKEN=${token}`);
return;
}
console.log('=== SETUP INSTRUCTIONS ===\n');
console.log('Method 1: Client Secret (Recommended for testing)\n');
console.log(' 1. Go to Authentik Admin: http://localhost:9000/if/admin/');
console.log(' 2. Navigate to: Applications → Providers → Kaboot OAuth2');
console.log(' 3. Change "Client type" from "Public" to "Confidential"');
console.log(' 4. Copy the "Client Secret" value');
console.log(' 5. Add to server/.env.test:');
console.log(' CLIENT_SECRET=<paste-secret-here>\n');
console.log('Method 2: Service Account + App Password\n');
console.log(' 1. Go to Authentik Admin: http://localhost:9000/if/admin/');
console.log(' 2. Navigate to: Directory → Users');
console.log(' 3. Click "Create Service Account"');
console.log(' 4. Name it (e.g., "kaboot-test")');
console.log(' 5. After creation, click on the user → "App passwords" tab');
console.log(' 6. Create a new app password, copy the token');
console.log(' 7. Bind the service account to Kaboot app:');
console.log(' Applications → Kaboot → Policy/Group/User Bindings → Bind existing user');
console.log(' 8. Add to server/.env.test:');
console.log(' TEST_USERNAME=<service-account-username>');
console.log(' TEST_PASSWORD=<app-password-token>\n');
process.exit(1);
}
main();

83
server/tests/run-tests.ts Normal file
View file

@ -0,0 +1,83 @@
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000';
const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa';
const USERNAME = process.env.TEST_USERNAME || '';
const PASSWORD = process.env.TEST_PASSWORD || '';
async function getToken(): Promise<string> {
if (!USERNAME || !PASSWORD) {
throw new Error(
'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' +
'See tests/README.md for setup instructions.'
);
}
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
username: USERNAME,
password: PASSWORD,
scope: 'openid profile email',
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to get token: ${response.status} - ${error}`);
}
const data = await response.json();
return data.access_token;
}
async function runTests(token: string): Promise<number> {
return new Promise((resolve) => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const testFile = join(__dirname, 'api.test.ts');
const child = spawn(process.execPath, [
'--import', 'tsx',
testFile
], {
env: {
...process.env,
TEST_TOKEN: token,
},
stdio: 'inherit',
});
child.on('close', (code) => {
resolve(code ?? 1);
});
});
}
async function main() {
console.log('Kaboot API Test Runner');
console.log('======================\n');
console.log('Obtaining access token from Authentik...');
let token: string;
try {
token = await getToken();
console.log(' Token obtained successfully.\n');
} catch (error) {
console.error(` Failed: ${error instanceof Error ? error.message : error}`);
process.exit(1);
}
console.log('Running API tests...\n');
const exitCode = await runTests(token);
process.exit(exitCode);
}
main();

15
server/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

19
src/config/oidc.ts Normal file
View file

@ -0,0 +1,19 @@
import { WebStorageStateStore } from 'oidc-client-ts';
const AUTHENTIK_URL = import.meta.env.VITE_AUTHENTIK_URL || 'http://localhost:9000';
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID || 'kaboot-spa';
const APP_SLUG = import.meta.env.VITE_OIDC_APP_SLUG || 'kaboot';
export const oidcConfig = {
authority: `${AUTHENTIK_URL}/application/o/${APP_SLUG}/`,
client_id: CLIENT_ID,
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email offline_access',
automaticSilentRenew: true,
silentRequestTimeoutInSeconds: 10,
loadUserInfo: true,
userStore: new WebStorageStateStore({ store: window.localStorage }),
monitorSession: false,
};

View file

@ -31,6 +31,26 @@ export interface Quiz {
questions: Question[];
}
export type QuizSource = 'manual' | 'ai_generated';
export interface SavedQuiz extends Quiz {
id: string;
source: QuizSource;
aiTopic?: string;
createdAt: string;
updatedAt: string;
}
export interface QuizListItem {
id: string;
title: string;
source: QuizSource;
aiTopic?: string;
questionCount: number;
createdAt: string;
updatedAt: string;
}
export interface Player {
id: string;
name: string;

View file

@ -6,7 +6,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
port: 5173,
host: '0.0.0.0',
},
plugins: [react()],