Compare commits
10 commits
5cdafc7a4d
...
1f88e291a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
1f88e291a9 |
|||
|
3e7b89bcad |
|||
|
a7e37024f5 |
|||
|
3a22b42492 |
|||
|
93ea01525e |
|||
|
342ff60b70 |
|||
|
66f15b49b2 |
|||
|
0cc099c00c |
|||
|
6d24f3c112 |
|||
|
9a3fc97a34 |
51 changed files with 6722 additions and 265 deletions
39
.env.example
Normal file
39
.env.example
Normal 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
16
.gitignore
vendored
|
|
@ -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
45
App.tsx
|
|
@ -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
20
Caddyfile.example
Normal 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
|
||||
}
|
||||
|
|
@ -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
124
README.md
|
|
@ -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
3
ai-todo.md
Normal 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
0
authentik/certs/.gitkeep
Normal file
0
authentik/custom-templates/.gitkeep
Normal file
0
authentik/custom-templates/.gitkeep
Normal file
0
authentik/media/.gitkeep
Normal file
0
authentik/media/.gitkeep
Normal file
53
components/AuthButton.tsx
Normal file
53
components/AuthButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
218
components/QuizLibrary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
102
components/SaveQuizPrompt.tsx
Normal file
102
components/SaveQuizPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
BIN
data/kaboot.db-shm
Normal file
Binary file not shown.
BIN
data/kaboot.db-wal
Normal file
BIN
data/kaboot.db-wal
Normal file
Binary file not shown.
35
docker-compose.caddy.yml
Normal file
35
docker-compose.caddy.yml
Normal 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
128
docker-compose.yml
Normal 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
239
docs/API.md
Normal 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
373
docs/AUTHENTIK_SETUP.md
Normal 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
326
docs/PRODUCTION.md
Normal 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
4
features.md
Normal 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
|
||||
97
hooks/useAuthenticatedFetch.ts
Normal file
97
hooks/useAuthenticatedFetch.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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
216
hooks/useQuizLibrary.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
42
index.tsx
42
index.tsx
|
|
@ -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
71
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
17
package.json
17
package.json
|
|
@ -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
48
scripts/setup.sh
Executable 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
4
server/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env*
|
||||
17
server/Dockerfile
Normal file
17
server/Dockerfile
Normal 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
2241
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
server/package.json
Normal file
31
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
server/src/db/connection.ts
Normal file
19
server/src/db/connection.ts
Normal 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
44
server/src/db/schema.sql
Normal 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
77
server/src/index.ts
Normal 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);
|
||||
});
|
||||
82
server/src/middleware/auth.ts
Normal file
82
server/src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
285
server/src/routes/quizzes.ts
Normal file
285
server/src/routes/quizzes.ts
Normal 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;
|
||||
32
server/src/routes/users.ts
Normal file
32
server/src/routes/users.ts
Normal 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
68
server/tests/README.md
Normal 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
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
122
server/tests/get-token.ts
Normal 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
83
server/tests/run-tests.ts
Normal 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
15
server/tsconfig.json
Normal 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
19
src/config/oidc.ts
Normal 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,
|
||||
};
|
||||
20
types.ts
20
types.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue