Expand e2e auth, Vitest CI, env-defaults, API catalog #64
@ -5,9 +5,12 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
branches: [master, main, dev]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# Daily DEV smoke (~06:00 America/Toronto)
|
||||
schedule:
|
||||
- cron: '0 10 * * *'
|
||||
|
||||
jobs:
|
||||
skip-ci-check:
|
||||
@ -68,6 +71,30 @@ jobs:
|
||||
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
|
||||
detect --source /repo --no-banner --redact ${extra}
|
||||
|
||||
# Vitest unit tests (viewer-frontend) — landed in PR #63, wired here.
|
||||
viewer-unit:
|
||||
needs: skip-ci-check
|
||||
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||
runs-on: [homelab, self-hosted, linux]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: viewer-frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: viewer-frontend/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run Vitest
|
||||
run: npm test
|
||||
|
||||
# Playwright smoke via @levkin/playkit — public host guards + API health.
|
||||
# Sign-out auth spec runs only when E2E_ADMIN_* secrets are present.
|
||||
e2e:
|
||||
@ -86,9 +113,9 @@ jobs:
|
||||
node-version: '20'
|
||||
|
||||
- name: Allow private playkit clone
|
||||
if: ${{ secrets.GITEA_TOKEN != '' }}
|
||||
if: ${{ secrets.PLAYKIT_GIT_TOKEN != '' }}
|
||||
run: |
|
||||
git config --global url."https://oauth2:${{ secrets.GITEA_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/"
|
||||
git config --global url."https://oauth2:${{ secrets.PLAYKIT_GIT_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/"
|
||||
|
||||
- name: Install e2e deps
|
||||
run: npm ci
|
||||
@ -107,10 +134,17 @@ jobs:
|
||||
MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }}
|
||||
MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }}
|
||||
MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }}
|
||||
PLAYKIT_MAIL_PROVIDER: ${{ secrets.PLAYKIT_MAIL_PROVIDER }}
|
||||
MAILPIT_BASE_URL: ${{ secrets.MAILPIT_BASE_URL }}
|
||||
MAILPIT_USER: ${{ secrets.MAILPIT_USER }}
|
||||
MAILPIT_PASSWORD: ${{ secrets.MAILPIT_PASSWORD }}
|
||||
CI: 'true'
|
||||
run: |
|
||||
export PLAYKIT_BASE_URL="${PLAYKIT_BASE_URL:-https://punimtagdev.levkin.ca}"
|
||||
export PLAYKIT_API_BASE_URL="${PLAYKIT_API_BASE_URL:-http://10.0.10.121:8000}"
|
||||
# Fallbacks from e2e/env-defaults.json (single source of truth with TS)
|
||||
DEFAULT_BASE_URL=$(node -p "require('./env-defaults.json').DEFAULT_BASE_URL")
|
||||
DEFAULT_API_BASE_URL=$(node -p "require('./env-defaults.json').DEFAULT_API_BASE_URL")
|
||||
export PLAYKIT_BASE_URL="${PLAYKIT_BASE_URL:-$DEFAULT_BASE_URL}"
|
||||
export PLAYKIT_API_BASE_URL="${PLAYKIT_API_BASE_URL:-$DEFAULT_API_BASE_URL}"
|
||||
npx playwright test
|
||||
|
||||
- name: Upload report
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
# Public UI (required for host-guard tests)
|
||||
PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca
|
||||
# Defaults also live in env-defaults.json — override here for local runs.
|
||||
# PLAYKIT_BASE_URL / PLAYKIT_API_BASE_URL fall back to that file when unset.
|
||||
|
||||
PLAYKIT_PROJECT=punimtag
|
||||
PLAYKIT_ENV=dev
|
||||
|
||||
# FastAPI on the DEV LXC (runners on LAN can reach this)
|
||||
PLAYKIT_API_BASE_URL=http://10.0.10.121:8000
|
||||
|
||||
# Dedicated e2e user — never commit real passwords
|
||||
E2E_ADMIN_EMAIL=
|
||||
E2E_ADMIN_PASSWORD=
|
||||
|
||||
# Mailtrap Email Testing (optional — forgot-password specs skip if unset)
|
||||
# Homelab Mailpit (preferred for DEV e2e)
|
||||
PLAYKIT_MAIL_PROVIDER=mailpit
|
||||
MAILPIT_BASE_URL=http://10.0.10.45:8025
|
||||
MAILPIT_USER=
|
||||
MAILPIT_PASSWORD=
|
||||
# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL
|
||||
|
||||
# Mailtrap Email Testing (optional SaaS fallback)
|
||||
# PLAYKIT_MAIL_PROVIDER=mailtrap
|
||||
# MAILTRAP_API_TOKEN=
|
||||
# MAILTRAP_ACCOUNT_ID=
|
||||
# MAILTRAP_INBOX_ID=
|
||||
# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL
|
||||
|
||||
@ -13,7 +13,7 @@ npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
Private Gitea installs need auth once: set a local git `insteadOf` with `GITEA_TOKEN`
|
||||
Private Gitea installs need auth once: set a local git `insteadOf` with a token
|
||||
before `npm ci`, then unset it. CI does this in `.gitea/workflows/ci.yml`.
|
||||
Never commit lockfile URLs that embed tokens.
|
||||
|
||||
@ -21,20 +21,29 @@ Never commit lockfile URLs that embed tokens.
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `PLAYKIT_BASE_URL` | yes (default in config) | `https://punimtagdev.levkin.ca` |
|
||||
| `PLAYKIT_API_BASE_URL` | no | Default `http://10.0.10.121:8000` (LAN API) |
|
||||
| `E2E_ADMIN_EMAIL` | for sign-out spec | Dedicated e2e user (not a human password) |
|
||||
| `E2E_ADMIN_PASSWORD` | for sign-out spec | Dedicated e2e password |
|
||||
| `PLAYKIT_BASE_URL` | yes (default in `env-defaults.json`) | Public viewer host |
|
||||
| `PLAYKIT_API_BASE_URL` | no | LAN API (see `env-defaults.json`) |
|
||||
| `E2E_ADMIN_EMAIL` | for auth specs | Dedicated e2e user (not a human password) |
|
||||
| `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password |
|
||||
| `PLAYKIT_MAIL_PROVIDER` | for mail specs | `mailpit` (homelab) |
|
||||
| `MAILPIT_BASE_URL` | for mail specs | `http://10.0.10.45:8025` |
|
||||
| `MAILPIT_USER` / `MAILPIT_PASSWORD` | for mail specs | Mailpit basic auth |
|
||||
|
||||
Without credentials, the public-host login-page + API health specs still run;
|
||||
sign-out is skipped.
|
||||
|
||||
**Secrets:** store in Infisical `LevkinOps` → sync to Gitea Actions. See ansible
|
||||
`docs/hardening/SECRETS.md`.
|
||||
**Secrets:** Infisical `LevkinOps` → `dev` → `/playkit/punimtag` + Gitea Actions.
|
||||
See ansible `docs/hardening/SECRETS.md`.
|
||||
|
||||
## What we assert
|
||||
|
||||
1. Login page loads on the public hostname
|
||||
2. Sign-out stays on that hostname (never `10.x`)
|
||||
3. FastAPI `/health` → `{ "status": "ok" }`
|
||||
4. Forgot-password email appears in Mailtrap; reset link uses public host
|
||||
4. Forgot-password / reset-password email in Mailpit; links use public host
|
||||
5. Register → unverified block → verify email via Mailpit → login
|
||||
6. Idle logout (`?e2e_idle_ms=`) stays on public host
|
||||
7. Upload page loads when authenticated
|
||||
8. Manage Users open/close then sign-out (Kolby #57 overlay path)
|
||||
9. API `/api/v1/auth/me` and `/api/v1/photos` return 401 without token
|
||||
10. API catalog: people/tags lists, login 422/401, users/roles 401, missing photo 404|401
|
||||
|
||||
Defaults for base URLs live in `env-defaults.json` (imported by `env-defaults.ts`,
|
||||
`fixtures.ts`, `playwright.config.ts`, and the Gitea `e2e` job).
|
||||
|
||||
6
e2e/env-defaults.json
Normal file
6
e2e/env-defaults.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"DEFAULT_BASE_URL": "https://punimtagdev.levkin.ca",
|
||||
"DEFAULT_API_BASE_URL": "http://10.0.10.121:8000",
|
||||
"DEFAULT_PROJECT": "punimtag",
|
||||
"DEFAULT_ENV": "dev"
|
||||
}
|
||||
27
e2e/env-defaults.ts
Normal file
27
e2e/env-defaults.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Canonical punimtag DEV targets — single source of truth for fixtures,
|
||||
* playwright.config.ts, and the Gitea Actions `e2e` job defaults.
|
||||
*
|
||||
* Values live in env-defaults.json so CI bash can read them without
|
||||
* compiling TypeScript (see `.gitea/workflows/ci.yml`).
|
||||
*/
|
||||
import defaults from './env-defaults.json' with { type: 'json' };
|
||||
|
||||
export const DEFAULT_BASE_URL = defaults.DEFAULT_BASE_URL;
|
||||
export const DEFAULT_API_BASE_URL = defaults.DEFAULT_API_BASE_URL;
|
||||
export const DEFAULT_PROJECT = defaults.DEFAULT_PROJECT;
|
||||
export const DEFAULT_ENV = defaults.DEFAULT_ENV;
|
||||
|
||||
/** Env bag for createPlaykitRuntime — fills gaps only, never overrides set vars. */
|
||||
export function playkitEnvFromProcess(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
PLAYKIT_PROJECT: env.PLAYKIT_PROJECT || env.CI_PROJECT || DEFAULT_PROJECT,
|
||||
PLAYKIT_ENV: env.PLAYKIT_ENV || env.APP_ENV || DEFAULT_ENV,
|
||||
PLAYKIT_BASE_URL: env.PLAYKIT_BASE_URL || env.BASE_URL || DEFAULT_BASE_URL,
|
||||
PLAYKIT_API_BASE_URL:
|
||||
env.PLAYKIT_API_BASE_URL || env.API_BASE_URL || DEFAULT_API_BASE_URL,
|
||||
};
|
||||
}
|
||||
@ -1,31 +1,23 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import {
|
||||
createPlaykitRuntime,
|
||||
MailtrapClient,
|
||||
createMailInbox,
|
||||
type PlaykitFixtures,
|
||||
type MailInbox,
|
||||
} from '@levkin/playkit';
|
||||
import { playkitEnvFromProcess } from './env-defaults';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { AccountMenu } from './pages/AccountMenu';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
|
||||
const runtime = createPlaykitRuntime({
|
||||
...process.env,
|
||||
PLAYKIT_PROJECT: process.env.PLAYKIT_PROJECT || 'punimtag',
|
||||
PLAYKIT_ENV: process.env.PLAYKIT_ENV || 'dev',
|
||||
PLAYKIT_API_BASE_URL:
|
||||
process.env.PLAYKIT_API_BASE_URL ||
|
||||
process.env.API_BASE_URL ||
|
||||
'http://10.0.10.121:8000',
|
||||
PLAYKIT_BASE_URL:
|
||||
process.env.PLAYKIT_BASE_URL ||
|
||||
process.env.BASE_URL ||
|
||||
'https://punimtagdev.levkin.ca',
|
||||
});
|
||||
const runtime = createPlaykitRuntime(playkitEnvFromProcess());
|
||||
|
||||
type PunimtagFixtures = PlaykitFixtures & {
|
||||
loginPage: LoginPage;
|
||||
accountMenu: AccountMenu;
|
||||
registerPage: RegisterPage;
|
||||
e2eCredentials: { email: string; password: string } | null;
|
||||
mailtrap: MailtrapClient | null;
|
||||
mail: MailInbox | null;
|
||||
};
|
||||
|
||||
export const test = base.extend<PunimtagFixtures>({
|
||||
@ -38,6 +30,8 @@ export const test = base.extend<PunimtagFixtures>({
|
||||
use(new LoginPage(page, playkitConfig.baseUrl)),
|
||||
accountMenu: async ({ page, playkitConfig }, use) =>
|
||||
use(new AccountMenu(page, playkitConfig.baseUrl)),
|
||||
registerPage: async ({ page, playkitConfig }, use) =>
|
||||
use(new RegisterPage(page, playkitConfig.baseUrl)),
|
||||
|
||||
e2eCredentials: async ({}, use) => {
|
||||
const email = process.env.E2E_ADMIN_EMAIL || process.env.E2E_EMAIL || '';
|
||||
@ -45,10 +39,8 @@ export const test = base.extend<PunimtagFixtures>({
|
||||
await use(email && password ? { email, password } : null);
|
||||
},
|
||||
|
||||
mailtrap: async ({ playkitLog }, use) => {
|
||||
await use(
|
||||
MailtrapClient.fromEnv(process.env, playkitLog.child({ component: 'mailtrap' })),
|
||||
);
|
||||
mail: async ({ playkitLog }, use) => {
|
||||
await use(createMailInbox(process.env, playkitLog.child({ component: 'mail' })));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
22
e2e/package-lock.json
generated
22
e2e/package-lock.json
generated
@ -8,20 +8,24 @@
|
||||
"name": "punimtag-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.0",
|
||||
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@levkin/playkit": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#2f3991dadc172aae86b98538eb5654cdb3237e67",
|
||||
"version": "0.3.0",
|
||||
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#7369b1c5e53e1f7511bea96be9042e663b60b315",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@ -112,6 +116,16 @@
|
||||
"version": "6.21.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "punimtag-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "PunimTag Playwright e2e — powered by @levkin/playkit",
|
||||
"description": "PunimTag Playwright e2e \u2014 powered by @levkin/playkit",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
@ -13,9 +13,10 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.0",
|
||||
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,4 +15,14 @@ export class AccountMenu extends BasePage {
|
||||
await this.openMenu();
|
||||
await this.click(this.page.getByRole('button', { name: 'Sign out' }));
|
||||
}
|
||||
|
||||
async openManageUsers(): Promise<void> {
|
||||
await this.openMenu();
|
||||
await this.click(this.page.getByRole('button', { name: 'Manage Users' }));
|
||||
await waitForVisible(this.page.getByRole('heading', { name: /Manage Users/i }));
|
||||
}
|
||||
|
||||
async closeManageUsers(): Promise<void> {
|
||||
await this.click(this.page.getByLabel('Close manage users'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,31 @@ export class LoginPage extends BasePage {
|
||||
|
||||
async openLogin(): Promise<void> {
|
||||
await this.open('/login');
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
await this.page.locator('#email').waitFor({ state: 'visible' });
|
||||
// Wait for client bundle / React hydration (native GET submit otherwise)
|
||||
await this.page.waitForFunction(() => {
|
||||
const form = document.querySelector('form');
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
return !!form && !!btn && !!(window as unknown as { next?: unknown }).next;
|
||||
}).catch(() => undefined);
|
||||
await this.page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<void> {
|
||||
await this.fill(this.page.locator('#email'), email);
|
||||
await this.fill(this.page.locator('#password'), password);
|
||||
await this.click(this.page.locator('button[type="submit"]'));
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await this.fill(this.page.locator('#email'), email);
|
||||
await this.fill(this.page.locator('#password'), password);
|
||||
await this.page.waitForTimeout(250 * (attempt + 1));
|
||||
await this.click(this.page.getByRole('button', { name: /Sign in/i }));
|
||||
await this.page.waitForTimeout(400);
|
||||
// Native GET fallback leaks credentials into the query string — retry.
|
||||
if (!this.page.url().includes('password=')) {
|
||||
return;
|
||||
}
|
||||
this.log.warn('login GET fallback detected; retrying after re-open', { attempt });
|
||||
await this.openLogin();
|
||||
}
|
||||
throw new Error('Login form submitted as GET (page not hydrated)');
|
||||
}
|
||||
}
|
||||
|
||||
27
e2e/pages/RegisterPage.ts
Normal file
27
e2e/pages/RegisterPage.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
import { BasePage } from '@levkin/playkit';
|
||||
|
||||
export class RegisterPage extends BasePage {
|
||||
constructor(page: Page, baseUrl: string) {
|
||||
super(page, baseUrl);
|
||||
}
|
||||
|
||||
async openRegister(): Promise<void> {
|
||||
await this.open('/register');
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
await this.page.locator('#email').waitFor({ state: 'visible' });
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async register(input: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
await this.fill(this.page.locator('#name'), input.name);
|
||||
await this.fill(this.page.locator('#email'), input.email);
|
||||
await this.fill(this.page.locator('#password'), input.password);
|
||||
await this.fill(this.page.locator('#confirmPassword'), input.password);
|
||||
await this.click(this.page.locator('button[type="submit"]'));
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { playkitFailureArtifacts } from '@levkin/playkit';
|
||||
import { DEFAULT_BASE_URL } from './env-defaults';
|
||||
|
||||
const baseURL =
|
||||
process.env.PLAYKIT_BASE_URL ||
|
||||
process.env.BASE_URL ||
|
||||
'https://punimtagdev.levkin.ca';
|
||||
process.env.PLAYKIT_BASE_URL || process.env.BASE_URL || DEFAULT_BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
@ -18,9 +18,7 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
...playkitFailureArtifacts(),
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
|
||||
40
e2e/tests/admin.manage-users.spec.ts
Normal file
40
e2e/tests/admin.manage-users.spec.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
/**
|
||||
* Kolby #57 class: Manage Users overlay must close cleanly before sign-out
|
||||
* and the post-logout URL must stay on the public host.
|
||||
*/
|
||||
test.describe('admin users @smoke', () => {
|
||||
test('manage users then sign-out stays on public host', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
loginPage,
|
||||
accountMenu,
|
||||
e2eCredentials,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)');
|
||||
assertPublicHost(playkitConfig.baseUrl);
|
||||
|
||||
await timings.measure('login', async () => {
|
||||
await loginPage.openLogin();
|
||||
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
|
||||
await timings.measure('manage_users', async () => {
|
||||
await accountMenu.openManageUsers();
|
||||
await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible();
|
||||
await accountMenu.closeManageUsers();
|
||||
});
|
||||
|
||||
await timings.measure('sign_out', () => accountMenu.signOut());
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
|
||||
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
18
e2e/tests/api.auth.spec.ts
Normal file
18
e2e/tests/api.auth.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
test.describe('api auth @smoke', () => {
|
||||
test('protected /api/v1/auth/me returns 401 without token', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_me_unauth', () =>
|
||||
api.get('/api/v1/auth/me', { expectedStatus: 401 }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.data).toMatchObject({ detail: expect.stringMatching(/not authenticated/i) });
|
||||
});
|
||||
|
||||
test('protected /api/v1/photos returns 401 without token', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_photos_unauth', () =>
|
||||
api.get('/api/v1/photos', { expectedStatus: 401 }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
81
e2e/tests/api.catalog.spec.ts
Normal file
81
e2e/tests/api.catalog.spec.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
const PeopleList = z.object({
|
||||
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
|
||||
});
|
||||
|
||||
const TagsList = z.object({
|
||||
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Public / lightly-gated FastAPI surfaces — shape + status contracts via playkit ApiClient.
|
||||
* Does not require e2e login credentials.
|
||||
*/
|
||||
test.describe('api catalog @smoke', () => {
|
||||
test('GET /api/v1/people returns items list', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_people', () =>
|
||||
api.get('/api/v1/people', { expectedStatus: 200, schema: PeopleList }),
|
||||
);
|
||||
expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) }));
|
||||
});
|
||||
|
||||
test('GET /api/v1/people/with-faces returns items list', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_people_faces', () =>
|
||||
api.get('/api/v1/people/with-faces', {
|
||||
expectedStatus: 200,
|
||||
schema: z.object({ items: z.array(z.unknown()) }),
|
||||
}),
|
||||
);
|
||||
expect(Array.isArray(res.data.items)).toBe(true);
|
||||
});
|
||||
|
||||
test('GET /api/v1/tags returns items list', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_tags', () =>
|
||||
api.get('/api/v1/tags', { expectedStatus: 200, schema: TagsList }),
|
||||
);
|
||||
expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) }));
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/login rejects empty body with 422', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_login_empty', () =>
|
||||
api.post('/api/v1/auth/login', { body: {}, expectedStatus: 422 }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/login rejects bad credentials with 401', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_login_bad', () =>
|
||||
api.post('/api/v1/auth/login', {
|
||||
body: { username: 'e2e-no-such-user', password: 'wrong-password' },
|
||||
expectedStatus: 401,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.data).toMatchObject({
|
||||
detail: expect.stringMatching(/incorrect|password|username/i),
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /api/v1/users returns 401 without token', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_users_unauth', () =>
|
||||
api.get('/api/v1/users', { expectedStatus: 401 }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('GET /api/v1/role-permissions returns 401 without token', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_roles_unauth', () =>
|
||||
api.get('/api/v1/role-permissions', { expectedStatus: 401 }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('GET /api/v1/photos/{id} for missing photo is 404 (or 401)', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_photo_missing', () =>
|
||||
api.get('/api/v1/photos/999999999', { expectedStatus: [401, 404] }),
|
||||
);
|
||||
expect([401, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
const HealthSchema = z.object({ status: z.literal('ok') });
|
||||
|
||||
test.describe('api @smoke', () => {
|
||||
test('backend /health returns ok', async ({ api, timings }) => {
|
||||
const res = await timings.measure('api_health', () =>
|
||||
api.get<{ status: string }>('/health', { expectedStatus: 200 }),
|
||||
api.get('/health', { expectedStatus: 200, schema: HealthSchema }),
|
||||
);
|
||||
expect(res.data).toMatchObject({ status: 'ok' });
|
||||
expect(res.data).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -39,7 +39,8 @@ test.describe('auth @smoke', () => {
|
||||
await timings.measure('login', async () => {
|
||||
await loginPage.openLogin();
|
||||
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
|
||||
await page.getByLabel('Account menu').waitFor({ state: 'visible' });
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
|
||||
@ -1,31 +1,26 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
firstLinkMatching,
|
||||
readMailHtml,
|
||||
waitForUrlHost,
|
||||
} from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
/**
|
||||
* Kolby #56 class: forgot-password must actually produce a message we can read,
|
||||
* and the reset link must use the public host (not 10.x).
|
||||
* Forgot-password must land in the mail trap (Mailpit) with a public reset link.
|
||||
*
|
||||
* Requires:
|
||||
* - MAILTRAP_API_TOKEN + MAILTRAP_INBOX_ID (+ optional MAILTRAP_ACCOUNT_ID)
|
||||
* - punimtag DEV SMTP → sandbox.smtp.mailtrap.io (see SECRETS.md)
|
||||
* - E2E_ADMIN_EMAIL (or E2E_MAIL_TO) for an existing verified user
|
||||
* Requires PLAYKIT_MAIL_PROVIDER=mailpit + MAILPIT_* (or Mailtrap SaaS envs),
|
||||
* punimtag DEV SMTP → Mailpit, and E2E_ADMIN_EMAIL / E2E_MAIL_TO.
|
||||
*/
|
||||
test.describe('mail @smoke', () => {
|
||||
test('forgot-password email lands in Mailtrap with public reset link', async ({
|
||||
test('forgot-password email lands in mail trap with public reset link', async ({
|
||||
request,
|
||||
playkitConfig,
|
||||
timings,
|
||||
mailtrap,
|
||||
mail,
|
||||
e2eCredentials,
|
||||
}) => {
|
||||
test.skip(
|
||||
!mailtrap,
|
||||
'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID (Infisical /playkit/punimtag)',
|
||||
);
|
||||
test.skip(!mail, 'Set MAILPIT_BASE_URL (+ auth) or MAILTRAP_* secrets');
|
||||
|
||||
const to =
|
||||
process.env.E2E_MAIL_TO ||
|
||||
@ -45,8 +40,8 @@ test.describe('mail @smoke', () => {
|
||||
);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const msg = await timings.measure('mailtrap_wait', () =>
|
||||
mailtrap!.waitForEmail({
|
||||
const msg = await timings.measure('mail_wait', () =>
|
||||
mail!.waitForEmail({
|
||||
to,
|
||||
subject: /reset|password/i,
|
||||
after,
|
||||
@ -55,7 +50,7 @@ test.describe('mail @smoke', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const html = await mailtrap!.getHtml(msg.id, msg.html_path);
|
||||
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });
|
||||
const link = firstLinkMatching(html, /reset-password/);
|
||||
expect(link, 'reset link missing from email HTML').toBeTruthy();
|
||||
|
||||
@ -65,14 +60,14 @@ test.describe('mail @smoke', () => {
|
||||
expect(url.pathname).toMatch(/reset-password/);
|
||||
});
|
||||
|
||||
test('forgot-password UI shows success and Mailtrap gets mail', async ({
|
||||
test('forgot-password UI shows success and mail trap gets mail', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
mailtrap,
|
||||
mail,
|
||||
e2eCredentials,
|
||||
}) => {
|
||||
test.skip(!mailtrap, 'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID');
|
||||
test.skip(!mail, 'Set MAILPIT_BASE_URL (+ auth) or MAILTRAP_* secrets');
|
||||
const to =
|
||||
process.env.E2E_MAIL_TO ||
|
||||
e2eCredentials?.email ||
|
||||
@ -89,17 +84,17 @@ test.describe('mail @smoke', () => {
|
||||
await page.locator('#forgot-email').fill(to);
|
||||
await page.getByRole('button', { name: /Send reset link/i }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toContainText(/sent|check your inbox/i, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(/Password reset email sent|check your inbox/i).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
|
||||
const msg = await mailtrap!.waitForEmail({
|
||||
const msg = await mail!.waitForEmail({
|
||||
to,
|
||||
subject: /reset|password/i,
|
||||
after,
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
expect(msg.id).toBeTruthy();
|
||||
expect(msg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
40
e2e/tests/idle.logout.spec.ts
Normal file
40
e2e/tests/idle.logout.spec.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
/**
|
||||
* Idle logout must redirect to the public host (same class as #57).
|
||||
* Uses ?e2e_idle_ms= to shorten the IdleLogoutHandler timeout.
|
||||
*/
|
||||
test.describe('auth idle @smoke', () => {
|
||||
test('idle logout stays on public host', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
loginPage,
|
||||
e2eCredentials,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required');
|
||||
assertPublicHost(playkitConfig.baseUrl);
|
||||
|
||||
await timings.measure('login', async () => {
|
||||
await loginPage.openLogin();
|
||||
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
|
||||
// Short idle window — requires IdleLogoutHandler e2e_idle_ms support on DEV
|
||||
await page.goto(`${playkitConfig.baseUrl}/?e2e_idle_ms=2500`);
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
await expect(page.getByLabel('Account menu')).toBeVisible();
|
||||
|
||||
// Stop activity; wait for idle sign-out
|
||||
await page.waitForTimeout(4_000);
|
||||
|
||||
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
82
e2e/tests/register.verify.mail.spec.ts
Normal file
82
e2e/tests/register.verify.mail.spec.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
firstLinkMatching,
|
||||
readMailHtml,
|
||||
waitForUrlHost,
|
||||
} from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
/**
|
||||
* Register → confirmation mail in Mailpit → verify link on public host.
|
||||
* Also asserts unverified users cannot sign in.
|
||||
*/
|
||||
test.describe('auth register @mail', () => {
|
||||
test('register, block unverified login, verify via mail trap', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
registerPage,
|
||||
loginPage,
|
||||
mail,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!mail, 'Mail trap not configured');
|
||||
|
||||
assertPublicHost(playkitConfig.baseUrl);
|
||||
const stamp = Date.now();
|
||||
const email = `e2e.register.${stamp}@levkin.test`;
|
||||
const password = `E2eReg${stamp}!a`;
|
||||
const after = new Date(Date.now() - 5_000);
|
||||
|
||||
await timings.measure('register', async () => {
|
||||
await registerPage.openRegister();
|
||||
await registerPage.register({
|
||||
name: `E2E Register ${stamp}`,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
await page.waitForURL(/\/login/, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
await expect(page.getByText(/Account created|check your email|confirm/i).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Unverified login must be blocked
|
||||
await loginPage.signIn(email, password);
|
||||
await expect(
|
||||
page.getByText(/verify your email|confirmation email/i).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
|
||||
const msg = await timings.measure('mail_wait', () =>
|
||||
mail!.waitForEmail({
|
||||
to: email,
|
||||
subject: /confirm|verify|welcome|account/i,
|
||||
after,
|
||||
timeoutMs: 90_000,
|
||||
}),
|
||||
);
|
||||
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });
|
||||
const link = firstLinkMatching(html, /verify-email|confirm|token=/);
|
||||
expect(link, 'verification link missing from email').toBeTruthy();
|
||||
|
||||
const url = new URL(link!);
|
||||
expect(url.hostname).toBe(playkitConfig.expectedHost);
|
||||
|
||||
// DEV next can briefly refuse during HMR — retry navigation
|
||||
await expect(async () => {
|
||||
const res = await page.goto(link!, { waitUntil: 'domcontentloaded', timeout: 20_000 });
|
||||
expect(res?.status() ?? 0).toBeLessThan(500);
|
||||
}).toPass({ timeout: 45_000 });
|
||||
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
await expect(page.getByText(/verified|sign in/i).first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// After verify, login should succeed
|
||||
await loginPage.openLogin();
|
||||
await loginPage.signIn(email, password);
|
||||
await page.waitForURL((u) => !u.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
});
|
||||
});
|
||||
56
e2e/tests/reset-password.flow.spec.ts
Normal file
56
e2e/tests/reset-password.flow.spec.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
firstLinkMatching,
|
||||
readMailHtml,
|
||||
waitForUrlHost,
|
||||
} from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
/**
|
||||
* Assert the reset link from the mail trap opens on the public host with a form.
|
||||
* Does not submit a new password (avoids rotating shared DEV admin credentials).
|
||||
*/
|
||||
test.describe('auth reset @mail', () => {
|
||||
test('reset-password link opens public form', async ({
|
||||
page,
|
||||
request,
|
||||
playkitConfig,
|
||||
mail,
|
||||
e2eCredentials,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!mail, 'Mail trap not configured');
|
||||
const to =
|
||||
process.env.E2E_MAIL_TO ||
|
||||
e2eCredentials?.email ||
|
||||
process.env.E2E_ADMIN_EMAIL ||
|
||||
'';
|
||||
test.skip(!to, 'E2E_MAIL_TO or E2E_ADMIN_EMAIL required');
|
||||
|
||||
assertPublicHost(playkitConfig.baseUrl);
|
||||
const after = new Date(Date.now() - 5_000);
|
||||
|
||||
const res = await request.post(`${playkitConfig.baseUrl}/api/auth/forgot-password`, {
|
||||
data: { email: to },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const msg = await timings.measure('mail_wait', () =>
|
||||
mail!.waitForEmail({ to, subject: /reset|password/i, after, timeoutMs: 90_000 }),
|
||||
);
|
||||
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });
|
||||
const link = firstLinkMatching(html, /reset-password/);
|
||||
expect(link).toBeTruthy();
|
||||
|
||||
const url = new URL(link!);
|
||||
expect(url.hostname).toBe(playkitConfig.expectedHost);
|
||||
|
||||
await page.goto(link!);
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
await expect(page.locator('#password, input[name="password"]').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
34
e2e/tests/upload.smoke.spec.ts
Normal file
34
e2e/tests/upload.smoke.spec.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
test.describe('upload @smoke', () => {
|
||||
test('authenticated user can open upload page', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
loginPage,
|
||||
e2eCredentials,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required');
|
||||
assertPublicHost(playkitConfig.baseUrl);
|
||||
|
||||
await timings.measure('login', async () => {
|
||||
await loginPage.openLogin();
|
||||
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
|
||||
await timings.measure('open_upload', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/upload`);
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname).toMatch(/\/upload/);
|
||||
// Upload UI chrome
|
||||
await expect(
|
||||
page.getByText(/upload|drop|select|choose file|photos/i).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByLabel('Account menu')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -6,7 +6,8 @@
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
"include": ["./**/*.ts", "./**/*.json"]
|
||||
}
|
||||
|
||||
@ -1,43 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prismaAuth } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Public site origin for redirects. Behind Caddy, `request.url` is often
|
||||
* http://localhost:3001 which sends browsers (and e2e) to a LAN/loopback
|
||||
* host — same class of bug as NEXTAUTH_URL → 10.x (#57).
|
||||
*/
|
||||
function publicOrigin(request: NextRequest): string {
|
||||
const fromEnv = (process.env.NEXTAUTH_URL || process.env.AUTH_URL || '').replace(/\/$/, '');
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const xfProto = request.headers.get('x-forwarded-proto');
|
||||
const xfHost = request.headers.get('x-forwarded-host') || request.headers.get('host');
|
||||
if (xfHost) {
|
||||
const proto = xfProto || 'https';
|
||||
return `${proto}://${xfHost.split(',')[0].trim()}`;
|
||||
}
|
||||
return request.nextUrl.origin;
|
||||
}
|
||||
|
||||
function redirectLogin(request: NextRequest, query: string): NextResponse {
|
||||
return NextResponse.redirect(new URL(`/login?${query}`, publicOrigin(request)));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?error=missing_token', request.url)
|
||||
);
|
||||
return redirectLogin(request, 'error=missing_token');
|
||||
}
|
||||
|
||||
// Find user with this token
|
||||
const user = await prismaAuth.user.findUnique({
|
||||
where: { emailConfirmationToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?error=invalid_token', request.url)
|
||||
);
|
||||
return redirectLogin(request, 'error=invalid_token');
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?error=token_expired', request.url)
|
||||
);
|
||||
return redirectLogin(request, 'error=token_expired');
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?message=already_verified', request.url)
|
||||
);
|
||||
return redirectLogin(request, 'message=already_verified');
|
||||
}
|
||||
|
||||
// Verify the email
|
||||
await prismaAuth.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
@ -47,21 +57,9 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Redirect to login with success message
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?verified=true', request.url)
|
||||
);
|
||||
} catch (error: any) {
|
||||
return redirectLogin(request, 'verified=true');
|
||||
} catch (error: unknown) {
|
||||
console.error('Error verifying email:', error);
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?error=verification_failed', request.url)
|
||||
);
|
||||
return redirectLogin(request, 'error=verification_failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,23 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useIdleLogout } from '@/hooks/useIdleLogout';
|
||||
|
||||
/**
|
||||
* Component that handles idle logout functionality
|
||||
* Must be rendered inside SessionProvider to use useSession hook
|
||||
* Idle logout — default 2 hours.
|
||||
* Optional e2e override: ?e2e_idle_ms=3000 (clamped 1s–60s) so Playwright can
|
||||
* assert sign-out stays on the public host without waiting two hours.
|
||||
*/
|
||||
export function IdleLogoutHandler() {
|
||||
// Log out users after 2 hours of inactivity
|
||||
useIdleLogout(2 * 60 * 60 * 1000); // 2 hours in milliseconds
|
||||
const searchParams = useSearchParams();
|
||||
const timeoutMs = useMemo(() => {
|
||||
const raw = searchParams.get('e2e_idle_ms');
|
||||
if (!raw) return 2 * 60 * 60 * 1000;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return 2 * 60 * 60 * 1000;
|
||||
return Math.min(60_000, Math.max(1_000, Math.floor(n)));
|
||||
}, [searchParams]);
|
||||
|
||||
// This component doesn't render anything
|
||||
useIdleLogout(timeoutMs);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { IdleLogoutHandler } from '@/components/IdleLogoutHandler';
|
||||
|
||||
@ -10,11 +11,10 @@ export function SessionProviderWrapper({
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<IdleLogoutHandler />
|
||||
<Suspense fallback={null}>
|
||||
<IdleLogoutHandler />
|
||||
</Suspense>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user