Compare commits
46 commits
feature/te
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 324aa32f6c | |||
| aab08d5e0f | |||
| 1c2adc1f1e | |||
| 374bba1a93 | |||
| 65b20f9ef9 | |||
| 0b749350d8 | |||
| 57483625ec | |||
| ed05d36b1c | |||
| 93e0f86310 | |||
| 04641f74ff | |||
| e89537f217 | |||
| d41d4687ee | |||
| b2652db613 | |||
| d0c3649cf3 | |||
| e0fa300213 | |||
| bad2b107ba | |||
| 2a6a7980ec | |||
| e8d557b0d2 | |||
| c8cf9b1573 | |||
| 4ae60473b6 | |||
| a4e8ae34ed | |||
| 8c6c13e02a | |||
| d6da6e6193 | |||
| 45368fb9c5 | |||
| b4435a1bdf | |||
| 2c4552d0a3 | |||
| 40b5c98e51 | |||
| 04eea01376 | |||
| 8ff2246ecf | |||
| 65e91fc6f6 | |||
| ed6a17fe4b | |||
| 137ea0287e | |||
| 3dc0d0713f | |||
| 85ea4e6200 | |||
| c4a35e97c3 | |||
| c14d2d6f26 | |||
| 0245e3d27c | |||
| eec41123ef | |||
| a1e653bbf7 | |||
| da062bcc27 | |||
| 547594294a | |||
| 09aaa1e7a3 | |||
| c5af98065c | |||
| 7a36d530a0 | |||
| 6d592ad48a | |||
| 0efd9237f6 |
95 changed files with 12440 additions and 205 deletions
11
.env.example
Normal file
11
.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
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=
|
||||
69
.forgejo/workflows/ci.yml
Normal file
69
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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 }}"
|
||||
46
.github/workflows/lint.yml
vendored
Normal file
46
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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
1
.gitignore
vendored
|
|
@ -32,6 +32,7 @@ yarn-error.log*
|
|||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
|
|
@ -7,4 +7,8 @@
|
|||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"sortImports": {
|
||||
"order": "asc",
|
||||
"ignoreCase": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml
Normal file
28
.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
- 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]
|
||||
49
.sisyphus/boulder.json
Normal file
49
.sisyphus/boulder.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
.sisyphus/drafts/convex-coolify-better-auth-debug.md
Normal file
33
.sisyphus/drafts/convex-coolify-better-auth-debug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 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
|
||||
7
.sisyphus/evidence/task-4-schema-validation-runtime.txt
Normal file
7
.sisyphus/evidence/task-4-schema-validation-runtime.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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.
|
||||
18
.sisyphus/evidence/task-6-password-change-http-qa.txt
Normal file
18
.sisyphus/evidence/task-6-password-change-http-qa.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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"}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
## 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.
|
||||
373
.sisyphus/plans/app-shell-and-route-fallbacks.md
Normal file
373
.sisyphus/plans/app-shell-and-route-fallbacks.md
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
# 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
|
||||
361
.sisyphus/plans/dashboard-password-reset-flow.md
Normal file
361
.sisyphus/plans/dashboard-password-reset-flow.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
# 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
264
AGENTS.md
|
|
@ -3,3 +3,267 @@
|
|||
|
||||
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 -->
|
||||
|
||||
# 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/)
|
||||
|
|
|
|||
437
DEVELOPMENT.md
Normal file
437
DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
# 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
|
||||
```
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# 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
174
README.md
|
|
@ -1,144 +1,72 @@
|
|||
# SaaS template
|
||||
# convex-next-saas
|
||||
|
||||
This is not gonna make You rich, but it sure as hell will save you a lot of time!
|
||||
A personal, opinionated SaaS template built for speed. Next.js 16 + Convex self-hosted + Better Auth + Resend.
|
||||
|
||||
This is production ready setup for Convex and Next SaaS.
|
||||
> 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.
|
||||
|
||||
## Features
|
||||
## Stack
|
||||
|
||||
- Auth
|
||||
- Stripe
|
||||
- shadcn/create UI templte
|
||||
- next intl
|
||||
- next themes
|
||||
- Backend/Frontend Tests
|
||||
- Tmuxinator
|
||||
- CICD
|
||||
- accurate instructions
|
||||
- **Frontend**: Next.js 16 (App Router), React 19, TypeScript 5, Tailwind CSS 4
|
||||
- **Backend**: Convex self-hosted (Docker on Coolify)
|
||||
- **Auth**: Better Auth (email/password, email verification, password reset)
|
||||
- **Email**: Resend (free tier: 3,000 emails/month)
|
||||
- **UI**: shadcn/ui (radix-nova), @hugeicons/react
|
||||
- **i18n**: next-intl v4 with locale routing (`/en`, `/pl`)
|
||||
- **State**: RSC + Client Components hybrid
|
||||
- **Validation**: Zod v4
|
||||
- **Linting**: ESLint + oxlint
|
||||
|
||||
## Development process
|
||||
## What's Included
|
||||
|
||||
### Environments
|
||||
- [x] Email/password auth with HIBP password checking
|
||||
- [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)
|
||||
|
||||
- Dev cloud environment - based on `develop` branch
|
||||
- Prod cloud environment - based on `main` branch
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
## Quick Start
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
> [!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
|
||||
> ⚠️ **Always use `--webpack`** — Turbopack is broken in Next.js 16.2.1 (900% CPU spike).
|
||||
|
||||
### Convex
|
||||
## Project Bootstrap
|
||||
|
||||
[Source of truth](https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md)
|
||||
|
||||
1. Go on Coolify, search for Convex and set it up.
|
||||
|
||||
> [!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
|
||||
```bash
|
||||
node bin/init-template.mjs my-project
|
||||
cd my-project
|
||||
# Fill .env.local, deploy Convex, done.
|
||||
```
|
||||
|
||||
Then make sure to update domains and env vars accordingly
|
||||
## Docs
|
||||
|
||||
2. Deploy the service(s - dashboard and the db). It has built-in persistent storage
|
||||
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
|
||||
- [`AGENTS.md`](./AGENTS.md) — Architecture, conventions, and anti-patterns for AI agents
|
||||
- [`DEVELOPMENT.md`](./DEVELOPMENT.md) — Local setup, Coolify deployment, troubleshooting
|
||||
|
||||
```
|
||||
Admin key:
|
||||
self-hosted-convex|010...
|
||||
```
|
||||
## Environments
|
||||
|
||||
4. Now Your base is ready. Go to .env.local and finish setting up the connection.
|
||||
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!
|
||||
- **Dev**: `develop` branch
|
||||
- **Prod**: `main` branch
|
||||
|
||||
#### Troubleshooting
|
||||
## License
|
||||
|
||||
```
|
||||
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.
|
||||
MIT — do whatever you want.
|
||||
|
|
|
|||
101
bin/init-template.mjs
Normal file
101
bin/init-template.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/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();
|
||||
25
components.json
Normal file
25
components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "mauve",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "hugeicons",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default-translucent",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
982
convex/_generated/api.d.ts
vendored
982
convex/_generated/api.d.ts
vendored
|
|
@ -8,7 +8,10 @@
|
|||
* @module
|
||||
*/
|
||||
|
||||
import type * as auth from "../auth.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 {
|
||||
ApiFromModules,
|
||||
|
|
@ -17,7 +20,10 @@ import type {
|
|||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
hello: typeof hello;
|
||||
http: typeof http;
|
||||
"lib/resend": typeof lib_resend;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
|
@ -46,4 +52,978 @@ export declare const internal: FilterApi<
|
|||
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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
6
convex/auth.config.ts
Normal file
6
convex/auth.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config';
|
||||
import type { AuthConfig } from 'convex/server';
|
||||
|
||||
export default {
|
||||
providers: [getAuthConfigProvider()],
|
||||
} satisfies AuthConfig;
|
||||
77
convex/auth.ts
Normal file
77
convex/auth.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
52
convex/betterAuth/_generated/api.ts
Normal file
52
convex/betterAuth/_generated/api.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* 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 {};
|
||||
1004
convex/betterAuth/_generated/component.ts
Normal file
1004
convex/betterAuth/_generated/component.ts
Normal file
File diff suppressed because it is too large
Load diff
60
convex/betterAuth/_generated/dataModel.ts
Normal file
60
convex/betterAuth/_generated/dataModel.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/* 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>;
|
||||
156
convex/betterAuth/_generated/server.ts
Normal file
156
convex/betterAuth/_generated/server.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* 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>;
|
||||
13
convex/betterAuth/adapter.ts
Normal file
13
convex/betterAuth/adapter.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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);
|
||||
3
convex/betterAuth/auth.ts
Normal file
3
convex/betterAuth/auth.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createAuth } from '../auth';
|
||||
|
||||
export const auth = createAuth({} as Parameters<typeof createAuth>[0]);
|
||||
5
convex/betterAuth/convex.config.ts
Normal file
5
convex/betterAuth/convex.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponent } from 'convex/server';
|
||||
|
||||
const component = defineComponent('betterAuth');
|
||||
|
||||
export default component;
|
||||
77
convex/betterAuth/schema.ts
Normal file
77
convex/betterAuth/schema.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* 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;
|
||||
8
convex/convex.config.ts
Normal file
8
convex/convex.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineApp } from 'convex/server';
|
||||
import betterAuth from './betterAuth/convex.config';
|
||||
|
||||
const app = defineApp();
|
||||
|
||||
app.use(betterAuth);
|
||||
|
||||
export default app;
|
||||
8
convex/http.ts
Normal file
8
convex/http.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { httpRouter } from 'convex/server';
|
||||
import { authComponent, createAuth } from './auth';
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
authComponent.registerRoutes(http, createAuth);
|
||||
|
||||
export default http;
|
||||
31
convex/lib/resend.ts
Normal file
31
convex/lib/resend.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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();
|
||||
}
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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}'
|
||||
6
eslint.config.mjs
Normal file
6
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTypeScript from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = [...nextCoreWebVitals, ...nextTypeScript];
|
||||
|
||||
export default eslintConfig;
|
||||
|
|
@ -1,5 +1,100 @@
|
|||
{
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
},
|
||||
"AuthPage": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,100 @@
|
|||
{
|
||||
"HomePage": {
|
||||
"title": "Witaj świecie!"
|
||||
},
|
||||
"AuthPage": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { NextConfig } from "next";
|
|||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
|
|
|
|||
22
package.json
22
package.json
|
|
@ -3,20 +3,36 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.0",
|
||||
"next": "16.2.1",
|
||||
"next-intl": "^4.8.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"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",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.21",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
|
|
|||
5156
pnpm-lock.yaml
5156
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
4
public/pwa/apple-touch-icon.svg
Normal file
4
public/pwa/apple-touch-icon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 363 B |
4
public/pwa/icon.svg
Normal file
4
public/pwa/icon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 363 B |
5
public/pwa/maskable-icon.svg
Normal file
5
public/pwa/maskable-icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 436 B |
5
src/app/[locale]/[...rest]/page.tsx
Normal file
5
src/app/[locale]/[...rest]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
43
src/app/[locale]/dashboard/page.tsx
Normal file
43
src/app/[locale]/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
51
src/app/[locale]/error.tsx
Normal file
51
src/app/[locale]/error.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
9
src/app/[locale]/forgot-password/page.tsx
Normal file
9
src/app/[locale]/forgot-password/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
33
src/app/[locale]/layout.tsx
Normal file
33
src/app/[locale]/layout.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
19
src/app/[locale]/loading.tsx
Normal file
19
src/app/[locale]/loading.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { ThemeChanger } from "../components/core/ThemeChanger";
|
||||
import { ThemeChanger } from "@/components/core/ThemeChanger";
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations("HomePage");
|
||||
5
src/app/[locale]/qa/route-error/page.tsx
Normal file
5
src/app/[locale]/qa/route-error/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
export default function RouteErrorPage() {
|
||||
throw new Error("Intentional error for QA testing - this should trigger the error boundary");
|
||||
}
|
||||
16
src/app/[locale]/qa/slow/page.tsx
Normal file
16
src/app/[locale]/qa/slow/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
28
src/app/[locale]/reset-password/page.tsx
Normal file
28
src/app/[locale]/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
23
src/app/[locale]/settings/page.tsx
Normal file
23
src/app/[locale]/settings/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
20
src/app/[locale]/sign-in/page.tsx
Normal file
20
src/app/[locale]/sign-in/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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} />;
|
||||
}
|
||||
20
src/app/[locale]/sign-up/page.tsx
Normal file
20
src/app/[locale]/sign-up/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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} />;
|
||||
}
|
||||
48
src/app/[locale]/verify-email/page.tsx
Normal file
48
src/app/[locale]/verify-email/page.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...all]/route.ts
Normal file
3
src/app/api/auth/[...all]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handler } from '@/lib/auth-server';
|
||||
|
||||
export const { GET, POST } = handler;
|
||||
|
|
@ -1,26 +1,130 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-heading: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0.008 326);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0.008 326);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0.008 326);
|
||||
--primary: oklch(0.514 0.222 16.935);
|
||||
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.96 0.003 325.6);
|
||||
--muted-foreground: oklch(0.542 0.034 322.5);
|
||||
--accent: oklch(0.96 0.003 325.6);
|
||||
--accent-foreground: oklch(0.212 0.019 322.12);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0.005 325.62);
|
||||
--input: oklch(0.922 0.005 325.62);
|
||||
--ring: oklch(0.711 0.019 323.02);
|
||||
--chart-1: oklch(0.828 0.111 230.318);
|
||||
--chart-2: oklch(0.685 0.169 237.323);
|
||||
--chart-3: oklch(0.588 0.158 241.966);
|
||||
--chart-4: oklch(0.5 0.134 242.749);
|
||||
--chart-5: oklch(0.443 0.11 240.79);
|
||||
--radius: 0.875rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0.008 326);
|
||||
--sidebar-primary: oklch(0.586 0.253 17.585);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--sidebar-accent: oklch(0.96 0.003 325.6);
|
||||
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
|
||||
--sidebar-border: oklch(0.922 0.005 325.62);
|
||||
--sidebar-ring: oklch(0.711 0.019 323.02);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0.008 326);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.212 0.019 322.12);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.212 0.019 322.12);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.455 0.188 13.697);
|
||||
--primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.263 0.024 320.12);
|
||||
--muted-foreground: oklch(0.711 0.019 323.02);
|
||||
--accent: oklch(0.263 0.024 320.12);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.542 0.034 322.5);
|
||||
--chart-1: oklch(0.828 0.111 230.318);
|
||||
--chart-2: oklch(0.685 0.169 237.323);
|
||||
--chart-3: oklch(0.588 0.158 241.966);
|
||||
--chart-4: oklch(0.5 0.134 242.749);
|
||||
--chart-5: oklch(0.443 0.11 240.79);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.645 0.246 16.439);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
|
||||
--sidebar-accent: oklch(0.263 0.024 320.12);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.542 0.034 322.5);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,56 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { ThemeProvider } from '@wrksz/themes/next';
|
||||
import { Geist_Mono } from 'next/font/google';
|
||||
import { cn } from '@/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' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SaaS Template",
|
||||
description: "Create SaaS in a day!",
|
||||
title: 'SaaS Template',
|
||||
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 function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const headersList = await headers();
|
||||
const locale = headersList.get('x-next-intl-locale') || 'en';
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased" suppressHydrationWarning>
|
||||
<html
|
||||
lang={locale}
|
||||
className={cn('h-full antialiased', 'font-mono', geistMono.variable)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider attribute="class" enableSystem defaultTheme="system">
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider attribute="class" enableSystem defaultTheme="system">
|
||||
<NextIntlClientProvider>
|
||||
<main>{children}</main>
|
||||
<Toaster />
|
||||
<CookieBanner />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
28
src/app/manifest.ts
Normal file
28
src/app/manifest.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
29
src/app/not-found.tsx
Normal file
29
src/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
261
src/components/auth/AuthForm.tsx
Normal file
261
src/components/auth/AuthForm.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
85
src/components/auth/ForgotPasswordForm.tsx
Normal file
85
src/components/auth/ForgotPasswordForm.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
135
src/components/auth/ResetPasswordForm.tsx
Normal file
135
src/components/auth/ResetPasswordForm.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
44
src/components/core/AppNav.tsx
Normal file
44
src/components/core/AppNav.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
14
src/components/core/AppShell.tsx
Normal file
14
src/components/core/AppShell.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
70
src/components/core/AuthNavActions.tsx
Normal file
70
src/components/core/AuthNavActions.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
150
src/components/core/CookieBanner.tsx
Normal file
150
src/components/core/CookieBanner.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,52 @@
|
|||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const ThemeChanger = () => {
|
||||
import { useTheme } from "@wrksz/themes/client";
|
||||
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 [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => undefined,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return <p>Loading theme...</p>;
|
||||
return (
|
||||
<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 (
|
||||
<div>
|
||||
<p>The current theme is: {theme}</p>
|
||||
<button type="button" onClick={() => setTheme("light")}>
|
||||
Light Mode
|
||||
</button>
|
||||
<button type="button" onClick={() => setTheme("dark")}>
|
||||
Dark Mode
|
||||
</button>
|
||||
<div data-testid="theme-switcher" className={cn("flex items-center gap-1", className)}>
|
||||
<Button
|
||||
variant={theme === "light" ? "default" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setTheme("light")}
|
||||
aria-label="Light mode"
|
||||
>
|
||||
<HugeiconsIcon icon={Sun01Icon} className="h-4 w-4" />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
261
src/components/settings/PasswordChangeCard.tsx
Normal file
261
src/components/settings/PasswordChangeCard.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
67
src/components/ui/button.tsx
Normal file
67
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
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",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
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",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"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",
|
||||
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",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
238
src/components/ui/field.tsx
Normal file
238
src/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"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,
|
||||
}
|
||||
156
src/components/ui/input-group.tsx
Normal file
156
src/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"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,
|
||||
}
|
||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"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 }
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"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 }
|
||||
50
src/components/ui/sonner.tsx
Normal file
50
src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"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 }
|
||||
12
src/components/ui/spinner.tsx
Normal file
12
src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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 }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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 }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"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 }
|
||||
|
|
@ -1 +1,7 @@
|
|||
export const supportedLocales = ["en", "pl"];
|
||||
import z from 'zod/v4';
|
||||
|
||||
export const supportedLocales = ['en', 'pl'];
|
||||
|
||||
export function defaultPasswordValidator(error = 'Hasło jest za krótkie') {
|
||||
return z.string().refine((val) => val.length >= 8, { error });
|
||||
}
|
||||
|
|
|
|||
96
src/hooks/use-cookie-consent.ts
Normal file
96
src/hooks/use-cookie-consent.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { getRequestConfig } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { routing } from './routing';
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language");
|
||||
|
||||
const browserLocale = acceptLanguage?.split(",")[0]?.split("-")[0];
|
||||
|
||||
const supportedLocales = ["en", "pl"];
|
||||
const locale = browserLocale && supportedLocales.includes(browserLocale) ? browserLocale : "en";
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested)
|
||||
? requested
|
||||
: routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
|
|
|
|||
7
src/i18n/routing.ts
Normal file
7
src/i18n/routing.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'pl'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix: 'as-needed',
|
||||
});
|
||||
6
src/lib/auth-client.ts
Normal file
6
src/lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createAuthClient } from 'better-auth/react';
|
||||
import { convexClient } from '@convex-dev/better-auth/client/plugins';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [convexClient()],
|
||||
});
|
||||
16
src/lib/auth-server.ts
Normal file
16
src/lib/auth-server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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,
|
||||
});
|
||||
35
src/lib/env.ts
Normal file
35
src/lib/env.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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);
|
||||
}
|
||||
14
src/lib/routes.ts
Normal file
14
src/lib/routes.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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',
|
||||
},
|
||||
};
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
src/proxy.ts
Normal file
10
src/proxy.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import createMiddleware from 'next-intl/middleware';
|
||||
import { routing } from './i18n/routing';
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next|_vercel|.*\\..*).*)',
|
||||
],
|
||||
};
|
||||
|
|
@ -19,7 +19,13 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"],
|
||||
"@/app/*": ["./src/app/*"],
|
||||
"@/i18n/*": ["./src/i18n/*"],
|
||||
"@/constants": ["./src/constants.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue