Replace ~440 lines of hand-written structural DB client types across 7 lib files
with `Pick<PrismaClient, ...>` from @capakraken/db. This eliminates all `as any`
casts at Prisma boundaries (cron routes, allocation effects, vacation procedures)
and surfaces two pre-existing bugs:
- weekly-digest.ts: `db.allocation.count()` called non-existent model (fixed → demandRequirement)
- estimate-reminders.ts: `submittedAt` field doesn't exist on EstimateVersion (fixed → updatedAt)
Also adds root eslint.config.mjs so lint-staged can lint package files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files
- Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error
- Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin
- Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments
- Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example
- Add coverage artifact upload step to CI test job
- Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Unify Saturday+Sunday into single isWeekend flag (header + grid lines)
- Replace hardcoded amber vacation bar/tooltip colors with brand-* classes
- Add global accent-color for checkboxes and radio buttons via CSS variable
- Update VACATION_TIMELINE_COLORS/BORDER to use brand palette (SICK stays red)
- Vacation-only tooltip uses neutral dark surface with brand accent border
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tailwind's gray-950 (rgb(3,7,18)) is blue-shifted. Add solid and opacity
variant overrides (/96, /95, /60, /50, /45, /40) to map gray-950 to
the neutral --surface-card CSS variable in dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace blue-shifted CSS variable values with balanced neutral RGB, add
comprehensive dark-mode overrides for bg-gray-*, border-gray-*, text-gray-*,
and their dark: variant forms. Remove light-mode text/border overrides that
leaked into both modes. Replace hardcoded rgba(255,255,255,...) in component
classes with CSS variable references. Merge duplicate fadeSlideIn keyframe
into fadeSlideUp. Change .app-data-table overflow to clip for sticky compat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rules inside @layer components lose to unlayered styles in the CSS cascade,
causing dark mode overrides to be silently ignored. Move ALL :is(.dark) rules
for app-surface, app-surface-strong, app-toolbar, app-input, app-select,
app-label, app-page-title, app-page-subtitle, app-data-table, and action
classes outside @layer — the same fix that resolved app-data-table white bg.
Also switch app-surface/strong from background: shorthand to separate
background-color + background-image to ensure the dark surface-card base
color is always applied independently of the gradient overlay.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace `@apply dark:bg-gray-900/95` with an explicit `:is(.dark) .app-data-table`
rule using CSS variables, matching the established pattern of `.app-surface` and
`.app-toolbar`. Fixes Allocations, ResourceTableWidget, and ProjectTableWidget
all appearing white in dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace sidebar #0d0e22 hardcoded hex with .sidebar-panel class backed by
--surface-card CSS variable so all three sidebar elements (desktop, mobile,
mobile header) share the same neutral-dark color as the main content
- Remove purple logo gradient (dark:from-[#0d0e22] dark:to-[#13162a]) — now uses
--surface-elevated for a neutral, unified look
- Add .dark slate-*/gray-900 overrides: bg-slate-700/800/900, border-slate-800,
hover:bg-slate-800 all map to --surface-elevated/--surface-card/--border-subtle
- Remove dead hardcoded rgb(45 51 71) rule for dark bg-gray-100 (was overridden
further down anyway; now consistently uses --surface-elevated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dark surface vars changed from purple-navy (8 8 22) to neutral near-black (10 10 12)
so sky/emerald/amber accent themes no longer have a purple cast
- Light surface vars made nearly neutral (252 252 253) — lavender tint removed
- All rgba(100, 80, 160, ...) replaced with rgb(var(--accent-400) / ...) in
.app-surface, .app-surface-strong, and .app-toolbar shadows/borders
- .app-page-title dark gradient midpoint changed from hardcoded #e8e4ff to
rgb(var(--accent-100)) so it adapts to the chosen accent color
- Body light-mode background gradient opacity reduced to avoid over-tinting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Password validation: min(8) → min(12) across auth.ts, user-procedure-support.ts,
and invite.ts (aligns with NIST SP 800-63B modern recommendations)
- Error boundary: stop rendering raw error.message which could leak internal
details; always show the generic fallback text
- Add `pnpm audit` script (--audit-level=high) for dependency vulnerability scanning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace max-w-5xl/max-w-7xl constrained wrappers with the app-page utility
class, consistent with other full-width pages like the projects list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Runtime errors in components now show a friendly "Something went wrong"
screen instead of a white page. Timeline and staffing panel are
individually wrapped. Route-level error.tsx handles server component errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resources, projects, and allocations filter state now syncs to/from
URL so filters survive refresh and can be shared via link.
Text inputs are debounced (300ms) to avoid URL churn.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Read-only capacity snapshot with utilization donut, top 5 active projects,
open demand alert banner, and quick-link grid — single-column card layout
optimised for PWA standalone mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sends a Monday digest to all ADMIN + MANAGER users with:
- Team utilization % for the next 4 weeks
- Overbooked resource count
- Open demand count
- Upcoming vacation count
- Top 5 most utilized resources
Route: GET /api/cron/weekly-digest (secured by CRON_SECRET).
HTML template and plain-text fallback included.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows resources with available capacity in a selected date window.
- Filter by date range (with DateRangePresets), min hours/day slider, and free-text search
- Cards show role, chapter, available h/day with color-coded capacity bar
- Links to individual resource profiles
- "Bench" nav entry added to Resources section in AppShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- EmptyState shared component; replace AllocationsClient inline empty state
- DateRangePresets (this month/quarter/3 months/year) integrated into AllocationModal
- Debounce conflict-check inputs in AllocationModal (400ms) using existing useDebounce
- Dashboard layout save feedback via SuccessToast after DB write completes
- Scenarios nav item in Planning sidebar + /scenarios list page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: useDashboardLayout initialised React state with createDefaultDashboardLayout()
(1 widget), so the wrong default rendered during the ~100–500ms window while React Query
fetched the user session and DB layout after login. On reload within staleTime the cache
hit resolved instantly, masking the bug.
Fix: add isHydrated boolean state that becomes true only once localStorage OR DB
hydration has settled; DashboardClient renders a GridLayoutSkeleton until then.
Also adds router.refresh() in the sign-in handler to bust the Next.js Router Cache
so the post-login navigation always lands on a fresh server component tree.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This override was blocking all dark: Tailwind classes on amber-50 elements.
Components now use explicit dark:bg-amber-950/30 instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add batchHardDelete adminProcedure to resource-mutations router
- Per-row Delete button visible to ADMIN role only
- Delete Selected button in BatchActionBar for ADMIN role only
- Two-step confirmation dialogs with permanent-action warnings
- Audit log written for each deleted resource
Co-Authored-By: claude-flow <ruv@ruv.net>
#58: Split the merged "Type: BD / INT" field in the wizard review step into
separate "Order Type", "Allocation Type", and "Status on create" rows so
users can clearly distinguish commercial classification from lifecycle status.
#60: Relabel FillOpenDemandModal staging CTA from "+ Add to Plan" to
"+ Queue Assignment" and the proceed CTA from "Review (N)" to
"Review Queued (N)" to make the staged/non-final nature of the action clear.
Also correct the project detail Assignments label from "N active" to
"N planned" and update the tooltip to include PROPOSED in the definition.
Co-Authored-By: claude-flow <ruv@ruv.net>
#66: Project detail "Open Demands" summary incorrectly counted COMPLETED
demands as open. Fix: add `status !== "COMPLETED"` to the activeDemands
filter in /projects/[id]/page.tsx.
#59/#67: Project creation and edit had two bugs:
1. Both invalidated `project.list` but the page queries `project.listWithCosts`
— the list never refreshed after a save.
2. Success toasts were either absent (ProjectModal) or mounted inside the
wizard component that unmounts before the toast finishes.
Fix: correct invalidation key to listWithCosts; add optional onSuccess prop
to both ProjectWizard and ProjectModal; ProjectsClient wires onSuccess to a
persistent SuccessToast rendered outside the modals.
Co-Authored-By: claude-flow <ruv@ruv.net>
#55: Add SuccessToast after new resource is created. ResourceModal gains an
optional onSuccess(displayName) prop; ResourcesClient wires it to a toast
that auto-dismisses after 2.5 s.
#56: Fix useFocusTrap stale-closure bug. Focusable elements are now queried
dynamically inside handleKeyDown (not captured once at mount), so Tab
navigation stays correct as the form re-renders. Initial focus is deferred
via requestAnimationFrame so the browser layout is stable before focus() fires.
Co-Authored-By: claude-flow <ruv@ruv.net>
- Split auth config into auth.config.ts (edge-safe, no argon2) and auth-edge.ts
for middleware use; auth.ts now spreads the shared config
- Middleware wraps with auth() to redirect unauthenticated requests to /auth/signin
before any page render; passes through /auth/, /api/, /invite/ paths
- SessionGuard client component watches useSession() and redirects on
status=unauthenticated, closing the SPA navigation gap
- QueryCache + MutationCache in TRPCProvider redirect on UNAUTHORIZED tRPC errors
without retrying; SessionProvider polls session state every 5 minutes
- Middleware tests updated for async auth wrapper and auth-edge mock
Co-Authored-By: claude-flow <ruv@ruv.net>
Four new test files — 27 tests total:
- role-router-auth.test.ts (8): UNAUTHORIZED/FORBIDDEN on all mutations for
unauthenticated/USER callers; MANAGER and ADMIN happy paths
- webhook-router-auth.test.ts (6): adminProcedure guard verified for all
six webhook procedures across USER/MANAGER/ADMIN roles
- comment-sanitization-router.test.ts (4): proves stripHtml runs before
db.comment.create — script tags stripped, plain text and @mentions preserved
- auth-anomaly-check/route.test.ts (+5 unit tests): detectAuthAnomalies()
unit coverage — empty window, global threshold, per-entity threshold, null
entityId, and both anomaly types firing simultaneously
Co-Authored-By: claude-flow <ruv@ruv.net>
- Invite flow: admin can invite users by email with role selection; accept-invite page
sets password and creates the account; 72-hour token expiry; E2E tests
- User deactivate/reactivate/delete: new tRPC procedures + UI buttons; deactivation
revokes all active sessions immediately; delete cascades vacation/broadcast records;
isActive field added via migration 20260402000000_user_isactive
- Auth: block login for inactive users with audit entry
- Favicon: SVG favicon + ICO/PNG fallbacks (16, 32, 180, 192, 512px); manifest updated
- Dashboard: GridLayout dynamic-import loading skeleton prevents blank dark area
on first login before react-grid-layout chunk is cached
- Admin users: remove max-w-5xl constraint so table uses full page width
- Dev: docker container restart workflow documented in LEARNINGS.md; Prisma generate
must run inside the container after schema changes (named node_modules volume)
Co-Authored-By: claude-flow <ruv@ruv.net>
Introduce getAppBaseUrl() in packages/api/src/lib/app-base-url.ts:
- Reads NEXTAUTH_URL (trimmed, trailing slash stripped)
- production: throws if NEXTAUTH_URL is missing/empty so broken
localhost links in emails are caught at runtime, not silently sent
- development/test: falls back to http://localhost:3100 with a
one-time console.warn
Replace the duplicated inline fallback in:
- packages/api/src/router/invite.ts (invite email link)
- packages/api/src/router/auth.ts (password reset email link)
Extend GET /api/health to report:
"baseUrl": { "configured": bool, "isLocalhost": bool }
so deployment checks can detect a misconfigured NEXTAUTH_URL.
Co-Authored-By: claude-flow <ruv@ruv.net>
- SMTP: SMTP_HOST/PORT/USER/FROM/TLS now all have ENV override support
(previously only SMTP_PASSWORD was env-aware). ENV takes priority over DB.
- docker-compose.yml: forward all SMTP_* env vars to app container + add
Mailhog service (ports 1025 SMTP / 8025 HTTP, always available in dev)
- Password reset: PasswordResetToken Prisma model + authRouter with
requestPasswordReset (timing-safe, no email enumeration) + resetPassword
- UI: /auth/forgot-password, /auth/reset-password/[token] pages +
"Forgot password?" link on sign-in page
- E2E: Mailhog helpers (getLatestEmailTo, clearMailhog, extractUrlFromEmail)
+ invite-flow.spec.ts + password-reset.spec.ts
Co-Authored-By: claude-flow <ruv@ruv.net>
#41 (critical): Replace plain Error throws in authorize() with CredentialsSignin
subclasses (MfaRequiredError / MfaRequiredSetupError / InvalidTotpError).
Auth.js v5 forwards CredentialsSignin.code to the client via SignInResponse.code;
plain throws become CallbackRouteError and the message is never visible.
Signin page now checks result.code ?? result.error for exact code matching.
#38: MfaPromptBanner converted to fully client-side component via
trpc.user.getMfaStatus.useQuery() — disappears immediately after MFA enable
without requiring page reload. Snooze key remains userId-scoped via useSession().
Server-side prisma.user.findUnique call removed from (app)/layout.tsx.
#40: NEXTAUTH_URL default fallback removed from docker-compose.yml.
The variable is now required (:?) — docker compose up fails with a descriptive
error if the value is missing, preventing silent localhost redirect bugs.
Tests: auth.test.ts (5), MfaPromptBanner.test.ts (7), reset-password.test.ts (6)
All new tests green. pnpm --filter @capakraken/web exec tsc --noEmit clean.
Co-Authored-By: claude-flow <ruv@ruv.net>
#28 - TOTP rate limiting (verifyTotp): added totpRateLimiter (10 req/30s),
throws TOO_MANY_REQUESTS before DB hit; 16 unit tests including rate-limit
exceeded + userId key isolation.
#29 - /api/reports/allocations role check: only ADMIN/MANAGER/CONTROLLER may
access; returns 403 otherwise; 9 unit tests (401 unauthenticated, 403 for
USER/VIEWER, 200 for allowed roles + xlsx format).
#31 - pgAdmin credentials moved out of docker-compose.yml into env vars;
PGADMIN_PASSWORD is now required (:?) to prevent accidental plaintext
exposure in committed files.
#34 - Server-side HTML sanitization for comment bodies via stripHtml():
strips all tags + decodes safe entities before persistence; 16 unit tests
covering passthrough, injection patterns, entity decoding.
#35 - MFA setup prompt banner (MfaPromptBanner): shown to ADMIN/MANAGER users
without TOTP enabled; user-scoped localStorage snooze (7 days); links to
/account/security; accessibility role=alert; 7 structural unit tests.
#33 - Auth anomaly alerting cron (/api/cron/auth-anomaly-check): detects
HIGH_GLOBAL_FAILURE_RATE and CONCENTRATED_FAILURES in 30-minute window;
CRITICAL notification to ADMINs; fail-closed via verifyCronSecret;
10 unit tests.
#32 - MFA enforcement policy: added requireMfaForRoles field to SystemSettings
schema + Prisma migration; auth.ts blocks login with MFA_REQUIRED_SETUP
signal if role is enforced but TOTP not set up; signin page redirects to
/account/security?mfa_required=1; settings schema + view model updated;
11 unit tests.
#30 - API keys architecture decision documented in LEARNINGS.md; no code
written — product decision required before implementation.
Co-Authored-By: claude-flow <ruv@ruv.net>
Three related fixes to prevent E2E test runs from disrupting real user sessions:
1. auth.ts: skip active_sessions registration in E2E mode
E2E logins now return early after setting token.sid without writing
to active_sessions. Prevents test sessions from kicking real user
sessions via the concurrent-session limit.
2. trpc/route.ts: skip active_sessions validation in E2E mode
Pairs with (1): if registration is skipped, validation must be too,
otherwise every storageState-based test gets a 401 "Session revoked".
3. docker-compose.yml: hardcode Docker-internal DATABASE_URL + E2E_TEST_MODE
Previously ${DATABASE_URL:-postgres:5432} picked up the host's
localhost:5433 override and passed it into the container, where
localhost refers to the container itself — breaking db:migrate:deploy
on container recreate. Now hardcoded to postgres:5432.
Also adds E2E_TEST_MODE=true to the dev container environment.
Result: 21/21 dev-system E2E tests pass, test runs leave zero footprint
in active_sessions and rate limiter counters for real user accounts.
The timeline disruption caused by test sessions kicking the admin's
real browser session is also resolved.
Co-Authored-By: claude-flow <ruv@ruv.net>
The active_sessions table was never migrated to production — the model
was added to the Prisma schema via db push only. prisma migrate deploy
was a no-op because no migration directories existed.
Without the table, prisma.activeSession.findUnique() throws P2021,
crashing the tRPC handler with 500 on every authenticated request.
This silently emptied all admin pages (users, system-roles, etc.).
Changes:
- Wrap the jti ActiveSession lookup in try-catch so the tRPC handler
degrades gracefully (fail-open) if the table is temporarily missing
- Add packages/db/prisma/migrations/20260401000000_active_sessions/
so prisma migrate deploy creates the table on next production deploy
(idempotent via IF NOT EXISTS — safe if table already exists)
Co-Authored-By: claude-flow <ruv@ruv.net>
#19 MFA QR code: render locally via qrcode package, remove external qrserver.com request
#20 Webhook SSRF: add ssrf-guard.ts with DNS-verified IP blocklist; enforce on create/update/test/dispatch
#21 /api/perf: fail-closed when CRON_SECRET missing; remove query-string token auth
#22 CSP: remove unsafe-eval and unsafe-inline from script-src in production builds
#23 Active session registry: forward jti into session object; validate against ActiveSession on every tRPC request
#24 Docker: add missing packages/application to Dockerfile.dev; fix pnpm-lock.yaml glob;
run db:migrate:deploy on container start so a fresh checkout boots without manual steps
Also: fix pre-existing TS error in e2e/allocations.spec.ts (args.length literal type overlap)
Co-Authored-By: claude-flow <ruv@ruv.net>