diff --git a/components/Landing.tsx b/components/Landing.tsx index c552466..6b90774 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -43,9 +43,16 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on }; const handleLoadQuiz = async (id: string) => { - const quiz = await loadQuiz(id); - setLibraryOpen(false); - onLoadQuiz(quiz); + 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 ( diff --git a/docker-compose.yml b/docker-compose.yml index 619a6e9..0c001fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,7 @@ services: 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} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} volumes: - kaboot-data:/data ports: diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md index 940c1b9..b18ea0c 100644 --- a/docs/AUTHENTIK_SETUP.md +++ b/docs/AUTHENTIK_SETUP.md @@ -70,7 +70,168 @@ This guide walks through configuring Authentik as the OAuth2/OIDC identity provi 7. Click **Submit** -## Step 4: Verify OIDC Endpoints +## 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** diff --git a/hooks/useAuthenticatedFetch.ts b/hooks/useAuthenticatedFetch.ts index c5b4e37..04438dd 100644 --- a/hooks/useAuthenticatedFetch.ts +++ b/hooks/useAuthenticatedFetch.ts @@ -6,35 +6,70 @@ 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 => { + 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 => { - if (!auth.user?.access_token) { - throw new Error('Not authenticated'); - } - + const token = await ensureValidToken(); const url = path.startsWith('http') ? path : `${API_URL}${path}`; const response = await fetch(url, { ...options, headers: { ...options.headers, - Authorization: `Bearer ${auth.user.access_token}`, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); if (response.status === 401) { try { - await auth.signinSilent(); + 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('Token expired, please retry'); + throw new Error('Session expired, redirecting to login'); } return response; }, - [auth] + [auth, ensureValidToken] ); return { diff --git a/index.tsx b/index.tsx index d228d62..f872db1 100644 --- a/index.tsx +++ b/index.tsx @@ -9,10 +9,20 @@ 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( - + { + window.localStorage.clear(); + }} + > diff --git a/src/config/oidc.ts b/src/config/oidc.ts index e31341a..ba3ac5a 100644 --- a/src/config/oidc.ts +++ b/src/config/oidc.ts @@ -12,9 +12,8 @@ export const oidcConfig = { response_type: 'code', scope: 'openid profile email offline_access', automaticSilentRenew: true, + silentRequestTimeoutInSeconds: 10, loadUserInfo: true, userStore: new WebStorageStateStore({ store: window.localStorage }), - onSigninCallback: () => { - window.history.replaceState({}, document.title, window.location.pathname); - }, + monitorSession: false, }; diff --git a/vite.config.ts b/vite.config.ts index ee5fb8d..8738b76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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()],