feat: add PRIVATE_MODE site-wide auth gate (0.48.0)
When `PRIVATE_MODE=true`, every API path except a small allowlist
(`/health`, `/auth/{config,login,logout,register}`) requires a valid
session cookie or bearer token — anonymous reads are rejected with
401. Self-registration is force-disabled in private mode regardless
of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a
single switch (admins still mint accounts via `POST /admin/users`).
The backend gate is a tower middleware that reuses the existing
`CurrentUser` extractor, so the cookie + bearer paths cannot drift
from per-handler auth. `/auth/config` now exposes the flag plus the
effective `self_register_enabled` value so the frontend can render
the navbar correctly on the first paint.
On the frontend, a new universal root `+layout.ts` fetches the
config and redirects anonymous visitors to `/login?next=<path>`
before page-specific loads fire. The redirect is UX only — the
backend middleware is the source of truth, so crafted requests
still 401.
Defaults stay public (`PRIVATE_MODE=false`); existing deployments
need no env change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
101
frontend/e2e/private-mode.spec.ts
Normal file
101
frontend/e2e/private-mode.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// Network-level mocks for the private-mode UX. The backend integration
|
||||
// tests (api_private_mode.rs) cover the actual gate; here we only
|
||||
// verify that the SvelteKit universal load redirects anonymous
|
||||
// visitors to /login and then back to where they were going.
|
||||
|
||||
const userFixture = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: false
|
||||
};
|
||||
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||
|
||||
async function stubPrivateInstance(page: Page) {
|
||||
let loggedIn = false;
|
||||
|
||||
// The flag that flips the gate on. Frontend reads it in
|
||||
// `+layout.ts` to decide whether to redirect.
|
||||
await page.route('**/api/v1/auth/config', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
self_register_enabled: false,
|
||||
private_mode: true
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/auth/me', async (route) => {
|
||||
if (loggedIn) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/auth/login', async (route) => {
|
||||
loggedIn = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
});
|
||||
});
|
||||
|
||||
// The real backend would 401 these too in private mode; we stub
|
||||
// success so the post-login navigation can render the home page
|
||||
// without an additional redirect cycle.
|
||||
await page.route('**/api/v1/mangas*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(emptyPage)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('private mode: anonymous visit to / redirects to /login?next=%2F', async ({ page }) => {
|
||||
await stubPrivateInstance(page);
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
||||
await expect(page.getByTestId('login-username')).toBeVisible();
|
||||
});
|
||||
|
||||
test('private mode: register link is hidden', async ({ page }) => {
|
||||
await stubPrivateInstance(page);
|
||||
await page.goto('/login');
|
||||
await expect(page.getByTestId('nav-login')).toBeVisible();
|
||||
// self_register_enabled is the effective value (false in private
|
||||
// mode regardless of ALLOW_SELF_REGISTER), so the navbar must
|
||||
// never render the register affordance here.
|
||||
await expect(page.getByTestId('nav-register')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('private mode: after login the user lands back on the requested page', async ({ page }) => {
|
||||
await stubPrivateInstance(page);
|
||||
|
||||
// Visit a deep link → bounced to /login with next= preserving it.
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
||||
|
||||
await page.getByTestId('login-username').fill('alice');
|
||||
await page.getByTestId('login-password').fill('hunter2hunter2');
|
||||
await page.getByTestId('login-submit').click();
|
||||
|
||||
// Authenticated → can now reach the home page without bouncing.
|
||||
await expect(page.getByTestId('session-user')).toContainText('alice');
|
||||
});
|
||||
Reference in New Issue
Block a user