Compare commits

..

3 commits

Author SHA1 Message Date
e082bc3532 feat: small ui changes 2026-03-28 12:50:52 +01:00
c0633a27df Merge branch 'main' into feature/test-shadcn-theme-overwrite 2026-03-28 12:47:30 +01:00
cff542cc21 feat: add shadcn 2026-03-28 12:46:49 +01:00
97 changed files with 335 additions and 8686 deletions

View file

@ -1,11 +0,0 @@
CONVEX_SELF_HOSTED_URL='https://your-convex-backend.example.com'
CONVEX_SELF_HOSTED_ADMIN_KEY='self-hosted-convex|YOUR_ADMIN_KEY'
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_CONVEX_URL=https://your-convex-backend.example.com
NEXT_PUBLIC_CONVEX_SITE_URL=https://your-convex-site.example.com
# OAuth providers (set in Convex with: npx convex env set NAME value)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

View file

@ -1,69 +0,0 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
deploy-dev:
if: github.ref == 'refs/heads/dev'
needs: lint
runs-on: ubuntu-latest
steps:
- name: Deploy to Coolify (Dev)
run: |
curl -X POST "${{ secrets.COOLIFY_DEV_WEBHOOK }}"
deploy-prod:
if: github.ref == 'refs/heads/main'
needs: lint
runs-on: ubuntu-latest
steps:
- name: Deploy to Coolify (Prod)
run: |
curl -X POST "${{ secrets.COOLIFY_PROD_WEBHOOK }}"

View file

@ -1,46 +0,0 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'

1
.gitignore vendored
View file

@ -32,7 +32,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel

View file

@ -7,8 +7,4 @@
"trailingComma": "all", "trailingComma": "all",
"bracketSpacing": true, "bracketSpacing": true,
"arrowParens": "always", "arrowParens": "always",
"sortImports": {
"order": "asc",
"ignoreCase": true
}
} }

View file

@ -1,28 +0,0 @@
- generic [active] [ref=e1]:
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e6]:
- link "Strona główna" [ref=e8] [cursor=pointer]:
- /url: /
- generic [ref=e12]:
- button "Light mode" [ref=e13]:
- img
- button "Dark mode" [ref=e14]:
- img
- main [ref=e15]:
- heading "Witaj świecie!" [level=1] [ref=e16]
- generic [ref=e17]:
- button "Light mode" [ref=e18]:
- img
- button "Dark mode" [ref=e19]:
- img
- region "Notifications alt+T"
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: Używamy plików cookie
- generic [ref=e24]: Używamy plików cookie, aby poprawić Twoje doświadczenie. Niezbędne pliki cookie są zawsze aktywne. Możesz zarządzać preferencjami poniżej.
- generic [ref=e26]:
- button "Akceptuj wszystkie" [ref=e27]
- button "Akceptuj tylko niezbędne" [ref=e28]
- button "Zarządzaj preferencjami" [ref=e29]
- alert [ref=e30]

View file

@ -1,49 +0,0 @@
{
"active_plan": "/home/nxtkofi/dev/templates/convex-next-saas/.sisyphus/plans/pwa-lite.md",
"started_at": "2026-05-15T17:16:56.579Z",
"session_ids": [
"ses_1d399daaaffebnoS7swOtGeMrQ",
"ses_1d3539915ffep5QJEbHVd0wtbn",
"ses_1d35391a6ffeamL03zDOdqK95a",
"ses_1d3538b00ffeXlbkMEaiJiAGib",
"ses_1d3538648ffejpOJeqKzTUP8fB"
],
"session_origins": {
"ses_1d399daaaffebnoS7swOtGeMrQ": "direct",
"ses_1d3539915ffep5QJEbHVd0wtbn": "appended",
"ses_1d35391a6ffeamL03zDOdqK95a": "appended",
"ses_1d3538b00ffeXlbkMEaiJiAGib": "appended",
"ses_1d3538648ffejpOJeqKzTUP8fB": "appended"
},
"plan_name": "pwa-lite",
"agent": "atlas",
"task_sessions": {
"todo:1": {
"task_key": "todo:1",
"task_label": "1",
"task_title": "Add lightweight PWA manifest, icon assets, and metadata",
"session_id": "ses_1d35a7e6effe7tixxqSuYdTznh",
"agent": "Sisyphus-Junior",
"category": "quick",
"updated_at": "2026-05-15T17:20:57.345Z"
},
"todo:2": {
"task_key": "todo:2",
"task_label": "2",
"task_title": "Document PWA customization checklist and store caveats",
"session_id": "ses_1d3563c89ffeh696YfGmAPPgMU",
"agent": "Sisyphus-Junior",
"category": "quick",
"updated_at": "2026-05-15T17:24:56.130Z"
},
"final-wave:f1": {
"task_key": "final-wave:f1",
"task_label": "F1",
"task_title": "Plan Compliance Audit — oracle",
"session_id": "ses_1d3538648ffejpOJeqKzTUP8fB",
"agent": "oracle",
"category": "",
"updated_at": "2026-05-15T17:40:02.032Z"
}
}
}

View file

@ -1,33 +0,0 @@
# Draft: Convex Coolify Better Auth Debug
## Requirements (confirmed)
- research how this should work with Coolify, Convex, and Let's Encrypt
- explain whether current sign-up flow is correct
- determine likely causes of Convex errors like `No available server` and TLS failures
- account for local Next.js (`SITE_URL=http://localhost:3000`) talking to Convex hosted on Coolify/VPS
## Technical Decisions
- investigate repo auth flow before drawing conclusions
- compare repo implementation against authoritative Better Auth + Convex guidance
- include infrastructure-side TLS/proxy hypotheses, not just app-code explanations
## Research Findings
- local app calls `authClient.signUp.email(...)` from `src/app/sign-up/page.tsx`
- Next route proxies Better Auth via `src/app/api/auth/[...all]/route.ts`
- server-side Better Auth bridge is configured in `src/lib/auth-server.ts`
- authoritative Better Auth + Convex guidance confirms sign-up/sign-in must happen from the client; `authClient.signUp.email(...)` is the canonical flow
- auth requests go browser -> Next `/api/auth/...` -> server-side fetch to Convex site URL; TLS is evaluated on that server-to-server hop, not in the browser
- Better Auth with Convex writes to auth component tables like `user`, `account`, `session`, `verification`; custom app user syncing requires additional trigger-style logic
- Coolify/Traefik can present a self-signed fallback cert when ACME/Let's Encrypt or routing is wrong, even if the public browser path appears healthy
- `No available server` aligns more with Coolify/Traefik upstream health/routing issues than with incorrect Better Auth API usage
- user confirmed local `SITE_URL` is `http://localhost:3000` and Convex runs remotely on Coolify with distinct backend, dashboard, and backend-site hostnames
- user's Coolify env exposes `SERVICE_URL_BACKEND=https://convex-backend.mentat.ovh` and `SERVICE_URL_BACKEND_SITE=https://backend-site-olnjg91x5ervt6j6owwgnlha.mentat.ovh`; these hostnames must not be conflated with the dashboard URL
## Open Questions
- which exact host is currently configured in `NEXT_PUBLIC_CONVEX_SITE_URL`
- whether the Next runtime reaches a different internal/proxied hostname than the browser does
- whether Coolify proxy health/port/DNS/IPv6 configuration is intermittently breaking Convex upstream availability
## Scope Boundaries
- INCLUDE: repo auth flow, Better Auth/Convex expectations, Coolify/Let's Encrypt TLS behavior
- EXCLUDE: implementing fixes in source files during research

View file

@ -1,7 +0,0 @@
Runtime schema validation summary
Verified with `npx tsx` against exported createChangePasswordSchema(...):
- mismatched confirmPassword -> parse failed on path ["confirmPassword"] with message `no-match`
- currentPassword === newPassword -> parse failed on path ["newPassword"] with message `must-differ`
This confirms the client-side schema blocks both required validation scenarios before submission.

View file

@ -1,18 +0,0 @@
QA summary for dashboard/settings password change flow
Verified:
- GET /settings unauthenticated -> 307 redirect to /sign-in?callbackURL=%2Fsettings
- GET /dashboard contains CTA href="/settings"
- Authenticated GET /settings renders currentPassword/newPassword/confirmPassword inputs
- POST /api/auth/change-password with wrong currentPassword -> 400 INVALID_PASSWORD
- Session remains valid after wrong currentPassword attempt
- POST /api/auth/change-password with valid currentPassword -> 200 OK
- Old password sign-in fails after change
- New password sign-in succeeds after change
- Second active session becomes unauthenticated after password rotation with revokeOtherSessions=true
- Accessing /settings with revoked session -> 307 redirect to sign-in
Environment note:
- Browser MCP could not run because Chrome was unavailable and could not be installed without sudo.
- Browser-level QA was replaced with HTTP/session-level verification plus runtime schema checks.
- Sign-in callback propagation verified after fix: POST /api/auth/sign-in/email with callbackURL=/settings returns {"url":"/settings"}

View file

@ -1,7 +0,0 @@
## 2026-04-21 Scope fidelity check
- Scope check verdict is REJECT.
- `src/components/auth/AuthForm.tsx` hard-codes `/dashboard`, `/sign-in`, `/sign-up`, and `/forgot-password` instead of using `routes` constants.
- `src/app/not-found.tsx` contains hard-coded English copy (`Page not found`, `Go back home`) and hard-coded `/`, so root-level fallback copy is not translated through locale messages and does not use route constants.
- `src/app/[locale]/layout.tsx` does not gate auth server-side, and shell auth awareness is client-hydrated through `authClient.useSession()` in `src/components/core/AuthNavActions.tsx`.
- No avatar dropdown, profile menu, locale switcher, breadcrumbs, sidebar, mobile drawer, `app/global-error.tsx`, or page-specific skeleton/loading systems were found.

View file

@ -1,373 +0,0 @@
# App Shell and Route Fallbacks
## TL;DR
> **Summary**: Add a minimal locale-aware application shell with auth-aware navigation, then add deterministic not-found, loading, and route-error UX for the App Router without expanding into broader product UI.
> **Deliverables**:
> - Shared shell in `src/app/[locale]/layout.tsx`
> - Header-safe theme switcher and auth-aware nav actions
> - Localized 404 handling plus root 404 fallback
> - Locale-scoped `loading.tsx` and `error.tsx`
> - Stable selectors and deterministic QA hooks for nav/fallback states
> - EN/PL translations for new shell and fallback copy
> **Effort**: Medium
> **Parallel**: YES - 2 waves
> **Critical Path**: 1 → 2 → 3 → 4/5/6
## Context
### Original Request
Plan implementation of items 1 and 2 from the previously proposed quick wins: error boundaries + loading states, and not-found page + global layout navigation.
### Interview Summary
- Scope is intentionally limited to shared shell/navigation and App Router fallbacks.
- Stripe, uploads, admin, and other template upgrades are explicitly out of scope for this slice.
- Locale routing already exists and must be preserved.
### Metis Review (gaps addressed)
- Avoid root-layout auth checks in the shared layout because they would force unnecessary dynamic rendering and conflict with existing page-level protection.
- Do not rely on `[locale]/not-found.tsx` alone for unknown localized URLs; add an explicit catch-all route.
- Do not assume `error.tsx` handles root-layout/provider failures; keep this slice scoped to locale-route runtime errors only.
- Make loading and error QA deterministic with stable selectors and an internal non-linked route error trigger.
## Work Objectives
### Core Objective
Provide a minimal, auth-aware app shell across localized routes and add deterministic localized UX for loading, 404, and route-segment runtime errors.
### Deliverables
- Shared shell wrapper in `src/app/[locale]/layout.tsx`
- New shell components under `src/components/core/`
- Refactored `ThemeChanger` suitable for header usage
- Localized route copy in `messages/en.json` and `messages/pl.json`
- `src/app/[locale]/not-found.tsx`
- `src/app/[locale]/[...rest]/page.tsx`
- `src/app/not-found.tsx`
- `src/app/[locale]/loading.tsx`
- `src/app/[locale]/error.tsx`
- Internal non-linked QA trigger routes for loading and route-error validation
### Definition of Done (verifiable conditions with commands)
- `pnpm lint` completes successfully
- `pnpm build` completes successfully
- Anonymous users see shell navigation with home/sign-in/sign-up actions and do not see dashboard/settings/sign-out controls
- Authenticated users see shell navigation with dashboard/settings/sign-out controls and do not see sign-in/sign-up controls
- Navigating to `/en/does-not-exist` renders the localized 404 UI
- Navigating to a non-localized unmatched URL renders the root 404 UI
- Navigating between locale routes can surface a deterministic loading fallback with `[data-testid="route-loading"]`
- Triggering the internal QA error route renders the locale error boundary with a retry control
### Must Have
- Shell lives at `[locale]` scope, not root scope
- Auth-awareness in nav is client-hydrated via Better Auth session hook
- Existing page-level redirect guards remain untouched as the only access-control source of truth
- Theme switcher remains available from the shared header
- All shell/fallback copy is translated in both locales
- Stable selectors/test IDs exist for nav, auth action groups, loading, 404, and retry controls
### Must NOT Have (guardrails, AI slop patterns, scope boundaries)
- No avatar dropdown, profile menu, locale switcher, breadcrumbs, sidebar, or mobile drawer in this slice
- No root-layout auth gating with `isAuthenticated()` in `src/app/[locale]/layout.tsx`
- No `app/global-error.tsx` or provider-failure handling in this slice
- No page-specific skeleton systems for dashboard/settings/auth pages
- No new test framework setup
- No hard-coded paths when route constants already exist
## Verification Strategy
> ZERO HUMAN INTERVENTION - all verification is agent-executed.
- Test decision: tests-after using agent-executed browser QA plus lint/build
- QA policy: every task includes explicit selectors, routes, and expected visibility states
- Evidence: `.sisyphus/evidence/task-{N}-{slug}.{ext}`
## Execution Strategy
### Parallel Execution Waves
> Target: 5-8 tasks per wave. <3 per wave (except final) = under-splitting.
> Extract shared dependencies as Wave-1 tasks for max parallelism.
Wave 1: 1) translation contract and selectors, 2) shell primitives + theme control, 3) auth-aware shell integration
Wave 2: 4) localized/root 404 handling, 5) route loading fallback, 6) locale error boundary + QA trigger
### Dependency Matrix (full, all tasks)
- 1 blocks 2, 3, 4, 5, 6
- 2 blocks 3, 4, 5, 6
- 3 blocks 4, 5, 6 because shell selectors and layout structure become QA baseline
- 4, 5, and 6 can proceed in parallel after 1-3
- 6 blocks final verification wave because retry/error QA depends on its internal trigger route
### Agent Dispatch Summary (wave → task count → categories)
- Wave 1 → 3 tasks → quick, visual-engineering, unspecified-high
- Wave 2 → 3 tasks → unspecified-high, quick, unspecified-high
## TODOs
> Implementation + Test = ONE task. Never separate.
> EVERY task MUST have: Agent Profile + Parallelization + QA Scenarios.
- [x] 1. Establish shell and fallback translation contract
**What to do**: Add new translation namespaces for shell navigation and fallback UX in both locale files before touching UI. Define exact labels for brand/home, dashboard, settings, sign-in, sign-up, sign-out, loading title/description, localized 404 title/description/actions, and route-error title/description/retry. Add a small selector contract in the plan implementation itself by reserving stable `data-testid` names that later tasks must use verbatim: `app-shell`, `app-nav`, `nav-public-links`, `nav-private-links`, `nav-auth-actions`, `theme-switcher`, `route-loading`, `localized-not-found`, `root-not-found`, `route-error`, `route-error-retry`, `sign-out-button`.
**Must NOT do**: Do not create a third locale. Do not move existing namespaces. Do not hardcode copy directly into components.
**Recommended Agent Profile**:
- Category: `quick` - Reason: bounded i18n and selector contract work across two JSON files
- Skills: `[]` - Existing repo patterns are sufficient
- Omitted: `writing` - This is product-copy extension, not long-form documentation
**Parallelization**: Can Parallel: YES | Wave 1 | Blocks: 2, 3, 4, 5, 6 | Blocked By: none
**References** (executor has NO interview context - be exhaustive):
- Translation pattern: `messages/en.json:1-58` - existing namespace structure to extend instead of flattening
- Translation mirror: `messages/pl.json` - keep key parity with `messages/en.json`
- Existing auth copy: `src/components/auth/AuthForm.tsx:62-225` - current `AuthPage` key usage pattern
- Existing route labels: `src/app/[locale]/dashboard/page.tsx:26-40` - server-side translation usage for page copy
**Acceptance Criteria** (agent-executable only):
- [ ] `messages/en.json` and `messages/pl.json` contain matching shell and fallback namespaces/keys
- [ ] Reserved `data-testid` names are documented in code comments-free implementation choices and used consistently in later tasks
- [ ] `pnpm build` succeeds after translation additions
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Translation key parity check
Tool: Bash
Steps: Run a Node script that loads `messages/en.json` and `messages/pl.json`, extracts the new shell/fallback namespaces, and compares key sets recursively.
Expected: Script exits 0 and prints no missing-key differences.
Evidence: .sisyphus/evidence/task-1-translation-parity.txt
Scenario: Existing auth copy not regressed
Tool: Playwright
Steps: Start app; open `/en/sign-in`; assert the sign-in title and forgot-password link still render.
Expected: Existing auth form renders with non-empty text and no raw i18n keys.
Evidence: .sisyphus/evidence/task-1-auth-copy-regression.png
```
**Commit**: YES | Message: `feat(i18n): add shell and fallback copy` | Files: `messages/en.json`, `messages/pl.json`
- [x] 2. Build shared shell primitives and refactor ThemeChanger for header use
**What to do**: Introduce the minimal shell primitives under `src/components/core/` needed for the shared layout: a server-friendly shell wrapper (`AppShell`), a presentational nav/header component (`AppNav`), and a compact theme control by refactoring `ThemeChanger` into a header-sized control that still uses `@wrksz/themes`. Keep the shell visual language aligned with existing card/button primitives: simple top border/header, constrained content width, no dropdowns, no drawer, no avatar. Ensure every interactive control exposes the reserved `data-testid` values from Task 1.
**Must NOT do**: Do not fetch auth state here. Do not add mobile menu logic, locale switcher, breadcrumbs, or profile UI. Do not leave the old verbose “The current theme is...” wording in the header.
**Recommended Agent Profile**:
- Category: `visual-engineering` - Reason: shared shell UX and header ergonomics must feel intentional, not placeholder
- Skills: `[]` - Existing UI primitives cover the work
- Omitted: `frontend-ui-ux` - The UI is deliberately minimal and tightly anchored to repo primitives
**Parallelization**: Can Parallel: NO | Wave 1 | Blocks: 3, 4, 5, 6 | Blocked By: 1
**References** (executor has NO interview context - be exhaustive):
- Root provider context: `src/app/layout.tsx:26-38` - shell components will render under these providers
- Locale insertion point: `src/app/[locale]/layout.tsx:10-25` - current wrapper that later task will extend
- Theme control baseline: `src/components/core/ThemeChanger.tsx:5-28` - refactor this component instead of creating duplicate theme logic
- Button styling: `src/components/ui/button.tsx:7-67` - use existing variants/sizes
- Card styling baseline: `src/components/ui/card.tsx:5-103` - follow existing spacing, radius, and muted sections if cardized sub-sections are needed
**Acceptance Criteria** (agent-executable only):
- [ ] New shell primitives exist under `src/components/core/` and compile without auth coupling
- [ ] `ThemeChanger` becomes header-compatible and no longer renders debug-like explanatory text
- [ ] Shell primitives expose stable selectors: `app-shell`, `app-nav`, and `theme-switcher`
- [ ] `pnpm lint` passes after component creation/refactor
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Header primitives render on a locale route
Tool: Playwright
Steps: Open `/en`; inspect the DOM for `[data-testid="app-shell"]`, `[data-testid="app-nav"]`, and `[data-testid="theme-switcher"]`.
Expected: All three selectors exist exactly once and are visible in the viewport.
Evidence: .sisyphus/evidence/task-2-shell-primitives.png
Scenario: Theme control is compact and interactive
Tool: Playwright
Steps: On `/en`, interact with the header theme control and toggle from the default theme to dark.
Expected: The control remains in the header, toggles theme successfully, and does not render the old debug sentence.
Evidence: .sisyphus/evidence/task-2-theme-toggle.png
```
**Commit**: YES | Message: `feat(shell): add shared app shell primitives` | Files: `src/components/core/AppShell.tsx`, `src/components/core/AppNav.tsx`, `src/components/core/ThemeChanger.tsx`, any minimal supporting files
- [x] 3. Integrate an auth-aware shell into the locale layout without changing route protection semantics
**What to do**: Extend `src/app/[locale]/layout.tsx` to render the shared shell around all locale routes. Keep locale validation and `setRequestLocale(locale)` exactly as the first logic in the layout. Implement auth-aware nav actions in a client component (for example `AuthNavActions`) that uses `authClient.useSession()` and `authClient.signOut(...)` with a success redirect to `routes.public.home`. Show home plus auth links (`sign-in`, `sign-up`) when unauthenticated; show home plus private links (`dashboard`, `settings`) and a sign-out button when authenticated. Keep access control in the pages themselves; the shell only changes visibility of actions.
**Must NOT do**: Do not call `isAuthenticated()` in `[locale]/layout.tsx`. Do not redirect from the shell. Do not hide the shell from public auth pages. Do not hardcode `/sign-in`, `/dashboard`, or `/settings`.
**Recommended Agent Profile**:
- Category: `unspecified-high` - Reason: mixed server/client composition with auth session hydration and layout integration
- Skills: `[]` - Existing auth client and layout patterns are sufficient
- Omitted: `playwright` - QA uses Playwright, implementation should stay focused on code
**Parallelization**: Can Parallel: NO | Wave 1 | Blocks: 4, 5, 6 | Blocked By: 1, 2
**References** (executor has NO interview context - be exhaustive):
- Locale layout anchor: `src/app/[locale]/layout.tsx:10-25` - preserve locale guard and wrap children with shell
- Route contract: `src/lib/routes.ts:1-14` - use these constants for every nav link and sign-out redirect target
- Client auth client: `src/lib/auth-client.ts:1-6` - Better Auth client instance for `useSession()` and `signOut()`
- Better Auth sign-out pattern: `https://github.com/better-auth/better-auth/blob/main/docs/content/docs/authentication/email-password.mdx` - `authClient.signOut({ fetchOptions: { onSuccess: ... } })`
- Existing protected-route semantics: `src/app/[locale]/dashboard/page.tsx:16-24` - keep these page-level guards intact
- Existing auth link styling: `src/components/auth/AuthForm.tsx:109-220` - link/button patterns already used in auth UI
**Acceptance Criteria** (agent-executable only):
- [ ] `[locale]/layout.tsx` renders the shell around all locale routes while preserving locale validation and `setRequestLocale(locale)`
- [ ] Anonymous state shows `[data-testid="nav-public-links"]` and hides `[data-testid="nav-private-links"]` and `[data-testid="sign-out-button"]`
- [ ] Authenticated state shows `[data-testid="nav-private-links"]` and `[data-testid="sign-out-button"]` and hides public auth links
- [ ] Clicking sign-out returns the user to `routes.public.home` and removes private nav actions
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Anonymous shell state on public route
Tool: Playwright
Steps: Open a fresh browser context; visit `/en/sign-in`; assert `[data-testid="app-nav"]` is visible; inspect public/private action groups.
Expected: `[data-testid="nav-public-links"]` is visible with sign-in/sign-up links; `[data-testid="nav-private-links"]` and `[data-testid="sign-out-button"]` are absent.
Evidence: .sisyphus/evidence/task-3-anonymous-shell.png
Scenario: Authenticated shell state and sign-out
Tool: Playwright
Steps: Create a fresh QA user via `/en/sign-up` (for example `shell-nav@example.com` / `TemplatePass123!`); after auto-sign-in lands on the authenticated flow, visit `/en/dashboard`; assert dashboard/settings/sign-out controls; click `[data-testid="sign-out-button"]`.
Expected: User lands on `/en` or `/`; private controls disappear; sign-in/sign-up controls are visible again.
Evidence: .sisyphus/evidence/task-3-authenticated-shell.png
```
**Commit**: YES | Message: `feat(shell): integrate auth-aware nav into locale layout` | Files: `src/app/[locale]/layout.tsx`, `src/components/core/AuthNavActions.tsx`, related shell imports
- [x] 4. Add localized and root not-found handling with explicit catch-all coverage
**What to do**: Add three coordinated pieces: `src/app/[locale]/not-found.tsx` for localized `notFound()` rendering inside the locale segment, `src/app/[locale]/[...rest]/page.tsx` that immediately calls `notFound()` to catch unknown localized URLs such as `/en/unknown`, and `src/app/not-found.tsx` as a root fallback for unmatched non-localized requests that bypass locale routing. The localized 404 must render inside the shared shell and use localized copy. The root 404 must be intentionally minimal, must not depend on locale context, and must not attempt to render the locale shell.
**Must NOT do**: Do not enable experimental `global-not-found`. Do not rely on `[locale]/not-found.tsx` alone for unmatched localized URLs. Do not duplicate the full shell in `src/app/not-found.tsx`.
**Recommended Agent Profile**:
- Category: `unspecified-high` - Reason: Next.js file-convention work across locale and root routing layers
- Skills: `[]` - Existing routing patterns plus docs references are enough
- Omitted: `ultrabrain` - this is nuanced but not research-heavy anymore
**Parallelization**: Can Parallel: YES | Wave 2 | Blocks: Final verification wave | Blocked By: 1, 2, 3
**References** (executor has NO interview context - be exhaustive):
- Locale layout invalid-locale behavior: `src/app/[locale]/layout.tsx:17-23` - invalid locales already call `notFound()`
- Route constants: `src/lib/routes.ts:1-14` - use `routes.public.home` for return-home actions where locale context exists
- Next.js not-found docs: `https://nextjs.org/docs/app/api-reference/file-conventions/not-found` - root `app/not-found.tsx` handles global unmatched URLs
- next-intl error-files guide: `https://next-intl.dev/docs/environments/error-files` - localized 404 requires `[locale]/not-found.tsx` plus `[locale]/[...rest]/page.tsx`
- Existing card style: `src/components/ui/card.tsx:23-103` - use for localized 404 presentation
**Acceptance Criteria** (agent-executable only):
- [ ] `/en/does-not-exist` resolves via `[locale]/[...rest]/page.tsx` into the localized 404 UI
- [ ] Invalid locale handling from `[locale]/layout.tsx` still resolves to a 404 outcome instead of crashing
- [ ] A non-localized unmatched request resolves to `src/app/not-found.tsx`
- [ ] Localized 404 shows shell/nav; root 404 does not depend on locale shell
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Unknown localized route shows localized 404 inside shell
Tool: Playwright
Steps: Visit `/en/does-not-exist`; inspect `[data-testid="localized-not-found"]` and `[data-testid="app-nav"]`.
Expected: Localized 404 card is visible; shell navigation is still present; return-home action links to localized home.
Evidence: .sisyphus/evidence/task-4-localized-not-found.png
Scenario: Non-localized unmatched route shows root 404 fallback
Tool: Playwright
Steps: Visit `/totally-missing-route`; inspect `[data-testid="root-not-found"]`.
Expected: Root 404 UI is visible; localized shell selector `[data-testid="app-shell"]` is absent.
Evidence: .sisyphus/evidence/task-4-root-not-found.png
```
**Commit**: YES | Message: `feat(routing): add localized and root not-found handling` | Files: `src/app/[locale]/not-found.tsx`, `src/app/[locale]/[...rest]/page.tsx`, `src/app/not-found.tsx`
- [x] 5. Add a deterministic locale route loading fallback
**What to do**: Add `src/app/[locale]/loading.tsx` as the single shared loading fallback for localized routes. The loading UI must be lightweight, shell-compatible, and selector-stable via `[data-testid="route-loading"]`. Use the existing `Spinner` and current visual primitives. Do not fetch runtime data in the loading file. Ensure the loading UI is designed to render while the shell remains interactive, which means the shell itself must not perform blocking auth work. To make QA deterministic, also add one internal non-linked slow route such as `src/app/[locale]/__qa/slow/page.tsx` that intentionally awaits ~1500ms before rendering success content.
**Must NOT do**: Do not add per-page skeleton files in this slice. Do not put auth/session fetching into `[locale]/layout.tsx`. Do not use vague text-only fallback without the stable selector. Do not expose the slow QA route in navigation or docs user flows.
**Recommended Agent Profile**:
- Category: `quick` - Reason: single-file route fallback plus selector discipline
- Skills: `[]` - Existing spinner/card primitives are enough
- Omitted: `visual-engineering` - this should stay intentionally simple
**Parallelization**: Can Parallel: YES | Wave 2 | Blocks: Final verification wave | Blocked By: 1, 2, 3
**References** (executor has NO interview context - be exhaustive):
- Locale layout caveat: `https://nextjs.org/docs/app/api-reference/file-conventions/loading` - loading fallback will not cover blocking runtime data in the same layout
- Spinner primitive: `src/components/ui/spinner.tsx:5-12` - reuse existing spinner icon and status semantics
- Existing shell insertion: `src/app/[locale]/layout.tsx:10-25` - loading will render beneath this layout
- Existing page spacing: `src/app/[locale]/dashboard/page.tsx:28-41` - use similar content width and spacing rhythm
**Acceptance Criteria** (agent-executable only):
- [ ] `src/app/[locale]/loading.tsx` exists and renders a visible fallback with `[data-testid="route-loading"]`
- [ ] The fallback can render while the shell remains mounted
- [ ] Internal slow QA route deterministically triggers the loading fallback
- [ ] `pnpm build` succeeds with the new loading file in place
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Loading fallback appears during deterministic slow route
Tool: Playwright
Steps: Visit `/en/__qa/slow`; immediately inspect the page before the delayed route resolves.
Expected: `[data-testid="route-loading"]` becomes visible before the success content appears, while `[data-testid="app-nav"]` remains visible.
Evidence: .sisyphus/evidence/task-5-route-loading.png
Scenario: Loading fallback does not replace the shell chrome
Tool: Playwright
Steps: Repeat `/en/__qa/slow` navigation and capture the header region plus loading region simultaneously.
Expected: Header navigation remains mounted; only the route content area swaps to the loading fallback.
Evidence: .sisyphus/evidence/task-5-shell-persistence.png
```
**Commit**: YES | Message: `feat(ux): add locale route loading fallback` | Files: `src/app/[locale]/loading.tsx`, `src/app/[locale]/__qa/slow/page.tsx`
- [x] 6. Add a localized route error boundary with deterministic retry QA
**What to do**: Add `src/app/[locale]/error.tsx` as a client component that uses localized copy, surfaces a stable `[data-testid="route-error"]` wrapper, and exposes a retry control `[data-testid="route-error-retry"]` wired to `unstable_retry()`. To make QA deterministic without adding a full test framework, add one internal non-linked route such as `src/app/[locale]/__qa/route-error/page.tsx` that throws a controlled runtime error for boundary validation. The QA route should continue throwing on retry so the expected post-click state is deterministic. Keep it excluded from navigation and docs user flows; it exists only so agents can validate the boundary reliably.
**Must NOT do**: Do not add `app/global-error.tsx`. Do not leak raw server error details into user-facing copy. Do not put the QA route in nav or route constants.
**Recommended Agent Profile**:
- Category: `unspecified-high` - Reason: file-convention error boundary plus deterministic QA mechanism
- Skills: `[]` - Existing i18n and UI patterns are enough
- Omitted: `writing` - localized copy already comes from Task 1
**Parallelization**: Can Parallel: YES | Wave 2 | Blocks: Final verification wave | Blocked By: 1, 2, 3
**References** (executor has NO interview context - be exhaustive):
- Next.js error docs: `https://nextjs.org/docs/app/api-reference/file-conventions/error` - `error.tsx` must be a client component and should use `unstable_retry()` in v16.2+
- next-intl error-files guide: `https://next-intl.dev/docs/environments/error-files` - error boundary can use translated messages under existing provider context
- Root provider context: `src/app/layout.tsx:32-38` - translated client error boundary can rely on intl provider because scope is `[locale]`, not root
- Existing toast pattern: `src/components/auth/AuthForm.tsx:78-106` - follow repo style for non-blocking error handling if logging/toasts are added
- Button primitive: `src/components/ui/button.tsx:44-64` - use for retry CTA
**Acceptance Criteria** (agent-executable only):
- [ ] `src/app/[locale]/error.tsx` exists, is a client component, and calls `unstable_retry()` from its retry control
- [ ] Error UI is localized and does not expose raw server stack details
- [ ] Internal QA route throws and renders the route error boundary reliably
- [ ] Retry control is selector-stable via `[data-testid="route-error-retry"]`
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Controlled route error renders localized boundary
Tool: Playwright
Steps: Visit `/en/__qa/route-error`; inspect `[data-testid="route-error"]` and retry control.
Expected: Localized route error UI is visible; the page does not hard-crash into a blank screen; retry control is present.
Evidence: .sisyphus/evidence/task-6-route-error.png
Scenario: Retry re-renders the failing boundary deterministically
Tool: Playwright
Steps: On `/en/__qa/route-error`, click `[data-testid="route-error-retry"]` once.
Expected: Retry executes without a white-screen crash and the same localized error fallback re-renders cleanly because the QA route is intentionally still failing.
Evidence: .sisyphus/evidence/task-6-route-error-retry.png
```
**Commit**: YES | Message: `feat(ux): add locale error boundary and retry flow` | Files: `src/app/[locale]/error.tsx`, `src/app/[locale]/__qa/route-error/page.tsx`, any minimal supporting component
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing.
> **Do NOT auto-proceed after verification. Wait for user's explicit approval before marking work complete.**
> **Never mark F1-F4 as checked before getting user's okay.** Rejection or user feedback -> fix -> re-run -> present again -> wait for okay.
- [x] F1. Plan Compliance Audit — oracle
- [x] F2. Code Quality Review — unspecified-high
- [x] F3. Real Manual QA — unspecified-high (+ playwright if UI)
- [x] F4. Scope Fidelity Check — deep
## Commit Strategy
- Commit 1: `feat(i18n): add shell and fallback copy`
- Commit 2: `feat(shell): add shared app shell primitives`
- Commit 3: `feat(shell): integrate auth-aware nav into locale layout`
- Commit 4: `feat(routing): add localized and root not-found handling`
- Commit 5: `feat(ux): add locale route loading fallback`
- Commit 6: `feat(ux): add locale error boundary and retry flow`
## Success Criteria
- The template gains one consistent shell across localized routes without changing access-control semantics
- Anonymous and authenticated navigation states are deterministic and testable
- Unknown localized URLs and non-localized unmatched URLs both resolve to intentional 404 experiences
- Route loading and runtime error states are intentionally designed, translated, and recoverable

View file

@ -1,361 +0,0 @@
# Dashboard Password Reset Flow
## TL;DR
> **Summary**: Add an authenticated password-change flow reachable from the dashboard, with the actual form hosted on a protected `/settings` page that follows the repo's existing server-page plus client-component auth pattern.
> **Deliverables**:
> - Dashboard entry affordance to account security settings
> - Protected `/settings` page with localized password-change UI
> - Better Auth `changePassword` integration with explicit success/error handling
> - Agent-executable manual QA evidence for auth protection, validation, and credential rotation
> **Effort**: Medium
> **Parallel**: YES - 2 waves
> **Critical Path**: 1 -> 3 -> 4 -> 5 -> 6
## Context
### Original Request
Implement a user-facing password reset flow from `src/app/dashboard/page.tsx`.
### Interview Summary
- User selected the authenticated in-session flow, not forgot-password by email.
- Dashboard is the entry point, but the actual form should live on `/settings`.
- Manual QA only for now; no test infrastructure setup in this slice.
### Metis Review (gaps addressed)
- Protect `/settings` explicitly instead of assuming auth.
- Keep scope to password/security only; do not expand into general settings architecture.
- Use Better Auth's existing `changePassword` flow instead of inventing custom backend plumbing.
- Define concrete browser QA with fixed credentials and explicit redirect/session assertions.
## Work Objectives
### Core Objective
Provide a secure, authenticated password-change experience that users can reach from the dashboard and complete on a dedicated settings page.
### Deliverables
- Auth-protected `src/app/settings/page.tsx`
- Dashboard entry UI from `src/app/dashboard/page.tsx` to `/settings`
- Localized client password-change component under `src/components/`
- Better Auth client submission flow with `revokeOtherSessions: true`
- Updated translation files for all new strings
- QA evidence captured for happy path and failure cases
### Definition of Done (verifiable conditions with commands)
- `pnpm lint` completes successfully
- `pnpm build` completes successfully
- Visiting `/settings` while unauthenticated redirects to sign-in with a callback back to `/settings`
- A signed-in user can open `/settings`, submit a valid current/new password pair, and subsequently sign in with only the new password
- Wrong current password and mismatched confirmation both fail with clear user feedback
### Must Have
- Protected `/settings` route with explicit redirect behavior
- Dashboard affordance linking to `/settings`
- Fields for `currentPassword`, `newPassword`, and `confirmPassword`
- Client validation for required fields, confirmation match, and rejecting `newPassword === currentPassword`
- Backend-driven password validation through Better Auth's configured policies, including HIBP plugin
- Success/error feedback and loading state consistent with existing auth UI
- New strings added to both `messages/en.json` and `messages/pl.json`
### Must NOT Have (guardrails, AI slop patterns, scope boundaries)
- No forgot-password email flow, token flow, reset page, or mail templates
- No full settings IA, profile editor, preferences, or navigation redesign
- No new test framework, Playwright project, or CI setup in this slice
- No custom Convex mutation/server action for password change unless Better Auth client API proves unusable during execution
- No route-protection behavior left implicit or undocumented
## Verification Strategy
> ZERO HUMAN INTERVENTION - all verification is agent-executed.
- Test decision: none + existing repo has no test framework; use browser-driven QA and build/lint verification
- QA policy: Every task includes agent-executed scenarios with exact credentials, selectors, and expected outcomes
- Evidence: `.sisyphus/evidence/task-{N}-{slug}.{ext}`
## Execution Strategy
### Parallel Execution Waves
> Target: 5-8 tasks per wave. <3 per wave (except final) = under-splitting.
> Extract shared dependencies as Wave-1 tasks for max parallelism.
Wave 1: 1) route protection and settings page scaffold, 2) dashboard entry affordance, 3) settings UI shell + translations
Wave 2: 4) client validation and field UX, 5) Better Auth submission + session behavior, 6) polish and end-to-end manual QA capture
### Dependency Matrix (full, all tasks)
- 1 blocks 4, 5, 6
- 2 is independent after route constants are confirmed; feeds 6 QA coverage
- 3 blocks 4 and 5
- 4 blocks 5 and 6
- 5 blocks 6
- 6 blocks final verification wave
### Agent Dispatch Summary (wave -> task count -> categories)
- Wave 1 -> 3 tasks -> unspecified-high, visual-engineering, visual-engineering
- Wave 2 -> 3 tasks -> quick, unspecified-high, unspecified-high
## TODOs
> Implementation + Test = ONE task. Never separate.
> EVERY task MUST have: Agent Profile + Parallelization + QA Scenarios.
- [ ] 1. Add protected `/settings` page scaffold
**What to do**: Read the relevant Next.js App Router docs under `node_modules/next/dist/docs/` before coding, then create `src/app/settings/page.tsx` as a server page that checks authentication with the existing server auth helpers from `src/lib/auth-server.ts`. If unauthenticated, redirect to `/sign-in?callbackURL=/settings`. If authenticated, render a dedicated client settings/password component and keep the page focused on security only.
**Must NOT do**: Do not implement forgot-password here. Do not place the full form directly in `src/app/settings/page.tsx`. Do not create middleware or a broader settings layout in this slice.
**Recommended Agent Profile**:
- Category: `unspecified-high` - Reason: auth-aware App Router work with redirect behavior and server/client boundary decisions
- Skills: `[]` - No special skill required beyond repo pattern matching
- Omitted: `playwright` - UI automation belongs in QA, not scaffolding
**Parallelization**: Can Parallel: YES | Wave 1 | Blocks: 4, 5, 6 | Blocked By: none
**References** (executor has NO interview context - be exhaustive):
- Pattern: `src/app/sign-in/page.tsx` - thin server page wrapper pattern already used for auth routes
- Pattern: `src/app/sign-up/page.tsx` - same page composition pattern for route-level wrappers
- Auth helper: `src/lib/auth-server.ts` - server-side auth utilities available for checking session/auth state
- Route contract: `src/lib/routes.ts` - `/settings` already exists as a private route constant
- Existing target: `src/app/dashboard/page.tsx` - current dashboard stub that will link into this route
- External: `node_modules/next/dist/docs/` - project instruction requires reading relevant Next.js docs before implementation
**Acceptance Criteria** (agent-executable only):
- [ ] `src/app/settings/page.tsx` exists and remains a server page wrapper rather than a client-heavy file
- [ ] Unauthenticated access to `/settings` redirects to `/sign-in?callbackURL=/settings`
- [ ] Authenticated access to `/settings` renders the dedicated password settings component
- [ ] `pnpm lint` passes after the page scaffold is added
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Unauthenticated redirect from settings
Tool: Playwright
Steps: Open a fresh browser context; visit http://localhost:3000/settings directly.
Expected: Browser lands on `/sign-in` and preserves a callback URL containing `/settings`.
Evidence: .sisyphus/evidence/task-1-settings-redirect.png
Scenario: Authenticated settings page render
Tool: Playwright
Steps: Sign up `changeflow@example.com` with password `OldPass123!`; sign in; visit http://localhost:3000/settings.
Expected: The page renders a password/security card instead of redirecting away.
Evidence: .sisyphus/evidence/task-1-settings-render.png
```
**Commit**: YES | Message: `add protected settings page scaffold and dashboard entry` | Files: `src/app/settings/page.tsx`, `src/lib/routes.ts` (only if needed), related imports
- [ ] 2. Add dashboard entry to account security
**What to do**: Replace the dashboard stub with a minimal, intentional dashboard card or section that exposes a single clear affordance to account security settings. Link to `/settings` via existing route constants. Keep the page intentionally narrow: one CTA, one short description, no full settings UI embedded here.
**Must NOT do**: Do not turn dashboard into a general settings page. Do not add unrelated profile, billing, or preferences UI.
**Recommended Agent Profile**:
- Category: `visual-engineering` - Reason: small UI composition task that must feel deliberate, not boilerplate
- Skills: `[]` - Existing component library provides all primitives needed
- Omitted: `playwright` - verification happens via QA scenario, not implementation
**Parallelization**: Can Parallel: YES | Wave 1 | Blocks: 6 QA coverage only | Blocked By: none
**References** (executor has NO interview context - be exhaustive):
- Target page: `src/app/dashboard/page.tsx` - currently only a stub and should become the entry point
- Route contract: `src/lib/routes.ts` - use the existing private route constant for settings
- UI pattern: `src/components/ui/card.tsx` - use existing card primitives for a compact dashboard section
- UI pattern: `src/components/ui/button.tsx` - use existing button variants for the CTA
**Acceptance Criteria** (agent-executable only):
- [ ] `/dashboard` contains a visible CTA linking to `/settings`
- [ ] The CTA uses route constants rather than a duplicated hard-coded path
- [ ] The dashboard change stays focused on password/security entry only
- [ ] `pnpm lint` passes after the dashboard update
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Dashboard exposes security entry
Tool: Playwright
Steps: Sign in as `changeflow@example.com` with `OldPass123!`; open http://localhost:3000/dashboard; inspect the visible security/settings CTA.
Expected: Clicking the CTA navigates to `/settings`.
Evidence: .sisyphus/evidence/task-2-dashboard-entry.png
Scenario: Dashboard does not embed the password form
Tool: Playwright
Steps: Load http://localhost:3000/dashboard while signed in.
Expected: No `currentPassword`, `newPassword`, or `confirmPassword` input fields are present on the dashboard page itself.
Evidence: .sisyphus/evidence/task-2-dashboard-no-form.png
```
**Commit**: YES | Message: `add protected settings page scaffold and dashboard entry` | Files: `src/app/dashboard/page.tsx`
- [ ] 3. Create the localized password settings component shell
**What to do**: Create a dedicated client component for password change under `src/components/settings/` and wire it into `src/app/settings/page.tsx`. Use the same card, field, button, spinner, and password input-group patterns already used in `src/components/auth/AuthForm.tsx`. Add all new translation keys to both locale files up front so no hard-coded strings remain.
**Must NOT do**: Do not reuse `AuthForm` directly for password change. Do not leave untranslated labels, helper text, button copy, or toast messages.
**Recommended Agent Profile**:
- Category: `visual-engineering` - Reason: new client UI should blend with existing auth UI while staying scoped
- Skills: `[]` - Existing component system is sufficient
- Omitted: `writing` - copy additions are straightforward translation entries, not long-form docs
**Parallelization**: Can Parallel: YES | Wave 1 | Blocks: 4, 5, 6 | Blocked By: 1
**References** (executor has NO interview context - be exhaustive):
- Pattern: `src/components/auth/AuthForm.tsx` - existing client auth form conventions for card layout, field composition, spinner, toast, and password visibility controls
- UI primitives: `src/components/ui/field.tsx`, `src/components/ui/input-group.tsx`, `src/components/ui/button.tsx`, `src/components/ui/spinner.tsx`
- Translation files: `messages/en.json`, `messages/pl.json` - extend with a dedicated settings/security namespace or equivalent flat keys
- Existing i18n usage: `src/components/auth/AuthForm.tsx` - `useTranslations(...)` pattern
**Acceptance Criteria** (agent-executable only):
- [ ] A dedicated client component exists under `src/components/settings/`
- [ ] `/settings` renders a single password/security card with localized heading, description, and button text
- [ ] Both `messages/en.json` and `messages/pl.json` contain all strings needed for the new UI
- [ ] No new hard-coded user-facing strings remain in the component
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Password settings shell renders expected fields
Tool: Playwright
Steps: Sign in; visit http://localhost:3000/settings; inspect the form.
Expected: Inputs named `currentPassword`, `newPassword`, and `confirmPassword` are visible, plus a submit button.
Evidence: .sisyphus/evidence/task-3-settings-shell.png
Scenario: Localized shell does not regress rendering
Tool: Playwright
Steps: Load the page in the default locale; inspect heading, field labels, and submit button.
Expected: All visible strings are rendered from translation data with no raw key names or empty labels.
Evidence: .sisyphus/evidence/task-3-settings-i18n.png
```
**Commit**: YES | Message: `add password change form UI and localization` | Files: `src/components/settings/PasswordChangeCard.tsx`, `src/app/settings/page.tsx`, `messages/en.json`, `messages/pl.json`
- [ ] 4. Implement client-side validation and field UX
**What to do**: Define a dedicated Zod schema for the password-change form that requires `currentPassword`, reuses `defaultPasswordValidator()` for `newPassword`, enforces `confirmPassword === newPassword`, and rejects `newPassword === currentPassword`. Provide inline field errors and disable duplicate submissions with a loading state. Keep password visibility UX consistent with the existing auth form pattern.
**Must NOT do**: Do not add uppercase/number/special-character composition rules back into the client. Do not rely only on toasts for validation errors that belong inline.
**Recommended Agent Profile**:
- Category: `quick` - Reason: bounded form-schema and field-state work inside one component
- Skills: `[]` - Existing validation patterns are enough
- Omitted: `ultrabrain` - no deep algorithmic work needed
**Parallelization**: Can Parallel: NO | Wave 2 | Blocks: 5, 6 | Blocked By: 1, 3
**References** (executor has NO interview context - be exhaustive):
- Validation helper: `src/constants.ts` - current shared minimum-length validator to reuse for `newPassword`
- Pattern: `src/components/auth/AuthForm.tsx` - React Hook Form + Zod resolver usage and inline `FieldError` handling
- UI primitives: `src/components/ui/field.tsx`, `src/components/ui/input-group.tsx`, `src/components/ui/spinner.tsx`
**Acceptance Criteria** (agent-executable only):
- [ ] Submitting mismatched `newPassword` and `confirmPassword` surfaces an inline error on the confirmation field
- [ ] Submitting the same value for current and new password surfaces an inline error before any network request
- [ ] Submitting an empty required field surfaces inline validation without a success toast
- [ ] Submit button disables while the request is pending
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Mismatched confirmation is blocked locally
Tool: Playwright
Steps: Sign in; open `/settings`; fill `currentPassword=OldPass123!`, `newPassword=NewPass123!`, `confirmPassword=Mismatch123!`; submit.
Expected: Inline error appears for `confirmPassword`; no network-driven success state is shown.
Evidence: .sisyphus/evidence/task-4-confirm-mismatch.png
Scenario: Reusing the same password is blocked locally
Tool: Playwright
Steps: Fill `currentPassword=OldPass123!`, `newPassword=OldPass123!`, `confirmPassword=OldPass123!`; submit.
Expected: Inline error indicates the new password must differ from the current password.
Evidence: .sisyphus/evidence/task-4-same-password.png
```
**Commit**: YES | Message: `add password change form UI and localization` | Files: `src/components/settings/PasswordChangeCard.tsx`, related validation helpers only if needed
- [ ] 5. Wire Better Auth password change submission
**What to do**: Connect the settings form to Better Auth's client password-change API using the authenticated session. Submit `currentPassword`, `newPassword`, and `revokeOtherSessions: true`. On success, stay on `/settings`, clear sensitive fields, and show a localized success toast. On failure, surface the returned error cleanly without clearing the current user session.
**Must NOT do**: Do not create a custom Convex mutation for password change unless the Better Auth client call is proven unusable. Do not redirect away from settings after success. Do not silently swallow backend errors.
**Recommended Agent Profile**:
- Category: `unspecified-high` - Reason: auth-sensitive submission flow with session behavior and backend error handling
- Skills: `[]` - Existing Better Auth client is already configured in repo
- Omitted: `writing` - this is behavior wiring, not docs work
**Parallelization**: Can Parallel: NO | Wave 2 | Blocks: 6 | Blocked By: 1, 3, 4
**References** (executor has NO interview context - be exhaustive):
- Client auth entry: `src/lib/auth-client.ts` - Better Auth client already configured with Convex plugin
- Existing auth pattern: `src/components/auth/AuthForm.tsx` - request pending state, toast handling, Better Auth client invocation patterns
- Backend policy: `convex/auth.ts` - Better Auth email/password configuration with HIBP plugin and min/max length
- Settings component: `src/components/settings/PasswordChangeCard.tsx` - task 3/4 output
**Acceptance Criteria** (agent-executable only):
- [ ] Valid submission calls Better Auth password-change API and succeeds for the signed-in user
- [ ] Wrong current password yields a visible error state without logging the user out of the current session
- [ ] Successful submission clears all password inputs and leaves the user on `/settings`
- [ ] `pnpm build` passes after the integration is complete
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Wrong current password is rejected
Tool: Playwright
Steps: Sign in; open `/settings`; fill `currentPassword=WrongPass123!`, `newPassword=NewPass123!`, `confirmPassword=NewPass123!`; submit.
Expected: Error feedback appears; the current session remains usable; revisiting `/dashboard` still works in the same tab.
Evidence: .sisyphus/evidence/task-5-wrong-current-password.png
Scenario: Valid password change succeeds
Tool: Playwright
Steps: Fill `currentPassword=OldPass123!`, `newPassword=NewPass123!`, `confirmPassword=NewPass123!`; submit.
Expected: Success toast/message appears; fields clear; page remains on `/settings`.
Evidence: .sisyphus/evidence/task-5-successful-change.png
```
**Commit**: YES | Message: `wire password change flow and verify session behavior` | Files: `src/components/settings/PasswordChangeCard.tsx`, any minimal auth client touch-ups only if required
- [ ] 6. Finalize credential-rotation behavior and capture manual QA evidence
**What to do**: Validate the full user journey end to end: dashboard -> settings -> change password -> sign out -> sign back in with the new credential. Because `revokeOtherSessions: true` is the default for this slice, also verify in a second browser context that another active session becomes unauthorized after the password change. Capture screenshots/logs into the evidence paths referenced below.
**Must NOT do**: Do not add automated test infrastructure or CI in order to satisfy this task. Do not leave QA as a vague manual checklist.
**Recommended Agent Profile**:
- Category: `unspecified-high` - Reason: auth/stateful browser verification across two sessions
- Skills: [`playwright`] - Use browser automation to validate auth and session transitions precisely
- Omitted: `frontend-ui-ux` - this task is verification-focused, not design-focused
**Parallelization**: Can Parallel: NO | Wave 2 | Blocks: Final verification wave | Blocked By: 1, 2, 3, 4, 5
**References** (executor has NO interview context - be exhaustive):
- Entry page: `src/app/dashboard/page.tsx`
- Protected page: `src/app/settings/page.tsx`
- Form component: `src/components/settings/PasswordChangeCard.tsx`
- Auth UI precedent: `src/components/auth/AuthForm.tsx`
- Command surface: `package.json` - use existing `pnpm dev`, `pnpm lint`, and `pnpm build`
**Acceptance Criteria** (agent-executable only):
- [ ] After password change, signing in with `OldPass123!` fails and signing in with `NewPass123!` succeeds
- [ ] A second active browser context is no longer authorized after the password change
- [ ] Evidence files exist for redirect, validation failure, success state, old-password rejection, and second-session invalidation
- [ ] `pnpm lint` and `pnpm build` both pass on the final implementation
**QA Scenarios** (MANDATORY - task incomplete without these):
```
Scenario: Old password fails and new password succeeds
Tool: Playwright
Steps: Complete the password change; sign out; attempt sign-in with `OldPass123!`; then attempt sign-in with `NewPass123!`.
Expected: Old password sign-in fails; new password sign-in succeeds and returns to an authenticated page.
Evidence: .sisyphus/evidence/task-6-old-vs-new-credential.png
Scenario: Other sessions are revoked
Tool: Playwright
Steps: Open session A and session B signed in as the same user; change the password in session A; in session B navigate to `/dashboard` or refresh.
Expected: Session B is redirected to sign-in or otherwise loses authenticated access.
Evidence: .sisyphus/evidence/task-6-revoke-other-sessions.png
```
**Commit**: YES | Message: `wire password change flow and verify session behavior` | Files: no net-new feature files expected beyond minimal polish; evidence output only
## Final Verification Wave (MANDATORY - after ALL implementation tasks)
> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing.
> **Do NOT auto-proceed after verification. Wait for user's explicit approval before marking work complete.**
> **Never mark F1-F4 as checked before getting user's okay.** Rejection or user feedback -> fix -> re-run -> present again -> wait for okay.
- [ ] F1. Plan Compliance Audit - oracle
- [ ] F2. Code Quality Review - unspecified-high
- [ ] F3. Real Manual QA - unspecified-high (+ playwright if UI)
- [ ] F4. Scope Fidelity Check - deep
## Commit Strategy
- Commit 1: `add protected settings page scaffold and dashboard entry`
- Commit 2: `add password change form UI and localization`
- Commit 3: `wire password change flow and verify session behavior`
## Success Criteria
- The feature remains scoped to authenticated password change only
- `/dashboard` clearly leads users into account security settings
- `/settings` is inaccessible without authentication
- Password change succeeds only with the correct current password and valid new password
- QA evidence proves redirect, validation failure, success path, and new-credential sign-in behavior

264
AGENTS.md
View file

@ -3,267 +3,3 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
# Agent Instructions
## Architecture Overview
This is a **Next.js 16 + Convex (self-hosted) + Better Auth** SaaS template.
- **Frontend**: Next.js 16 App Router, React 19, TypeScript 5, Tailwind CSS 4
- **Backend**: Convex self-hosted (Coolify) with Better Auth for authentication
- **UI**: shadcn/ui (radix-nova style), @hugeicons/react icons
- **i18n**: next-intl v4 with locale-based routing (`/en`, `/pl`)
- **State**: React Server Components + Client Components hybrid. No global state library.
- **Auth**: Better Auth with email/password, HIBP plugin, Convex adapter
### Key Files
| File | Purpose |
|------|---------|
| `src/app/[locale]/layout.tsx` | Locale layout — validates locale, enables static rendering |
| `src/app/layout.tsx` | Root layout — theme provider, i18n provider, dynamic `lang` |
| `src/lib/auth-server.ts` | Server-side auth helpers (`isAuthenticated`, `getToken`) |
| `src/lib/auth-client.ts` | Client-side auth client (`authClient.signIn.email(...)`) |
| `src/i18n/routing.ts` | next-intl routing config (locales, defaultLocale, localePrefix) |
| `src/i18n/request.ts` | Request config — reads locale from middleware, loads messages |
| `src/proxy.ts` | next-intl middleware (Next.js 16 convention — was `middleware.ts`) |
| `convex/auth.ts` | Better Auth configuration (plugins, password policy, HIBP) |
| `convex/convex.config.ts` | Convex app definition |
### Directory Structure
```
src/
app/[locale]/ # All pages live under locale segment
layout.tsx # Locale validation + setRequestLocale
page.tsx # Home page
dashboard/page.tsx # Protected dashboard (server page + isAuthenticated)
settings/page.tsx # Protected settings (server page + isAuthenticated)
sign-in/page.tsx # Auth form with callbackURL support
sign-up/page.tsx # Auth form with callbackURL support
api/auth/[...all]/ # Better Auth API route
components/
ui/ # shadcn/ui primitives (button, card, field, etc.)
auth/ # Auth-specific components
settings/ # Settings-specific components
core/ # App-wide components (ThemeChanger)
lib/
auth-server.ts # Server auth helpers
auth-client.ts # Client auth client
routes.ts # Route constants
utils.ts # cn() and utilities
env.ts # Validated environment variables (Zod)
i18n/
routing.ts # next-intl routing config
request.ts # next-intl request config
convex/
auth.ts # Better Auth setup
auth.config.ts # Convex auth config provider
convex.config.ts # Convex app definition
http.ts # Convex HTTP actions
betterAuth/ # Better Auth Convex component
messages/
en.json # English translations
pl.json # Polish translations
```
## Conventions
### File Naming
- **Components**: PascalCase (`PasswordChangeCard.tsx`, `AuthForm.tsx`)
- **Pages**: `page.tsx` inside kebab-case directory (`sign-in/page.tsx`)
- **Layouts**: `layout.tsx`
- **Utilities**: camelCase (`auth-server.ts`, `utils.ts`)
- **Constants**: `UPPER_SNAKE_CASE` for values, camelCase for files (`constants.ts`)
### Imports
```ts
// Good
import { Button } from '@/components/ui/button';
import { routes } from '@/lib/routes';
// Bad — never use @/src/components/
import { Card } from '@/src/components/ui/card';
```
### Auth Patterns
#### Server Component (check auth)
```tsx
import { isAuthenticated } from '@/lib/auth-server';
import { routes } from '@/lib/routes';
import { redirect } from 'next/navigation';
export default async function ProtectedPage() {
const authenticated = await isAuthenticated();
if (!authenticated) {
const searchParams = new URLSearchParams({ callbackURL: '/dashboard' });
redirect(`${routes.public.signIn}?${searchParams.toString()}`);
}
// ... render protected content
}
```
#### Client Component (auth actions)
```tsx
'use client';
import { authClient } from '@/lib/auth-client';
async function handleSignIn(email: string, password: string) {
const result = await authClient.signIn.email({
email,
password,
callbackURL: '/dashboard',
});
if (result.error) {
toast.error(result.error.message);
}
}
```
#### Password Change
```tsx
const result = await authClient.changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
revokeOtherSessions: true,
});
```
### i18n Patterns
#### Server Component
```tsx
import { getTranslations } from 'next-intl/server';
export default async function Page() {
const t = await getTranslations('Namespace');
return <h1>{t('Title')}</h1>;
}
```
#### Client Component
```tsx
'use client';
import { useTranslations } from 'next-intl';
export function Component() {
const t = useTranslations('Namespace');
return <h1>{t('Title')}</h1>;
}
```
#### Adding Translations
1. Add keys to `messages/en.json` AND `messages/pl.json`
2. Use namespace grouping (`AuthPage`, `DashboardPage`, `SettingsPage`)
3. Never hardcode user-facing strings in components
### Route Constants
Always use `src/lib/routes.ts`:
```ts
import { routes } from '@/lib/routes';
// Good
<Link href={routes.private.settings}>Settings</Link>
// Bad
<Link href="/settings">Settings</Link>
```
### Environment Variables
All env vars are validated at runtime in `src/lib/env.ts`. **Never read `process.env` directly** — import `env` instead:
```ts
import { env } from '@/lib/env';
// Good
const url = env.NEXT_PUBLIC_CONVEX_URL;
// Bad
const url = process.env.NEXT_PUBLIC_CONVEX_URL;
```
## Anti-Patterns (NEVER DO)
### Auth
- ❌ **Never create custom Convex mutations for auth** — use Better Auth client API
- ❌ **Never use `useTranslations` in async server components** — use `getTranslations` instead
- ❌ **Never leave a private route unprotected** — always use `isAuthenticated()` + redirect
- ❌ **Never hardcode `/sign-in` or `/dashboard`** — use `routes.public.signIn` / `routes.private.dashboard`
### i18n
- ❌ **Never read `Accept-Language` manually** — next-intl middleware handles locale detection
- ❌ **Never hardcode `lang="en"`** — read from `x-next-intl-locale` header
- ❌ **Never forget to add both `en.json` and `pl.json`** — keep translations in sync
### Code Quality
- ❌ **Never use `as any`** — proper typing exists (see `convex/betterAuth/auth.ts`)
- ❌ **Never use `@/src/components/`** — use `@/components/` directly
- ❌ **Never mix `@ts-ignore` or `@ts-expect-error`** — fix the type error instead
- ❌ **Never read `process.env` directly** — always use validated `env` from `src/lib/env.ts`
### Next.js 16 Specifics
- ❌ **Never use `middleware.ts`** — Next.js 16 uses `proxy.ts` instead
- ❌ **Never use Turbopack** — it's broken in 16.2.1 (900% CPU spike). Use `pnpm dev --webpack`
## Adding a New Feature
### New Protected Page
1. Create directory under `src/app/[locale]/my-page/`
2. Add `page.tsx` as async server component
3. Call `isAuthenticated()` at the top
4. Redirect to `routes.public.signIn` with `callbackURL` if unauthenticated
5. Add route to `src/lib/routes.ts` if it's a major page
6. Add translations to `messages/en.json` and `messages/pl.json`
### New UI Component
1. Check if shadcn/ui primitive exists first (`src/components/ui/`)
2. If not, create in `src/components/my-feature/MyComponent.tsx`
3. Use existing patterns: `cn()` for class merging, `Field`/`FieldGroup` for forms
4. Export from barrel file if you create an index
### New Convex Function
1. Create in `convex/myFeature.ts`
2. Use `query({ args: {}, handler: async (ctx) => { ... } })`
3. Export and use via generated API
4. Never put auth logic in Convex — Better Auth owns that
## Troubleshooting
### `missing field functions` on `npx convex dev`
CLI and Convex backend image versions don't match. Upgrade the lower one.
### Locale not switching
Check `src/proxy.ts` matcher includes the route. Check browser has `NEXT_LOCALE` cookie.
### Auth redirect loop
Ensure `callbackURL` starts with `/`. Ensure `routes.public.signIn` doesn't itself require auth.
## External References
- [Next.js 16 Docs](https://nextjs.org/docs) — check `node_modules/next/dist/docs/` for exact APIs
- [Convex Docs](https://docs.convex.dev/)
- [Better Auth Docs](https://www.better-auth.com/)
- [next-intl Docs](https://next-intl.dev/)

View file

@ -1,437 +0,0 @@
# Development Guide
Personal cheat sheet for bootstrapping and deploying projects from this template.
## Local Development Setup
### 1. Clone & Install
```bash
git clone <your-forgejo-repo> my-project
cd my-project
pnpm install
```
### 2. Environment Variables
Copy `.env.example` to `.env.local` and fill in:
```bash
CONVEX_SELF_HOSTED_URL='https://convex-backend.mentat.ovh'
CONVEX_SELF_HOSTED_ADMIN_KEY='self-hosted-convex|...'
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_CONVEX_URL=https://convex-backend.mentat.ovh
NEXT_PUBLIC_CONVEX_SITE_URL=https://backend-site-xxx.mentat.ovh
```
> **Never commit `.env.local`** — it's in `.gitignore`.
### 3. Run Dev Server
```bash
pnpm dev --webpack
```
> ⚠️ **Never use Turbopack** — Next.js 16.2.1 has a CPU spike bug. Always `--webpack`.
### 4. Convex Setup (Local)
```bash
npx convex dev
```
This connects to your self-hosted Convex instance. Ensure CLI version matches your backend image version.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ LOCAL DEV │
│ pnpm dev --webpack → npx convex dev → DEV CONVEX DB │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ FORGEJO / GIT │
│ main branch ──► PROD DEPLOY │
│ dev branch ──► DEV DEPLOY │
└─────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ COOLIFY (PROD) │ │ COOLIFY (DEV) │
│ Next.js Docker Container │ │ Next.js Docker Container │
│ (frontend) │ │ (frontend) │
└─────────────────────────────┘ └─────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ CONVEX (PROD DB) │ │ CONVEX (DEV DB) │
│ Self-hosted on Coolify │ │ Self-hosted on Coolify │
│ or Convex Cloud │ │ or Convex Cloud │
└─────────────────────────────┘ └─────────────────────────────┘
```
### Convex Backend
Convex runs **separately** from the Next.js frontend. You need two instances:
- **Dev**: for local development + dev environment testing
- **Prod**: for production
On Coolify, create a new service for each Convex backend using the official docker-compose:
```yaml
services:
backend:
image: 'ghcr.io/get-convex/convex-backend:latest'
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- '${PORT:-3210}:3210'
- '${SITE_PROXY_PORT:-3211}:3211'
volumes:
- 'data:/convex/data'
environment:
INSTANCE_NAME: '${INSTANCE_NAME}'
INSTANCE_SECRET: '${INSTANCE_SECRET}'
CONVEX_CLOUD_ORIGIN: '${SERVICE_URL_BACKEND}'
CONVEX_SITE_ORIGIN: '${SERVICE_URL_BACKEND_SITE}'
DO_NOT_REQUIRE_SSL: '${DO_NOT_REQUIRE_SSL:-true}'
DATABASE_URL: '${DATABASE_URL:-}'
healthcheck:
test: ['CMD-SHELL', 'curl -f http://localhost:3210/version']
interval: 5s
start_period: 10s
timeout: 5s
retries: 10
dashboard:
image: 'ghcr.io/get-convex/convex-dashboard:latest'
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- '${DASHBOARD_PORT:-6791}:6791'
environment:
PORT: '6791'
NEXT_PUBLIC_DEPLOYMENT_URL: '${SERVICE_URL_BACKEND}'
depends_on:
backend:
condition: service_healthy
volumes:
data: null
```
> **Best practice**: Copy the [official self-hosted config](https://github.com/get-convex/convex-backend/blob/main/self-hosted/docker/docker-compose.yml) and adapt env vars for Coolify.
### Generate Admin Key
SSH into the server and run:
```bash
docker exec -it <backend-container-name> ./generate_admin_key.sh
```
Save the key. You'll use it to log into the Convex dashboard.
### 3. Configure Domains
Add **2 domains** in Coolify for the backend:
1. Backend API: `https://convex-backend.mentat.ovh:3210`
2. Actions proxy: `https://backend-site-xxx.mentat.ovh:3211`
### 4. Better Auth Env Vars
Set these on your Convex backend via CLI:
```bash
pnpm dlx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
pnpm dlx convex env set SITE_URL=http://localhost:3000
```
For production, `SITE_URL` should be your deployed frontend URL.
### 5. Resend Email Setup
Email is handled by Resend. The API key is **server-side only** and lives on Convex, not in `.env.local`.
1. Get your API key from [resend.com](https://resend.com)
2. Verify your domain in Resend and create a sender email (e.g. `noreply@yourdomain.com`)
3. Set Convex environment variables:
```bash
pnpm dlx convex env set RESEND_API_KEY=re_xxxxxxxxxxxxx
pnpm dlx convex env set RESEND_FROM_EMAIL=noreply@yourdomain.com
```
> These are read by `convex/lib/resend.ts` at runtime. Never commit them.
## Adding a New Project from Template
### Option A: Manual Clone (Private Forgejo)
```bash
git clone <forgejo-url>/convex-next-saas.git new-project
cd new-project
# Remove .git and re-init
git remote remove origin
git init
git remote add origin <new-project-url>
```
### Option B: Using the Init Script
```bash
node bin/init-template.mjs new-project
```
This will:
- Copy the template to `new-project/`
- Replace personal/project-specific strings with placeholders
- Initialize a fresh git repo
- Run `pnpm install`
**What gets replaced:**
| Original | Replaced With |
|----------|---------------|
| `convex-next-saas` | `<project-name>` |
| `SaaS Template` | `<project-name>` |
| `Create SaaS in a day!` | `<project-name> — built with convex-next-saas` |
| `https://convex-backend.mentat.ovh` | `https://your-convex-backend.example.com` |
| `https://convex-backend.mentat.ovh:3210` | `https://your-convex-backend.example.com:3210` |
| `https://backend-site-olnjg91x5ervt6j6owwgnlha.mentat.ovh` | `https://your-convex-site.example.com` |
| `https://backend-site-xxx.mentat.ovh:3211` | `https://your-convex-site.example.com:3211` |
| `self-hosted-convex\|01eea0ecf04bed0f70b73021564229f7f08eecff003f7294e5f9279faa4c19ffd5c501b0ac` | `self-hosted-convex\|YOUR_ADMIN_KEY` |
| `/home/nxtkofi/.config/tmuxinator/` | `~/.config/tmuxinator/` |
| `~/vaults/mentat/` | `~/workspace/` |
> **Note:** The script performs a simple string replacement across all text files. Review the output if you have customizations that might collide with these patterns.
## Common Issues
### `missing field functions` on `npx convex dev`
Your CLI and backend image versions differ. Check:
```bash
npx convex --version
# Compare with backend image tag in Coolify
```
Upgrade whichever is lower.
### Auth redirect loop
- Ensure `callbackURL` starts with `/`
- Ensure `sign-in` and `sign-up` pages are **not** protected by `isAuthenticated()`
- Check `NEXT_PUBLIC_SITE_URL` matches your actual URL
### Locale not switching
- Check `src/proxy.ts` matcher includes the route
- Check browser has `NEXT_LOCALE` cookie
- Ensure both `messages/en.json` and `messages/pl.json` exist and are valid JSON
### Build fails with Turbopack
You forgot `--webpack`:
```bash
# Wrong
pnpm build
# Right
# build script in package.json already does next build
# dev only:
pnpm dev --webpack
```
## Project Checklist
When starting a new project:
- [ ] Clone template / run init script
- [ ] Update `package.json` name
- [ ] Update `README.md` title and description
- [ ] Fill `.env.local`
- [ ] Deploy Convex backend on Coolify
- [ ] Set `BETTER_AUTH_SECRET` and `SITE_URL` on Convex
- [ ] Set `RESEND_API_KEY` and `RESEND_FROM_EMAIL` on Convex
- [ ] Update `src/app/layout.tsx` metadata
- [ ] Remove/replace placeholder content in `src/app/[locale]/page.tsx`
- [ ] Add project-specific routes to `src/lib/routes.ts`
- [ ] Run `pnpm lint` and `pnpm build` to verify
## PWA Lite
The template includes lightweight PWA installability out of the box. It is intentionally minimal and does **not** include offline caching, push notifications, or app-store wrappers.
### What is included
- `src/app/manifest.ts` — Web App Manifest with installability metadata
- `public/pwa/icon.svg` — template placeholder icon
- `public/pwa/maskable-icon.svg` — template placeholder maskable icon
- `public/pwa/apple-touch-icon.svg` — template placeholder Apple touch icon
- `src/app/layout.tsx` — PWA-relevant metadata (`applicationName`, `appleWebApp`, `icons`)
### What is NOT included
- Service worker or offline caching
- Push notifications
- Google Play TWA / Bubblewrap wrapper
- Apple App Store native wrapper
### Before shipping, customize these
| Field | File |
|-------|------|
| `name`, `short_name`, `description` | `src/app/manifest.ts` |
| `theme_color`, `background_color` | `src/app/manifest.ts` |
| `start_url`, `scope` | `src/app/manifest.ts` |
| Icon files | `public/pwa/` |
| `applicationName`, `appleWebApp.title` | `src/app/layout.tsx` |
> Replace the placeholder SVG icons with real branded assets before production. The template uses neutral `S` initials as a placeholder only.
### Locale behavior
The default manifest uses `start_url: '/'`. With `localePrefix: 'as-needed'` in `src/i18n/routing.ts`, Next.js/next-intl resolves the locale automatically when the app opens.
### Auth / cache note
Do not add aggressive caching for authenticated SaaS pages (dashboard, settings) unless you are deliberately designing offline behavior. The template intentionally skips the service worker to avoid accidental auth data leaks.
### Store distribution
- **Google Play**: requires a native/TWA wrapper such as Bubblewrap. The manifest alone is not enough.
- **Apple App Store**: usually rejects simple repackaged websites without native value. A PWA wrapper alone is not sufficient.
## Docker Deployment
The template includes a `Dockerfile` and `docker-compose.yml` for containerized deployment.
### Dockerfile
Multi-stage build optimized for production:
- **deps**: Installs dependencies
- **builder**: Builds the Next.js app
- **runner**: Minimal image with only production artifacts
```bash
# Build locally
docker build -t my-app .
# Run locally
docker run -p 3000:3000 --env-file .env.local my-app
```
### Docker Compose
Frontend-only deployment:
```bash
# Build and start Next.js app
docker compose up --build
```
Services:
| Service | Port | Description |
|---------|------|-------------|
| `app` | 3000 | Next.js application |
> **Note**: Convex backend runs separately. See [Coolify Deployment](#coolify-deployment) for Convex setup.
### Server Components in Docker
Next.js 16 App Router with Server Components (RSC) works identically in Docker:
- **Build time**: `next build` generates static pages + server bundles
- **Runtime**: `next start` serves both static and server-rendered content
- **No special config needed**`output: 'standalone'` in `next.config.ts` handles everything
The Dockerfile uses `output: 'standalone'` which creates a self-contained server bundle. This means:
- Smaller image size (only production files)
- Faster startup
- No need for `node_modules` in final image
### Environment Variables
When running in Docker, pass env vars via:
1. `.env` file in the same directory as `docker-compose.yml`
2. `environment` section in `docker-compose.yml`
3. `-e` flags with `docker run`
Required variables:
```bash
NEXT_PUBLIC_SITE_URL=https://your-domain.com
NEXT_PUBLIC_CONVEX_URL=https://your-convex-backend.example.com
NEXT_PUBLIC_CONVEX_SITE_URL=https://your-convex-site.example.com
CONVEX_SELF_HOSTED_URL=https://your-convex-backend.example.com
CONVEX_SELF_HOSTED_ADMIN_KEY=your-admin-key
```
### Coolify Deployment
#### 1. Setup Two Environments
Create two services in Coolify:
| Service | Branch | Convex DB | Domain |
|---------|--------|-----------|--------|
| `my-app-prod` | `main` | Prod | `app.example.com` |
| `my-app-dev` | `dev` | Dev | `dev-app.example.com` |
#### 2. Configure Webhooks (Auto-Deploy)
In each Coolify service, copy the **Deploy Webhook URL** and add it to Forgejo Secrets:
```bash
# Forgejo → Repo Settings → Secrets
COOLIFY_PROD_WEBHOOK=https://coolify.example.com/webhooks/...
COOLIFY_DEV_WEBHOOK=https://coolify.example.com/webhooks/...
```
The `.forgejo/workflows/ci.yml` automatically triggers deploy on push:
- `main` branch → Prod webhook
- `dev` branch → Dev webhook
#### 3. Set Environment Variables
In each Coolify service, set env vars:
**Prod**:
```bash
NEXT_PUBLIC_SITE_URL=https://app.example.com
NEXT_PUBLIC_CONVEX_URL=https://convex-prod.example.com
NEXT_PUBLIC_CONVEX_SITE_URL=https://convex-site-prod.example.com
CONVEX_SELF_HOSTED_URL=https://convex-prod.example.com
CONVEX_SELF_HOSTED_ADMIN_KEY=prod-admin-key
```
**Dev**:
```bash
NEXT_PUBLIC_SITE_URL=https://dev-app.example.com
NEXT_PUBLIC_CONVEX_URL=https://convex-dev.example.com
NEXT_PUBLIC_CONVEX_SITE_URL=https://convex-site-dev.example.com
CONVEX_SELF_HOSTED_URL=https://convex-dev.example.com
CONVEX_SELF_HOSTED_ADMIN_KEY=dev-admin-key
```
#### 4. Local Development Connects to Dev
Your `.env.local` should point to the **dev** Convex:
```bash
CONVEX_SELF_HOSTED_URL='https://convex-dev.example.com'
CONVEX_SELF_HOSTED_ADMIN_KEY='dev-admin-key'
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_CONVEX_URL=https://convex-dev.example.com
NEXT_PUBLIC_CONVEX_SITE_URL=https://convex-site-dev.example.com
```
### Health Check
Verify the app is running:
```bash
curl http://localhost:3000
```

View file

@ -1,40 +0,0 @@
# syntax=docker/dockerfile:1
# Stage 1: Dependencies
FROM node:20-slim AS deps
WORKDIR /app
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@9 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-slim AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN pnpm build
# Stage 3: Runner
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

174
README.md
View file

@ -1,72 +1,144 @@
# convex-next-saas # SaaS template
A personal, opinionated SaaS template built for speed. Next.js 16 + Convex self-hosted + Better Auth + Resend. This is not gonna make You rich, but it sure as hell will save you a lot of time!
> This template is tailored for self-hosted Convex on Coolify with a private Forgejo workflow. It's not trying to be a generic marketplace template — it's the setup I actually use to ship projects fast. This is production ready setup for Convex and Next SaaS.
## Stack ## Features
- **Frontend**: Next.js 16 (App Router), React 19, TypeScript 5, Tailwind CSS 4 - Auth
- **Backend**: Convex self-hosted (Docker on Coolify) - Stripe
- **Auth**: Better Auth (email/password, email verification, password reset) - shadcn/create UI templte
- **Email**: Resend (free tier: 3,000 emails/month) - next intl
- **UI**: shadcn/ui (radix-nova), @hugeicons/react - next themes
- **i18n**: next-intl v4 with locale routing (`/en`, `/pl`) - Backend/Frontend Tests
- **State**: RSC + Client Components hybrid - Tmuxinator
- **Validation**: Zod v4 - CICD
- **Linting**: ESLint + oxlint - accurate instructions
## What's Included ## Development process
- [x] Email/password auth with HIBP password checking ### Environments
- [x] Email verification flow
- [x] Forgot / reset password flow
- [x] Change password (authenticated)
- [x] Locale-based routing (EN / PL)
- [x] Theme switching (dark/light/system)
- [x] Protected routes with redirect + callbackURL
- [x] Runtime env validation (Zod)
- [x] GitHub Actions CI (lint + build)
- [x] Project init script (`bin/init-template.mjs`)
- [x] PWA Lite installability (manifest + icons)
- [x] Docker deployment (Dockerfile + docker-compose)
- [x] Security scanning (Trivy SAST + SCA)
## Quick Start - Dev cloud environment - based on `develop` branch
- Prod cloud environment - based on `main` branch
## Getting Started
First, run the development server:
```bash ```bash
git clone <your-forgejo-repo> my-project
cd my-project
pnpm install
# Copy and fill env vars
cp .env.example .env.local
# Set up Convex backend (see DEVELOPMENT.md)
# Then:
pnpm dev --webpack pnpm dev --webpack
``` ```
> ⚠️ **Always use `--webpack`** — Turbopack is broken in Next.js 16.2.1 (900% CPU spike). > [!NOTE]
> As of Next.js@16.2.1 turbopack is broken ant it spikes CPU usage by 900% and 8-9GiB of RAM usage, thus why we use webpack. Wait for 16.3.0 to migrate to turbopack
## Project Bootstrap ### Convex
```bash [Source of truth](https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md)
node bin/init-template.mjs my-project
cd my-project 1. Go on Coolify, search for Convex and set it up.
# Fill .env.local, deploy Convex, done.
> [!IMPORTANT]
> Best case: [copy config from here](https://github.com/get-convex/convex-backend/blob/main/self-hosted/docker/docker-compose.yml), paste it instead of Coolify's docker-compose file, then go into environment variables, copy them, paste them and the new docker-compose into an AI agent and tell him to properly map the envs to docker-compose so it's ready to deploy on coolify.
This works for Convex 1.34.0 (CLI+backend/dashboard)
```
services:
backend:
image: 'ghcr.io/get-convex/convex-backend:latest'
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- '${PORT:-3210}:3210'
- '${SITE_PROXY_PORT:-3211}:3211'
volumes:
- 'data:/convex/data'
environment:
APPLICATION_MAX_CONCURRENT_MUTATIONS: '${APPLICATION_MAX_CONCURRENT_MUTATIONS:-16}'
APPLICATION_MAX_CONCURRENT_NODE_ACTIONS: '${APPLICATION_MAX_CONCURRENT_NODE_ACTIONS:-16}'
APPLICATION_MAX_CONCURRENT_QUERIES: '${APPLICATION_MAX_CONCURRENT_QUERIES:-16}'
APPLICATION_MAX_CONCURRENT_V8_ACTIONS: '${APPLICATION_MAX_CONCURRENT_V8_ACTIONS:-16}'
INSTANCE_NAME: '${INSTANCE_NAME}'
INSTANCE_SECRET: '${INSTANCE_SECRET}'
CONVEX_CLOUD_ORIGIN: '${SERVICE_URL_BACKEND}'
CONVEX_SITE_ORIGIN: '${SERVICE_URL_BACKEND_SITE}'
DISABLE_METRICS_ENDPOINT: '${DISABLE_METRICS_ENDPOINT:-true}'
DOCUMENT_RETENTION_DELAY: '${DOCUMENT_RETENTION_DELAY:-172800}'
DO_NOT_REQUIRE_SSL: '${DO_NOT_REQUIRE_SSL:-true}'
DISABLE_BEACON: '${DISABLE_BEACON:-false}'
REDACT_LOGS_TO_CLIENT: '${REDACT_LOGS_TO_CLIENT:-false}'
RUST_LOG: '${RUST_LOG:-info}'
RUST_BACKTRACE: '${RUST_BACKTRACE:-0}'
ACTIONS_USER_TIMEOUT_SECS: '${ACTIONS_USER_TIMEOUT_SECS:-600}'
HTTP_SERVER_TIMEOUT_SECONDS: '${HTTP_SERVER_TIMEOUT_SECONDS:-300}'
CONVEX_RELEASE_VERSION_DEV: '${CONVEX_RELEASE_VERSION_DEV:-}'
DATABASE_URL: '${DATABASE_URL:-}'
POSTGRES_URL: '${POSTGRES_URL:-}'
MYSQL_URL: '${MYSQL_URL:-}'
AWS_REGION: '${AWS_REGION:-}'
AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID:-}'
AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY:-}'
AWS_SESSION_TOKEN: '${AWS_SESSION_TOKEN:-}'
AWS_S3_FORCE_PATH_STYLE: '${AWS_S3_FORCE_PATH_STYLE:-}'
AWS_S3_DISABLE_SSE: '${AWS_S3_DISABLE_SSE:-}'
AWS_S3_DISABLE_CHECKSUMS: '${AWS_S3_DISABLE_CHECKSUMS:-}'
S3_ENDPOINT_URL: '${S3_ENDPOINT_URL:-}'
S3_STORAGE_EXPORTS_BUCKET: '${S3_STORAGE_EXPORTS_BUCKET:-}'
S3_STORAGE_SNAPSHOT_IMPORTS_BUCKET: '${S3_STORAGE_SNAPSHOT_IMPORTS_BUCKET:-}'
S3_STORAGE_MODULES_BUCKET: '${S3_STORAGE_MODULES_BUCKET:-}'
S3_STORAGE_FILES_BUCKET: '${S3_STORAGE_FILES_BUCKET:-}'
S3_STORAGE_SEARCH_BUCKET: '${S3_STORAGE_SEARCH_BUCKET:-}'
healthcheck:
test:
- CMD-SHELL
- 'curl -f http://localhost:3210/version'
interval: 5s
start_period: 10s
timeout: 5s
retries: 10
dashboard:
image: 'ghcr.io/get-convex/convex-dashboard:latest'
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- '${DASHBOARD_PORT:-6791}:6791'
environment:
PORT: '6791'
NEXT_PUBLIC_DEPLOYMENT_URL: '${SERVICE_URL_BACKEND}'
NEXT_PUBLIC_LOAD_MONACO_INTERNALLY: '${NEXT_PUBLIC_LOAD_MONACO_INTERNALLY:-true}'
depends_on:
backend:
condition: service_healthy
volumes:
data: null
``` ```
## Docs Then make sure to update domains and env vars accordingly
- [`AGENTS.md`](./AGENTS.md) — Architecture, conventions, and anti-patterns for AI agents 2. Deploy the service(s - dashboard and the db). It has built-in persistent storage
- [`DEVELOPMENT.md`](./DEVELOPMENT.md) — Local setup, Coolify deployment, troubleshooting 3. Connect to server with ssh, jump into a **backend** container with `docker exec -it <name> ./generate_admin_key.sh`
You'll receive Your admin key and You'll be able to log into dashboard
## Environments ```
Admin key:
self-hosted-convex|010...
```
- **Dev**: `develop` branch 4. Now Your base is ready. Go to .env.local and finish setting up the connection.
- **Prod**: `main` branch 5. Run `npm install` (only if Your image is latest, else use a proper version)
6. After that You can run `npx convex dev` and You should be ready to go!
7. Time to pick a theme! Go to [shadcn/create](https://ui.shadcn.com/create), create a theme, and simply use the received command to init this theme in Your project!
## License #### Troubleshooting
MIT — do whatever you want. ```
󰣇 dev/templates/convex-next-saas  main  !? npx convex dev
✖ Error: Unable to start push to https://backend-i8e44nvzhj8sxw4qm9tbu1f7.mentat.ovh
✖ Error fetching POST https://backend-i8e44nvzhj8sxw4qm9tbu1f7.mentat.ovh/api/deploy2/start_push 400 Bad Request: BadJsonBody: Failed to deserialize the JSON body into th
e target type: appDefinition: missing field `functions` at line 1 column 272745
```
**Solution** - ensure both CLI and convex self-hosted cloud images versions are the same. If not - I suggest upgrading whatever version is lower to be higher.

View file

@ -1,101 +0,0 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const EXCLUDE = [
'.git',
'node_modules',
'.next',
'.sisyphus',
'.memsearch',
'.playwright-mcp',
'pnpm-lock.yaml',
'bin',
'features.md',
'.env.local',
'README.md',
'tmuxi.template.yml',
];
function copyRecursive(src, dest, replacements) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (EXCLUDE.includes(path.basename(src))) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
copyRecursive(path.join(src, entry), path.join(dest, entry), replacements);
}
} else {
if (EXCLUDE.includes(path.basename(src))) return;
let content = fs.readFileSync(src, 'utf-8');
for (const [key, value] of Object.entries(replacements)) {
content = content.split(key).join(value);
}
fs.writeFileSync(dest, content);
}
}
function main() {
const projectName = process.argv[2];
if (!projectName) {
console.error('Usage: node bin/init-template.mjs <project-name>');
process.exit(1);
}
const targetDir = path.resolve(process.cwd(), projectName);
if (fs.existsSync(targetDir)) {
console.error(`Directory already exists: ${targetDir}`);
process.exit(1);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templateDir = path.resolve(__dirname, '..');
const replacements = {
'convex-next-saas': projectName,
'https://convex-backend.mentat.ovh': 'https://your-convex-backend.example.com',
'https://backend-site-olnjg91x5ervt6j6owwgnlha.mentat.ovh': 'https://your-convex-site.example.com',
'self-hosted-convex|01eea0ecf04bed0f70b73021564229f7f08eecff003f7294e5f9279faa4c19ffd5c501b0ac': 'self-hosted-convex|YOUR_ADMIN_KEY',
'http://localhost:3000': 'http://localhost:3000',
'SaaS Template': projectName,
'Create SaaS in a day!': `${projectName} — built with convex-next-saas`,
'https://convex-backend.mentat.ovh:3210': 'https://your-convex-backend.example.com:3210',
'https://backend-site-xxx.mentat.ovh:3211': 'https://your-convex-site.example.com:3211',
'/home/nxtkofi/.config/tmuxinator/': '~/.config/tmuxinator/',
'~/vaults/mentat/': '~/workspace/',
};
// Safety: exclude target dir name to prevent recursion if run inside template
EXCLUDE.push(projectName);
const srcDir = path.join(targetDir, 'src');
const docsDir = path.join(targetDir, 'docs');
console.log(`Creating ${projectName}...`);
// Copy template files into <name>/src/
copyRecursive(templateDir, srcDir, replacements);
// Create empty docs/readme.md for the new project
fs.mkdirSync(docsDir, { recursive: true });
fs.writeFileSync(
path.join(docsDir, 'readme.md'),
`# ${projectName}\n\nProject documentation goes here.\n`
);
console.log('Initializing git...');
execSync('git init', { cwd: targetDir, stdio: 'inherit' });
console.log('Installing dependencies...');
execSync('pnpm install', { cwd: srcDir, stdio: 'inherit' });
console.log(`\nDone!`);
console.log(` cd ${projectName}/src && pnpm dev --webpack`);
}
main();

View file

@ -10,16 +10,16 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "hugeicons", "iconLibrary": "lucide",
"rtl": false, "rtl": false,
"aliases": { "aliases": {
"components": "@/components", "components": "@/src/components",
"utils": "@/lib/utils", "utils": "@/src/lib/utils",
"ui": "@/components/ui", "ui": "@/src/components/shadcn",
"lib": "@/lib", "lib": "@/src/lib",
"hooks": "@/hooks" "hooks": "@/src/hooks"
}, },
"menuColor": "default-translucent", "menuColor": "default",
"menuAccent": "subtle", "menuAccent": "subtle",
"registries": {} "registries": {}
} }

View file

@ -8,10 +8,7 @@
* @module * @module
*/ */
import type * as auth from "../auth.js";
import type * as hello from "../hello.js"; import type * as hello from "../hello.js";
import type * as http from "../http.js";
import type * as lib_resend from "../lib/resend.js";
import type { import type {
ApiFromModules, ApiFromModules,
@ -20,10 +17,7 @@ import type {
} from "convex/server"; } from "convex/server";
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
auth: typeof auth;
hello: typeof hello; hello: typeof hello;
http: typeof http;
"lib/resend": typeof lib_resend;
}>; }>;
/** /**
@ -52,978 +46,4 @@ export declare const internal: FilterApi<
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >;
export declare const components: { export declare const components: {};
betterAuth: {
adapter: {
create: FunctionReference<
"mutation",
"internal",
{
input:
| {
data: {
createdAt: number;
email: string;
emailVerified: boolean;
image?: null | string;
name: string;
updatedAt: number;
userId?: null | string;
};
model: "user";
}
| {
data: {
createdAt: number;
expiresAt: number;
ipAddress?: null | string;
token: string;
updatedAt: number;
userAgent?: null | string;
userId: string;
};
model: "session";
}
| {
data: {
accessToken?: null | string;
accessTokenExpiresAt?: null | number;
accountId: string;
createdAt: number;
idToken?: null | string;
password?: null | string;
providerId: string;
refreshToken?: null | string;
refreshTokenExpiresAt?: null | number;
scope?: null | string;
updatedAt: number;
userId: string;
};
model: "account";
}
| {
data: {
createdAt: number;
expiresAt: number;
identifier: string;
updatedAt: number;
value: string;
};
model: "verification";
}
| {
data: {
createdAt: number;
expiresAt?: null | number;
privateKey: string;
publicKey: string;
};
model: "jwks";
};
onCreateHandle?: string;
select?: Array<string>;
},
any
>;
deleteMany: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onDeleteHandle?: string;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
},
any
>;
deleteOne: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onDeleteHandle?: string;
},
any
>;
findMany: FunctionReference<
"query",
"internal",
{
join?: any;
limit?: number;
model: "user" | "session" | "account" | "verification" | "jwks";
offset?: number;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
select?: Array<string>;
sortBy?: { direction: "asc" | "desc"; field: string };
where?: Array<{
connector?: "AND" | "OR";
field: string;
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
},
any
>;
findOne: FunctionReference<
"query",
"internal",
{
join?: any;
model: "user" | "session" | "account" | "verification" | "jwks";
select?: Array<string>;
where?: Array<{
connector?: "AND" | "OR";
field: string;
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
},
any
>;
updateMany: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
update: {
createdAt?: number;
email?: string;
emailVerified?: boolean;
image?: null | string;
name?: string;
updatedAt?: number;
userId?: null | string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
update: {
createdAt?: number;
expiresAt?: number;
ipAddress?: null | string;
token?: string;
updatedAt?: number;
userAgent?: null | string;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
update: {
accessToken?: null | string;
accessTokenExpiresAt?: null | number;
accountId?: string;
createdAt?: number;
idToken?: null | string;
password?: null | string;
providerId?: string;
refreshToken?: null | string;
refreshTokenExpiresAt?: null | number;
scope?: null | string;
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
update: {
createdAt?: number;
expiresAt?: number;
identifier?: string;
updatedAt?: number;
value?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
update: {
createdAt?: number;
expiresAt?: null | number;
privateKey?: string;
publicKey?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onUpdateHandle?: string;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
},
any
>;
updateOne: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
update: {
createdAt?: number;
email?: string;
emailVerified?: boolean;
image?: null | string;
name?: string;
updatedAt?: number;
userId?: null | string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
update: {
createdAt?: number;
expiresAt?: number;
ipAddress?: null | string;
token?: string;
updatedAt?: number;
userAgent?: null | string;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
update: {
accessToken?: null | string;
accessTokenExpiresAt?: null | number;
accountId?: string;
createdAt?: number;
idToken?: null | string;
password?: null | string;
providerId?: string;
refreshToken?: null | string;
refreshTokenExpiresAt?: null | number;
scope?: null | string;
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
update: {
createdAt?: number;
expiresAt?: number;
identifier?: string;
updatedAt?: number;
value?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
update: {
createdAt?: number;
expiresAt?: null | number;
privateKey?: string;
publicKey?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onUpdateHandle?: string;
},
any
>;
};
};
};

View file

@ -1,6 +0,0 @@
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config';
import type { AuthConfig } from 'convex/server';
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;

View file

@ -1,77 +0,0 @@
import { createClient, type GenericCtx } from '@convex-dev/better-auth';
import { convex } from '@convex-dev/better-auth/plugins';
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
import { haveIBeenPwned } from 'better-auth/plugins';
import { components } from './_generated/api';
import { DataModel } from './_generated/dataModel';
import { query } from './_generated/server';
import authConfig from './auth.config';
import authSchema from './betterAuth/schema';
import { sendEmail } from './lib/resend';
const siteUrl = process.env.SITE_URL!;
export const authComponent = createClient<DataModel, typeof authSchema>(
components.betterAuth,
{
local: {
schema: authSchema,
},
},
);
export const createAuthOptions = (
ctx: GenericCtx<DataModel>,
): BetterAuthOptions => {
return {
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: {
minPasswordLength: 8,
maxPasswordLength: 128,
autoSignIn: true,
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: 'Reset your password',
html: `<p>Click <a href="${url}">here</a> to reset your password.</p>`,
});
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: 'Verify your email',
html: `<p>Click <a href="${url}">here</a> to verify your email address.</p>`,
});
},
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [convex({ authConfig }), haveIBeenPwned()],
};
};
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth(createAuthOptions(ctx));
};
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});

View file

@ -1,52 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as adapter from "../adapter.js";
import type * as auth from "../auth.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
const fullApi: ApiFromModules<{
adapter: typeof adapter;
auth: typeof auth;
}> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
> = anyApi as any;
export const components = componentsGeneric() as unknown as {};

File diff suppressed because it is too large Load diff

View file

@ -1,60 +0,0 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View file

@ -1,156 +0,0 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery: QueryBuilder<DataModel, "internal"> =
internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation: MutationBuilder<DataModel, "internal"> =
internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction: ActionBuilder<DataModel, "internal"> =
internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction: HttpActionBuilder = httpActionGeneric;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*
* If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -1,13 +0,0 @@
import { createApi } from '@convex-dev/better-auth';
import { createAuthOptions } from '../auth';
import schema from './schema';
export const {
create,
findOne,
findMany,
updateOne,
updateMany,
deleteOne,
deleteMany,
} = createApi(schema, createAuthOptions);

View file

@ -1,3 +0,0 @@
import { createAuth } from '../auth';
export const auth = createAuth({} as Parameters<typeof createAuth>[0]);

View file

@ -1,5 +0,0 @@
import { defineComponent } from 'convex/server';
const component = defineComponent('betterAuth');
export default component;

View file

@ -1,77 +0,0 @@
/**
* This file is auto-generated. Do not edit this file manually.
* To regenerate the schema, run:
* `npx @better-auth/cli generate --output undefined -y`
*
* To customize the schema, generate to an alternate file and import
* the table definitions to your schema file. See
* https://labs.convex.dev/better-auth/features/local-install#adding-custom-indexes.
*/
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export const tables = {
user: defineTable({
name: v.string(),
email: v.string(),
emailVerified: v.boolean(),
image: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
userId: v.optional(v.union(v.null(), v.string())),
})
.index("email_name", ["email","name"])
.index("name", ["name"])
.index("userId", ["userId"]),
session: defineTable({
expiresAt: v.number(),
token: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
ipAddress: v.optional(v.union(v.null(), v.string())),
userAgent: v.optional(v.union(v.null(), v.string())),
userId: v.string(),
})
.index("expiresAt", ["expiresAt"])
.index("expiresAt_userId", ["expiresAt","userId"])
.index("token", ["token"])
.index("userId", ["userId"]),
account: defineTable({
accountId: v.string(),
providerId: v.string(),
userId: v.string(),
accessToken: v.optional(v.union(v.null(), v.string())),
refreshToken: v.optional(v.union(v.null(), v.string())),
idToken: v.optional(v.union(v.null(), v.string())),
accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
scope: v.optional(v.union(v.null(), v.string())),
password: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("accountId", ["accountId"])
.index("accountId_providerId", ["accountId","providerId"])
.index("providerId_userId", ["providerId","userId"])
.index("userId", ["userId"]),
verification: defineTable({
identifier: v.string(),
value: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("expiresAt", ["expiresAt"])
.index("identifier", ["identifier"]),
jwks: defineTable({
publicKey: v.string(),
privateKey: v.string(),
createdAt: v.number(),
expiresAt: v.optional(v.union(v.null(), v.number())),
}),
};
const schema = defineSchema(tables);
export default schema;

View file

@ -1,8 +0,0 @@
import { defineApp } from 'convex/server';
import betterAuth from './betterAuth/convex.config';
const app = defineApp();
app.use(betterAuth);
export default app;

View file

@ -1,8 +0,0 @@
import { httpRouter } from 'convex/server';
import { authComponent, createAuth } from './auth';
const http = httpRouter();
authComponent.registerRoutes(http, createAuth);
export default http;

View file

@ -1,31 +0,0 @@
const RESEND_API_KEY = process.env.RESEND_API_KEY!;
const RESEND_FROM_EMAIL = process.env.RESEND_FROM_EMAIL!;
interface SendEmailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: RESEND_FROM_EMAIL,
to,
subject,
html,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to send email: ${error}`);
}
return response.json();
}

View file

@ -1,14 +0,0 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '${PORT:-3000}:3000'
environment:
NODE_ENV: '${NODE_ENV:-production}'
NEXT_PUBLIC_SITE_URL: '${NEXT_PUBLIC_SITE_URL}'
NEXT_PUBLIC_CONVEX_URL: '${NEXT_PUBLIC_CONVEX_URL}'
NEXT_PUBLIC_CONVEX_SITE_URL: '${NEXT_PUBLIC_CONVEX_SITE_URL}'
CONVEX_SELF_HOSTED_URL: '${CONVEX_SELF_HOSTED_URL}'
CONVEX_SELF_HOSTED_ADMIN_KEY: '${CONVEX_SELF_HOSTED_ADMIN_KEY}'

View file

@ -1,6 +0,0 @@
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
import nextTypeScript from 'eslint-config-next/typescript';
const eslintConfig = [...nextCoreWebVitals, ...nextTypeScript];
export default eslintConfig;

View file

@ -1,100 +1,7 @@
{ {
"HomePage": { "Core": {
"title": "Hello world!" "Save": "Save",
}, "Confirm": "Confirm",
"AuthPage": { "Cancel": "Cancel"
"SignUpTitle": "Sign up",
"SignInTitle": "Sign in",
"SignUpLink": "Sign in",
"SignInLink": "Sign up",
"NameLabel": "Name",
"NamePlaceholder": "John Doe",
"EmailLabel": "Email",
"EmailPlaceholder": "email@example.com",
"PasswordLabel": "Password",
"PasswordPlaceholder": "******",
"HidePasswordTooltip": "Hide password",
"ShowPasswordTooltip": "Show password",
"Submit": "Submit",
"CheckYourEmail": "Check your email to complete the process.",
"SignInWithGoogle": "Sign in with Google",
"SignInWithGitHub": "Sign in with GitHub",
"OrContinueWith": "or continue with",
"ForgotPasswordLink": "Forgot password?",
"ForgotPasswordTitle": "Forgot password",
"SendResetLink": "Send reset link",
"ResetEmailSent": "Reset link sent to your email",
"ResetPasswordTitle": "Reset password",
"NewPasswordLabel": "New password",
"ConfirmPasswordLabel": "Confirm password",
"ConfirmPasswordPlaceholder": "Confirm your new password",
"ResetPasswordSubmit": "Reset password",
"PasswordResetSuccess": "Password reset successfully",
"VerifyingEmail": "Verifying your email...",
"EmailVerified": "Email verified successfully",
"EmailVerificationFailed": "Email verification failed"
},
"DashboardPage": {
"SecurityCardTitle": "Account security",
"SecurityCardDescription": "Update your password and keep your account protected.",
"SecurityCardAction": "Open settings"
},
"SettingsPage": {
"Title": "Change password",
"Description": "Use your current password to set a new one for this account.",
"CurrentPasswordLabel": "Current password",
"CurrentPasswordPlaceholder": "Enter your current password",
"CurrentPasswordRequired": "Enter your current password",
"NewPasswordLabel": "New password",
"NewPasswordPlaceholder": "Enter your new password",
"PasswordTooShort": "Password must be at least 8 characters long",
"ConfirmPasswordLabel": "Confirm new password",
"ConfirmPasswordPlaceholder": "Repeat your new password",
"ConfirmPasswordRequired": "Repeat your new password",
"PasswordsDoNotMatch": "Passwords do not match",
"PasswordMustDiffer": "Your new password must be different from the current password",
"ShowPasswordTooltip": "Show password",
"HidePasswordTooltip": "Hide password",
"Submit": "Save new password",
"PasswordUpdatedSuccessfully": "Password updated successfully"
},
"CookieConsent": {
"Title": "We use cookies",
"Description": "We use cookies to enhance your experience. Necessary cookies are always active. You can manage your preferences below.",
"Necessary": "Necessary",
"NecessaryDescription": "Required for authentication, security, and basic site functionality.",
"Analytics": "Analytics",
"AnalyticsDescription": "Helps us understand how visitors interact with our site.",
"Marketing": "Marketing",
"MarketingDescription": "Used to deliver personalized advertisements and measure their effectiveness.",
"AcceptAll": "Accept all",
"AcceptSelected": "Save preferences",
"AcceptNecessary": "Accept necessary only",
"ManagePreferences": "Manage preferences",
"SavePreferences": "Save preferences"
},
"Navigation": {
"Home": "Home",
"Dashboard": "Dashboard",
"Settings": "Settings",
"SignIn": "Sign in",
"SignUp": "Sign up",
"SignOut": "Sign out"
},
"Fallback": {
"Loading": {
"Title": "Loading...",
"Description": "Please wait while we load the content."
},
"NotFound": {
"Title": "Page not found",
"Description": "Sorry, the page you're looking for doesn't exist.",
"GoHome": "Go back home"
},
"Error": {
"Title": "Something went wrong",
"Description": "An unexpected error occurred. Please try again.",
"Retry": "Try again"
}
} }
} }

View file

@ -1,100 +1,7 @@
{ {
"HomePage": { "Core": {
"title": "Witaj świecie!" "Save": "Zapisz",
}, "Confirm": "Potwierdź",
"AuthPage": { "Cancel": "Anuluj"
"SignUpTitle": "Zarejestruj się",
"SignInTitle": "Zaloguj się",
"SignUpLink": "Zaloguj się",
"SignInLink": "Zarejestruj się",
"NameLabel": "Imię i nazwisko",
"NamePlaceholder": "Jan Kowalski",
"EmailLabel": "Email",
"EmailPlaceholder": "email@example.com",
"PasswordLabel": "Hasło",
"PasswordPlaceholder": "******",
"HidePasswordTooltip": "Ukryj hasło",
"ShowPasswordTooltip": "Pokaż hasło",
"Submit": "Wyślij",
"CheckYourEmail": "Sprawdź email, aby dokończyć proces.",
"SignInWithGoogle": "Zaloguj się przez Google",
"SignInWithGitHub": "Zaloguj się przez GitHub",
"OrContinueWith": "lub kontynuuj przez",
"ForgotPasswordLink": "Nie pamiętasz hasła?",
"ForgotPasswordTitle": "Nie pamiętasz hasła",
"SendResetLink": "Wyślij link resetujący",
"ResetEmailSent": "Link resetujący został wysłany na Twój email",
"ResetPasswordTitle": "Zresetuj hasło",
"NewPasswordLabel": "Nowe hasło",
"ConfirmPasswordLabel": "Potwierdź hasło",
"ConfirmPasswordPlaceholder": "Potwierdź nowe hasło",
"ResetPasswordSubmit": "Zresetuj hasło",
"PasswordResetSuccess": "Hasło zostało zresetowane",
"VerifyingEmail": "Weryfikowanie adresu email...",
"EmailVerified": "Adres email zweryfikowany",
"EmailVerificationFailed": "Weryfikacja emaila nie powiodła się"
},
"DashboardPage": {
"SecurityCardTitle": "Bezpieczeństwo konta",
"SecurityCardDescription": "Zmień hasło i zadbaj o bezpieczeństwo swojego konta.",
"SecurityCardAction": "Otwórz ustawienia"
},
"SettingsPage": {
"Title": "Zmień hasło",
"Description": "Użyj obecnego hasła, aby ustawić nowe hasło dla tego konta.",
"CurrentPasswordLabel": "Obecne hasło",
"CurrentPasswordPlaceholder": "Wpisz obecne hasło",
"CurrentPasswordRequired": "Wpisz obecne hasło",
"NewPasswordLabel": "Nowe hasło",
"NewPasswordPlaceholder": "Wpisz nowe hasło",
"PasswordTooShort": "Hasło musi mieć co najmniej 8 znaków",
"ConfirmPasswordLabel": "Potwierdź nowe hasło",
"ConfirmPasswordPlaceholder": "Powtórz nowe hasło",
"ConfirmPasswordRequired": "Powtórz nowe hasło",
"PasswordsDoNotMatch": "Hasła nie są takie same",
"PasswordMustDiffer": "Nowe hasło musi różnić się od obecnego hasła",
"ShowPasswordTooltip": "Pokaż hasło",
"HidePasswordTooltip": "Ukryj hasło",
"Submit": "Zapisz nowe hasło",
"PasswordUpdatedSuccessfully": "Hasło zostało zmienione"
},
"CookieConsent": {
"Title": "Używamy plików cookie",
"Description": "Używamy plików cookie, aby poprawić Twoje doświadczenie. Niezbędne pliki cookie są zawsze aktywne. Możesz zarządzać preferencjami poniżej.",
"Necessary": "Niezbędne",
"NecessaryDescription": "Wymagane do uwierzytelniania, bezpieczeństwa i podstawowej funkcjonalności strony.",
"Analytics": "Analityczne",
"AnalyticsDescription": "Pomaga nam zrozumieć, jak odwiedzający korzystają z naszej strony.",
"Marketing": "Marketingowe",
"MarketingDescription": "Używane do wyświetlania spersonalizowanych reklam i mierzenia ich skuteczności.",
"AcceptAll": "Akceptuj wszystkie",
"AcceptSelected": "Zapisz preferencje",
"AcceptNecessary": "Akceptuj tylko niezbędne",
"ManagePreferences": "Zarządzaj preferencjami",
"SavePreferences": "Zapisz preferencje"
},
"Navigation": {
"Home": "Strona główna",
"Dashboard": "Panel",
"Settings": "Ustawienia",
"SignIn": "Zaloguj się",
"SignUp": "Zarejestruj się",
"SignOut": "Wyloguj się"
},
"Fallback": {
"Loading": {
"Title": "Ładowanie...",
"Description": "Proszę czekać, trwa ładowanie zawartości."
},
"NotFound": {
"Title": "Strona nie znaleziona",
"Description": "Przepraszamy, ale strona której szukasz nie istnieje.",
"GoHome": "Wróć do strony głównej"
},
"Error": {
"Title": "Wystąpił błąd",
"Description": "Coś poszło nie tak. Spróbuj ponownie.",
"Retry": "Spróbuj ponownie"
}
} }
} }

View file

@ -2,7 +2,7 @@ import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', /* config options here */
}; };
const withNextIntl = createNextIntlPlugin(); const withNextIntl = createNextIntlPlugin();

View file

@ -3,36 +3,27 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@convex-dev/better-auth": "^0.11.4",
"@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6",
"@wrksz/themes": "^0.7.9",
"better-auth": "^1.5.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.34.0", "convex": "^1.34.0",
"lucide-react": "^1.7.0",
"next": "16.2.1", "next": "16.2.1",
"next-intl": "^4.8.3", "next-intl": "^4.8.3",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.72.0",
"resend": "^6.12.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0"
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@better-auth/cli": "^1.4.21",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="112" fill="#09090B" />
<path d="M146 370V142H312C360 142 392 174 392 220C392 260 366 286 330 294L398 370H313L252 302H214V370H146ZM214 228H303C320 228 331 216 331 200C331 184 320 172 303 172H214V228Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

View file

@ -1,4 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="112" fill="#09090B" />
<path d="M146 370V142H312C360 142 392 174 392 220C392 260 366 286 330 294L398 370H313L252 302H214V370H146ZM214 228H303C320 228 331 216 331 200C331 184 320 172 303 172H214V228Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

View file

@ -1,5 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="128" fill="#09090B" />
<rect x="96" y="96" width="320" height="320" rx="80" fill="#09090B" />
<path d="M168 344V168H286C330 168 360 196 360 236C360 268 341 290 313 299L370 344H301L252 295H230V344H168ZM230 231H289C304 231 314 221 314 205C314 190 304 180 289 180H230V231Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 436 B

View file

@ -1,5 +0,0 @@
import { notFound } from "next/navigation";
export default function CatchAllPage() {
notFound();
}

View file

@ -1,43 +0,0 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { redirect } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { isAuthenticated } from '@/lib/auth-server';
import { routes } from '@/lib/routes';
export default async function DashboardPage() {
const authenticated = await isAuthenticated();
if (!authenticated) {
const searchParams = new URLSearchParams({
callbackURL: routes.private.dashboard,
});
redirect(`${routes.public.signIn}?${searchParams.toString()}`);
}
const t = await getTranslations('DashboardPage');
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Card className="w-full">
<CardHeader>
<CardTitle>{t('SecurityCardTitle')}</CardTitle>
<CardDescription>{t('SecurityCardDescription')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href={routes.private.settings}>{t('SecurityCardAction')}</Link>
</Button>
</CardContent>
</Card>
</section>
);
}

View file

@ -1,51 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
const t = useTranslations("Fallback.Error");
const handleRetry = () => {
if (typeof window !== "undefined" && "unstable_retry" in window.location) {
(window.location as unknown as { unstable_retry: () => void }).unstable_retry();
} else {
reset();
}
};
return (
<div
data-testid="route-error"
className="flex items-center justify-center min-h-[60vh] p-4"
>
<Card className="max-w-md w-full">
<CardHeader>
<CardTitle className="text-2xl">{t("Title")}</CardTitle>
<CardDescription>{t("Description")}</CardDescription>
</CardHeader>
<CardContent>
<Button
data-testid="route-error-retry"
onClick={handleRetry}
className="w-full"
>
{t("Retry")}
</Button>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,9 +0,0 @@
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
export default function ForgotPasswordPage() {
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-start px-4 py-8 sm:px-6 lg:px-8">
<ForgotPasswordForm />
</section>
);
}

View file

@ -1,33 +0,0 @@
import { setRequestLocale } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import { AppShell } from '@/components/core/AppShell';
import { AppNav } from '@/components/core/AppNav';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
return (
<AppShell>
<AppNav />
<main className="flex-1">{children}</main>
</AppShell>
);
}

View file

@ -1,19 +0,0 @@
import { useTranslations } from "next-intl";
import { Spinner } from "@/components/ui/spinner";
export default function Loading() {
const t = useTranslations("Fallback.Loading");
return (
<div
data-testid="route-loading"
className="flex flex-col items-center justify-center min-h-[60vh] p-4"
>
<Spinner className="h-8 w-8 mb-4" />
<h2 className="text-lg font-semibold mb-2">{t("Title")}</h2>
<p className="text-muted-foreground text-center max-w-sm">
{t("Description")}
</p>
</div>
);
}

View file

@ -1,34 +0,0 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { routes } from "@/lib/routes";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export default async function NotFound() {
const t = await getTranslations("Fallback.NotFound");
return (
<div
data-testid="localized-not-found"
className="flex items-center justify-center min-h-[60vh] p-4"
>
<Card className="max-w-md w-full">
<CardHeader>
<CardTitle className="text-2xl">{t("Title")}</CardTitle>
<CardDescription>{t("Description")}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href={routes.public.home}>{t("GoHome")}</Link>
</Button>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,12 +0,0 @@
import { useTranslations } from "next-intl";
import { ThemeChanger } from "@/components/core/ThemeChanger";
export default function Home() {
const t = useTranslations("HomePage");
return (
<>
<h1>{t("title")}</h1>
<ThemeChanger />
</>
);
}

View file

@ -1,5 +0,0 @@
"use client";
export default function RouteErrorPage() {
throw new Error("Intentional error for QA testing - this should trigger the error boundary");
}

View file

@ -1,16 +0,0 @@
async function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export default async function SlowPage() {
await delay(1500);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-4">
<h1 className="text-2xl font-bold mb-4">Slow Page Loaded</h1>
<p className="text-muted-foreground">
This page intentionally delayed rendering by 1.5 seconds for QA testing.
</p>
</div>
);
}

View file

@ -1,28 +0,0 @@
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
type ResetPasswordPageProps = {
searchParams: Promise<{
token?: string | string[];
}>;
};
export default async function ResetPasswordPage({
searchParams,
}: ResetPasswordPageProps) {
const params = await searchParams;
const token = Array.isArray(params.token) ? params.token[0] : params.token;
if (!token) {
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-start px-4 py-8 sm:px-6 lg:px-8">
<p className="text-destructive">Invalid or missing reset token.</p>
</section>
);
}
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-start px-4 py-8 sm:px-6 lg:px-8">
<ResetPasswordForm token={token} />
</section>
);
}

View file

@ -1,23 +0,0 @@
import { redirect } from 'next/navigation';
import { PasswordChangeCard } from '@/components/settings/PasswordChangeCard';
import { isAuthenticated } from '@/lib/auth-server';
import { routes } from '@/lib/routes';
export default async function SettingsPage() {
const authenticated = await isAuthenticated();
if (!authenticated) {
const searchParams = new URLSearchParams({
callbackURL: routes.private.settings,
});
redirect(`${routes.public.signIn}?${searchParams.toString()}`);
}
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-2xl items-start px-4 py-8 sm:px-6 lg:px-8">
<PasswordChangeCard />
</section>
);
}

View file

@ -1,20 +0,0 @@
import { AuthForm } from '@/components/auth/AuthForm';
import { routes } from '@/lib/routes';
type SignInPageProps = {
searchParams: Promise<{
callbackURL?: string | string[];
}>;
};
export default async function SignInPage({ searchParams }: SignInPageProps) {
const params = await searchParams;
const callbackURL = Array.isArray(params.callbackURL)
? params.callbackURL[0]
: params.callbackURL;
const redirectPath = callbackURL?.startsWith('/')
? callbackURL
: routes.private.dashboard;
return <AuthForm mode="sign-in" redirectPath={redirectPath} />;
}

View file

@ -1,20 +0,0 @@
import { AuthForm } from '@/components/auth/AuthForm';
import { routes } from '@/lib/routes';
type SignUpPageProps = {
searchParams: Promise<{
callbackURL?: string | string[];
}>;
};
export default async function SignUpPage({ searchParams }: SignUpPageProps) {
const params = await searchParams;
const callbackURL = Array.isArray(params.callbackURL)
? params.callbackURL[0]
: params.callbackURL;
const redirectPath = callbackURL?.startsWith('/')
? callbackURL
: routes.private.dashboard;
return <AuthForm mode="sign-up" redirectPath={redirectPath} />;
}

View file

@ -1,48 +0,0 @@
'use client';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { authClient } from '@/lib/auth-client';
export default function VerifyEmailPage() {
const t = useTranslations('AuthPage');
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
token ? 'loading' : 'error',
);
const verifiedRef = useRef(false);
useEffect(() => {
if (!token || verifiedRef.current) return;
verifiedRef.current = true;
authClient
.verifyEmail({ query: { token } })
.then((result) => {
if (result.error) {
toast(result.error.message);
setStatus('error');
} else {
toast(t('EmailVerified'));
setStatus('success');
}
})
.catch(() => {
setStatus('error');
});
}, [token, t]);
return (
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-md items-center px-4 py-8 sm:px-6 lg:px-8">
<div className="w-full text-center">
{status === 'loading' && <p>{t('VerifyingEmail')}</p>}
{status === 'success' && <p className="text-green-600">{t('EmailVerified')}</p>}
{status === 'error' && <p className="text-destructive">{t('EmailVerificationFailed')}</p>}
</div>
</section>
);
}

View file

@ -1,3 +0,0 @@
import { handler } from '@/lib/auth-server';
export const { GET, POST } = handler;

16
src/app/auth/page.tsx Normal file
View file

@ -0,0 +1,16 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function AuthPage() {
const searchParams = useSearchParams();
const [mode, setMode] = useState<'login' | 'register'>();
useEffect(() => {
const searchParamValue = searchParams.get('mode') as
| 'login'
| 'register'
| null;
if (searchParamValue) setMode(searchParamValue);
}, [searchParams]);
}

View file

@ -7,9 +7,9 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-mono); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -55,8 +55,8 @@
--card-foreground: oklch(0.145 0.008 326); --card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326); --popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.514 0.222 16.935); --primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.969 0.015 12.422); --primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.96 0.003 325.6); --muted: oklch(0.96 0.003 325.6);
@ -67,16 +67,16 @@
--border: oklch(0.922 0.005 325.62); --border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62); --input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02); --ring: oklch(0.711 0.019 323.02);
--chart-1: oklch(0.828 0.111 230.318); --chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.685 0.169 237.323); --chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.588 0.158 241.966); --chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.5 0.134 242.749); --chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.443 0.11 240.79); --chart-5: oklch(0.263 0.024 320.12);
--radius: 0.875rem; --radius: 0;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0.008 326); --sidebar-foreground: oklch(0.145 0.008 326);
--sidebar-primary: oklch(0.586 0.253 17.585); --sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422); --sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.96 0.003 325.6); --sidebar-accent: oklch(0.96 0.003 325.6);
--sidebar-accent-foreground: oklch(0.212 0.019 322.12); --sidebar-accent-foreground: oklch(0.212 0.019 322.12);
--sidebar-border: oklch(0.922 0.005 325.62); --sidebar-border: oklch(0.922 0.005 325.62);
@ -90,8 +90,8 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12); --popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.455 0.188 13.697); --primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.969 0.015 12.422); --primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12); --muted: oklch(0.263 0.024 320.12);
@ -102,15 +102,15 @@
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.034 322.5); --ring: oklch(0.542 0.034 322.5);
--chart-1: oklch(0.828 0.111 230.318); --chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.685 0.169 237.323); --chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.588 0.158 241.966); --chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.5 0.134 242.749); --chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.443 0.11 240.79); --chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.212 0.019 322.12);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.645 0.246 16.439); --sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422); --sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.263 0.024 320.12); --sidebar-accent: oklch(0.263 0.024 320.12);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
@ -125,6 +125,6 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-mono; @apply font-sans;
} }
} }

View file

@ -1,56 +1,32 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css'; import './globals.css';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { ThemeProvider } from '@wrksz/themes/next'; import { ThemeProvider } from 'next-themes';
import { Geist_Mono } from 'next/font/google'; import { Figtree } from 'next/font/google';
import { cn } from '@/lib/utils'; import { cn } from '@/src/lib/utils';
import { Toaster } from '@/components/ui/sonner';
import { CookieBanner } from '@/components/core/CookieBanner';
import { TooltipProvider } from '../components/ui/tooltip';
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-mono' }); const figtree = Figtree({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'SaaS Template', title: 'SaaS Template',
description: 'Create SaaS in a day!', description: 'Create SaaS in a day!',
applicationName: 'SaaS Template',
appleWebApp: {
capable: true,
title: 'SaaS Template',
statusBarStyle: 'default',
},
icons: {
icon: [{ url: '/pwa/icon.svg', sizes: 'any', type: 'image/svg+xml' }],
apple: [{ url: '/pwa/apple-touch-icon.svg', sizes: 'any', type: 'image/svg+xml' }],
other: [{ rel: 'mask-icon', url: '/pwa/maskable-icon.svg' }],
},
}; };
export default async function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const headersList = await headers();
const locale = headersList.get('x-next-intl-locale') || 'en';
return ( return (
<html <html
lang={locale} lang="en"
className={cn('h-full antialiased', 'font-mono', geistMono.variable)} className={cn('h-full antialiased', 'font-sans', figtree.variable)}
suppressHydrationWarning suppressHydrationWarning
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<TooltipProvider>
<ThemeProvider attribute="class" enableSystem defaultTheme="system"> <ThemeProvider attribute="class" enableSystem defaultTheme="system">
<NextIntlClientProvider> <NextIntlClientProvider>{children}</NextIntlClientProvider>
<main>{children}</main>
<Toaster />
<CookieBanner />
</NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
</TooltipProvider>
</body> </body>
</html> </html>
); );

View file

@ -1,28 +0,0 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'SaaS Template',
short_name: 'SaaS',
description: 'Create SaaS in a day!',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#09090b',
icons: [
{
src: '/pwa/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/pwa/maskable-icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'maskable',
},
],
};
}

View file

@ -1,29 +0,0 @@
"use client";
import Link from "next/link";
export default function NotFound() {
return (
<html lang="en">
<body>
<div
data-testid="root-not-found"
className="flex items-center justify-center min-h-screen bg-background"
>
<div className="text-center p-8">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-lg text-muted-foreground mb-6">
Page not found
</p>
<Link
href="/"
className="text-primary hover:underline"
>
Go back home
</Link>
</div>
</div>
</body>
</html>
);
}

24
src/app/page.tsx Normal file
View file

@ -0,0 +1,24 @@
import { useTranslations } from 'next-intl';
import { ThemeChanger } from '../components/core/ThemeChanger';
import Link from 'next/link';
import { routes } from '../constants';
import { Button } from '../components/shadcn/button';
export default function Home() {
const t = useTranslations('Core');
return (
<>
<h1>{t('Save')}</h1>
<ThemeChanger />
<div className="m-4 gap-4 flex">
<Button variant={'default'}>
<Link href={routes.unauthorized.login}>Login</Link>
</Button>
<Button variant={'secondary'}>
<Link href={routes.unauthorized.register}>Register</Link>
</Button>
</div>
</>
);
}

View file

@ -1,261 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { EyeIcon, EyeOff } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { defaultPasswordValidator } from '@/constants';
import { authClient } from '@/lib/auth-client';
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@/components/ui/input-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Separator } from '@/components/ui/separator';
import { Spinner } from '../ui/spinner';
const signInSchema = z.object({
email: z.email(),
password: defaultPasswordValidator(),
});
const signUpSchema = z.object({
email: z.email(),
password: defaultPasswordValidator(),
name: z.string().min(1).max(100),
});
type SignInValues = z.infer<typeof signInSchema>;
type SignUpValues = z.infer<typeof signUpSchema>;
interface AuthFormProps {
mode: 'sign-up' | 'sign-in';
redirectPath?: string;
}
export function AuthForm({ mode, redirectPath = '/dashboard' }: AuthFormProps) {
const t = useTranslations('AuthPage');
const isSignUp = mode === 'sign-up';
const [passwordVisible, setPasswordVisible] = useState<boolean>();
const form = useForm<SignInValues | SignUpValues>({
resolver: zodResolver(isSignUp ? signUpSchema : signInSchema),
defaultValues: {
email: '',
name: '',
password: '',
},
});
const isSubmitting = form.formState.isSubmitting;
async function onSubmit(values: SignInValues | SignUpValues) {
const email = values.email;
const password = values.password;
const name = 'name' in values ? values.name : undefined;
if (isSignUp) {
const result = await authClient.signUp.email({
email,
password,
name: name!,
callbackURL: redirectPath,
});
if (result.error) {
toast(result.error.message);
} else {
toast(t('CheckYourEmail'));
}
} else {
const result = await authClient.signIn.email({
email,
password,
callbackURL: redirectPath,
});
if (result.error) {
toast(result.error.message);
}
}
}
return (
<Card>
<CardHeader>
<CardTitle>{isSignUp ? t('SignUpTitle') : t('SignInTitle')}</CardTitle>
<CardAction>
<Button variant={'link'}>
<Link href={isSignUp ? '/sign-in' : '/sign-up'}>
{isSignUp ? t('SignUpLink') : t('SignInLink')}
</Link>
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
authClient.signIn.social({
provider: 'google',
callbackURL: redirectPath,
})
}
>
{t('SignInWithGoogle')}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
authClient.signIn.social({
provider: 'github',
callbackURL: redirectPath,
})
}
>
{t('SignInWithGitHub')}
</Button>
</div>
<div className="my-4 flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs uppercase">
{t('OrContinueWith')}
</span>
<Separator className="flex-1" />
</div>
<form id="form-auth" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
{isSignUp && (
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-name">
{t('NameLabel')}
</FieldLabel>
<Input
{...field}
id="form-name"
aria-invalid={fieldState.invalid}
placeholder={t('NamePlaceholder')}
autoComplete="off"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
)}
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-email">
{t('EmailLabel')}
</FieldLabel>
<Input
{...field}
id="form-email"
aria-invalid={fieldState.invalid}
placeholder={t('EmailPlaceholder')}
autoComplete="off"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-password">
{t('PasswordLabel')}
</FieldLabel>
<InputGroup>
<InputGroupInput
{...field}
id="form-password"
type={passwordVisible ? 'text' : 'password'}
aria-invalid={fieldState.invalid}
autoComplete="off"
placeholder={t('PasswordPlaceholder')}
/>
<InputGroupAddon
align={'inline-end'}
onClick={() => setPasswordVisible(!passwordVisible)}
>
<Tooltip>
<TooltipTrigger asChild>
{passwordVisible ? (
<HugeiconsIcon icon={EyeOff} />
) : (
<HugeiconsIcon icon={EyeIcon} />
)}
</TooltipTrigger>
<TooltipContent>
{passwordVisible ? (
<p>{t('HidePasswordTooltip')}</p>
) : (
<p>{t('ShowPasswordTooltip')}</p>
)}
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
{!isSignUp && (
<Button variant="link" asChild className="justify-start p-0">
<Link href="/forgot-password">{t('ForgotPasswordLink')}</Link>
</Button>
)}
<Button type="submit" form="form-auth" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('Submit')}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}

View file

@ -1,85 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/auth-client';
import { Spinner } from '@/components/ui/spinner';
const forgotPasswordSchema = z.object({
email: z.email(),
});
type ForgotPasswordValues = z.infer<typeof forgotPasswordSchema>;
export function ForgotPasswordForm() {
const t = useTranslations('AuthPage');
const form = useForm<ForgotPasswordValues>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email: '' },
});
const isSubmitting = form.formState.isSubmitting;
async function onSubmit(values: ForgotPasswordValues) {
const result = await authClient.requestPasswordReset({
email: values.email,
redirectTo: '/reset-password',
});
if (result.error) {
toast(result.error.message);
} else {
toast(t('ResetEmailSent'));
form.reset();
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t('ForgotPasswordTitle')}</CardTitle>
</CardHeader>
<CardContent>
<form id="form-forgot-password" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-email">{t('EmailLabel')}</FieldLabel>
<Input
{...field}
id="form-email"
type="email"
aria-invalid={fieldState.invalid}
placeholder={t('EmailPlaceholder')}
autoComplete="email"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" form="form-forgot-password" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('SendResetLink')}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}

View file

@ -1,135 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { defaultPasswordValidator } from '@/constants';
import { authClient } from '@/lib/auth-client';
import { Spinner } from '@/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { EyeIcon, EyeOff } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';
import { useState } from 'react';
const resetPasswordSchema = z
.object({
password: defaultPasswordValidator(),
confirmPassword: z.string().min(1),
})
.refine((value) => value.password === value.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword'],
});
type ResetPasswordValues = z.infer<typeof resetPasswordSchema>;
export function ResetPasswordForm({ token }: { token: string }) {
const t = useTranslations('AuthPage');
const [passwordVisible, setPasswordVisible] = useState<boolean>();
const form = useForm<ResetPasswordValues>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: { password: '', confirmPassword: '' },
});
const isSubmitting = form.formState.isSubmitting;
async function onSubmit(values: ResetPasswordValues) {
const result = await authClient.resetPassword({
newPassword: values.password,
token,
});
if (result.error) {
toast(result.error.message);
} else {
toast(t('PasswordResetSuccess'));
form.reset();
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t('ResetPasswordTitle')}</CardTitle>
</CardHeader>
<CardContent>
<form id="form-reset-password" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-password">{t('NewPasswordLabel')}</FieldLabel>
<InputGroup>
<InputGroupInput
{...field}
id="form-password"
type={passwordVisible ? 'text' : 'password'}
aria-invalid={fieldState.invalid}
placeholder={t('PasswordPlaceholder')}
autoComplete="new-password"
/>
<InputGroupAddon
align="inline-end"
onClick={() => setPasswordVisible(!passwordVisible)}
>
<Tooltip>
<TooltipTrigger asChild>
{passwordVisible ? (
<HugeiconsIcon icon={EyeOff} />
) : (
<HugeiconsIcon icon={EyeIcon} />
)}
</TooltipTrigger>
<TooltipContent>
<p>{passwordVisible ? t('HidePasswordTooltip') : t('ShowPasswordTooltip')}</p>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="confirmPassword"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-confirm-password">{t('ConfirmPasswordLabel')}</FieldLabel>
<Input
{...field}
id="form-confirm-password"
type="password"
aria-invalid={fieldState.invalid}
placeholder={t('ConfirmPasswordPlaceholder')}
autoComplete="new-password"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" form="form-reset-password" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('ResetPasswordSubmit')}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}

View file

@ -1,44 +0,0 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { routes } from "@/lib/routes";
import { ThemeChanger } from "./ThemeChanger";
import { AuthNavActions } from "./AuthNavActions";
import { cn } from "@/lib/utils";
interface AppNavProps {
className?: string;
}
export function AppNav({ className }: AppNavProps) {
const t = useTranslations("Navigation");
return (
<header
data-testid="app-nav"
className={cn(
"border-b bg-background sticky top-0 z-50",
className
)}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center">
<Link
href={routes.public.home}
className="text-xl font-bold text-foreground hover:text-foreground/80 transition-colors"
>
{t("Home")}
</Link>
</div>
<div className="flex items-center gap-4">
<AuthNavActions />
<ThemeChanger />
</div>
</div>
</div>
</header>
);
}

View file

@ -1,14 +0,0 @@
import { cn } from "@/lib/utils";
interface AppShellProps {
children: React.ReactNode;
className?: string;
}
export function AppShell({ children, className }: AppShellProps) {
return (
<div data-testid="app-shell" className={cn("min-h-screen flex flex-col", className)}>
{children}
</div>
);
}

View file

@ -1,70 +0,0 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { routes } from "@/lib/routes";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface AuthNavActionsProps {
className?: string;
}
export function AuthNavActions({ className }: AuthNavActionsProps) {
const t = useTranslations("Navigation");
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="h-9 w-20 bg-muted animate-pulse rounded" />
</div>
);
}
const isAuthenticated = !!session?.user;
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = routes.public.home;
},
},
});
};
return (
<div data-testid="nav-auth-actions" className={cn("flex items-center gap-2", className)}>
{isAuthenticated ? (
<>
<div data-testid="nav-private-links" className="flex items-center gap-2">
<Button variant="ghost" asChild>
<Link href={routes.private.dashboard}>{t("Dashboard")}</Link>
</Button>
<Button variant="ghost" asChild>
<Link href={routes.private.settings}>{t("Settings")}</Link>
</Button>
</div>
<Button
data-testid="sign-out-button"
variant="outline"
onClick={handleSignOut}
>
{t("SignOut")}
</Button>
</>
) : (
<div data-testid="nav-public-links" className="flex items-center gap-2">
<Button variant="ghost" asChild>
<Link href={routes.public.signIn}>{t("SignIn")}</Link>
</Button>
<Button asChild>
<Link href={routes.public.signUp}>{t("SignUp")}</Link>
</Button>
</div>
)}
</div>
);
}

View file

@ -1,150 +0,0 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useCookieConsent } from "@/hooks/use-cookie-consent";
import { cn } from "@/lib/utils";
export function CookieBanner() {
const t = useTranslations("CookieConsent");
const {
hasResponded,
analytics,
marketing,
acceptAll,
acceptNecessaryOnly,
savePreferences,
} = useCookieConsent();
const [showPreferences, setShowPreferences] = useState(false);
const [prefAnalytics, setPrefAnalytics] = useState(analytics);
const [prefMarketing, setPrefMarketing] = useState(marketing);
if (hasResponded) {
return null;
}
return (
<div
data-testid="cookie-banner"
className="fixed bottom-0 left-0 right-0 z-[100] p-4"
>
<Card className="mx-auto max-w-3xl shadow-lg">
<CardHeader className="pb-2">
<CardTitle className="text-lg">{t("Title")}</CardTitle>
<CardDescription>{t("Description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{showPreferences && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-start gap-3">
<Checkbox checked disabled id="cookie-necessary" />
<div className="grid gap-1 leading-none">
<label
htmlFor="cookie-necessary"
className="text-sm font-medium"
>
{t("Necessary")}
</label>
<p className="text-xs text-muted-foreground">
{t("NecessaryDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="cookie-analytics"
checked={prefAnalytics}
onCheckedChange={(checked) =>
setPrefAnalytics(checked === true)
}
/>
<div className="grid gap-1 leading-none">
<label
htmlFor="cookie-analytics"
className="text-sm font-medium"
>
{t("Analytics")}
</label>
<p className="text-xs text-muted-foreground">
{t("AnalyticsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="cookie-marketing"
checked={prefMarketing}
onCheckedChange={(checked) =>
setPrefMarketing(checked === true)
}
/>
<div className="grid gap-1 leading-none">
<label
htmlFor="cookie-marketing"
className="text-sm font-medium"
>
{t("Marketing")}
</label>
<p className="text-xs text-muted-foreground">
{t("MarketingDescription")}
</p>
</div>
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{showPreferences ? (
<>
<Button
variant="default"
onClick={() =>
savePreferences({
analytics: prefAnalytics,
marketing: prefMarketing,
})
}
>
{t("SavePreferences")}
</Button>
<Button
variant="outline"
onClick={() => setShowPreferences(false)}
>
{t("AcceptNecessary")}
</Button>
</>
) : (
<>
<Button variant="default" onClick={acceptAll}>
{t("AcceptAll")}
</Button>
<Button variant="outline" onClick={acceptNecessaryOnly}>
{t("AcceptNecessary")}
</Button>
<Button
variant="ghost"
onClick={() => setShowPreferences(true)}
>
{t("ManagePreferences")}
</Button>
</>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,52 +1,28 @@
"use client"; "use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useTheme } from "@wrksz/themes/client"; export const ThemeChanger = () => {
import { useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sun01Icon, Moon02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
interface ThemeChangerProps {
className?: string;
}
export function ThemeChanger({ className }: ThemeChangerProps) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const mounted = useSyncExternalStore( const [mounted, setMounted] = useState(false);
() => () => undefined,
() => true, useEffect(() => {
() => false, setMounted(true);
); }, []);
if (!mounted) { if (!mounted) {
return ( return <p>Loading theme...</p>;
<div data-testid="theme-switcher" className={cn("flex items-center gap-1", className)}>
<Button variant="ghost" size="icon" disabled>
<HugeiconsIcon icon={Sun01Icon} className="h-4 w-4" />
</Button>
</div>
);
} }
return ( return (
<div data-testid="theme-switcher" className={cn("flex items-center gap-1", className)}> <div>
<Button <p>The current theme is: {theme}</p>
variant={theme === "light" ? "default" : "ghost"} <button type="button" onClick={() => setTheme("light")}>
size="icon" Light Mode
onClick={() => setTheme("light")} </button>
aria-label="Light mode" <button type="button" onClick={() => setTheme("dark")}>
> Dark Mode
<HugeiconsIcon icon={Sun01Icon} className="h-4 w-4" /> </button>
</Button>
<Button
variant={theme === "dark" ? "default" : "ghost"}
size="icon"
onClick={() => setTheme("dark")}
aria-label="Dark mode"
>
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
</Button>
</div> </div>
); );
} };

View file

@ -1,261 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { EyeIcon, EyeOff } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { Button } from '@/components/ui/button';
import { defaultPasswordValidator } from '@/constants';
import { authClient } from '@/lib/auth-client';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type PasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword';
type ChangePasswordMessages = {
currentPasswordRequired: string;
passwordTooShort: string;
confirmPasswordRequired: string;
passwordsDoNotMatch: string;
passwordMustDiffer: string;
};
export function createChangePasswordSchema(messages: ChangePasswordMessages) {
return z
.object({
currentPassword: z.string().min(1, {
error: messages.currentPasswordRequired,
}),
newPassword: defaultPasswordValidator(messages.passwordTooShort),
confirmPassword: z.string().min(1, {
error: messages.confirmPasswordRequired,
}),
})
.refine((value) => value.newPassword === value.confirmPassword, {
error: messages.passwordsDoNotMatch,
path: ['confirmPassword'],
})
.refine((value) => value.currentPassword !== value.newPassword, {
error: messages.passwordMustDiffer,
path: ['newPassword'],
});
}
export function PasswordChangeCard() {
const t = useTranslations('SettingsPage');
const [visibleFields, setVisibleFields] = useState<Record<PasswordField, boolean>>({
currentPassword: false,
newPassword: false,
confirmPassword: false,
});
const changePasswordSchema = createChangePasswordSchema({
currentPasswordRequired: t('CurrentPasswordRequired'),
passwordTooShort: t('PasswordTooShort'),
confirmPasswordRequired: t('ConfirmPasswordRequired'),
passwordsDoNotMatch: t('PasswordsDoNotMatch'),
passwordMustDiffer: t('PasswordMustDiffer'),
});
type ChangePasswordValues = z.infer<typeof changePasswordSchema>;
const form = useForm<ChangePasswordValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const isSubmitting = form.formState.isSubmitting;
function toggleVisibility(field: PasswordField) {
setVisibleFields((current) => ({
...current,
[field]: !current[field],
}));
}
async function onSubmit(values: ChangePasswordValues) {
const result = await authClient.changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
revokeOtherSessions: true,
});
if (result.error) {
toast(result.error.message);
return;
}
form.reset();
toast(t('PasswordUpdatedSuccessfully'));
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle>{t('Title')}</CardTitle>
<CardDescription>{t('Description')}</CardDescription>
</CardHeader>
<CardContent>
<form id="password-change-form" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="currentPassword"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="current-password">
{t('CurrentPasswordLabel')}
</FieldLabel>
<InputGroup>
<InputGroupInput
{...field}
id="current-password"
name="currentPassword"
type={visibleFields.currentPassword ? 'text' : 'password'}
aria-invalid={fieldState.invalid}
placeholder={t('CurrentPasswordPlaceholder')}
autoComplete="current-password"
/>
<InputGroupAddon
align="inline-end"
onClick={() => toggleVisibility('currentPassword')}
>
<Tooltip>
<TooltipTrigger asChild>
{visibleFields.currentPassword ? (
<HugeiconsIcon icon={EyeOff} />
) : (
<HugeiconsIcon icon={EyeIcon} />
)}
</TooltipTrigger>
<TooltipContent>
<p>
{visibleFields.currentPassword
? t('HidePasswordTooltip')
: t('ShowPasswordTooltip')}
</p>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="newPassword"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="new-password">{t('NewPasswordLabel')}</FieldLabel>
<InputGroup>
<InputGroupInput
{...field}
id="new-password"
name="newPassword"
type={visibleFields.newPassword ? 'text' : 'password'}
aria-invalid={fieldState.invalid}
placeholder={t('NewPasswordPlaceholder')}
autoComplete="new-password"
/>
<InputGroupAddon
align="inline-end"
onClick={() => toggleVisibility('newPassword')}
>
<Tooltip>
<TooltipTrigger asChild>
{visibleFields.newPassword ? (
<HugeiconsIcon icon={EyeOff} />
) : (
<HugeiconsIcon icon={EyeIcon} />
)}
</TooltipTrigger>
<TooltipContent>
<p>
{visibleFields.newPassword
? t('HidePasswordTooltip')
: t('ShowPasswordTooltip')}
</p>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="confirmPassword"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="confirm-password">
{t('ConfirmPasswordLabel')}
</FieldLabel>
<InputGroup>
<InputGroupInput
{...field}
id="confirm-password"
name="confirmPassword"
type={visibleFields.confirmPassword ? 'text' : 'password'}
aria-invalid={fieldState.invalid}
placeholder={t('ConfirmPasswordPlaceholder')}
autoComplete="new-password"
/>
<InputGroupAddon
align="inline-end"
onClick={() => toggleVisibility('confirmPassword')}
>
<Tooltip>
<TooltipTrigger asChild>
{visibleFields.confirmPassword ? (
<HugeiconsIcon icon={EyeOff} />
) : (
<HugeiconsIcon icon={EyeIcon} />
)}
</TooltipTrigger>
<TooltipContent>
<p>
{visibleFields.confirmPassword
? t('HidePasswordTooltip')
: t('ShowPasswordTooltip')}
</p>
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" form="password-change-form" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : t('Submit')}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}

View file

@ -1,57 +1,57 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from "radix-ui" import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/src/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: "text-primary underline-offset-4 hover:underline", link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: "size-8", icon: 'size-8',
"icon-xs": 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": 'icon-sm':
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
"icon-lg": "size-9", 'icon-lg': 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) );
function Button({ function Button({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : 'button';
return ( return (
<Comp <Comp
@ -61,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View file

@ -1,103 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -1,30 +0,0 @@
import { cn } from "@/lib/utils";
interface CheckboxProps {
checked?: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
id?: string;
}
export function Checkbox({
checked,
disabled,
onCheckedChange,
id,
}: CheckboxProps) {
return (
<input
id={id}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onCheckedChange?.(e.target.checked)}
className={cn(
"h-4 w-4 shrink-0 cursor-pointer rounded-sm border border-primary",
"accent-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
/>
);
}

View file

@ -1,238 +0,0 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View file

@ -1,156 +0,0 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View file

@ -1,19 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -1,24 +0,0 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -1,50 +0,0 @@
"use client"
import { useTheme } from "@wrksz/themes/client"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { HugeiconsIcon } from "@hugeicons/react"
import { CheckmarkCircle02Icon, InformationCircleIcon, Alert02Icon, MultiplicationSignCircleIcon, Loading03Icon } from "@hugeicons/core-free-icons"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<HugeiconsIcon icon={CheckmarkCircle02Icon} strokeWidth={2} className="size-4" />
),
info: (
<HugeiconsIcon icon={InformationCircleIcon} strokeWidth={2} className="size-4" />
),
warning: (
<HugeiconsIcon icon={Alert02Icon} strokeWidth={2} className="size-4" />
),
error: (
<HugeiconsIcon icon={MultiplicationSignCircleIcon} strokeWidth={2} className="size-4" />
),
loading: (
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View file

@ -1,12 +0,0 @@
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { Loading03Icon } from "@hugeicons/core-free-icons"
function Spinner({ className, strokeWidth, ...props }: React.ComponentProps<"svg">) {
const width = typeof strokeWidth === 'number' ? strokeWidth : 2;
return (
<HugeiconsIcon icon={Loading03Icon} strokeWidth={width} role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View file

@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -1,57 +0,0 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View file

@ -1,7 +1,12 @@
import z from 'zod/v4';
export const supportedLocales = ['en', 'pl']; export const supportedLocales = ['en', 'pl'];
export function defaultPasswordValidator(error = 'Hasło jest za krótkie') { export const routes = {
return z.string().refine((val) => val.length >= 8, { error }); authorized: {},
} unauthorized: {
home: '/',
login: '/auth?mode=login',
register: '/auth?mode=register',
},
};
//TODO: <Link href={routes.unauthorized.auth.addSearchParams(x=>x.mode.login).addSearchParams(x=>x.cookies.refresh)}>

View file

@ -1,96 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export type CookieCategory = "necessary" | "analytics" | "marketing";
export interface CookieConsentState {
necessary: boolean;
analytics: boolean;
marketing: boolean;
hasResponded: boolean;
}
const STORAGE_KEY = "cookie-consent";
function getInitialState(): CookieConsentState {
if (typeof window === "undefined") {
return {
necessary: true,
analytics: false,
marketing: false,
hasResponded: false,
};
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as CookieConsentState;
}
} catch {
// eslint-disable-next-line no-empty
}
return {
necessary: true,
analytics: false,
marketing: false,
hasResponded: false,
};
}
export function useCookieConsent() {
const [state, setState] = useState<CookieConsentState>(getInitialState);
const acceptAll = useCallback(() => {
const newState: CookieConsentState = {
necessary: true,
analytics: true,
marketing: true,
hasResponded: true,
};
setState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
}, []);
const acceptNecessaryOnly = useCallback(() => {
const newState: CookieConsentState = {
necessary: true,
analytics: false,
marketing: false,
hasResponded: true,
};
setState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
}, []);
const savePreferences = useCallback(
(preferences: Pick<CookieConsentState, "analytics" | "marketing">) => {
const newState: CookieConsentState = {
necessary: true,
analytics: preferences.analytics,
marketing: preferences.marketing,
hasResponded: true,
};
setState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
},
[]
);
const isAllowed = useCallback(
(category: CookieCategory) => {
return state[category];
},
[state]
);
return {
...state,
acceptAll,
acceptNecessaryOnly,
savePreferences,
isAllowed,
};
}

View file

@ -1,12 +1,14 @@
import { getRequestConfig } from 'next-intl/server'; import { getRequestConfig } from "next-intl/server";
import { hasLocale } from 'next-intl'; import { headers } from "next/headers";
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => { export default getRequestConfig(async () => {
const requested = await requestLocale; const headersList = await headers();
const locale = hasLocale(routing.locales, requested) const acceptLanguage = headersList.get("accept-language");
? requested
: routing.defaultLocale; const browserLocale = acceptLanguage?.split(",")[0]?.split("-")[0];
const supportedLocales = ["en", "pl"];
const locale = browserLocale && supportedLocales.includes(browserLocale) ? browserLocale : "en";
return { return {
locale, locale,

View file

@ -1,7 +0,0 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'pl'],
defaultLocale: 'en',
localePrefix: 'as-needed',
});

View file

@ -1,6 +0,0 @@
import { createAuthClient } from 'better-auth/react';
import { convexClient } from '@convex-dev/better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [convexClient()],
});

View file

@ -1,16 +0,0 @@
import { convexBetterAuthNextJs } from '@convex-dev/better-auth/nextjs';
import { env } from './env';
export const {
handler,
preloadAuthQuery,
isAuthenticated,
getToken,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthNextJs({
convexUrl: env.NEXT_PUBLIC_CONVEX_URL,
convexSiteUrl: env.NEXT_PUBLIC_CONVEX_SITE_URL,
});

View file

@ -1,35 +0,0 @@
import { z } from 'zod/v4';
const serverSchema = z.object({
NEXT_PUBLIC_CONVEX_URL: z.string().url(),
NEXT_PUBLIC_CONVEX_SITE_URL: z.string().url(),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
const clientSchema = z.object({
NEXT_PUBLIC_CONVEX_URL: z.string().url(),
NEXT_PUBLIC_CONVEX_SITE_URL: z.string().url(),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
const processEnv = {
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_CONVEX_SITE_URL: process.env.NEXT_PUBLIC_CONVEX_SITE_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
};
const parsed = serverSchema.safeParse(processEnv);
if (!parsed.success) {
console.error(
'Invalid environment variables:',
parsed.error.flatten().fieldErrors,
);
throw new Error('Invalid environment variables');
}
export const env = parsed.data;
export function getClientEnv() {
return clientSchema.parse(processEnv);
}

View file

@ -1,14 +0,0 @@
export const routes = {
public: {
home: '/',
signIn: '/sign-in',
signUp: '/sign-up',
forgotPassword: '/forgot-password',
resetPassword: '/reset-password',
verifyEmail: '/verify-email',
},
private: {
dashboard: '/dashboard',
settings: '/settings',
},
};

View file

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View file

@ -1,10 +0,0 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: [
'/((?!api|_next|_vercel|.*\\..*).*)',
],
};

View file

@ -19,13 +19,7 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"]
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/app/*": ["./src/app/*"],
"@/i18n/*": ["./src/i18n/*"],
"@/constants": ["./src/constants.ts"]
} }
}, },
"include": [ "include": [