diff --git a/AGENTS.md b/AGENTS.md index ca5b976..4c56ba4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ page padding in an individual settings page — change the container instead. | | value | why | |---|---|---| -| Horizontal | 32px (`paddingInline={8}`) | clears the sub-nav and the Copilot rail | +| Horizontal | 32px (`paddingInline={8}`) | clears the sub-nav and the Loyaly AI rail | | Top | 32px (`paddingBlock={8}`) | title never touches the header | | Bottom | 48px (`className="pb-12"`) | last card is never flush to the fold | | Header → body | 32px (`gap={8}`) | title block reads as its own layer | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..82d1316 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,120 @@ +# Architecture + +How this codebase is organised, and the rules that keep it that way. Read this +before adding a feature; it should take about five minutes. + +## The layers + +Data flows in one direction, and each layer is allowed to know only the one +below it: + +``` + component / page renders state, owns no rules + ↓ + features//hooks binds a service or repository to React + ↓ + features//services domain rules: validation, interpretation, defaults + ↓ + features//repositories TRANSPORT ONLY — the single place that knows a URL + ↓ + app/api//route.ts HTTP boundary: authorises, parses, responds + ↓ + features//mock/*.mock fixtures, server-side only +``` + +**Connecting a real backend touches the repository layer and nothing else.** +Point `NEXT_PUBLIC_API_BASE` at a host, or rewrite the four lines in a +repository, and every component above it is untouched. That is the property the +whole structure exists to protect, so: + +- **Never** `import` from a `mock/` module in a component, hook or page. Server + Components read through a `*ServerRepository`; clients go over HTTP. +- **Never** call `fetch` in a component. `shared/services/httpClient` is the + only module that does. +- A repository never interprets a response, and a service never builds a URL. + +## Where things live + +``` +src/ + app/ routing only — thin files, no logic + (public)/login unauthenticated routes → PublicLayout → GuestGuard + (workspace)/… authenticated routes → ProtectedLayout → AuthGuard + api/ route handlers + proxy.ts the server-side auth gate (Next 16 renamed middleware → proxy) + features// + components/ UI for this feature only + hooks/ data access + view state + services/ domain rules, framework-free, unit-testable + repositories/ URLs and verbs + types/ the wire contract for this feature + mock/ fixtures (server-side) + utils/ · config/ · guards/ · providers/ as needed + shared/ + components/ brand · charts · patterns · primitives · scope · data · motion + hooks/ useResource · useScope · useBreakpoint · usePersistentFlag + layouts/ ProtectedLayout · PublicLayout · workspace shell + providers/ app-wide state (workspace scope) + services/ httpClient (transport) · apiRoute (handler helpers) + types/ the response envelope + mock/ seeded RNG shared by every fixture + theme/ design tokens; edit loyalyTheme.ts, run `npm run theme:build` +``` + +A feature owns everything about itself. If two features need the same thing, it +moves to `shared/` — it does not get imported across features, with one +deliberate exception: `features/dashboard/types` holds the analytics primitives +(`TimePoint`, `ActivityEvent`) that LYTs and Stores genuinely share, because +duplicating them would let two modules disagree about the same wire shape. + +## Authentication + +Three independent gates, in order of authority: + +1. **`src/proxy.ts`** — runs before any protected route renders. No valid + session cookie, no page. API paths get a 401 JSON; pages get a redirect to + `/login?next=…`. This is the one that matters. +2. **`requireApiSession()`** — every data route handler calls it. Next's own + docs warn that proxy coverage can be silently lost by a matcher change or a + route move, so the check is repeated where the data is. +3. **`AuthGuard` / `GuestGuard`** — client-side. Covers what the server never + sees: a session expiring in an open tab, a logout in another tab, a + client-side navigation. **Not a security boundary.** + +The session is a signed httpOnly cookie (`features/auth/services/sessionToken`), +so the browser cannot read or forge it. `SessionProvider` holds only what to +draw, never what to permit — it asks `GET /api/auth/session` and believes the +answer. + +Swapping the mock for a real identity provider means changing +`verifyCredentials` in `features/auth/mock/users.mock.ts` and the three URLs in +`authRepository`. Nothing else, including the login form, is aware. + +## Conventions + +- **No `any`.** The codebase has zero, and `tsc --noEmit` is expected to pass + before every commit. +- **Components stay under ~300 lines.** When one grows past that, the split is + usually behaviour-into-a-hook, not markup-into-more-markup. +- **Names say what the thing is**: `DashboardKpiCard`, `StaffAttendanceTable`. + Never `Card.tsx`, `utils.ts`, `NewFile.tsx`. +- **Styling comes from the design system**, in this order: component props → + token-backed Tailwind utilities → a theme override in `loyalyTheme.ts`. There + are no per-feature CSS files; see the note below. + +### On CSS + +The brief that produced this refactor asked for per-feature stylesheets +(`dashboard/styles/kpi.css`, and so on) so that changing one module cannot +affect another. That isolation is already total here, by construction rather +than by convention: styling lives in component props and utility classes scoped +to the element they are written on, so there is no selector in the codebase +that *can* reach another feature. Adding stylesheets would introduce global +selectors — the one mechanism that can leak — and they would have to fight +StyleX's `@layer astryx-base` for the cascade. Shared visual decisions belong in +`theme/loyalyTheme.ts`, which is the single place a change is meant to be +system-wide. + +If a future component genuinely needs CSS that props and utilities cannot +express, `astryx swizzle ` ejects the source for that one component +rather than opening a stylesheet the whole app can be edited through. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc04b69 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Stage 1: Install dependencies +FROM node:22-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +# Stage 2: Build the Next.js application +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# Stage 3: Production runner with Nginx reverse proxy +FROM node:22-alpine AS runner +WORKDIR /app + +RUN apk add --no-cache nginx + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Copy public static assets and standalone build output +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Copy custom Nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 80 + +# Start Next.js standalone server in background and Nginx in foreground +CMD ["sh", "-c", "node server.js & nginx -g 'daemon off;'"] diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..bb4b57f --- /dev/null +++ b/nginx.conf @@ -0,0 +1,56 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + gzip on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + upstream nextjs_upstream { + server 127.0.0.1:3000; + } + + server { + listen 80; + server_name localhost; + + # Serve Next.js compiled static assets directly via Nginx + location /_next/static/ { + alias /app/.next/static/; + expires 365d; + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Serve public directory assets + location /public/ { + alias /app/public/; + expires 30d; + access_log off; + } + + # Reverse proxy dynamic routes, SSR, and API routes to Next.js standalone server + location / { + proxy_pass http://nextjs_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/package-lock.json b/package-lock.json index 08d6008..bdec1f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "next": "^16.3.0", "react": "19.2.4", "react-dom": "19.2.4", - "recharts": "^3.10.1" + "recharts": "^3.10.1", + "server-only": "^0.0.1" }, "devDependencies": { "@astryxdesign/cli": "^0.2.0", @@ -7050,6 +7051,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 1e31189..5e8065a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "next": "^16.3.0", "react": "19.2.4", "react-dom": "19.2.4", - "recharts": "^3.10.1" + "recharts": "^3.10.1", + "server-only": "^0.0.1" }, "devDependencies": { "@astryxdesign/cli": "^0.2.0", diff --git a/src/app/(dev)/charts/page.tsx b/src/app/(dev)/charts/page.tsx index 048fc27..25942d8 100644 --- a/src/app/(dev)/charts/page.tsx +++ b/src/app/(dev)/charts/page.tsx @@ -16,22 +16,22 @@ import {Card} from '@astryxdesign/core/Card'; import {Heading, Text} from '@astryxdesign/core/Text'; import {Button} from '@astryxdesign/core/Button'; import {Badge} from '@astryxdesign/core/Badge'; -import {useResource} from '@/lib/api/useResource'; -import {endpoints} from '@/lib/api/client'; -import {ChartCard} from '@/components/charts/ChartCard'; -import {LineChartView} from '@/components/charts/LineChartView'; -import {AreaChartView} from '@/components/charts/AreaChartView'; -import {BarChartView} from '@/components/charts/BarChartView'; -import {Sparkline} from '@/components/charts/Sparkline'; -import {HeatmapGrid} from '@/components/charts/HeatmapGrid'; -import type {HourCell, Kpi, TimePoint} from '@/lib/api/contracts'; +import {useResource} from '@/shared/hooks/useResource'; +import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository'; +import {ChartCard} from '@/shared/components/charts/ChartCard'; +import {LineChartView} from '@/shared/components/charts/LineChartView'; +import {AreaChartView} from '@/shared/components/charts/AreaChartView'; +import {BarChartView} from '@/shared/components/charts/BarChartView'; +import {Sparkline} from '@/shared/components/charts/Sparkline'; +import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid'; +import type {HourCell, Kpi, TimePoint} from '@/features/dashboard/types/dashboard'; import { formatCompact, formatDayLabel, formatDelta, formatInrCompact, formatPct, -} from '@/lib/format'; +} from '@/shared/utils/format'; type Forced = '' | 'empty' | 'error' | 'delay'; @@ -52,11 +52,11 @@ export default function ChartsGallery() { ({...e, params: {...e.params, ...extra}}) as never as T; const series = useResource( - withExtra(endpoints.dashboardTimeseries(scope)), + withExtra(dashboardRepository.timeseries(scope)), ); - const kpis = useResource(withExtra(endpoints.dashboardKpis(scope))); + const kpis = useResource(withExtra(dashboardRepository.kpis(scope))); const peak = useResource( - withExtra(endpoints.dashboardPeakHours(scope)), + withExtra(dashboardRepository.peakHours(scope)), ); return ( diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx new file mode 100644 index 0000000..c526c28 --- /dev/null +++ b/src/app/(public)/layout.tsx @@ -0,0 +1,10 @@ +import {PublicLayout} from '@/shared/layouts/PublicLayout'; + +/** The route-group boundary for screens reachable without a session. */ +export default function PublicRouteLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx new file mode 100644 index 0000000..d6fafce --- /dev/null +++ b/src/app/(public)/login/page.tsx @@ -0,0 +1,19 @@ +import type {Metadata} from 'next'; +import {LoginSplit} from '@/features/auth/components/LoginSplit'; + +export const metadata: Metadata = { + title: 'Sign in · Loyaly.ai', +}; + +/** + * Sits in the (public) group on purpose: no shell, no nav, no store scope, and + * a GuestGuard instead of an AuthGuard — see PublicLayout. + * + * Reaching this page with a live session is already impossible via a fresh + * request (src/proxy.ts redirects it to /dashboard); the guard covers the + * client-side navigation the proxy never sees. + */ +export default function LoginPage() { + return ; +} + diff --git a/src/app/(workspace)/activity/page.tsx b/src/app/(workspace)/activity/page.tsx index 3e7eae0..eac1f24 100644 --- a/src/app/(workspace)/activity/page.tsx +++ b/src/app/(workspace)/activity/page.tsx @@ -1,13 +1,11 @@ 'use client'; import {VStack} from '@astryxdesign/core/Layout'; -import {PageHeader} from '@/components/primitives/PageHeader'; -import {ScopeControls} from '@/components/scope/ScopeControls'; -import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline'; -import {useResource} from '@/lib/api/useResource'; -import {endpoints} from '@/lib/api/client'; -import {useWorkspace} from '@/components/shell/WorkspaceProvider'; -import {storeName} from '@/lib/mock/stores'; +import {PageHeader} from '@/shared/components/primitives/PageHeader'; +import {ScopeControls} from '@/shared/components/scope/ScopeControls'; +import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline'; +import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard'; +import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; /** * The complete event history — where the dashboard's "View all" leads. @@ -22,10 +20,8 @@ import {storeName} from '@/lib/mock/stores'; * uncapped: one feed implementation, two budgets. */ export default function ActivityPage() { - const {storeId, range} = useWorkspace(); - const activity = useResource(endpoints.dashboardActivity({range, storeId})); - - const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId); + const activity = useDashboardActivity(); + const scopeLabel = useScopeLabel(); return ( diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx index d97002f..ba50341 100644 --- a/src/app/(workspace)/dashboard/page.tsx +++ b/src/app/(workspace)/dashboard/page.tsx @@ -5,30 +5,37 @@ import {VStack} from '@astryxdesign/core/Layout'; import {Grid} from '@astryxdesign/core/Grid'; import {Button} from '@astryxdesign/core/Button'; import {Icon} from '@astryxdesign/core/Icon'; -import {PageHeader} from '@/components/primitives/PageHeader'; -import {ScopeControls} from '@/components/scope/ScopeControls'; -import {ICONS} from '@/lib/icons'; -import {ChartCard} from '@/components/charts/ChartCard'; -import {AreaChartView} from '@/components/charts/AreaChartView'; -import {LineChartView} from '@/components/charts/LineChartView'; -import {BarChartView} from '@/components/charts/BarChartView'; -import {HeatmapGrid} from '@/components/charts/HeatmapGrid'; -import {KpiRow} from '@/features/dashboard/KpiRow'; -import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline'; -import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart'; -import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison'; -import {PerformancePanel} from '@/features/dashboard/PerformancePanel'; -import {useResource} from '@/lib/api/useResource'; -import {endpoints} from '@/lib/api/client'; -import {useWorkspace} from '@/components/shell/WorkspaceProvider'; -import {storeName} from '@/lib/mock/stores'; -import type {Granularity} from '@/lib/api/contracts'; +import {PageHeader} from '@/shared/components/primitives/PageHeader'; +import {ScopeControls} from '@/shared/components/scope/ScopeControls'; +import {ICONS} from '@/shared/utils/icons'; +import {ChartCard} from '@/shared/components/charts/ChartCard'; +import {AreaChartView} from '@/shared/components/charts/AreaChartView'; +import {LineChartView} from '@/shared/components/charts/LineChartView'; +import {BarChartView} from '@/shared/components/charts/BarChartView'; +import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid'; +import {KpiRow} from '@/features/dashboard/components/KpiRow'; +import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline'; +import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart'; +import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison'; +import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel'; +import { + useDashboardActivity, + useDashboardKpis, + useDashboardPeakHours, + useDashboardRewardUsage, + useDashboardStoreComparison, + useDashboardTimeseries, +} from '@/features/dashboard/hooks/useDashboard'; +import {useWorkspace} from '@/shared/providers/WorkspaceProvider'; +import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; +import {greetingFor} from '@/features/dashboard/services/dashboardService'; +import type {Granularity} from '@/features/dashboard/types/dashboard'; import { formatCompact, formatDayLabel, formatInrCompact, formatPct, -} from '@/lib/format'; +} from '@/shared/utils/format'; /** * An analytics workspace. Charts are the subject, not evidence for a to-do list. @@ -39,7 +46,7 @@ import { * * Operational widgets — quick actions, tasks, the AI briefing, top store/reward, * staff status — deliberately do NOT live here. The briefing endpoint they were - * built on is still live and feeds the Copilot's AI tab, which is where that + * built on is still live and feeds Loyaly AI's AI tab, which is where that * class of content belongs: a panel you open to be told what to do, beside a * dashboard you read to work it out yourself. * @@ -52,75 +59,35 @@ import { * charts drawn from it, so they cannot disagree. */ -/** - * Derived from the SERVER's clock, carried on the response meta. - * - * Calling Date.now() during render is impure and disagrees between the SSR and - * hydration passes. Before meta lands there is no instant to reason about, so - * the greeting is the time-independent one; both strings are a single line, so - * the swap costs no layout shift. getHours() resolves the server's instant in - * the viewer's zone, which is the hour the merchant is actually living in. - */ -function greetingFor(generatedAt?: string): string { - if (!generatedAt) return 'Welcome back'; - const hour = new Date(generatedAt).getHours(); - if (hour < 12) return 'Good morning'; - if (hour < 17) return 'Good afternoon'; - return 'Good evening'; -} - export default function DashboardPage() { - const {storeId, range} = useWorkspace(); + const {storeId} = useWorkspace(); const [granularity, setGranularity] = useState('weekly'); // On by default: store comparison has always been part of the all-stores // dashboard, so Compare starts pressed and switches the panel off, rather // than hiding a panel that used to be there until someone finds the button. const [isComparing, setIsComparing] = useState(true); - const scope = {range, storeId}; - - const kpis = useResource(endpoints.dashboardKpis(scope)); - const series = useResource(endpoints.dashboardTimeseries(scope)); - const peak = useResource(endpoints.dashboardPeakHours(scope)); - const activity = useResource(endpoints.dashboardActivity(scope)); - const rewards = useResource(endpoints.dashboardRewardUsage(scope)); - const comparison = useResource(endpoints.dashboardStoreComparison(scope)); + const kpis = useDashboardKpis(); + const series = useDashboardTimeseries(); + const peak = useDashboardPeakHours(); + const activity = useDashboardActivity(); + const rewards = useDashboardRewardUsage(); + const comparison = useDashboardStoreComparison(); const isAllStores = storeId === 'all'; - const scopeLabel = isAllStores ? 'all stores' : storeName(storeId); + const scopeLabel = useScopeLabel(); return ( - {/* 1 + 2 — greeting, title, and the store / period / compare filters. */} - - - - - {/* OAuth Divider */} -
-
-
-
- - or continue with - -
- - {/* OAuth Buttons */} -
- - -
- -
- - {/* Bottom Footer Access Note */} - - - - - - {/* Page Footer Terms */} -
-

- By continuing, you agree to Loyaly's{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . -

-
- - ); -} diff --git a/src/features/auth/components/LoginCredentialsForm.tsx b/src/features/auth/components/LoginCredentialsForm.tsx new file mode 100644 index 0000000..f1f625e --- /dev/null +++ b/src/features/auth/components/LoginCredentialsForm.tsx @@ -0,0 +1,236 @@ +'use client'; + +import {useState} from 'react'; +import {useLoginForm} from '@/features/auth/hooks/useLoginForm'; +import {GoogleIcon, MicrosoftIcon} from './ProviderIcons'; + +/** + * The sign-in form itself: two fields, the remember-me control, the submit + * button and the federated options. + * + * All state and every rule come from useLoginForm — this file decides only how + * things look. That separation is what lets the error copy, the redirect + * target and the validation change without touching markup, and vice versa. + * + * ── On the hand-rolled Tailwind here ───────────────────────────────────── + * The rest of the app is built from Astryx components, and this screen + * deliberately is not: it predates the workspace, it is the only page a + * merchant sees before the product, and it was signed off looking like this. + * The brief says preserve the existing UI, so the markup is unchanged — + * what changed is that the button beneath it now talks to a real endpoint. + */ +export function LoginCredentialsForm() { + const [showPassword, setShowPassword] = useState(false); + const { + email, + password, + rememberMe, + errors, + isSubmitting, + setEmail, + setPassword, + setRememberMe, + submit, + } = useLoginForm(); + + return ( +
+
+ + setEmail(e.target.value)} + placeholder="name@company.com" + // aria-invalid + aria-describedby so the message is announced, not + // just coloured — a red border alone tells a screen reader nothing. + aria-invalid={!!errors.email || !!errors.form} + aria-describedby={errors.email ? 'login-email-error' : undefined} + disabled={isSubmitting} + className={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${ + errors.email || errors.form ? 'border-red-500/80' : 'border-white/10' + } text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all disabled:opacity-60`} + /> + {errors.email && ( +

+ {errors.email} +

+ )} +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Enter your password" + aria-invalid={!!errors.password || !!errors.form} + aria-describedby={ + errors.password ? 'login-password-error' : undefined + } + disabled={isSubmitting} + className={`w-full h-11 pl-3.5 pr-10 rounded-xl bg-white/[0.04] border ${ + errors.password || errors.form ? 'border-red-500/80' : 'border-white/10' + } text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all disabled:opacity-60`} + /> + +
+ {errors.password && ( +

+ {errors.password} +

+ )} +
+ + {/* Anything the server could not attribute to a field — network, 5xx. */} + {errors.form && ( +

+ {errors.form} +

+ )} + +
+ + + + Forgot password? + +
+ + + +
+
+
+
+ + or continue with + +
+ + {/* + Federated sign-in is not wired up — there is no OAuth client yet, and a + button that silently does nothing is worse than one that says so. They + stay disabled and labelled until /api/auth/oauth/:provider exists. + */} +
+ + +
+ + ); +} diff --git a/src/features/auth/components/LoginHeroPanel.tsx b/src/features/auth/components/LoginHeroPanel.tsx new file mode 100644 index 0000000..0f23c7a --- /dev/null +++ b/src/features/auth/components/LoginHeroPanel.tsx @@ -0,0 +1,52 @@ +'use client'; + +import {useState} from 'react'; +import Image from 'next/image'; +import {BrandMark} from '@/shared/components/brand/BrandLogo'; + +const HERO_IMAGE_URL = + 'https://images.unsplash.com/photo-1556742049-0a670fc8078a?q=80&w=1400&auto=format&fit=crop'; +const HERO_FALLBACK_URL = + 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?q=80&w=1400&auto=format&fit=crop'; + +/** + * The left half of the sign-in screen: photography, scrim, brand watermark. + * + * Split from LoginSplit purely so that neither file has to be read to change + * the other — this one is entirely decorative and holds the only piece of + * state on the screen that has nothing to do with signing in (the image + * fallback), while the form half holds all the behaviour. + * + * Hidden below `md`, where the photograph would push the form off the fold. + */ +export function LoginHeroPanel() { + const [heroSrc, setHeroSrc] = useState(HERO_IMAGE_URL); + + return ( +
+
+ setHeroSrc(HERO_FALLBACK_URL)} + /> + +
+
+ +
+ + + Loyaly.ai · Merchant Operating System + +
+
+
+ ); +} diff --git a/src/features/auth/components/LoginSplit.tsx b/src/features/auth/components/LoginSplit.tsx new file mode 100644 index 0000000..33b028f --- /dev/null +++ b/src/features/auth/components/LoginSplit.tsx @@ -0,0 +1,108 @@ +'use client'; + +import {BrandLogo} from '@/shared/components/brand/BrandLogo'; +import {LoginCredentialsForm} from './LoginCredentialsForm'; +import {LoginHeroPanel} from './LoginHeroPanel'; + +/** + * The sign-in screen's frame: ambient background, the split card, and the two + * halves that fill it. + * + * This file used to be 314 lines holding the photograph, the form, the fake + * submit and the page chrome at once. It is now the composition only — + * behaviour lives in useLoginForm, the form markup in LoginCredentialsForm, + * the photograph in LoginHeroPanel — which is what makes each of them + * separately readable and separately changeable. + */ +export function LoginSplit() { + return ( +
+ {/* Premium Charcoal Radial Gradient (Center #17171C -> Mid #101015 -> Edges #050506) */} +
+ + {/* Soft Vignette Layer focusing vision on the center container */} +
+ + {/* Ultra-subtle High-Frequency Fine Grain Noise Overlay (1.8% Opacity) */} +
+ +
+ + +
+
+
+ +
+ + Merchant OS + +
+ + v2.4 Enterprise + +
+ +
+
+

+ Welcome back +

+

+ Access your Merchant Operating System to manage stores, rewards, + staff and AI insights. +

+
+ + +
+ + +
+
+ +
+

+ By continuing, you agree to Loyaly's{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

+
+
+ ); +} diff --git a/src/features/auth/components/ProviderIcons.tsx b/src/features/auth/components/ProviderIcons.tsx new file mode 100644 index 0000000..1040a26 --- /dev/null +++ b/src/features/auth/components/ProviderIcons.tsx @@ -0,0 +1,43 @@ +/** + * Brand marks for the federated sign-in buttons. + * + * Inline SVG rather than the icon registry, and the ONLY place in the app + * exempt from the monochrome rule: Google's and Microsoft's marks are + * trademarked artwork whose colours are part of the identity. Recolouring them + * to grey would be both wrong and, in Google's case, a brand-guideline + * violation. + */ + +export function GoogleIcon() { + return ( + + ); +} + +export function MicrosoftIcon() { + return ( + + ); +} diff --git a/src/features/auth/guards/AuthGuard.tsx b/src/features/auth/guards/AuthGuard.tsx new file mode 100644 index 0000000..55c448e --- /dev/null +++ b/src/features/auth/guards/AuthGuard.tsx @@ -0,0 +1,48 @@ +'use client'; + +import {useEffect} from 'react'; +import {usePathname, useRouter} from 'next/navigation'; +import {Center} from '@astryxdesign/core/Center'; +import {Spinner} from '@astryxdesign/core/Spinner'; +import {useSession} from '@/features/auth/providers/SessionProvider'; + +/** + * Client-side half of route protection. The server half is src/proxy.ts. + * + * ── Why both ────────────────────────────────────────────────────────────── + * The proxy is the one that MATTERS: it refuses to serve the route at all, so + * there is nothing to defeat by disabling JavaScript. But it only runs on + * navigations the server sees. This guard covers what happens afterwards — a + * session that expires while a tab sits open, a logout in another tab, a + * client-side route change within the already-loaded app. Without it, the + * workspace would keep rendering against an identity that no longer exists + * until something happened to hit the network. + * + * Treat this as a correctness guard, never as the security boundary. Anything + * that must not leak is fetched through a route handler, and route handlers + * check the session themselves. + */ +export function AuthGuard({children}: {children: React.ReactNode}) { + const {status} = useSession(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (status !== 'unauthenticated') return; + const next = pathname && pathname !== '/dashboard' ? `?next=${encodeURIComponent(pathname)}` : ''; + router.replace(`/login${next}`); + }, [status, router, pathname]); + + // 'loading' means the bootstrap request is still in flight. Rendering the + // workspace here would paint an empty-looking shell and then swap it, and + // rendering the redirect would fire it for users who ARE signed in. + if (status !== 'authenticated') { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/src/features/auth/guards/GuestGuard.tsx b/src/features/auth/guards/GuestGuard.tsx new file mode 100644 index 0000000..5f03670 --- /dev/null +++ b/src/features/auth/guards/GuestGuard.tsx @@ -0,0 +1,45 @@ +'use client'; + +import {useEffect} from 'react'; +import {useRouter, useSearchParams} from 'next/navigation'; +import {Center} from '@astryxdesign/core/Center'; +import {Spinner} from '@astryxdesign/core/Spinner'; +import {useSession} from '@/features/auth/providers/SessionProvider'; +import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; + +/** + * The mirror of AuthGuard: keeps a signed-in user OFF the sign-in screen. + * + * The proxy already redirects /login → /dashboard for a request carrying a + * valid cookie. This covers the case the proxy cannot see: a client-side + * navigation back to /login after logging in within the same page session. + * Offering the form again to someone who is already authenticated reads as the + * login having failed. + * + * While `loading`, the form is NOT rendered. Painting it and then yanking it + * away is worse than a beat of spinner, and it invites someone to start typing + * into a form that is about to disappear. + */ +export function GuestGuard({children}: {children: React.ReactNode}) { + const {status} = useSession(); + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + if (status !== 'authenticated') return; + // The SAME destination the login form resolves. These two redirects race + // on a successful sign-in, and a hardcoded /dashboard here silently threw + // away the deep link the proxy had preserved in `?next=`. + router.replace(resolveRedirectTarget(searchParams.get('next'))); + }, [status, router, searchParams]); + + if (status !== 'unauthenticated') { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/src/features/auth/hooks/useLoginForm.ts b/src/features/auth/hooks/useLoginForm.ts new file mode 100644 index 0000000..3fc831d --- /dev/null +++ b/src/features/auth/hooks/useLoginForm.ts @@ -0,0 +1,114 @@ +'use client'; + +import {useCallback, useState} from 'react'; +import {useRouter, useSearchParams} from 'next/navigation'; +import {useToast} from '@astryxdesign/core/Toast'; +import {useSession} from '@/features/auth/providers/SessionProvider'; +import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; +import type {LoginError} from '@/features/auth/types/auth'; + +/** + * Every behaviour of the sign-in form, with no markup attached. + * + * Pulled out of the component for the reason the brief asks for: the form was + * a 314-line file where a fake `setTimeout` login sat in the middle of layout + * markup. Now the view renders state and the rules live here — which also + * makes the flow testable without mounting a page, and means a redesign of the + * screen cannot silently change what "signed in" means. + * + * The chain below this hook is: SessionProvider → authService (validation and + * domain rules) → authRepository (transport) → POST /api/auth/login. This hook + * knows about none of it beyond the first link. + */ + +export interface LoginFormState { + email: string; + password: string; + rememberMe: boolean; + /** Field-addressed, so a message renders under the input that caused it. */ + errors: Partial>; + isSubmitting: boolean; +} + +export function useLoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const toast = useToast(); + const {login} = useSession(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [rememberMe, setRememberMe] = useState(false); + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Shared with GuestGuard, which redirects on the same event — see + // resolveRedirectTarget for why that matters and how `next` is validated. + const destination = resolveRedirectTarget(searchParams.get('next')); + + const submit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (isSubmitting) return; + + setErrors({}); + setIsSubmitting(true); + + const result = await login({email, password, rememberMe}); + + if (!result.ok) { + setErrors({[result.error.field]: result.error.message}); + // The toast carries what the inline message cannot: it is announced, + // it survives a field the user has scrolled past, and it distinguishes + // "we rejected your credentials" from "we never reached the server". + toast({type: 'error', body: result.error.message}); + setIsSubmitting(false); + return; + } + + // Deliberately NOT clearing isSubmitting on success: the button stays in + // its loading state until the navigation commits, so the form cannot be + // submitted twice while the route transition is in flight. + router.replace(destination); + // The session cookie changed, so any server-rendered layout above this + // route is stale. Without this, the workspace can paint its signed-out + // seed until something else happens to refetch. + router.refresh(); + }, + [ + isSubmitting, + login, + email, + password, + rememberMe, + toast, + router, + destination, + ], + ); + + /** Clearing on edit: an error that outlives the typo it describes is noise. */ + const updateEmail = useCallback((value: string) => { + setEmail(value); + setErrors((prev) => (prev.email ? {...prev, email: undefined} : prev)); + }, []); + + const updatePassword = useCallback((value: string) => { + setPassword(value); + setErrors((prev) => + prev.password ? {...prev, password: undefined} : prev, + ); + }, []); + + return { + email, + password, + rememberMe, + errors, + isSubmitting, + setEmail: updateEmail, + setPassword: updatePassword, + setRememberMe, + submit, + }; +} diff --git a/src/features/auth/mock/users.mock.ts b/src/features/auth/mock/users.mock.ts new file mode 100644 index 0000000..c2f5cda --- /dev/null +++ b/src/features/auth/mock/users.mock.ts @@ -0,0 +1,105 @@ +import type {AuthUser} from '@/features/auth/types/auth'; + +/** + * The mock user directory. Server-only — nothing here is ever bundled to the + * client, because the only importers are route handlers. + * + * ── On the plaintext passwords ──────────────────────────────────────────── + * They are plaintext BECAUSE this is a fixture, and the fixture is the one + * place where that is safe: it never leaves the server, and there is no real + * credential in it. What matters architecturally is that `verifyCredentials` + * below is the ONLY function that ever sees a password. When a real backend + * arrives, that function's body becomes an HTTP call (or an argon2/bcrypt + * comparison against a user table) and every other file in the app is + * untouched — including the login form, which already only knows how to ask. + * + * Do not add a "demo" user that accepts any password. The whole point of this + * layer is that wrong credentials fail. + */ + +interface MockUserRecord extends AuthUser { + password: string; +} + +const USERS: MockUserRecord[] = [ + { + id: 'usr_owner_01', + email: 'aravind@nealre.in', + name: 'Aravind', + role: 'owner', + organisation: 'Loyaly Retail Pvt Ltd', + password: 'admin123', + }, + { + id: 'usr_owner_02', + email: 'aravind@nearle.in', + name: 'Aravind', + role: 'owner', + organisation: 'Loyaly Retail Pvt Ltd', + password: 'admin123', + }, + { + id: 'usr_mgr_01', + email: 'priya.sharma@loyaly.ai', + name: 'Priya Sharma', + role: 'manager', + organisation: 'Loyaly Retail Pvt Ltd', + password: 'indiranagar@01', + }, + { + id: 'usr_analyst_01', + email: 'analyst@loyaly.ai', + name: 'Rahul Menon', + role: 'analyst', + organisation: 'Loyaly Retail Pvt Ltd', + password: 'reports@2026', + }, +]; + +/** Public projection — the record minus the credential. */ +function toAuthUser(record: MockUserRecord): AuthUser { + // Field-by-field rather than a rest-spread that drops `password`: a spread + // silently carries anything added to the record later, and this projection + // is the boundary that keeps credentials out of every response. + return { + id: record.id, + email: record.email, + name: record.name, + role: record.role, + organisation: record.organisation, + }; +} + +export type CredentialCheck = + | {outcome: 'ok'; user: AuthUser} + | {outcome: 'unknown_email'} + | {outcome: 'wrong_password'}; + +/** + * The single credential-checking seam. + * + * Note the two distinct failure outcomes. That is a deliberate product choice + * — the brief asks for "wrong email" and "wrong password" to read differently + * — and it is worth knowing that it also makes the login form a user + * enumeration oracle: an attacker can discover which addresses have accounts. + * If that trade stops being acceptable, collapse both branches at the ROUTE + * (map them to one 'invalid_credentials' message) rather than here, so the + * distinction stays available to audit logging. + */ +export function verifyCredentials( + email: string, + password: string, +): CredentialCheck { + const record = USERS.find( + (u) => u.email.toLowerCase() === email.trim().toLowerCase(), + ); + if (!record) return {outcome: 'unknown_email'}; + if (record.password !== password) return {outcome: 'wrong_password'}; + return {outcome: 'ok', user: toAuthUser(record)}; +} + +/** Used by the session endpoint to re-resolve a cookie subject to a user. */ +export function findUserById(id: string): AuthUser | null { + const record = USERS.find((u) => u.id === id); + return record ? toAuthUser(record) : null; +} diff --git a/src/features/auth/providers/SessionProvider.tsx b/src/features/auth/providers/SessionProvider.tsx new file mode 100644 index 0000000..00f2d48 --- /dev/null +++ b/src/features/auth/providers/SessionProvider.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import {useRouter} from 'next/navigation'; +import {authService} from '@/features/auth/services/authService'; +import type { + AuthSession, + AuthUser, + LoginCredentials, + LoginResult, +} from '@/features/auth/types/auth'; + +/** + * Who is signed in, according to the SERVER. + * + * ── What changed, and why it matters ────────────────────────────────────── + * This used to read a `loyaly.session` object out of localStorage, which meant + * the client both stored and validated its own identity — anything with a + * devtools console could grant itself a session. Now the credential is an + * httpOnly cookie the client cannot read, and this provider's only job is to + * ASK (`GET /api/auth/session`) and cache the answer for rendering. + * + * The consequence to keep in mind: nothing here is a security boundary. It + * decides what to draw, never what to permit. Permission is decided in + * src/proxy.ts before the route renders, and again in every route handler. + * + * ── The three states ────────────────────────────────────────────────────── + * 'loading' the bootstrap request is in flight; render nothing + * identity-shaped or the UI flashes "signed out" + * 'authenticated' user present + * 'unauthenticated' server says no session + * + * `loading` is a first-class state rather than `user === null`, because those + * two mean opposite things to a guard: one should wait, the other redirect. + */ + +export type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated'; + +interface SessionValue { + status: SessionStatus; + session: AuthSession | null; + user: AuthUser | null; + isAuthenticated: boolean; + login: (credentials: LoginCredentials) => Promise; + logout: () => Promise; + /** Re-ask the server. Used after anything that could change identity. */ + refresh: () => Promise; +} + +const SessionContext = createContext(null); + +export function SessionProvider({ + children, + /** + * The server-resolved session, from the signed cookie in the root layout. + * + * REQUIRED, not optional. Making it optional would mean a mount path where + * the client has to ask who it is before it can draw anything, and that + * costs a guard spinner on every full page load for an answer the server + * already had in the same request. + */ + initialSession, +}: { + children: React.ReactNode; + initialSession: AuthSession | null; +}) { + const router = useRouter(); + const [session, setSession] = useState(initialSession); + const [status, setStatus] = useState( + initialSession ? 'authenticated' : 'unauthenticated', + ); + + const refresh = useCallback(async () => { + const next = await authService.currentSession(); + setSession(next); + setStatus(next ? 'authenticated' : 'unauthenticated'); + }, []); + + /** + * Re-check on focus. + * + * A workspace tab can sit open for hours. In that time the session can + * expire, or the merchant can sign out in another tab — and without this the + * shell keeps rendering an identity that no longer exists until something + * happens to hit the network. Re-asking when the tab is looked at again is + * cheap (one request, only on a real focus change) and it is what makes + * AuthGuard's redirect fire at the moment the user comes back. + * + * This is the subscribe-to-an-external-system shape an effect is for: the + * setState happens in the event callback, not in the effect body. + */ + useEffect(() => { + const onFocus = () => { + if (document.visibilityState === 'visible') void refresh(); + }; + window.addEventListener('visibilitychange', onFocus); + return () => window.removeEventListener('visibilitychange', onFocus); + }, [refresh]); + + const login = useCallback( + async (credentials: LoginCredentials): Promise => { + const result = await authService.login(credentials); + if (result.ok) { + setSession(result.session); + setStatus('authenticated'); + } + return result; + }, + [], + ); + + const logout = useCallback(async () => { + await authService.logout(); + setSession(null); + setStatus('unauthenticated'); + + /** + * Belt and braces on the client side of the door. + * + * The cookie is already gone server-side, which is what actually ends the + * session. This clears the *other* things a signed-in user leaves behind — + * a stored sidebar preference, a cached scope — so a shared machine does + * not hand the next person a workspace shaped like the last one. Wrapped + * because storage throws in private mode, and a failed cleanup must not + * strand someone in a session they asked to leave. + */ + try { + window.localStorage.clear(); + window.sessionStorage.clear(); + } catch { + /* ignore */ + } + + // replace, not push: Back must not return to a workspace the session no + // longer authorises. The proxy would bounce it anyway; this keeps the + // history clean rather than relying on that. + router.replace('/login'); + // Drop the client router cache too, or a Back gesture can repaint the + // previous authenticated render from memory before the proxy is consulted. + router.refresh(); + }, [router]); + + const value = useMemo( + () => ({ + status, + session, + user: session?.user ?? null, + isAuthenticated: status === 'authenticated', + login, + logout, + refresh, + }), + [status, session, login, logout, refresh], + ); + + return {children}; +} + +export function useSession(): SessionValue { + const ctx = useContext(SessionContext); + if (!ctx) { + throw new Error('useSession must be used inside '); + } + return ctx; +} diff --git a/src/features/auth/repositories/authRepository.ts b/src/features/auth/repositories/authRepository.ts new file mode 100644 index 0000000..b29bcbc --- /dev/null +++ b/src/features/auth/repositories/authRepository.ts @@ -0,0 +1,42 @@ +import {postJson, getJson} from '@/shared/services/httpClient'; +import type { + AuthSession, + LoginCredentials, + LoginError, +} from '@/features/auth/types/auth'; + +/** + * TRANSPORT ONLY. + * + * This is the layer that knows URLs, verbs and status codes, and it is the + * only one. Swapping the mock route handlers for `https://api.loyaly.ai` means + * editing the three strings below — the service above it, the hook above that + * and the form above that never learn where the data came from. + * + * It deliberately does NOT interpret results: mapping an HTTP failure onto a + * field-addressed form error is a domain decision and belongs in the service. + */ + +export interface AuthTransportResult { + ok: boolean; + status: number; + data?: T; + message?: string; + /** Present when the server attributed a failure to one input. */ + field?: LoginError['field']; +} + +export const authRepository = { + login(credentials: LoginCredentials) { + return postJson('/api/auth/login', credentials); + }, + + logout() { + return postJson<{ok: boolean}>('/api/auth/logout', {}); + }, + + /** Null data means "no session" — a normal answer, not a failure. */ + currentSession() { + return getJson('/api/auth/session'); + }, +}; diff --git a/src/features/auth/services/authService.ts b/src/features/auth/services/authService.ts new file mode 100644 index 0000000..8eb55f0 --- /dev/null +++ b/src/features/auth/services/authService.ts @@ -0,0 +1,99 @@ +import {authRepository} from '@/features/auth/repositories/authRepository'; +import type { + AuthSession, + LoginCredentials, + LoginError, + LoginResult, +} from '@/features/auth/types/auth'; + +/** + * The domain layer for authentication. + * + * It owns the RULES — what counts as valid input, what an HTTP status means in + * user terms, what "signed out" looks like — and it owns them independently of + * both the transport below it and the React tree above it. Nothing here is + * async-framework-specific, so it is directly unit-testable and it survives a + * backend swap untouched. + */ + +const EMAIL_PATTERN = /^\S+@\S+\.\S+$/; + +/** + * Client-side validation, mirroring the server's. + * + * Duplicated on purpose, and the duplication is the point: this copy exists + * for latency (no round trip to be told a field is blank), the server's copy + * exists for correctness (a POST can arrive without ever passing through this + * form). Neither is redundant, because they answer to different threats. + */ +export function validateCredentials( + input: Pick, +): LoginError | null { + if (!input.email.trim()) { + return {field: 'email', message: 'Enter your email address.'}; + } + if (!EMAIL_PATTERN.test(input.email.trim())) { + return {field: 'email', message: 'Enter a valid email address.'}; + } + if (!input.password) { + return {field: 'password', message: 'Enter your password.'}; + } + return null; +} + +/** Narrow the server's free-form `field` back onto the union. */ +function toErrorField(field: string | undefined): LoginError['field'] { + return field === 'email' || field === 'password' ? field : 'form'; +} + +export const authService = { + /** + * Validate, then attempt. Returns a discriminated result — callers cannot + * accidentally treat a failure as a success, and nothing throws, because a + * wrong password is an expected outcome of logging in, not an exception. + */ + async login(credentials: LoginCredentials): Promise { + const invalid = validateCredentials(credentials); + if (invalid) return {ok: false, error: invalid}; + + const res = await authRepository.login(credentials); + + if (!res.ok || !res.data) { + return { + ok: false, + error: { + field: toErrorField(res.field), + message: + res.message ?? + (res.status === 0 + ? 'Could not reach the server. Check your connection.' + : 'Sign-in failed. Please try again.'), + }, + }; + } + return {ok: true, session: res.data}; + }, + + /** + * Ends the server session. Resolves even when the request fails — a user who + * pressed Log out must never be left looking at a workspace because the + * network blipped; the client clears its own state and redirects regardless, + * and the cookie's own expiry is the backstop. + */ + async logout(): Promise { + try { + await authRepository.logout(); + } catch { + /* deliberately ignored — see above */ + } + }, + + /** + * Who the server says we are. `null` is a valid answer (signed out), so it + * is returned as data rather than raised as an error. + */ + async currentSession(): Promise { + const res = await authRepository.currentSession(); + return res.ok ? (res.data ?? null) : null; + }, +}; diff --git a/src/features/auth/services/redirectTarget.ts b/src/features/auth/services/redirectTarget.ts new file mode 100644 index 0000000..cde67ca --- /dev/null +++ b/src/features/auth/services/redirectTarget.ts @@ -0,0 +1,31 @@ +/** + * Where a signed-in user should land, from the proxy's `?next=` hint. + * + * ── Why this is shared rather than inlined ─────────────────────────────── + * Two things redirect after a successful sign-in and they RACE: the login form + * (which knows the attempt just succeeded) and GuestGuard (which reacts to the + * session becoming authenticated). Measured: signing in from + * `/login?next=/settings/billing` landed on /dashboard, because the guard's + * effect fired first with a hardcoded destination and the form's redirect was + * replaced. Both now resolve through this function, so whichever wins, the + * user arrives at the same place. + * + * ── Why the validation matters ─────────────────────────────────────────── + * `next` is attacker-controllable — it is a query parameter on a public page. + * Anything that is not a same-origin absolute path is discarded, which closes + * the open-redirect that `router.replace(next)` would otherwise be: + * + * /settings/billing → allowed + * https://evil.test → rejected (absolute URL) + * //evil.test → rejected (protocol-relative — still leaves the site) + * settings/billing → rejected (relative; resolves against the current path) + */ + +export const DEFAULT_DESTINATION = '/dashboard'; + +export function resolveRedirectTarget(next: string | null): string { + if (!next || !next.startsWith('/') || next.startsWith('//')) { + return DEFAULT_DESTINATION; + } + return next; +} diff --git a/src/features/auth/services/serverSession.ts b/src/features/auth/services/serverSession.ts new file mode 100644 index 0000000..b1cfddc --- /dev/null +++ b/src/features/auth/services/serverSession.ts @@ -0,0 +1,58 @@ +import 'server-only'; +import {cookies} from 'next/headers'; +import {findUserById} from '@/features/auth/mock/users.mock'; +import { + SESSION_COOKIE, + verifySessionToken, +} from '@/features/auth/services/sessionToken'; +import type {AuthSession} from '@/features/auth/types/auth'; + +/** + * Resolve the session on the server, from the signed cookie. + * + * Used in two places: + * • the root layout, to SEED the client provider — so a full page load + * paints the signed-in shell immediately instead of flashing a spinner + * while the browser asks who it is + * • route handlers, to authorise a request before it touches data + * + * `import 'server-only'` is load-bearing: this module reads cookies and the + * user directory, and importing it from a client component would be a build + * error rather than a silent leak of the fixture into the browser bundle. + */ +export async function getServerSession(): Promise { + const store = await cookies(); + const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value); + if (!payload) return null; + + // Re-resolved from the directory rather than trusted off the token, so a + // role change or a deactivation takes effect on the next request instead of + // whenever the cookie happens to expire. + const user = findUserById(payload.sub); + if (!user) return null; + + return {user, expiresAt: new Date(payload.exp * 1000).toISOString()}; +} + +/** + * Guard for route handlers that must not answer an anonymous request. + * + * Returns the session, or a ready-made 401 to return early with: + * + * const auth = await requireSession(); + * if ('response' in auth) return auth.response; + * // auth.session is now available + */ +export async function requireSession(): Promise< + {session: AuthSession} | {response: Response} +> { + const session = await getServerSession(); + if (session) return {session}; + + return { + response: Response.json( + {error: {code: 'unauthorized', message: 'Sign in to continue.'}}, + {status: 401, headers: {'cache-control': 'no-store'}}, + ), + }; +} diff --git a/src/features/auth/services/sessionToken.ts b/src/features/auth/services/sessionToken.ts new file mode 100644 index 0000000..96bd5e1 --- /dev/null +++ b/src/features/auth/services/sessionToken.ts @@ -0,0 +1,144 @@ +import {createHmac, timingSafeEqual} from 'node:crypto'; + +/** + * The session cookie format, and the only place that knows how to mint or + * verify one. SERVER ONLY — imported by the auth route handlers and by + * src/proxy.ts, never by a component. + * + * ── Why a signed cookie rather than localStorage ───────────────────────── + * The session this replaces lived in localStorage, which means (a) any script + * on the origin could read it, (b) the "session" was whatever the client said + * it was, and (c) the server had no way to gate a request. An httpOnly cookie + * is invisible to JS, travels automatically, and can be checked in the proxy + * before a protected page is ever rendered. That is the difference between a + * login screen and authentication. + * + * ── Format ──────────────────────────────────────────────────────────────── + * base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload, secret)) + * + * Self-contained and stateless, like a JWT without the algorithm-confusion + * surface: there is no `alg` field to downgrade, because there is exactly one + * algorithm. If a real backend issues JWTs instead, only `verifySessionToken` + * changes — its callers already treat the result as an opaque payload. + * + * NOT encrypted, only signed: the payload is readable by anyone holding the + * cookie (which is the user themselves), so it carries an id and a display + * name, never anything secret. + */ + +const DEV_SECRET = 'loyaly-dev-secret-not-for-production'; + +/** + * Falls back to a constant in development so a fresh clone runs with no setup. + * In production a missing AUTH_SECRET is fatal rather than silently signing + * every session with a value that is checked into git. + */ +function secret(): string { + const fromEnv = process.env.AUTH_SECRET; + if (fromEnv) return fromEnv; + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'AUTH_SECRET is required in production — refusing to sign sessions with the development key.', + ); + } + return DEV_SECRET; +} + +export const SESSION_COOKIE = 'loyaly_session'; + +/** Cookie lifetimes. `remember me` is the difference between the two. */ +export const REMEMBERED_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 12; // 12 hours + +export interface SessionPayload { + /** User id. The name is display-only; authorisation resolves from this. */ + sub: string; + email: string; + name: string; + role: string; + organisation: string; + /** Issued-at and expiry, epoch seconds. */ + iat: number; + exp: number; +} + +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64url'); +} + +function sign(data: string): string { + return createHmac('sha256', secret()).update(data).digest('base64url'); +} + +export function createSessionToken( + payload: Omit, + maxAgeSeconds: number, +): string { + const now = Math.floor(Date.now() / 1000); + const full: SessionPayload = { + ...payload, + iat: now, + exp: now + maxAgeSeconds, + }; + const body = b64url(JSON.stringify(full)); + return `${body}.${sign(body)}`; +} + +/** + * Returns the payload only when the signature verifies AND the token is + * unexpired. Every failure mode — malformed, tampered, expired — returns null, + * because callers must treat all of them identically: no session. + * + * The comparison is timing-safe. A `===` here leaks how many leading bytes of + * a forged signature were correct, which is enough to forge one byte at a time. + */ +export function verifySessionToken( + token: string | undefined, +): SessionPayload | null { + if (!token) return null; + + const dot = token.lastIndexOf('.'); + if (dot <= 0) return null; + + const body = token.slice(0, dot); + const provided = Buffer.from(token.slice(dot + 1)); + const expected = Buffer.from(sign(body)); + + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + return null; + } + + let payload: SessionPayload; + try { + payload = JSON.parse( + Buffer.from(body, 'base64url').toString('utf8'), + ) as SessionPayload; + } catch { + return null; + } + + if (typeof payload.exp !== 'number' || payload.exp * 1000 <= Date.now()) { + return null; + } + return payload; +} + +/** + * Cookie attributes, in one place so the login route and the logout route + * cannot disagree about scope — a delete that misses on `path` leaves a live + * session behind. + */ +export function sessionCookieOptions(maxAgeSeconds?: number) { + return { + httpOnly: true, + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + path: '/', + // Omitted for a browser-session cookie: the browser drops it on close, + // which is exactly what "don't remember me" should mean. + ...(maxAgeSeconds !== undefined ? {maxAge: maxAgeSeconds} : {}), + }; +} diff --git a/src/features/auth/types/auth.ts b/src/features/auth/types/auth.ts new file mode 100644 index 0000000..fec40a5 --- /dev/null +++ b/src/features/auth/types/auth.ts @@ -0,0 +1,60 @@ +/** + * The auth contract. + * + * Written as the shape a real identity service would return, not as the shape + * the current mock happens to have. When /api/auth/* is repointed at Node, + * Nest, Spring or anything else, this file is the negotiation artifact and the + * UI below it does not move. + */ + +export type UserRole = 'owner' | 'manager' | 'analyst'; + +/** The authenticated principal. Never contains credentials. */ +export interface AuthUser { + id: string; + email: string; + name: string; + role: UserRole; + /** Merchant/tenant this user belongs to. */ + organisation: string; +} + +/** What the client is allowed to know about its own session. */ +export interface AuthSession { + user: AuthUser; + /** ISO-8601. The client uses this only to display, never to authorise. */ + expiresAt: string; +} + +export interface LoginCredentials { + email: string; + password: string; + /** + * Long-lived cookie vs browser-session cookie. Sent to the server because + * cookie lifetime is a server decision — the client cannot be trusted to + * enforce its own expiry. + */ + rememberMe: boolean; +} + +/** + * Field-addressed failures, so the form can put the message on the input that + * caused it rather than dumping everything into a banner. + * + * `form` covers anything not attributable to one field (network, 500s). + */ +export type LoginErrorField = 'email' | 'password' | 'form'; + +export interface LoginError { + field: LoginErrorField; + message: string; +} + +/** + * The result of an attempt. A discriminated union rather than + * `{user?, error?}`: the caller cannot forget to check, because there is no + * shape where both are present. + */ +export type LoginResult = + | {ok: true; session: AuthSession} + | {ok: false; error: LoginError}; diff --git a/src/features/copilot/CopilotPanel.tsx b/src/features/copilot/CopilotPanel.tsx deleted file mode 100644 index 0707692..0000000 --- a/src/features/copilot/CopilotPanel.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import {TabList, Tab} from '@astryxdesign/core/TabList'; -import {VStack, HStack} from '@astryxdesign/core/Layout'; -import {Heading} from '@astryxdesign/core/Text'; -import {IconButton} from '@astryxdesign/core/IconButton'; -import {Icon} from '@astryxdesign/core/Icon'; -import {ICONS} from '@/lib/icons'; -import {useCopilot} from './CopilotProvider'; -import type {CopilotTab} from './CopilotProvider'; -import {AiTab} from './tabs/AiTab'; -import {AnalyticsTab} from './tabs/AnalyticsTab'; -import {ChatTab} from './tabs/ChatTab'; - -/** - * The Copilot body. Rendered identically inside the inline LayoutPanel and - * inside the slide-over — presentation differs, this does not. All of its - * state comes from CopilotProvider, so it is safe to unmount and remount. - */ -export function CopilotPanel({onDismiss}: {onDismiss?: () => void}) { - const {tab, setTab} = useCopilot(); - - return ( - - - - - Loyaly AI - - {onDismiss ? ( - } - label="Hide Loyaly AI" - variant="ghost" - size="sm" - onClick={onDismiss} - /> - ) : null} - - - setTab(v as CopilotTab)} - layout="fill" - size="sm" - hasDivider - > - } /> - } - /> - } - /> - - - {/* The chat tab owns its own scroll container (ChatLayout docks the - composer), so it must not be wrapped in a scrolling VStack. */} - {tab === 'chat' ? ( - - ) : ( - - {tab === 'ai' ? : } - - )} - - ); -} diff --git a/src/features/copilot/CopilotProvider.tsx b/src/features/copilot/CopilotProvider.tsx deleted file mode 100644 index 42b2f38..0000000 --- a/src/features/copilot/CopilotProvider.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client'; - -import {createContext, useCallback, useContext, useMemo, useState} from 'react'; - -/** - * All Copilot state lives here, and this provider is mounted in - * app/providers.tsx — ABOVE the route tree. - * - * Two things force that placement: - * 1. Route changes. The panel itself sits in (workspace)/layout.tsx, which the - * App Router preserves across sibling navigations, so it would survive - * /dashboard → /staff on its own. - * 2. The tablet breakpoint. Crossing it swaps the inline LayoutPanel for a - * slide-over, which genuinely unmounts the panel. Holding state up here - * means presentation can change without losing a half-typed message. - * - * The panel is a pure view over this context. Nothing below it owns state. - */ - -export type CopilotTab = 'ai' | 'analytics' | 'chat'; - -export interface ChatMessage { - id: string; - sender: 'user' | 'assistant'; - text: string; - at: string; -} - -interface CopilotValue { - tab: CopilotTab; - setTab: (t: CopilotTab) => void; - isOpen: boolean; - setIsOpen: (v: boolean) => void; - toggle: () => void; - /** Slide-over visibility, used below the laptop breakpoint. */ - isSlideOverOpen: boolean; - setSlideOverOpen: (v: boolean) => void; - messages: ChatMessage[]; - /** The in-progress composer value. Kept here so navigation never drops it. */ - draft: string; - setDraft: (v: string) => void; - send: (text: string) => void; - isStreaming: boolean; -} - -const CopilotContext = createContext(null); - -export function CopilotProvider({children}: {children: React.ReactNode}) { - const [tab, setTab] = useState('ai'); - const [isOpen, setIsOpen] = useState(true); - const [isSlideOverOpen, setSlideOverOpen] = useState(false); - const [messages, setMessages] = useState([]); - const [draft, setDraft] = useState(''); - const [isStreaming] = useState(false); - - const send = useCallback((text: string) => { - const trimmed = text.trim(); - if (!trimmed) return; - setMessages((prev) => [ - ...prev, - { - id: `m${prev.length + 1}`, - sender: 'user', - text: trimmed, - // Timestamps are assigned on send (a user event), never during render — - // Date.now() in a render path is a hydration mismatch waiting to happen. - at: new Date().toISOString(), - }, - ]); - setDraft(''); - }, []); - - const toggle = useCallback(() => setIsOpen((v) => !v), []); - - const value = useMemo( - () => ({ - tab, - setTab, - isOpen, - setIsOpen, - toggle, - isSlideOverOpen, - setSlideOverOpen, - messages, - draft, - setDraft, - send, - isStreaming, - }), - [tab, isOpen, isSlideOverOpen, messages, draft, send, toggle, isStreaming], - ); - - return {children}; -} - -export function useCopilot(): CopilotValue { - const ctx = useContext(CopilotContext); - if (!ctx) { - throw new Error('useCopilot must be used inside '); - } - return ctx; -} diff --git a/src/features/copilot/CopilotSlideOver.tsx b/src/features/copilot/CopilotSlideOver.tsx deleted file mode 100644 index 4b03801..0000000 --- a/src/features/copilot/CopilotSlideOver.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import {MobileNav} from '@astryxdesign/core/MobileNav'; -import {useBreakpoint} from '@/lib/breakpoints'; -import {useCopilot} from './CopilotProvider'; -import {CopilotPanel} from './CopilotPanel'; - -/** - * Tablet and mobile presentation of the Copilot. - * - * MobileNav rather than Dialog: a Dialog is content-sized (height: fit-content, - * maxHeight 480px) and ignores top/bottom insets, so it renders as a floating - * card rather than a drawer. MobileNav is the edge-anchored, full-height - * primitive this actually needs. - * - * It renders the SAME as the inline path — crossing the - * breakpoint changes where the panel lives, never what it contains or - * remembers, because all state sits in CopilotProvider above the route tree. - */ -export function CopilotSlideOver() { - const bp = useBreakpoint(); - const {isSlideOverOpen, setSlideOverOpen} = useCopilot(); - - return ( - - setSlideOverOpen(false)} /> - - ); -} diff --git a/src/features/copilot/tabs/AiTab.tsx b/src/features/copilot/tabs/AiTab.tsx deleted file mode 100644 index 7d9d18e..0000000 --- a/src/features/copilot/tabs/AiTab.tsx +++ /dev/null @@ -1,122 +0,0 @@ -'use client'; - -import {VStack, HStack} from '@astryxdesign/core/Layout'; -import {Card} from '@astryxdesign/core/Card'; -import {Text, Heading} from '@astryxdesign/core/Text'; -import {Banner} from '@astryxdesign/core/Banner'; -import {Button} from '@astryxdesign/core/Button'; -import {Icon} from '@astryxdesign/core/Icon'; -import {AsyncBoundary} from '@/components/data/AsyncBoundary'; -import {SkeletonRows} from '@/components/patterns/LoadingState'; -import {EmptyPanel} from '@/components/patterns/EmptyPanel'; -import {useResource} from '@/lib/api/useResource'; -import {endpoints} from '@/lib/api/client'; -import {useWorkspace} from '@/components/shell/WorkspaceProvider'; -import {ICONS} from '@/lib/icons'; -import type {InsightSeverity} from '@/lib/api/contracts'; - -/** - * Reads the SAME briefing endpoint the dashboard does. - * - * This panel used to hold hardcoded Phase-1 copy, which was harmless while the - * dashboard had no narrative of its own. Now that it does, static copy here is - * actively wrong: the panel claimed "3 rewards expire in 48 hours" beside a - * dashboard saying one, from the same screen. Two AI surfaces disagreeing about - * the business is worse than one of them not existing. - * - * Scope comes from WorkspaceProvider, so the Copilot follows the store and - * period the merchant is looking at rather than reporting on something else. - */ -const BANNER_STATUS: Record< - InsightSeverity, - 'error' | 'warning' | 'info' | 'success' -> = { - error: 'error', - warning: 'warning', - info: 'info', - success: 'success', -}; - -export function AiTab() { - const {storeId, range} = useWorkspace(); - const briefing = useResource(endpoints.dashboardBriefing({range, storeId})); - - return ( - - }> - {(b) => ( - - - - - - Summary - - - {b.summary || 'Not enough activity in this period to summarise.'} - - - - - - - Needs attention - - {b.alerts.length === 0 ? ( - - ) : ( - b.alerts.map((a) => ( - - ) : undefined - } - /> - )) - )} - - - - - Open tasks - - {b.tasks - .filter((t) => !t.isDone) - .map((t) => ( - - - - {t.label} - - {t.due} - - - {t.detail ? ( - - {t.detail} - - ) : null} - - - ))} - - - )} - - - ); -} diff --git a/src/features/copilot/tabs/AnalyticsTab.tsx b/src/features/copilot/tabs/AnalyticsTab.tsx deleted file mode 100644 index 40ee6c9..0000000 --- a/src/features/copilot/tabs/AnalyticsTab.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import {VStack} from '@astryxdesign/core/Layout'; -import {Text} from '@astryxdesign/core/Text'; -import {EmptyPanel} from '@/components/patterns/EmptyPanel'; - -/** - * Placeholder until the chart primitives land in 1.7. It then hosts revenue - * forecast, customer growth and store/reward comparison at height={180} — - * the narrow-column variant of the same ChartCard used in the workspace. - */ -export function AnalyticsTab() { - return ( - - - Forecasts and comparisons, scoped to the current store and range. - - - - ); -} diff --git a/src/features/copilot/tabs/ChatTab.tsx b/src/features/copilot/tabs/ChatTab.tsx deleted file mode 100644 index 0f3bb1f..0000000 --- a/src/features/copilot/tabs/ChatTab.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'use client'; - -import { - ChatComposer, - ChatComposerInput, - ChatDictationButton, - ChatLayout, - ChatMessage, - ChatMessageBubble, - ChatMessageList, - ChatMessageMetadata, - useChatDictation, -} from '@astryxdesign/core/Chat'; -import {Avatar} from '@astryxdesign/core/Avatar'; -import {Button} from '@astryxdesign/core/Button'; -import {Timestamp} from '@astryxdesign/core/Timestamp'; -import {VStack} from '@astryxdesign/core/Layout'; -import {Text} from '@astryxdesign/core/Text'; -import {useCopilot} from '../CopilotProvider'; - -/** - * Suggested prompts double as the empty state — an empty AI panel with a bare - * input tells a merchant nothing about what it can answer. - */ -const SUGGESTED = [ - "Generate today's report", - 'Compare stores', - 'Suggest rewards', - 'Analyze sales', - 'Show best staff', - 'Predict weekend traffic', -]; - -function SuggestedPrompts({onPick}: {onPick: (p: string) => void}) { - return ( - - - Ask about footfall, LYT redemption, staff or store performance. - - - {SUGGESTED.map((p) => ( -