Phase 2 + 3 complete
This commit is contained in:
parent
9a3fc97a34
commit
6d24f3c112
25 changed files with 3275 additions and 98 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,6 +27,7 @@ dist-ssr
|
|||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.test
|
||||
|
||||
# Authentik volumes (keep structure, ignore data)
|
||||
authentik/media/*
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
|
|||
## Phase 2: Backend API Development
|
||||
|
||||
### 2.1 Project Setup
|
||||
- [ ] Create `server/` directory structure:
|
||||
- [x] Create `server/` directory structure:
|
||||
```
|
||||
server/
|
||||
├── Dockerfile
|
||||
|
|
@ -84,110 +84,110 @@ 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 }`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -401,8 +401,8 @@ kaboot/
|
|||
| Phase | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs |
|
||||
| Phase 2 | Not Started | |
|
||||
| Phase 3 | Not Started | |
|
||||
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
|
||||
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
|
||||
| Phase 4 | Not Started | |
|
||||
| Phase 5 | Not Started | |
|
||||
| Phase 6 | Not Started | |
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react';
|
||||
import { AuthButton } from './AuthButton';
|
||||
|
||||
interface LandingProps {
|
||||
onGenerate: (topic: string) => void;
|
||||
|
|
@ -27,7 +28,10 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
};
|
||||
|
||||
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 }}
|
||||
|
|
|
|||
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.
|
|
@ -105,11 +105,10 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_PATH: /data/kaboot.db
|
||||
# OIDC Configuration - points to Authentik
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-http://authentik-server:9000/application/o/kaboot/}
|
||||
OIDC_JWKS_URI: ${OIDC_JWKS_URI:-http://authentik-server:9000/application/o/kaboot/jwks/}
|
||||
# CORS - allow frontend origin
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
|
||||
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:3000}
|
||||
volumes:
|
||||
- kaboot-data:/data
|
||||
ports:
|
||||
|
|
|
|||
46
hooks/useAuthenticatedFetch.ts
Normal file
46
hooks/useAuthenticatedFetch.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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 authFetch = useCallback(
|
||||
async (path: string, options: RequestInit = {}): Promise<Response> => {
|
||||
if (!auth.user?.access_token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const url = path.startsWith('http') ? path : `${API_URL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${auth.user.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
} catch {
|
||||
auth.signinRedirect();
|
||||
}
|
||||
throw new Error('Token expired, please retry');
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[auth]
|
||||
);
|
||||
|
||||
return {
|
||||
authFetch,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
user: auth.user,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { AuthProvider } from 'react-oidc-context';
|
||||
import App from './App';
|
||||
import { oidcConfig } from './src/config/oidc';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
|
|
@ -10,6 +12,8 @@ if (!rootElement) {
|
|||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
37
package-lock.json
generated
37
package-lock.json
generated
|
|
@ -12,9 +12,11 @@
|
|||
"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-oidc-context": "^3.2.0",
|
||||
"recharts": "^3.6.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
|
@ -2212,6 +2214,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 +2351,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",
|
||||
|
|
@ -2489,6 +2513,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,7 +17,9 @@
|
|||
"recharts": "^3.6.0",
|
||||
"framer-motion": "^12.26.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"peerjs": "^1.5.2"
|
||||
"peerjs": "^1.5.2",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react-oidc-context": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
|
|
|
|||
4
server/.dockerignore
Normal file
4
server/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env*
|
||||
|
|
@ -8,7 +8,9 @@ COPY package*.json ./
|
|||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm run build && cp src/db/schema.sql dist/db/
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
|
|
|
|||
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
|
|
@ -5,7 +5,9 @@
|
|||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"test": "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",
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -1,16 +1,38 @@
|
|||
import express from 'express';
|
||||
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' }));
|
||||
app.use(express.json());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
);
|
||||
}
|
||||
225
server/src/routes/quizzes.ts
Normal file
225
server/src/routes/quizzes.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||
const body = req.body as QuizBody;
|
||||
const { title, source, aiTopic, questions } = body;
|
||||
|
||||
if (!title || !source || !questions?.length) {
|
||||
res.status(400).json({ error: 'Missing required fields: title, source, questions' });
|
||||
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;
|
||||
|
||||
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 |
|
||||
191
server/tests/api.test.ts
Normal file
191
server/tests/api.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||
const TOKEN = process.env.TEST_TOKEN;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('ERROR: TEST_TOKEN environment variable is required');
|
||||
console.log('Run: npm run test:get-token');
|
||||
console.log('Then: export TEST_TOKEN="<token>"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
expectStatus = 200
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = response.headers.get('content-type')?.includes('application/json')
|
||||
? await response.json()
|
||||
: null;
|
||||
|
||||
if (response.status !== expectStatus) {
|
||||
throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return { status: response.status, data };
|
||||
}
|
||||
|
||||
async function test(name: string, fn: () => Promise<void>) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, passed: true });
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
results.push({ name, passed: false, error: message });
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Kaboot API Tests ===\n');
|
||||
console.log(`API: ${API_URL}`);
|
||||
console.log('');
|
||||
|
||||
let createdQuizId: string | null = null;
|
||||
|
||||
console.log('Health Check:');
|
||||
await test('GET /health returns ok', async () => {
|
||||
const res = await fetch(`${API_URL}/health`);
|
||||
const data = await res.json();
|
||||
if (data.status !== 'ok') throw new Error('Health check failed');
|
||||
});
|
||||
|
||||
console.log('\nAuth Tests:');
|
||||
await test('GET /api/quizzes without token returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/quizzes`);
|
||||
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes with invalid token returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/quizzes`, {
|
||||
headers: { Authorization: 'Bearer invalid-token' },
|
||||
});
|
||||
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
console.log('\nUser Tests:');
|
||||
await test('GET /api/users/me returns user info', async () => {
|
||||
const { data } = await request('GET', '/api/users/me');
|
||||
const user = data as Record<string, unknown>;
|
||||
if (!user.id) throw new Error('Missing user id');
|
||||
if (!user.username) throw new Error('Missing username');
|
||||
});
|
||||
|
||||
console.log('\nQuiz CRUD Tests:');
|
||||
await test('GET /api/quizzes returns array', async () => {
|
||||
const { data } = await request('GET', '/api/quizzes');
|
||||
if (!Array.isArray(data)) throw new Error('Expected array');
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes creates quiz', async () => {
|
||||
const quiz = {
|
||||
title: 'Test Quiz',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'What is 2 + 2?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', quiz, 201);
|
||||
const result = data as { id: string };
|
||||
if (!result.id) throw new Error('Missing quiz id');
|
||||
createdQuizId = result.id;
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes/:id returns full quiz', async () => {
|
||||
if (!createdQuizId) throw new Error('No quiz created');
|
||||
const { data } = await request('GET', `/api/quizzes/${createdQuizId}`);
|
||||
const quiz = data as Record<string, unknown>;
|
||||
if (quiz.title !== 'Test Quiz') throw new Error('Wrong title');
|
||||
if (!Array.isArray(quiz.questions)) throw new Error('Missing questions');
|
||||
const questions = quiz.questions as Record<string, unknown>[];
|
||||
if (questions.length !== 1) throw new Error('Wrong question count');
|
||||
const q = questions[0];
|
||||
if (!Array.isArray(q.options)) throw new Error('Missing options');
|
||||
if ((q.options as unknown[]).length !== 4) throw new Error('Wrong option count');
|
||||
});
|
||||
|
||||
await test('PUT /api/quizzes/:id updates quiz', async () => {
|
||||
if (!createdQuizId) throw new Error('No quiz created');
|
||||
const updatedQuiz = {
|
||||
title: 'Updated Test Quiz',
|
||||
questions: [
|
||||
{
|
||||
text: 'Updated question?',
|
||||
timeLimit: 30,
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||
{ text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: 'D', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await request('PUT', `/api/quizzes/${createdQuizId}`, updatedQuiz);
|
||||
|
||||
const { data } = await request('GET', `/api/quizzes/${createdQuizId}`);
|
||||
const quiz = data as Record<string, unknown>;
|
||||
if (quiz.title !== 'Updated Test Quiz') throw new Error('Title not updated');
|
||||
});
|
||||
|
||||
await test('DELETE /api/quizzes/:id deletes quiz', async () => {
|
||||
if (!createdQuizId) throw new Error('No quiz created');
|
||||
await request('DELETE', `/api/quizzes/${createdQuizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes/:id returns 404 for deleted quiz', async () => {
|
||||
if (!createdQuizId) throw new Error('No quiz created');
|
||||
await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404);
|
||||
});
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
console.log(`Passed: ${passed}/${results.length}`);
|
||||
console.log(`Failed: ${failed}/${results.length}`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nFailed tests:');
|
||||
results
|
||||
.filter((r) => !r.passed)
|
||||
.forEach((r) => console.log(` - ${r.name}: ${r.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nAll tests passed!');
|
||||
}
|
||||
|
||||
runTests().catch((err) => {
|
||||
console.error('Test runner error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
79
server/tests/get-token.ts
Normal file
79
server/tests/get-token.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000';
|
||||
const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa';
|
||||
const APP_SLUG = process.env.APP_SLUG || 'kaboot';
|
||||
const USERNAME = process.env.TEST_USERNAME || 'kaboottest';
|
||||
const PASSWORD = process.env.TEST_PASSWORD || 'kaboottest';
|
||||
|
||||
async function getTokenViaPasswordGrant(): Promise<string> {
|
||||
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(`Token URL: ${tokenUrl}`);
|
||||
|
||||
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(`Password grant failed: ${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(`Username: ${USERNAME}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
console.log('Attempting password/client_credentials grant...');
|
||||
const token = await getTokenViaPasswordGrant();
|
||||
console.log('\n✓ Token obtained successfully!\n');
|
||||
console.log('=== ACCESS TOKEN ===');
|
||||
console.log(token);
|
||||
console.log('\n=== EXPORT COMMAND ===');
|
||||
console.log(`export TEST_TOKEN="${token}"`);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.log(`✗ ${error instanceof Error ? error.message : error}\n`);
|
||||
}
|
||||
|
||||
console.log('=== MANUAL TOKEN SETUP ===\n');
|
||||
console.log('Option 1: Create a Service Account in Authentik');
|
||||
console.log(' 1. Go to: Admin > Directory > Users');
|
||||
console.log(' 2. Click "Create Service Account"');
|
||||
console.log(' 3. Give it a name (e.g., "kaboot-test")');
|
||||
console.log(' 4. Copy the username and token generated');
|
||||
console.log(' 5. Run: TEST_USERNAME=<username> TEST_PASSWORD=<token> npm run test:get-token\n');
|
||||
|
||||
console.log('Option 2: Get token from browser');
|
||||
console.log(' 1. Log into Kaboot frontend with Authentik');
|
||||
console.log(' 2. Open browser DevTools > Application > Local Storage');
|
||||
console.log(' 3. Find the oidc.user entry');
|
||||
console.log(' 4. Copy the access_token value');
|
||||
console.log(' 5. Run: export TEST_TOKEN="<token>"\n');
|
||||
|
||||
console.log('Option 3: Use Authentik API directly');
|
||||
console.log(' 1. Go to: Admin > Directory > Tokens & App passwords');
|
||||
console.log(' 2. Create a new token for your user');
|
||||
console.log(' 3. Use that token for API testing\n');
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
20
src/config/oidc.ts
Normal file
20
src/config/oidc.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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,
|
||||
loadUserInfo: true,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
onSigninCallback: () => {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue