diff --git a/dashboard/tests/e2e/auth/auth.spec.ts b/dashboard/tests/e2e/auth/auth.spec.ts
new file mode 100644
index 0000000..85443f1
--- /dev/null
+++ b/dashboard/tests/e2e/auth/auth.spec.ts
@@ -0,0 +1,111 @@
+import { expect, test, type Page } from '@playwright/test';
+import { loginAsAdmin, logout } from '../fixtures/auth';
+
+// Phase B1 — Auth & Navigation. Every interaction with the login form
+// and the layout-level redirects, plus the obvious adversarial inputs.
+
+const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
+const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
+
+function failOnDialog(page: Page): void {
+ page.on('dialog', async (dialog) => {
+ await dialog.dismiss();
+ throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
+ });
+}
+
+test.describe('B1 auth — unauthenticated', () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ test('valid credentials land on the apps list', async ({ page }) => {
+ await loginAsAdmin(page);
+ await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
+ });
+
+ test('wrong password shows an inline error and stays on /login', async ({ page }) => {
+ await page.goto('/admin/login');
+ await page.getByLabel('Username').fill(VALID_USERNAME);
+ await page.getByLabel('Password').fill('definitely-not-the-password');
+ await page.getByRole('button', { name: /sign in/i }).click();
+
+ const error = page.locator('.error');
+ await expect(error).toBeVisible();
+ await expect(error).not.toHaveText('');
+ await expect(page).toHaveURL(/\/admin\/login$/);
+ // localStorage must remain empty — a failed login should not
+ // leak a session token.
+ const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
+ expect(token).toBeNull();
+ });
+
+ test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
+ await page.goto('/admin/login');
+ await page.getByRole('button', { name: /sign in/i }).click();
+ // HTML5 validation prevents submission; URL is unchanged and the
+ // username input is reported invalid.
+ await expect(page).toHaveURL(/\/admin\/login$/);
+ const usernameInvalid = await page
+ .getByLabel('Username')
+ .evaluate((el: HTMLInputElement) => !el.validity.valid);
+ expect(usernameInvalid).toBe(true);
+ await expect(page.locator('.error')).toBeHidden();
+ });
+
+ test('visiting an authed route redirects to /login', async ({ page }) => {
+ await page.goto('/admin/apps');
+ await expect(page).toHaveURL(/\/admin\/login$/);
+ await expect(page.getByLabel('Username')).toBeVisible();
+ });
+
+ test('password field is type=password (no plaintext echo)', async ({ page }) => {
+ await page.goto('/admin/login');
+ await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
+ });
+
+ test('xss payload in username is escaped and does not execute', async ({ page }) => {
+ failOnDialog(page);
+ const payload = '
';
+
+ await page.goto('/admin/login');
+ await page.getByLabel('Username').fill(payload);
+ await page.getByLabel('Password').fill('whatever');
+ await page.getByRole('button', { name: /sign in/i }).click();
+
+ // Whatever the API does with that input, the page must remain
+ // safe: no script tag injected into the DOM, no global side
+ // effect, and a visible error (since the credentials don't
+ // match any user).
+ await expect(page.locator('.error')).toBeVisible();
+ const xssRan = await page.evaluate(
+ () => (window as unknown as { __xss?: boolean }).__xss === true
+ );
+ expect(xssRan).toBe(false);
+ const injectedScript = await page.locator('script:has-text("__xss")').count();
+ expect(injectedScript).toBe(0);
+ // The form must still be functional after the rejected attempt.
+ await page.getByLabel('Username').fill('');
+ await page.getByLabel('Username').fill(VALID_USERNAME);
+ await page.getByLabel('Password').fill('');
+ await page.getByLabel('Password').fill(VALID_PASSWORD);
+ await page.getByRole('button', { name: /sign in/i }).click();
+ await expect(page).toHaveURL(/\/admin\/apps$/);
+ });
+});
+
+test.describe('B1 auth — authenticated', () => {
+ test('visiting /login while signed in bounces to /apps', async ({ page }) => {
+ await page.goto('/admin/login');
+ await expect(page).toHaveURL(/\/admin\/apps$/);
+ });
+
+ test('logout clears the session and lands on /login', async ({ page }) => {
+ await page.goto('/admin/apps');
+ await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
+ await logout(page);
+ const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
+ expect(token).toBeNull();
+ // And the authed area is now gated again.
+ await page.goto('/admin/apps');
+ await expect(page).toHaveURL(/\/admin\/login$/);
+ });
+});