feat(001-agent-admin-ui): Setup + Foundational + User Story 0 (sign-in)

Configures both previously-empty test runners (vitest.config.ts,
playwright.config.ts) and adds the typed lib/api client layer (axios +
interceptors), the session-cookie plumbing (lib/auth), TanStack Query
infrastructure (lib/query, providers), and a real sign-in flow consuming
supporthub-api's own login (010-identity-auth) - the true foundation
every other user story in this feature depends on.

Two structural fixes to the existing scaffold, both found only by
running the app rather than by inspection: middleware.ts belongs at
src/middleware.ts under this project's src/ layout, not the repo root;
and next.config.mjs's output:'export' is incompatible with Next.js
Middleware outright (the dev server refuses to start it), so this app
now runs as a standard Next.js server - confirmed with the user before
making that deployment-mode change.

Verified end-to-end with a real, locally-running supporthub-api: all 5
Playwright scenarios (unauthenticated redirect, sign-in, wrong-password
generic error, non-admin role gating, sign-out) pass against a live
backend, not a mock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-07 14:27:06 +05:30
co-authored by Claude Sonnet 5
parent 326859e27b
commit 95b5a1e03d
36 changed files with 2068 additions and 48 deletions
+5
View File
@@ -26,6 +26,11 @@ yarn-error.log*
# Vercel
.vercel
# Playwright
/test-results/
/playwright-report/
/blob-report/
# TypeScript & Next build cache
*.tsbuildinfo
next-env.d.ts
View File
+3 -4
View File
@@ -1,10 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// 001-agent-admin-ui: static export ('output: export') is incompatible with Next.js
// Middleware, which the (support)/(admin) portals' sign-in guard requires (FR-000) — this app
// now runs as a standard Next.js server (`next build && next start`), not a static export.
reactStrictMode: true,
images: {
unoptimized: true, // Required for static HTML export
},
};
export default nextConfig;
+1279 -1
View File
File diff suppressed because it is too large Load Diff
+19 -14
View File
@@ -2,11 +2,9 @@
"name": "supporthub-web",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22 <23"
},
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
@@ -20,30 +18,37 @@
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.51.15",
"axios": "^1.7.2",
"clsx": "^2.1.1",
"jose": "^6.2.12",
"js-cookie": "^3.0.8",
"lucide-react": "^0.417.0",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.15",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0",
"lucide-react": "^0.417.0",
"axios": "^1.7.2"
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/test": "^1.45.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"dotenv-cli": "^7.4.2",
"typescript": "^5.5.4",
"tailwindcss": "^3.4.7",
"postcss": "^8.4.40",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"dotenv-cli": "^7.4.2",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"vitest": "^2.0.4",
"@playwright/test": "^1.45.3"
"jsdom": "^29.1.1",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"vitest": "^2.0.4"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
// Serial, not fullyParallel: against a Next.js DEV server, concurrent first-hit requests to
// different routes contend on that dev server's own on-demand page compilation, causing
// response-time flakiness unrelated to the app itself. A production build (`next build &&
// next start`) doesn't have this cold-compile-per-route behavior; revisit if E2E ever runs
// against one.
workers: 1,
retries: process.env.CI ? 2 : 0,
reporter: 'list',
use: {
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
@@ -55,3 +55,17 @@
reworded FR-011/the existing Edge Case/the closing Assumption to say "supporthub-api session
role" instead of "SaaS-provided role." This is a correction of a wrong premise, not new scope
creep — nothing else in User Stories 1-7 changes.
- **Implementation-time finding**: two structural issues with this project's existing scaffold
only surfaced by actually running the app, not by inspection — (1) with this project's `src/`
directory layout, Next.js requires `middleware.ts` to live at `src/middleware.ts`, not the
repo root; the pre-existing empty `middleware.ts` had been scaffolded in the wrong location.
(2) `next.config.mjs`'s `output: 'export'` (static HTML export) is fundamentally incompatible
with Next.js Middleware — the dev server refused to run it outright. Removing static export
is a deployment-mode change (this app now needs `next build && next start`, not a static file
host), flagged to and confirmed by the user before making it, since it has real infrastructure
implications beyond this feature's own code.
- A second discovered blocker, upstream of this feature entirely: supporthub-api had no endpoint
to list "tickets currently assigned to agent X" at all, and no way to resolve a logged-in
session to its own agent roster row — User Story 1 had no data source without it. Resolved by
a new supporthub-api feature, 011-agent-ticket-queue, built and merged into this feature's own
data-model.md/contracts before Setup began.
+8 -2
View File
@@ -17,7 +17,11 @@ logic is computed client-side (Constitution Principle II).
## Technical Context
**Language/Version**: TypeScript 5.5, Node.js 22 (per `package.json` `engines`), Next.js 14
(App Router), React 18.
(App Router), React 18. `next.config.mjs`'s `output: 'export'` (static HTML export) is removed
by this feature — Next.js Middleware, which FR-000's sign-in guard requires, cannot run under
static export (confirmed by the dev server itself refusing to start it); this app now runs as a
standard Next.js server (`next build && next start`), a deployment-mode change, not just a code
change.
**Primary Dependencies**: `@tanstack/react-query` (server state), `axios` (wrapped by `lib/api`),
`tailwindcss` (styling, already configured), `clsx`/`tailwind-merge` (already present),
@@ -100,7 +104,9 @@ specs/001-agent-admin-ui/
```text
supporthub-web/
├── middleware.ts # MODIFIED (was empty) — session presence + role
├── src/middleware.ts # MODIFIED (was an empty file at repo root — moved
│ under src/, required by this project's src/
│ directory layout) — session presence + role
│ redirect for (support)/(admin), FR-000/research.md
├── vitest.config.ts # MODIFIED (was empty) — first real test config
├── playwright.config.ts # MODIFIED (was empty) — first real E2E config
+27 -23
View File
@@ -26,15 +26,15 @@ All file paths are relative to `supporthub-web/` (repo root).
## Phase 1: Setup
- [ ] T001 [P] Add `js-cookie` + `@types/js-cookie` and `jose` to `package.json`
- [ ] T002 [P] Populate `src/lib/env/index.ts` — typed accessor over the existing
- [x] T001 [P] Add `js-cookie` + `@types/js-cookie` and `jose` to `package.json`
- [x] T002 [P] Populate `src/lib/env/index.ts` — typed accessor over the existing
`NEXT_PUBLIC_*` env vars (zod-validated, matching supporthub-api's own `env.ts` pattern),
exporting `env.apiUrl` etc.
- [ ] T003 [P] Configure `vitest.config.ts` (currently empty) — `environment: 'jsdom'`, path
- [x] T003 [P] Configure `vitest.config.ts` (currently empty) — `environment: 'jsdom'`, path
aliases matching `tsconfig.json`, `tests/unit` + `tests/integration` include globs
- [ ] T004 [P] Configure `playwright.config.ts` (currently empty) — base URL from
- [x] T004 [P] Configure `playwright.config.ts` (currently empty) — base URL from
`NEXT_PUBLIC_APP_URL`, `tests/e2e` test dir
- [ ] T005 [P] Add `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` as dev
- [x] T005 [P] Add `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` as dev
dependencies (needed by T003's `jsdom` environment; not yet present in `package.json`)
**Checkpoint**: Both test runners actually run (even with zero tests) and `env` is typed.
@@ -48,33 +48,37 @@ story is built on.
**⚠️ CRITICAL**: No user-story work can begin until this phase is complete.
- [ ] T006 Add `lib/auth/session-cookie.ts``getSessionToken()`/`setSessionToken(token)`/
- [x] T006 Add `lib/auth/session-cookie.ts``getSessionToken()`/`setSessionToken(token)`/
`clearSessionToken()` using `js-cookie` (`sh_session`, `Secure` in production,
`SameSite=Lax`) (depends on T001)
- [ ] T007 Add `lib/api/client.ts` — axios instance (`baseURL: env.apiUrl`), a request
- [x] T007 Add `lib/api/client.ts` — axios instance (`baseURL: env.apiUrl`), a request
interceptor attaching `Authorization: Bearer <token>` from T006, a response interceptor
throwing a typed `ApiError` (data-model.md) and clearing the session + redirecting to
`/sign-in` on `401` (depends on T002, T006)
- [ ] T008 [P] Add `lib/api/types/``Session`, `ApiError`, `AssignedTicketSummary` (011's
- [x] T008 [P] Add `lib/api/types/``Session`, `ApiError`, `AssignedTicketSummary` (011's
contract), and the ticket/message/problem-resolution/team/agent/hierarchy types
data-model.md names, each referencing its source backend contract in a comment
- [ ] T009 Add `lib/api/auth.ts``login`, `getCurrentSession`, `logout` per
- [x] T009 Add `lib/api/auth.ts``login`, `getCurrentSession`, `logout` per
contracts/api-client-contract.md (depends on T007, T008)
- [ ] T010 Add `lib/query/query-client.ts` (the shared `QueryClient` instance) and
- [x] T010 Add `lib/query/query-client.ts` (the shared `QueryClient` instance) and
`lib/query/query-state.ts` (research.md's `'loading'|'empty'|'error'|'ready'`
discriminated union helper, taking a `UseQueryResult` and an optional
`isEmpty(data)` predicate)
- [ ] T011 Add `providers/query-provider.tsx` (`QueryClientProvider` wrapping T010's client) and
- [x] T011 Add `providers/query-provider.tsx` (`QueryClientProvider` wrapping T010's client) and
`providers/session-provider.tsx` (calls `getCurrentSession` on mount via TanStack Query,
exposes `useSession()`; the query's own `onError` for a `401` clears the session and
redirects, per contracts/api-client-contract.md's "Session guard contract") (depends on
T009, T010)
- [ ] T012 Wire both providers into `src/app/layout.tsx` (currently the root layout with no
- [x] T012 Wire both providers into `src/app/layout.tsx` (currently the root layout with no
providers)
- [ ] T013 Write `middleware.ts` (currently empty) — no `sh_session` cookie on a
`(support)`/`(admin)` path → redirect to `/sign-in?from=<path>`; cookie present but
`jose`-decoded `role` isn't `ADMIN` on an `(admin)` path → redirect to
`/support/dashboard` (research.md's unverified-decode decision) (depends on T001)
- [x] T013 Write `src/middleware.ts` (this project's `src/` layout requires middleware there,
not at the repo root — an empty root-level `middleware.ts` had been scaffolded in the
wrong place) — no `sh_session` cookie on a `(support)`/`(admin)` path → redirect to
`/sign-in?from=<path>`; cookie present but `jose`-decoded `role` isn't `ADMIN` on an
`(admin)` path → redirect to `/support/dashboard` (research.md's unverified-decode
decision) (depends on T001). Also requires removing `next.config.mjs`'s
`output: 'export'` — incompatible with Middleware (discovered by actually running the
dev server, not by inspection).
**Checkpoint**: A session can be established, read, and cleared; every subsequent API call
carries it; an unauthenticated or wrongly-roled request never reaches portal content.
@@ -89,28 +93,28 @@ carries it; an unauthenticated or wrongly-roled request never reaches portal con
### Tests for User Story 0
- [ ] T014 [P] [US0] Unit test for `lib/query/query-state.ts`'s discriminated-union logic
- [x] T014 [P] [US0] Unit test for `lib/query/query-state.ts`'s discriminated-union logic
(loading/empty/error/ready, each input combination) in `tests/unit/lib/query-state.test.ts`
- [ ] T015 [US0] Integration test (mocked `lib/api/auth.ts`) covering Quickstart Scenario 0
- [x] T015 [US0] Integration test (mocked `lib/api/auth.ts`) covering Quickstart Scenario 0
steps 2-3 (successful sign-in redirects and stores a session; wrong credentials show one
generic error) in `tests/integration/auth/sign-in.test.tsx` (depends on T009)
- [ ] T016 [US0] Playwright E2E covering Quickstart Scenario 0 end-to-end against a real
- [x] T016 [US0] Playwright E2E covering Quickstart Scenario 0 end-to-end against a real
supporthub-api (redirect-when-unauthenticated, sign-in, role-gated redirect, sign-out) in
`tests/e2e/agent-sign-in-and-resolve.spec.ts` — this is also Constitution Principle VII's
journey (B), continued by User Story 2's own steps once that story is built
### Implementation for User Story 0
- [ ] T017 [US0] Add `features/auth/sign-in-form.tsx` + `features/auth/use-login.ts` (a
- [x] T017 [US0] Add `features/auth/sign-in-form.tsx` + `features/auth/use-login.ts` (a
TanStack `useMutation` wrapping `lib/api/auth.ts`'s `login`, redirecting to a
role-appropriate landing page on success) (depends on T009, T011)
- [ ] T018 [US0] Add the `/sign-in` page (`src/app/sign-in/page.tsx`, new — outside every
- [x] T018 [US0] Add the `/sign-in` page (`src/app/sign-in/page.tsx`, new — outside every
existing route group, since it's neither an admin nor support surface) rendering T017's
form
- [ ] T019 [US0] Add a sign-out action (`features/auth/use-logout.ts`, wrapping
- [x] T019 [US0] Add a sign-out action (`features/auth/use-logout.ts`, wrapping
`lib/api/auth.ts`'s `logout`) wired into both `(support)/layout.tsx` and
`(admin)/layout.tsx`'s existing nav shells (depends on T009, T011)
- [ ] T020 [US0] Run Quickstart Scenario 0 locally and confirm all 5 steps pass
- [x] T020 [US0] Run Quickstart Scenario 0 locally and confirm all 5 steps pass
**Checkpoint**: A real session exists end-to-end. Every other user story can now consume one.
+2 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { PortalShell } from '@/features/auth/portal-shell';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <div className="adminlayout">{children}</div>;
return <PortalShell title="SupportHub — Admin">{children}</PortalShell>;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { PortalShell } from '@/features/auth/portal-shell';
export default function SupportLayout({ children }: { children: React.ReactNode }) {
return <div className="supportlayout">{children}</div>;
return <PortalShell title="SupportHub — Agent Workspace">{children}</PortalShell>;
}
+5 -1
View File
@@ -1,5 +1,7 @@
import React from 'react';
import '../styles/globals.css';
import { QueryProvider } from '@/providers/query-provider';
import { SessionProvider } from '@/providers/session-provider';
export const metadata = {
title: 'SupportHub | Customer Support Platform',
@@ -14,7 +16,9 @@ export default function RootLayout({
return (
<html lang="en" className="h-full scroll-smooth">
<body className="min-h-full flex flex-col font-sans bg-background text-foreground antialiased">
{children}
<QueryProvider>
<SessionProvider>{children}</SessionProvider>
</QueryProvider>
</body>
</html>
);
+17
View File
@@ -0,0 +1,17 @@
import { SignInForm } from '@/features/auth/sign-in-form';
export default function SignInPage() {
return (
<div className="flex-1 flex items-center justify-center px-4">
<div className="w-full max-w-sm flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-2xl font-semibold text-foreground">Sign in to SupportHub</h1>
<p className="text-sm text-muted-foreground mt-1">
Agent and admin access for the support workspace.
</p>
</div>
<SignInForm />
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import { ReactNode } from 'react';
import { LogOut, Headphones } from 'lucide-react';
import { Button } from '@/components/ui';
import { useSession } from '@/providers/session-provider';
import { useLogout } from './use-logout';
/** Minimal session-aware shell shared by (support) and (admin) — a portal-specific composition
* (Constitution Principle III), not the marketing-site Navbar. Distinct per-portal navigation
* (US3-US7's own admin sections) is added incrementally as each story is built; this covers
* only what User Story 0 itself requires: visible identity + a working sign-out. */
export function PortalShell({ title, children }: { title: string; children: ReactNode }) {
const { session } = useSession();
const logout = useLogout();
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold text-foreground">
<Headphones className="h-5 w-5" />
{title}
</div>
<div className="flex items-center gap-4">
{session && (
<span className="text-sm text-muted-foreground">
{session.name} · {session.role}
</span>
)}
<Button
variant="ghost"
size="sm"
leftIcon={<LogOut className="h-4 w-4" />}
isLoading={logout.isPending}
onClick={() => logout.mutate()}
>
Sign out
</Button>
</div>
</header>
<main className="flex-1">{children}</main>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { FormEvent, useState } from 'react';
import { Button, Input, Alert, AlertDescription } from '@/components/ui';
import { useLogin } from './use-login';
import { ApiError } from '@/lib/api/types';
/** US0 acceptance scenario 3: a login failure (wrong password or unknown email) is shown as
* one generic message — 010-identity-auth's own identical-failure-response guarantee — never
* a hint about which part was wrong. */
export function SignInForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const login = useLogin();
function handleSubmit(event: FormEvent) {
event.preventDefault();
login.mutate({ email, password });
}
// Renders the backend's own message verbatim (Principle IV) — 010-identity-auth already
// guarantees this single, generic message for every failure branch (wrong password, unknown
// email, inactive account), so there's nothing for the frontend to add or rephrase.
const errorMessage = login.error instanceof ApiError ? login.error.message : undefined;
return (
<form onSubmit={handleSubmit} className="w-full max-w-sm flex flex-col gap-4">
{errorMessage && (
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<Input
type="email"
label="Email"
required
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
type="password"
label="Password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit" isLoading={login.isPending} className="w-full">
Sign in
</Button>
</form>
);
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { login } from '@/lib/api/auth';
/** US0 acceptance scenario 2: on success, lands on the role-appropriate landing page. */
export function useLogin() {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
login(email, password),
onSuccess: (result) => {
queryClient.setQueryData(['session'], result.user);
router.push(result.user.role === 'ADMIN' ? '/admin/dashboard' : '/support/dashboard');
},
});
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { logout } from '@/lib/api/auth';
/** US0 acceptance scenario 5: clears the session (010-identity-auth's own POST /auth/logout,
* plus the local cookie regardless of that call's outcome) and redirects to sign-in. */
export function useLogout() {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: logout,
onSettled: () => {
queryClient.setQueryData(['session'], undefined);
router.push('/sign-in');
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { apiClient } from './client';
import { clearSessionToken, setSessionToken } from '@/lib/auth/session-cookie';
import { LoginResult, Session } from './types/session';
interface ApiEnvelope<T> {
success: true;
data: T;
meta: null;
}
/** specs/010-identity-auth/contracts/identity-auth-contract.md */
export async function login(email: string, password: string): Promise<LoginResult> {
const response = await apiClient.post<ApiEnvelope<LoginResult>>('/auth/login', {
email,
password,
});
setSessionToken(response.data.data.token);
return response.data.data;
}
/** Re-validates against live account state (010's own FR-007) — called on mount and after any
* 401, never trusted from a locally-decoded JWT claim. */
export async function getCurrentSession(): Promise<Session> {
const response = await apiClient.get<ApiEnvelope<Session>>('/auth/me');
return response.data.data;
}
export async function logout(): Promise<void> {
try {
await apiClient.post('/auth/logout');
} finally {
// A failed logout call must not leave a dead session behind client-side.
clearSessionToken();
}
}
+47
View File
@@ -0,0 +1,47 @@
import axios, { AxiosError } from 'axios';
import { env } from '@/lib/env';
import { clearSessionToken, getSessionToken } from '@/lib/auth/session-cookie';
import { ApiError } from './types/api-error';
export const apiClient = axios.create({ baseURL: env.apiUrl });
apiClient.interceptors.request.use((config) => {
const token = getSessionToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
interface BackendErrorBody {
success: false;
error: { code: string; message: string };
}
/** research.md / contracts/api-client-contract.md: business-rule rejections (409/400) and auth
* failures (401) are never swallowed or rewritten — every error becomes this same ApiError
* shape, carrying the backend's own message verbatim (FR-012/SC-002), so every screen can
* render it directly. A 401 additionally clears the local session — the real gate is
* supporthub-api's own token check, not this frontend's middleware guess (research.md). */
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError<BackendErrorBody>) => {
const statusCode = error.response?.status ?? 0;
const body = error.response?.data;
// /auth/login's own 401 (wrong password / unknown email) is an expected, form-level
// rejection the sign-in screen handles itself — not "the session went stale," which is
// the only case this redirect exists for.
const isLoginAttempt = error.config?.url?.endsWith('/auth/login');
if (statusCode === 401 && !isLoginAttempt) {
clearSessionToken();
if (typeof window !== 'undefined') {
window.location.href = '/sign-in';
}
}
const code = body?.error?.code ?? 'UNKNOWN_ERROR';
const message = body?.error?.message ?? error.message;
return Promise.reject(new ApiError(message, code, statusCode));
},
);
+15
View File
@@ -0,0 +1,15 @@
/** The shape every supporthub-api error response already uses:
* { success: false, error: { code, message, statusCode } } — see e.g.
* specs/010-identity-auth/contracts/identity-auth-contract.md. Thrown by lib/api/client.ts's
* response interceptor so every failed mutation's `error` is always this shape, never a raw
* axios error (Constitution Principle IV; contracts/api-client-contract.md). */
export class ApiError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
) {
super(message);
this.name = 'ApiError';
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './session';
export * from './api-error';
export * from './tickets';
export * from './teams';
+13
View File
@@ -0,0 +1,13 @@
/** specs/010-identity-auth/contracts/identity-auth-contract.md (supporthub-api) —
* POST /auth/login and GET /auth/me's `data.user` shape. */
export interface Session {
id: string;
email: string;
name: string;
role: 'ADMIN' | 'AGENT';
}
export interface LoginResult {
token: string;
user: Session;
}
+55
View File
@@ -0,0 +1,55 @@
/** specs/006-support-organization/contracts/support-org-contract.md (supporthub-api) —
* raw Prisma records, no DTO mapping on the backend side, so these mirror the Prisma schema
* fields exactly. */
export interface Agent {
id: string;
teamId: string;
name: string;
active: boolean;
userId: string | null;
createdAt: string;
updatedAt: string;
}
export interface Team {
id: string;
name: string;
active: boolean;
createdAt: string;
updatedAt: string;
agents: Agent[];
}
export interface AgentSkill {
id: string;
agentId: string;
skillTag: string;
level: number;
}
export interface AgentAvailability {
id: string;
agentId: string;
status: 'available' | 'busy' | 'away' | 'offline';
workingHours: unknown;
currentLoad: number;
updatedAt: string;
}
export interface HierarchyNode {
id: string;
name: string;
parentId: string | null;
order: number;
teamId: string | null;
skills: string[];
productScope: string[];
categoryScope: string[];
priorityScope: string[];
assignmentStrategy: string;
slaPolicyId: string | null;
escalationPolicyId: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
}
+46
View File
@@ -0,0 +1,46 @@
/** specs/011-agent-ticket-queue/contracts/agent-ticket-queue-contract.md (supporthub-api) —
* GET /agents/me/tickets and GET /admin/agents/:agentId/tickets response items exactly. */
export interface AssignedTicketSummary {
id: string;
code: string;
status: string;
priority: string;
severity: string;
product: { id: string; externalProductId: string; name: string };
customer: { externalUserId: string; externalTenantId: string };
assignedAt: string | null;
sla: {
status: string;
firstResponseDueAt: string | null;
resolutionDueAt: string | null;
breachedAt: string | null;
} | null;
}
/** specs/003-ticketing/contracts/ticket-lifecycle-contract.md (supporthub-api) —
* GET /tickets/:ticketId response. */
export interface Ticket {
id: string;
code: string;
productId: string;
problemId: string;
status: string;
priority: string;
severity: string;
version: number;
}
/** specs/003-ticketing/contracts/ticket-lifecycle-contract.md — one ticket message. `type` is
* a plain string, not a closed frontend enum (Constitution Principle V — never a second,
* driftable copy of the backend's own values: CUSTOMER_MESSAGE | AI_MESSAGE | AGENT_MESSAGE |
* INTERNAL_NOTE | SYSTEM_EVENT | INVESTIGATION_NOTE | SOLUTION_NOTE). `visibleToCustomer` is
* the one field message-thread.tsx actually needs to distinguish an internal note. */
export interface TicketMessage {
id: string;
ticketId: string;
type: string;
authorRef: string;
body: string;
visibleToCustomer: boolean;
createdAt: string;
}
+22
View File
@@ -0,0 +1,22 @@
import Cookies from 'js-cookie';
const SESSION_COOKIE_NAME = 'sh_session';
/** research.md: a plain (non-httpOnly) cookie — readable by both Next.js middleware (server-
* side, for the redirect-before-render guard) and this same client-side code (to attach the
* Authorization header supporthub-api actually reads). */
export function getSessionToken(): string | undefined {
return Cookies.get(SESSION_COOKIE_NAME);
}
export function setSessionToken(token: string): void {
Cookies.set(SESSION_COOKIE_NAME, token, {
expires: 1 / 6, // 4 hours, matching supporthub-api's own AUTH_TOKEN_LIFETIME_HOURS default
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
}
export function clearSessionToken(): void {
Cookies.remove(SESSION_COOKIE_NAME);
}
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod';
const envSchema = z.object({
appName: z.string().min(1),
appEnv: z.enum(['development', 'test', 'production']),
appUrl: z.string().url(),
apiUrl: z.string().url(),
});
/** Typed accessor over the project's existing NEXT_PUBLIC_* vars (see .env.example) — mirrors
* supporthub-api's own src/config/env.ts pattern: validate once, import the typed result
* everywhere else. */
export const env = envSchema.parse({
appName: process.env.NEXT_PUBLIC_APP_NAME,
appEnv: process.env.NEXT_PUBLIC_APP_ENV,
appUrl: process.env.NEXT_PUBLIC_APP_URL,
apiUrl: process.env.NEXT_PUBLIC_API_URL,
});
+10
View File
@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
+16
View File
@@ -0,0 +1,16 @@
import { UseQueryResult } from '@tanstack/react-query';
export type QueryState = 'loading' | 'empty' | 'error' | 'ready';
/** research.md: a discriminated union, not three independently-checked booleans — FR-010/
* SC-003 require never conflating loading, empty, and error, and this makes "forgot to handle
* the error case" a TypeScript exhaustiveness error at the call site. */
export function getQueryState<T>(
result: Pick<UseQueryResult<T>, 'isLoading' | 'isError' | 'data'>,
isEmpty: (data: T) => boolean = (data) => Array.isArray(data) && data.length === 0,
): QueryState {
if (result.isLoading) return 'loading';
if (result.isError) return 'error';
if (result.data !== undefined && isEmpty(result.data)) return 'empty';
return 'ready';
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { decodeJwt } from 'jose';
const SESSION_COOKIE_NAME = 'sh_session';
/**
* research.md: a cheap, UNVERIFIED presence/role check for UX redirects only — the real
* security boundary is supporthub-api's own token verification on every API call
* (fastify.authenticate / requireRole, 010-identity-auth). This middleware never re-implements
* that decision (Constitution Principle I); it only avoids rendering a portal that would just
* fail its own data fetches, and never lets the admin portal render for a non-ADMIN role
* (FR-011/SC-005).
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
if (!token) {
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('from', pathname);
return NextResponse.redirect(signInUrl);
}
if (pathname.startsWith('/admin')) {
let role: string | undefined;
try {
role = decodeJwt(token).role as string | undefined;
} catch {
role = undefined;
}
if (role !== 'ADMIN') {
return NextResponse.redirect(new URL('/support/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/support/:path*', '/admin/:path*'],
};
+9
View File
@@ -0,0 +1,9 @@
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { queryClient } from '@/lib/query/query-client';
export function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { createContext, ReactNode, useContext } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getCurrentSession } from '@/lib/api/auth';
import { getSessionToken } from '@/lib/auth/session-cookie';
import { Session } from '@/lib/api/types';
interface SessionContextValue {
session: Session | undefined;
isLoading: boolean;
}
const SessionContext = createContext<SessionContextValue>({ session: undefined, isLoading: false });
/** Re-validates against live account state (010's own FR-007) on mount, never trusting a
* locally-decoded JWT claim for anything beyond middleware's own UX-only redirect (research.md).
* Only queries when a session cookie exists at all — an unauthenticated visit to a public page
* (e.g. /sign-in) must not call GET /auth/me and trip the 401 interceptor for no reason. */
export function SessionProvider({ children }: { children: ReactNode }) {
const query = useQuery({
queryKey: ['session'],
queryFn: getCurrentSession,
enabled: !!getSessionToken(),
});
return (
<SessionContext.Provider value={{ session: query.data, isLoading: query.isLoading }}>
{children}
</SessionContext.Provider>
);
}
export function useSession(): SessionContextValue {
return useContext(SessionContext);
}
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
/**
* Constitution Principle VII journey (B): an agent signing in and (eventually) working an
* escalated ticket through to resolution. specs/001-agent-admin-ui/quickstart.md Scenario 0 —
* User Story 2's own steps extend this spec once that story is built (tasks.md T027).
*/
test.describe('Agent sign-in and portal guard (User Story 0)', () => {
test('redirects to sign-in when no session exists', async ({ page }) => {
await page.goto('/support/dashboard');
await expect(page).toHaveURL(/\/sign-in/);
});
test('signs in with the seeded admin and lands on the admin dashboard', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
await page.getByLabel(/Password/).fill('ChangeMe123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/admin\/dashboard/);
await expect(page.getByText('System Admin · ADMIN')).toBeVisible();
});
test('a wrong password shows one generic error', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
await page.getByLabel(/Password/).fill('wrong-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Invalid email or password.')).toBeVisible();
await expect(page).toHaveURL(/\/sign-in/);
});
test('a non-admin session is redirected away from the admin portal', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/Email/).fill('agent@supporthub.internal');
await page.getByLabel(/Password/).fill('ChangeMe123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/support\/dashboard/);
await page.goto('/admin/teams');
await expect(page).toHaveURL(/\/support\/dashboard/);
});
test('signing out clears the session and redirects to sign-in', async ({ page }) => {
await page.goto('/sign-in');
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
await page.getByLabel(/Password/).fill('ChangeMe123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/admin\/dashboard/);
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL(/\/sign-in/);
await page.goto('/support/dashboard');
await expect(page).toHaveURL(/\/sign-in/);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SignInForm } from '@/features/auth/sign-in-form';
import { ApiError } from '@/lib/api/types';
const pushMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock('@/lib/api/auth', () => ({
login: vi.fn(),
}));
import { login } from '@/lib/api/auth';
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
describe('SignInForm', () => {
beforeEach(() => {
vi.mocked(login).mockReset();
pushMock.mockReset();
});
it('US0 scenario 2: on success, redirects to the role-appropriate landing page', async () => {
vi.mocked(login).mockResolvedValue({
token: 'fake-token',
user: { id: 'u1', email: 'admin@supporthub.internal', name: 'Admin', role: 'ADMIN' },
});
renderWithQueryClient(<SignInForm />);
await userEvent.type(screen.getByLabelText(/Email/), 'admin@supporthub.internal');
await userEvent.type(screen.getByLabelText(/Password/), 'ChangeMe123!');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(pushMock).toHaveBeenCalledWith('/admin/dashboard'));
});
it('US0 scenario 3: a login failure shows one generic message, the backend\'s own', async () => {
vi.mocked(login).mockRejectedValue(new ApiError('Invalid email or password.', 'UNAUTHORIZED', 401));
renderWithQueryClient(<SignInForm />);
await userEvent.type(screen.getByLabelText(/Email/), 'admin@supporthub.internal');
await userEvent.type(screen.getByLabelText(/Password/), 'wrong-password');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
expect(await screen.findByText('Invalid email or password.')).toBeInTheDocument();
expect(pushMock).not.toHaveBeenCalled();
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { getQueryState } from '@/lib/query/query-state';
describe('getQueryState', () => {
it('returns loading while the query is loading', () => {
expect(getQueryState({ isLoading: true, isError: false, data: undefined })).toBe('loading');
});
it('returns error when the query failed, even if stale data is present', () => {
expect(getQueryState({ isLoading: false, isError: true, data: [1] })).toBe('error');
});
it('returns empty for a successful query whose data is an empty array', () => {
expect(getQueryState({ isLoading: false, isError: false, data: [] })).toBe('empty');
});
it('returns ready for a successful query with non-empty data', () => {
expect(getQueryState({ isLoading: false, isError: false, data: [1, 2] })).toBe('ready');
});
it('supports a custom isEmpty predicate for non-array data', () => {
const result = getQueryState(
{ isLoading: false, isError: false, data: { items: [] } },
(data) => data.items.length === 0,
);
expect(result).toBe('empty');
});
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['tests/unit/**/*.test.{ts,tsx}', 'tests/integration/**/*.test.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});