Compare commits

..

40 Commits

Author SHA1 Message Date
igm
c60041a1bb Replace dbmate with direct schema.sql execution in tests
Some checks failed
CI / ci (push) Has been cancelled
Instead of running dbmate migrations, tests now directly execute the
db/schema.sql file on the test database. This is faster and removes
the dbmate dependency from tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:12:10 +08:00
igm
e43c006bb1 Fix merge conflicts and add withTransaction helper
- Add withTransaction helper that gracefully handles nested transactions
  (reuses existing transaction in tests, starts new one otherwise)
- Update auth procedures to use withTransaction instead of direct .transaction()
- Add email config to all e2e test contexts (required by merged code)
- Remove duplicate verification token code from signup procedure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:07:14 +08:00
igm
8e65c2e698 Merge branch 'transactions-in-procedure' 2026-01-12 15:53:41 +08:00
igm
b085a315be Add transactions to auth procedures and extract DB models
- Wrap multiple DB operations in transactions for atomicity:
  - login-if-completed: device upsert + session + login_request deletion
  - forgot-password: delete old tokens + insert new token
  - signup: session + email_verification creation

- Extract reusable DB model operations to packages/db/src/models/:
  - sessions.ts: insertSession()
  - user-devices.ts: upsertUserDevice(), isDeviceTrusted()

- Update session.ts to use new model functions from @reviq/db
- Fix type narrowing in admin.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:52:05 +08:00
igm
1ed41e5c4c Merge branch 'db-coverage' 2026-01-12 15:51:48 +08:00
igm
84644c8bfb Merge branch 'email-cleanup' 2026-01-12 15:51:38 +08:00
igm
5ecf12a1a1 Consolidate duplicate components and create reusable MetricsTable
- Merge two ConfirmDialog components into single shared ui/confirm-dialog
  with consistent API across account and org pages
- Create MetricsTable component to reduce duplication across dashboard
  table components (ad-unit, country, domain, source tables)
- Reduces code duplication by ~200 lines
- Consistent styling and behavior across all confirmation dialogs
- Single source of truth for metrics table structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:51:29 +08:00
igm
c2b815dd6a Extract emails into separate package with clean interface
- Create packages/emails/ with EmailClient interface abstraction
- Wrap Postmark ServerClient in adapter for clean typing
- Add createLoggingEmailClient for dev mode (logs to console)
- Split email templates into individual files with full test coverage
- Update api-server to use new package via context injection
- Remove EMAIL_DEV_MODE - now uses POSTMARK_API_KEY presence
- Delete apps/api-server/src/utils/email.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:51:12 +08:00
igm
67930d90d5 Simplify apps/cli/ code
- config.ts: Convert arrow functions to function declarations
- api-client.ts: Extract duplicated RPCLink logic into buildClient helper
- format-error.ts: Add centralized ORPCError handling
- complete-login.ts: Remove redundant error handling (now in formatError)
- status.ts: Simplify formatRelativeTime, improve whitespace
- create.ts: Rename validRoles to VALID_ROLES, add as const, early return
- completions.ts: Derive Shell type from SUPPORTED_SHELLS array

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:42:39 +08:00
igm
58ffa68f4c Add tests for @reviq/db package
- token.test.ts: Unit tests for generateToken, parseToken, hashToken
- client.test.ts: Tests for createDb validation and e2e connectivity
- execute-bootstrap.test.ts: Comprehensive e2e tests for bootstrap
  operation including overwrite mode and related record cleanup

Coverage: client.ts 100%, token.ts 100%, execute-bootstrap.ts 98.69%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:35:02 +08:00
igm
5a2e0297e5 Merge branch 'more-spec'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 15:23:25 +08:00
igm
c9de0b1ac5 Add sideEffects: false to all library packages
Enables tree-shaking for bundlers by marking all library packages
as side-effect-free.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:22:24 +08:00
igm
0f50291490 Add @types/bun to api-contract for test file compilation
The test file imports from bun:test which requires bun types. Added
@types/bun dependency and configured tsconfig to include bun types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:20:21 +08:00
igm
9c6694cad4 Auto-skip e2e tests when TEST_DATABASE_URL is not configured
Some checks failed
CI / ci (push) Has been cancelled
Previously, e2e tests would fail with a confusing URL parse error when
TEST_DATABASE_URL was not set. Now SKIP_DB_TESTS automatically becomes
true when the database URL is missing, gracefully skipping these tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:15:15 +08:00
igm
f9f1dc7403 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-12 15:08:49 +08:00
igm
b27a977809 Document test scripts in README and CLAUDE.md
- Add test:unit, test:cov, test:unit:cov to README scripts table
- Add Running Tests section to CLAUDE.md recommending test:cov

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:08:35 +08:00
igm
7edc4ba8a9 gnu sed 2026-01-12 15:08:17 +08:00
igm
16f827e8f0 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add test utilities and ast-grep rules for code quality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:05:07 +08:00
igm
947c73dbdc Remove unnecessary exclude from tsconfig files
TypeScript excludes node_modules by default, and dist is handled
by outDir or include patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:55 +08:00
igm
2baf10b0cd Replace String() calls with .toString()/.toLocaleString() per ast-grep rule
- Add formatError() helper in CLI to safely handle unknown error types
- Add uniqueTestId() helper for generating unique test identifiers
- Replace String(id) with id.toString() for database ID conversions
- Replace String(n) with n.toLocaleString() for user-facing number formatting
- Fix TypeScript errors in test files (undefined checks, unused variables)
- Update lint commands to include ast-grep scanning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:46 +08:00
igm
8b081d5ba8 Remove unnecessary testing/README.md
Sub-packages test-helpers and virtual-authenticator have their own READMEs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:57:28 +08:00
igm
01f1e1c9e3 Add READMEs for remaining packages
- db-schema: Database schema types from kysely-codegen
- db: Database client and helper functions
- testing: Overview of testing packages
- test-helpers: Database testing utilities
- virtual-authenticator: WebAuthn virtual authenticator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:59:34 +08:00
igm
26d10d452f Rename @reviq/utils to @reviq/server-utils and add package READMEs
- Rename packages/utils/ to packages/server-utils/
- Update all imports and package.json references
- Add READMEs for frontend-utils, server-utils, and common packages
- Update main README with new package structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:57:28 +08:00
igm
8b63eb3538 Add ast-grep rule to prevent String() function usage
Prefer .toString() or .toLocaleString() over String() for
more predictable behavior and consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:48:13 +08:00
igm
587e151fbd Fix ast-grep tests and add no-countall-number test
- Update zod-namespace-import snapshot (semicolon fix)
- Add test cases for no-countall-number rule
- Update rule pattern to match method calls on objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:45:14 +08:00
igm
94b6de5970 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add @reviq/test-helpers package with e2e tests for admin, auth, orgs, and webauthn.
Move test utilities to shared package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:43:28 +08:00
igm
6fa4da1abb Fix lint errors and add ast-grep rule for countAll
- Fix template literal expressions: wrap Date.now() in String()
- Add missing afterAll import in admin.test.ts
- Fix countOwners to use countAll() without misleading <number> type
- Add ast-grep rule to prevent countAll<number>() usage
- Fix formatting issues from merge conflict resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:40:06 +08:00
igm
92f7e1df09 Merge origin/master and migrate tests to describeE2E
- Resolve merge conflicts in auth.test.ts, me.test.ts, db/schema.sql
- Merge new loginRequestMiddleware tests into auth.test.ts describeE2E wrapper
- Merge new authMiddleware tests into me.test.ts describeE2E wrapper
- Add me.apiTokens and me.invites tests in separate describeE2E block
- Migrate admin.test.ts to use describeE2E and @reviq/test-helpers
- Migrate orgs.test.ts to use describeE2E and @reviq/test-helpers

All e2e tests now properly use the describeE2E helper which enables
SKIP_DB_TESTS environment variable support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:19:29 +08:00
igm
b2fba6e150 Add test infrastructure with coverage and DB test skipping
- Create @reviq/test-helpers package with shared test utilities
- Add describeE2E helper that auto-prefixes test names with [e2e]
- Support SKIP_DB_TESTS=1 to skip database-dependent tests
- Add unix socket support for TEST_DATABASE_URL
- Add root commands: test:unit, test:all, test:cov, test:unit:cov
- Configure bunfig.toml to exclude dist/ from coverage reports
- Clean up tsconfig.json files to remove redundant settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:03:41 +08:00
igm
ebc85af62c Add comprehensive e2e tests for API procedures with 100% coverage
- Add admin.test.ts: Tests for superuser operations (users, orgs, sites)
- Add orgs.test.ts: Tests for org management, members, invites, sites
- Expand me.test.ts: Add API tokens, invites, authMiddleware error paths
- Expand auth.test.ts: Add loginRequestMiddleware tests, weak password test fix

Bug fixes:
- Fix countOwners() in orgs/helpers.ts to convert PostgreSQL bigint to number
- Fix signup race condition by handling unique constraint violations gracefully

All 283 tests pass with 100% function coverage on procedures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:53:19 +08:00
igm
6b8dd27898 Merge branch 'schema-sql-fix' 2026-01-12 12:41:15 +08:00
igm
61fdd3329f Add OrgAvatar component and frontend-utils package
Some checks failed
CI / ci (push) Has been cancelled
- Create @reviq/frontend-utils package for frontend-specific utilities
- Add OrgAvatar component with size variants (xs, sm, md, lg, xl)
- Display org initials with deterministic colors when no logo available
- Add getOrgInitials and getOrgColor utility functions
- Update org-switcher and all org display pages to use OrgAvatar
- Add noNonNullAssertion lint rule as error in biome.jsonc

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:34:23 +08:00
igm
848d9e9af1 Add db-dump and db-migrate scripts to strip \restrict lines
PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump
output (CVE-2025-8714 security fix), causing schema.sql to appear
changed on every dump even when the schema hasn't changed.

These wrapper scripts run dbmate and strip the \restrict lines from
the output to keep schema.sql stable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:33:20 +08:00
igm
44a480179b Merge remote-tracking branch 'origin/master'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-11 14:19:58 +08:00
igm
628b01f4d8 Add type-safe navigation helpers and public pages
- Create gotoLogin() helper for login redirects with search params
- Add /terms and /privacy public routes with Tailwind typography
- Update auth-guard to allow unauthenticated access to public pages
- Fix resolve() usage across navigation components using as const pattern
- Fix eslint-disable-next-line placement for svelte/no-navigation-without-resolve
- Document SvelteKit resolve() patterns in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:19:33 +08:00
igm
8939deefbe Merge pull request 'Update db and db-schema packages to export from dist/' (#1) from fix-exports into master
Reviewed-on: https://git.rev.iq/igm/publisher-dashboard/pulls/1
2026-01-11 05:19:11 +00:00
igm
76a5e40900 Merge branch 'gitea-action' 2026-01-11 12:34:17 +08:00
igm
b1d07626f3 Add packages/common for shared utilities
Create new @reviq/common package with environment-agnostic utilities:
- Date formatting: formatDate, formatDateTime, formatLongDate,
  formatRelativeDate, formatRelativeTime
- User utilities: getUserInitials, formatRole

Consolidate date formatting from publisher-dashboard into shared package.
All utilities include comprehensive test coverage with bun:test.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:34:10 +08:00
igm
99539bbdcb Update Bun version to 1.3.5
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:27:52 +08:00
igm
eedd664db8 Add Gitea Action CI workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:22:51 +08:00
189 changed files with 13565 additions and 5348 deletions

View File

@@ -0,0 +1,16 @@
id: no-countall-number
snapshots:
countAll<number>():
fixed: countAll()
labels:
- source: countAll<number>()
style: primary
start: 0
end: 18
eb.fn.countAll<number>().as("count"):
fixed: eb.fn.countAll().as("count")
labels:
- source: eb.fn.countAll<number>()
style: primary
start: 0
end: 24

View File

@@ -0,0 +1,20 @@
id: no-string-function
snapshots:
String(123):
labels:
- source: String(123)
style: primary
start: 0
end: 11
String(Date.now()):
labels:
- source: String(Date.now())
style: primary
start: 0
end: 18
String(value):
labels:
- source: String(value)
style: primary
start: 0
end: 13

View File

@@ -3,7 +3,7 @@ snapshots:
? | ? |
import { z } from "zod"; import { z } from "zod";
: fixed: | : fixed: |
import * as z from "zod" import * as z from "zod";
labels: labels:
- source: import { z } from "zod"; - source: import { z } from "zod";
style: primary style: primary
@@ -12,7 +12,7 @@ snapshots:
? | ? |
import { z, ZodError } from "zod"; import { z, ZodError } from "zod";
: fixed: | : fixed: |
import * as z from "zod" import * as z from "zod";
labels: labels:
- source: import { z, ZodError } from "zod"; - source: import { z, ZodError } from "zod";
style: primary style: primary

View File

@@ -0,0 +1,9 @@
id: no-countall-number
valid:
# Plain countAll() is fine
- eb.fn.countAll().as("count")
# Other type arguments are fine
- eb.fn.countAll<string>().as("count")
invalid:
# countAll<number>() should be flagged
- eb.fn.countAll<number>().as("count")

View File

@@ -0,0 +1,13 @@
id: no-string-function
valid:
# toString() is fine
- value.toString()
- (123).toString()
- date.toLocaleString()
# Other functions named String are fine
- myString(value)
invalid:
# String() function should be flagged
- String(value)
- String(123)
- String(Date.now())

View File

@@ -0,0 +1,8 @@
id: no-countall-number
language: typescript
severity: error
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
note: "Use Number() to convert the result if you need a number type."
rule:
pattern: $OBJ.countAll<number>()
fix: $OBJ.countAll()

View File

@@ -0,0 +1,7 @@
id: no-string-function
language: typescript
severity: error
message: "Don't use String() - use .toString() or .toLocaleString() instead."
note: "String() can have unexpected behavior. Use .toString() for general conversion or .toLocaleString() for locale-aware formatting."
rule:
pattern: String($VAL)

34
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,34 @@
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.5"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Build
run: bun run build
- name: Test
run: bun run test

View File

@@ -1,5 +1,20 @@
# Claude Code Notes # Claude Code Notes
## Running Tests
Use `bun run test:cov` to run all tests with coverage. This runs both unit tests and e2e tests that require a database connection.
- `bun run test:cov` - Run all tests with coverage (preferred)
- `bun run test:unit:cov` - Run only unit tests with coverage (no database required)
## Database Scripts
Use the wrapper scripts instead of running dbmate directly:
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
- `./scripts/db-migrate` - Run migrations and dump clean schema
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
## Development Server ## Development Server
Before starting the dev server, check if it's already running: Before starting the dev server, check if it's already running:
@@ -14,10 +29,57 @@ This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs) - tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work) - Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
## macOS sed Syntax ## sed Syntax (GNU coreutils)
macOS uses BSD sed which differs from GNU sed: This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file` - In-place edit: `sed -i 's/old/new/g' file`
- GNU sed (Linux): `sed -i 's/old/new/g' file` - Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|g' file`
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file` - For multiple files: `for f in *.txt; do sed -i 's/old/new/g' "$f"; done`
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done` - Do NOT use BSD sed syntax (`sed -i ''`) - we have GNU sed available
## SvelteKit resolve() Usage
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
### Static routes - use resolve() directly
```svelte
href={resolve("/auth/login")}
href={resolve("/dashboard")}
```
### Dynamic routes - use two-argument form
```svelte
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
```
### Login redirects - use gotoLogin helper
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
```typescript
import { gotoLogin } from "$lib/utils/navigation";
gotoLogin(page.url.pathname);
```
This helper uses resolve() internally and handles the query string correctly.
### Navigation arrays - use `as const` with route patterns
For type-safe navigation arrays, define routes as literal strings with `as const`:
```typescript
const navItems = [
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
] as const;
```
Then use resolve with params:
```svelte
{#each navItems as item (item.route)}
<a href={resolve(item.route, { slug })}>
{/each}
```
### Runtime strings - skip resolve, use eslint-disable
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
```typescript
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(redirectUrl);
```

View File

@@ -26,9 +26,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
### Shared Packages ### Shared Packages
- `@reviq/api-contract` - Shared API contract (oRPC) - `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/common` - Shared utilities for frontend and backend
- `@reviq/db` - Database client and queries - `@reviq/db` - Database client and queries
- `@reviq/db-schema` - Database schema and codegen - `@reviq/db-schema` - Database schema and codegen
- `@reviq/utils` - Shared utilities - `@reviq/frontend-utils` - Frontend-specific utilities
- `@reviq/server-utils` - Server/CLI utilities
## Project Structure ## Project Structure
@@ -40,10 +42,12 @@ publisher-dashboard/
│ └── publisher-dashboard/ # SvelteKit frontend │ └── publisher-dashboard/ # SvelteKit frontend
├── packages/ ├── packages/
│ ├── api-contract/ # Shared oRPC contract │ ├── api-contract/ # Shared oRPC contract
│ ├── common/ # Shared utilities (frontend + backend)
│ ├── db/ # Database client │ ├── db/ # Database client
│ ├── db-schema/ # DB schema & codegen │ ├── db-schema/ # DB schema & codegen
│ ├── testing/ # Test utilities │ ├── frontend-utils/ # Frontend utilities
── utils/ # Shared utilities ── server-utils/ # Server/CLI utilities
│ └── testing/ # Test utilities
└── db/ # Database migrations └── db/ # Database migrations
``` ```
@@ -109,8 +113,13 @@ bun run dev
| `bun run typecheck` | Run TypeScript type checking | | `bun run typecheck` | Run TypeScript type checking |
| `bun run lint` | Run Biome and ESLint | | `bun run lint` | Run Biome and ESLint |
| `bun run lint:fix` | Fix linting issues | | `bun run lint:fix` | Fix linting issues |
| `bun run test` | Run tests | | `bun run test` | Run all tests (requires database) |
| `bun run test:unit` | Run unit tests only (no database required) |
| `bun run test:cov` | Run all tests with coverage report |
| `bun run test:unit:cov` | Run unit tests with coverage (no database) |
| `bun run db:codegen` | Generate database types | | `bun run db:codegen` | Generate database types |
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
## CLI ## CLI

View File

@@ -9,19 +9,17 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", "test": "bun test src/ --no-parallel"
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2", "@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2", "@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*", "@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*", "@reviq/emails": "workspace:*",
"@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0", "@simplewebauthn/types": "^12.0.0",
@@ -34,12 +32,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:" "typescript": "catalog:"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -41,14 +41,21 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
uniqueTestId,
withTestTransaction,
} from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js"; import { hashPassword } from "../../utils/password.js";
import { TEST_RP } from "../helpers/test-constants.js";
import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -94,6 +101,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -141,7 +153,7 @@ async function createSession(
userId: number, userId: number,
options?: { deviceId?: bigint }, options?: { deviceId?: bigint },
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -149,7 +161,7 @@ async function createSession(
.insertInto("sessions") .insertInto("sessions")
.values({ .values({
user_id: userId, user_id: userId,
device_id: options?.deviceId ? String(options.deviceId) : null, device_id: options?.deviceId ? options.deviceId.toString() : null,
token_hash: tokenHashValue, token_hash: tokenHashValue,
trusted_mode: false, trusted_mode: false,
expires_at: expiresAt, expires_at: expiresAt,
@@ -173,7 +185,7 @@ async function createLoginRequest(
expiresAt?: Date; expiresAt?: Date;
}, },
): Promise<{ token: string; id: number }> { ): Promise<{ token: string; id: number }> {
const token = `login_test-${String(Date.now())}${String(Math.random())}`; const token = `login_test-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
@@ -223,7 +235,7 @@ async function createEmailVerification(
userId: number, userId: number,
options?: { expiresAt?: Date }, options?: { expiresAt?: Date },
): Promise<string> { ): Promise<string> {
const token = `verify-${String(Date.now())}${String(Math.random())}`; const token = `verify-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000); options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
@@ -247,7 +259,7 @@ async function createPasswordReset(
userId: number, userId: number,
options?: { expiresAt?: Date; usedAt?: Date | null }, options?: { expiresAt?: Date; usedAt?: Date | null },
): Promise<string> { ): Promise<string> {
const token = `reset-${String(Date.now())}${String(Math.random())}`; const token = `reset-${uniqueTestId()}`;
const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000); const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000);
await db await db
@@ -263,16 +275,17 @@ async function createPasswordReset(
return token; return token;
} }
// Test setup describeE2E("auth", () => {
beforeAll(async () => { // Test setup
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
// ============================================================================= // =============================================================================
// auth.signup tests // auth.signup tests
// ============================================================================= // =============================================================================
describe("auth.signup", () => { describe("auth.signup", () => {
test("creates user with valid password", async () => { test("creates user with valid password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
@@ -391,7 +404,9 @@ describe("auth.signup", () => {
// nested transactions. // nested transactions.
test("creates user with passkey", async () => { test("creates user with passkey", async () => {
const db = getSharedDb(); const db = getSharedDb();
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
// Step 1: Create registration options // Step 1: Create registration options
@@ -449,7 +464,7 @@ describe("auth.signup", () => {
const challenges = await db const challenges = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.selectAll() .selectAll()
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
@@ -475,7 +490,7 @@ describe("auth.signup", () => {
await db await db
.updateTable("webauthn_challenges") .updateTable("webauthn_challenges")
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
// Step 4: Try to signup with expired challenge // Step 4: Try to signup with expired challenge
@@ -532,18 +547,18 @@ describe("auth.signup", () => {
const challenges = await db const challenges = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.selectAll() .selectAll()
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.createLoginRequest tests // auth.createLoginRequest tests
// ============================================================================= // =============================================================================
describe("auth.createLoginRequest", () => { describe("auth.createLoginRequest", () => {
test("returns auth methods for existing user with password", async () => { test("returns auth methods for existing user with password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, { await createTestUser(db, {
@@ -671,13 +686,13 @@ describe("auth.createLoginRequest", () => {
expect(fingerprint).not.toBeNull(); expect(fingerprint).not.toBeNull();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginPassword tests // auth.loginPassword tests
// ============================================================================= // =============================================================================
describe("auth.loginPassword", () => { describe("auth.loginPassword", () => {
test("completes login immediately for trusted device", async () => { test("completes login immediately for trusted device", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -863,13 +878,13 @@ describe("auth.loginPassword", () => {
).rejects.toThrow("Invalid email or password"); ).rejects.toThrow("Invalid email or password");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginPasswordConfirm tests // auth.loginPasswordConfirm tests
// ============================================================================= // =============================================================================
describe("auth.loginPasswordConfirm", () => { describe("auth.loginPasswordConfirm", () => {
test("marks login request as completed with valid token", async () => { test("marks login request as completed with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -964,13 +979,13 @@ describe("auth.loginPasswordConfirm", () => {
).rejects.toThrow("Invalid or expired confirmation link"); ).rejects.toThrow("Invalid or expired confirmation link");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginIfRequestIsCompleted tests // auth.loginIfRequestIsCompleted tests
// ============================================================================= // =============================================================================
describe("auth.loginIfRequestIsCompleted", () => { describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for incomplete login request", async () => { test("returns pending for incomplete login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1064,7 +1079,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
const loginRequest = await db const loginRequest = await db
.selectFrom("login_requests") .selectFrom("login_requests")
.selectAll() .selectAll()
.where("id", "=", String(loginRequestId)) .where("id", "=", loginRequestId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(loginRequest).toBeUndefined(); expect(loginRequest).toBeUndefined();
@@ -1111,7 +1126,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for fake/non-existent token", async () => { test("returns pending for fake/non-existent token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" }); const ctx = createAPIContext(db, {
loginRequestToken: "fake-token-xyz",
});
const result = await call( const result = await call(
router.auth.loginIfRequestIsCompleted, router.auth.loginIfRequestIsCompleted,
undefined, undefined,
@@ -1142,7 +1159,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
}); });
// Create login request without device fingerprint // Create login request without device fingerprint
const token = `login_test-${String(Date.now())}`; const token = `login_test-${uniqueTestId()}`;
await db await db
.insertInto("login_requests") .insertInto("login_requests")
.values({ .values({
@@ -1165,13 +1182,13 @@ describe("auth.loginIfRequestIsCompleted", () => {
expect(result.status).toBe("pending"); expect(result.status).toBe("pending");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.verifyEmail tests // auth.verifyEmail tests
// ============================================================================= // =============================================================================
describe("auth.verifyEmail", () => { describe("auth.verifyEmail", () => {
test("verifies email with valid token", async () => { test("verifies email with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1247,13 +1264,13 @@ describe("auth.verifyEmail", () => {
expect(verifications.length).toBe(0); expect(verifications.length).toBe(0);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.resendVerificationEmail tests // auth.resendVerificationEmail tests
// ============================================================================= // =============================================================================
describe("auth.resendVerificationEmail", () => { describe("auth.resendVerificationEmail", () => {
test("creates new verification token for unverified user", async () => { test("creates new verification token for unverified user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1344,17 +1361,19 @@ describe("auth.resendVerificationEmail", () => {
const ctx = createAPIContext(db); // No session const ctx = createAPIContext(db); // No session
await expect( await expect(
call(router.auth.resendVerificationEmail, undefined, { context: ctx }), call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
}),
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.forgotPassword tests // auth.forgotPassword tests
// ============================================================================= // =============================================================================
describe("auth.forgotPassword", () => { describe("auth.forgotPassword", () => {
test("creates password reset token for existing user", async () => { test("creates password reset token for existing user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1449,13 +1468,13 @@ describe("auth.forgotPassword", () => {
expect(resets.length).toBe(1); expect(resets.length).toBe(1);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.resetPassword tests // auth.resetPassword tests
// ============================================================================= // =============================================================================
describe("auth.resetPassword", () => { describe("auth.resetPassword", () => {
test("resets password with valid token", async () => { test("resets password with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1503,7 +1522,6 @@ describe("auth.resetPassword", () => {
// Create some sessions // Create some sessions
await createSession(db, user.id); await createSession(db, user.id);
await createSession(db, user.id);
const token = await createPasswordReset(db, user.id); const token = await createPasswordReset(db, user.id);
@@ -1604,13 +1622,13 @@ describe("auth.resetPassword", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.logout tests // auth.logout tests
// ============================================================================= // =============================================================================
describe("auth.logout", () => { describe("auth.logout", () => {
test("revokes current session", async () => { test("revokes current session", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1633,7 +1651,7 @@ describe("auth.logout", () => {
const session = await db const session = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["revoked_at"]) .select(["revoked_at"])
.where("id", "=", String(sessionId)) .where("id", "=", sessionId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(session?.revoked_at).not.toBeNull(); expect(session?.revoked_at).not.toBeNull();
@@ -1656,13 +1674,13 @@ describe("auth.logout", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// End-to-end login scenarios from docs/initial-app.md // End-to-end login scenarios from docs/initial-app.md
// ============================================================================= // =============================================================================
describe("End-to-end login scenarios", () => { describe("End-to-end login scenarios", () => {
test("Scenario: Password login with trusted device (immediate completion)", async () => { test("Scenario: Password login with trusted device (immediate completion)", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with password and trusted device // Setup: User with password and trusted device
@@ -1875,7 +1893,10 @@ describe("End-to-end login scenarios", () => {
const ctx2 = createAPIContext(db); const ctx2 = createAPIContext(db);
await call( await call(
router.auth.resetPassword, router.auth.resetPassword,
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, {
token: assertDefined(reset).token,
newPassword: "NewSecureP@ss123!",
},
{ context: ctx2 }, { context: ctx2 },
); );
@@ -1967,7 +1988,7 @@ describe("End-to-end login scenarios", () => {
// Clean up registration session // Clean up registration session
await db await db
.deleteFrom("sessions") .deleteFrom("sessions")
.where("id", "=", String(regSessionId)) .where("id", "=", regSessionId.toString())
.execute(); .execute();
// Step 1: Create login request // Step 1: Create login request
@@ -1991,7 +2012,8 @@ describe("End-to-end login scenarios", () => {
loginRequestToken: assertDefined(loginToken), loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint, deviceFingerprint: fingerprint,
}); });
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: ctx2 }, { context: ctx2 },
@@ -2104,4 +2126,63 @@ describe("End-to-end login scenarios", () => {
expect(loginRequest?.completed_at).toBeNull(); expect(loginRequest?.completed_at).toBeNull();
}); });
}); });
}); });
// =============================================================================
// loginRequestMiddleware tests (base.ts)
// =============================================================================
describe("loginRequestMiddleware", () => {
test("rejects request with no login request cookie", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// No login request token in context
const ctx = createAPIContext(db);
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("No login request found");
});
});
test("rejects request with invalid login request token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Invalid token that doesn't exist in DB
const ctx = createAPIContext(db, {
loginRequestToken: "invalid-login-request-token",
});
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("Login request expired or not found");
});
});
test("rejects request with expired login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expiredloginreq@example.com",
});
// Create an expired login request
const { token: loginToken } = await createLoginRequest(
db,
user.id,
user.email,
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("Login request expired or not found");
});
});
});
}); // Close outer describeE2E

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,23 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
destroySharedDb,
getSharedDb,
initTestDb,
KNOWN_AAGUIDS,
TEST_RP,
uniqueTestId,
withTestTransaction,
} from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { getUserPasskeys } from "../../utils/webauthn.js"; import { getUserPasskeys } from "../../utils/webauthn.js";
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
import {
createTestUser,
destroySharedDb,
getSharedDb,
initTestDb,
} from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -48,6 +52,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -58,7 +67,7 @@ async function createSession(
db: Kysely<Database>, db: Kysely<Database>,
userId: number, userId: number,
): Promise<string> { ): Promise<string> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -85,7 +94,7 @@ async function createLoginRequest(
userId: number, userId: number,
email: string, email: string,
): Promise<{ id: number; token: string }> { ): Promise<{ id: number; token: string }> {
const token = `test-login-${String(Date.now())}${String(Math.random())}`; const token = `test-login-${uniqueTestId()}`;
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
const result = await db const result = await db
@@ -130,6 +139,11 @@ function createLoginRequestContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -198,15 +212,16 @@ async function authenticate(
); );
} }
beforeAll(async () => { describeE2E("webauthn", () => {
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
afterAll(async () => { afterAll(async () => {
await destroySharedDb(); await destroySharedDb();
}); });
describe("registration flow", () => { describe("registration flow", () => {
test("creates registration options with challenge stored in DB via router", async () => { test("creates registration options with challenge stored in DB via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -233,7 +248,7 @@ describe("registration flow", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeDefined(); expect(challengeRow).toBeDefined();
@@ -379,7 +394,7 @@ describe("registration flow", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
@@ -419,9 +434,9 @@ describe("registration flow", () => {
} }
}); });
}); });
}); });
describe("authentication flow", () => { describe("authentication flow", () => {
test("creates authentication options with user's passkeys via router", async () => { test("creates authentication options with user's passkeys via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -483,7 +498,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -525,7 +541,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -563,7 +580,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -579,7 +597,7 @@ describe("authentication flow", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(authChallengeId)) .where("id", "=", authChallengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
@@ -636,9 +654,9 @@ describe("authentication flow", () => {
} }
}); });
}); });
}); });
describe("security tests", () => { describe("security tests", () => {
test("rejects replayed credentials (counter check) via router", async () => { test("rejects replayed credentials (counter check) via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -772,9 +790,9 @@ describe("security tests", () => {
} }
}); });
}); });
}); });
describe("full passkey lifecycle", () => { describe("full passkey lifecycle", () => {
test("register → authenticate → add second passkey → authenticate with either via router", async () => { test("register → authenticate → add second passkey → authenticate with either via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "lifecycle@test.com" }); const user = await createTestUser(db, { email: "lifecycle@test.com" });
@@ -834,9 +852,9 @@ describe("full passkey lifecycle", () => {
} }
}); });
}); });
}); });
describe("passkey management", () => { describe("passkey management", () => {
test("lists passkeys with correct data via router", async () => { test("lists passkeys with correct data via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -864,7 +882,9 @@ describe("passkey management", () => {
expect(passkeys).toHaveLength(2); expect(passkeys).toHaveLength(2);
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt) // Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain"); const icloudPasskey = passkeys.find(
(p) => p.name === "iCloud Keychain",
);
if (!icloudPasskey) { if (!icloudPasskey) {
throw new Error("Expected iCloud Keychain passkey to exist"); throw new Error("Expected iCloud Keychain passkey to exist");
} }
@@ -1003,7 +1023,9 @@ describe("passkey management", () => {
email: "delete-with-password@test.com", email: "delete-with-password@test.com",
passwordHash: "fake-password-hash", passwordHash: "fake-password-hash",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(db, user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
@@ -1019,7 +1041,9 @@ describe("passkey management", () => {
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
// Verify passkey is deleted // Verify passkey is deleted
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(0); expect(passkeys).toHaveLength(0);
}); });
@@ -1052,7 +1076,9 @@ describe("passkey management", () => {
); );
// Verify only one passkey remains // Verify only one passkey remains
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.id).not.toBe(firstPasskeyId); expect(firstPasskey.id).not.toBe(firstPasskeyId);
@@ -1066,7 +1092,9 @@ describe("passkey management", () => {
email: "delete-last@test.com", email: "delete-last@test.com",
// No password set // No password set
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(db, user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
@@ -1139,9 +1167,13 @@ describe("passkey management", () => {
} }
// User2's passkey should still exist // User2's passkey should still exist
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { const user2PasskeysAfter = await call(
router.me.passkeys.list,
undefined,
{
context: ctx2, context: ctx2,
}); },
);
expect(user2PasskeysAfter).toHaveLength(1); expect(user2PasskeysAfter).toHaveLength(1);
}); });
@@ -1193,4 +1225,5 @@ describe("passkey management", () => {
expect(firstPasskey.transports).toContain("hybrid"); expect(firstPasskey.transports).toContain("hybrid");
}); });
}); });
}); });
}); // Close outer describe.skipIf

View File

@@ -22,7 +22,7 @@ export const getAllowedOrigins = (): string[] => {
// Default to localhost origins for development // Default to localhost origins for development
return [ return [
`http://localhost:${String(DEFAULT_PORT)}`, `http://localhost:${DEFAULT_PORT.toString()}`,
"http://localhost:6827", "http://localhost:6827",
"http://localhost:6828", "http://localhost:6828",
]; ];
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
/** Base URL for generating email links */ /** Base URL for generating email links */
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827"; export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
/** Dev mode: log emails instead of sending (default: true) */ /** Postmark API key (optional - uses logging client if not set) */
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY; export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
// ===== Token Expiration Times ===== // ===== Token Expiration Times =====

View File

@@ -3,8 +3,18 @@
*/ */
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { EmailClient } from "@reviq/emails";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
/**
* Email configuration for the API
*/
export interface EmailConfig {
client: EmailClient;
fromAddress: string;
baseUrl: string;
}
/** /**
* Base API context available to all handlers * Base API context available to all handlers
*/ */
@@ -23,6 +33,8 @@ export interface APIContext {
resHeaders: Headers; resHeaders: Headers;
/** Client IP address from direct connection (fallback when no proxy headers) */ /** Client IP address from direct connection (fallback when no proxy headers) */
clientIP?: string | null; clientIP?: string | null;
/** Email client and configuration */
email: EmailConfig;
} }
/** /**

View File

@@ -2,10 +2,17 @@ import type { APIContext } from "./context.js";
import { LoggingHandlerPlugin } from "@orpc/experimental-pino"; import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import { createDb } from "@reviq/db"; import { createDb } from "@reviq/db";
import {
createLoggingEmailClient,
createPostmarkClient,
} from "@reviq/emails";
import pino from "pino"; import pino from "pino";
import { import {
BASE_URL,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_RP_NAME, DEFAULT_RP_NAME,
EMAIL_FROM,
POSTMARK_API_KEY,
getAllowedOrigins, getAllowedOrigins,
} from "./constants.js"; } from "./constants.js";
import { router } from "./router.js"; import { router } from "./router.js";
@@ -24,6 +31,16 @@ if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required"); throw new Error("DATABASE_URL environment variable is required");
} }
const db = createDb(databaseUrl); const db = createDb(databaseUrl);
// Create email client - use Postmark if API key is set, otherwise log to console
const emailClient = POSTMARK_API_KEY
? createPostmarkClient(POSTMARK_API_KEY)
: createLoggingEmailClient();
if (!POSTMARK_API_KEY) {
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
}
const handler = new RPCHandler(router, { const handler = new RPCHandler(router, {
plugins: [ plugins: [
new LoggingHandlerPlugin({ new LoggingHandlerPlugin({
@@ -45,7 +62,7 @@ Bun.serve({
if (url.pathname.startsWith("/api/v1/rpc")) { if (url.pathname.startsWith("/api/v1/rpc")) {
// Build context for the request // Build context for the request
const origin = const origin =
request.headers.get("origin") ?? `http://localhost:${String(port)}`; request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
// Create response headers for setting cookies // Create response headers for setting cookies
const resHeaders = new Headers(); const resHeaders = new Headers();
@@ -62,6 +79,11 @@ Bun.serve({
reqHeaders: request.headers, reqHeaders: request.headers,
resHeaders, resHeaders,
clientIP, clientIP,
email: {
client: emailClient,
fromAddress: EMAIL_FROM,
baseUrl: BASE_URL,
},
}; };
const { response } = await handler.handle(request, { const { response } = await handler.handle(request, {

View File

@@ -6,12 +6,13 @@
* This prevents attackers from determining which emails are registered * This prevents attackers from determining which emails are registered
*/ */
import { withTransaction } from "@reviq/db";
import { sendPasswordResetEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendPasswordResetEmail } from "../../utils/email.js";
import { os } from "../base.js"; import { os } from "../base.js";
export const forgotPassword = os.auth.forgotPassword.handler( export const forgotPassword = os.auth.forgotPassword.handler(
@@ -30,19 +31,21 @@ export const forgotPassword = os.auth.forgotPassword.handler(
// If user exists, create password reset token and send email // If user exists, create password reset token and send email
if (user) { if (user) {
// Delete any existing password reset tokens for this user (security measure)
await context.db
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
// Generate secure base58 token // Generate secure base58 token
const token = generateSecureBase58Token(); const token = generateSecureBase58Token();
// Create password reset record with 1 hour expiry // Create password reset record with 1 hour expiry
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
await context.db // Delete old tokens and insert new one in transaction
await withTransaction(context.db, async (trx) => {
// Delete any existing password reset tokens for this user (security measure)
await trx
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
await trx
.insertInto("password_resets") .insertInto("password_resets")
.values({ .values({
user_id: user.id, user_id: user.id,
@@ -50,9 +53,17 @@ export const forgotPassword = os.auth.forgotPassword.handler(
expires_at: expiresAt, expires_at: expiresAt,
}) })
.execute(); .execute();
});
// Send password reset email (stubbed) // Send password reset email
await sendPasswordResetEmail(user.email, token); await sendPasswordResetEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: user.email,
token,
expiryHours: 1,
});
} }
// Always return success (anti-enumeration) // Always return success (anti-enumeration)

View File

@@ -16,6 +16,7 @@
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' } * e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
*/ */
import { withTransaction } from "@reviq/db";
import { import {
COOKIE_NAMES, COOKIE_NAMES,
COOKIE_OPTIONS, COOKIE_OPTIONS,
@@ -89,9 +90,13 @@ export const loginIfRequestIsCompleted =
const geo = getGeoInfo(context.reqHeaders, context.clientIP); const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
// Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await withTransaction(
context.db,
async (trx) => {
// Upsert user device // Upsert user device
const deviceId = await upsertUserDevice( const deviceId = await upsertUserDevice(
context.db, trx,
userId, userId,
deviceFingerprint, deviceFingerprint,
geo, geo,
@@ -99,14 +104,10 @@ export const loginIfRequestIsCompleted =
); );
// Check if device is already trusted // Check if device is already trusted
const deviceTrusted = await isDeviceTrusted( const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
context.db,
userId,
deviceFingerprint,
);
// Create session with trusted mode = true (email-confirmed login) // Create session with trusted mode = true (email-confirmed login)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId, deviceId,
trustedMode: true, trustedMode: true,
@@ -115,11 +116,14 @@ export const loginIfRequestIsCompleted =
}); });
// Delete the login request (it's been consumed) // Delete the login request (it's been consumed)
await context.db await trx
.deleteFrom("login_requests") .deleteFrom("login_requests")
.where("id", "=", loginRequest.id) .where("id", "=", loginRequest.id)
.execute(); .execute();
return { session: newSession, deviceTrusted: trusted };
});
// Set session cookie // Set session cookie
setCookie( setCookie(
context.resHeaders, context.resHeaders,

View File

@@ -4,8 +4,8 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { sendLoginConfirmationEmail } from "@reviq/emails";
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
import { sendLoginConfirmationEmail } from "../../utils/email.js";
import { verifyPassword } from "../../utils/password.js"; import { verifyPassword } from "../../utils/password.js";
import { isDeviceTrusted } from "../../utils/session.js"; import { isDeviceTrusted } from "../../utils/session.js";
import { os } from "../base.js"; import { os } from "../base.js";
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
} else { } else {
// Device is untrusted - send confirmation email with existing token // Device is untrusted - send confirmation email with existing token
// The same base58 token is used for both cookie lookup and email confirmation // The same base58 token is used for both cookie lookup and email confirmation
await sendLoginConfirmationEmail(result.email, result.token); await sendLoginConfirmationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: result.email,
token: result.token,
expiryMinutes: 15,
});
} }
return { success: true }; return { success: true };

View File

@@ -10,12 +10,12 @@
* 5. Send verification email (stubbed) * 5. Send verification email (stubbed)
*/ */
import { sendVerificationEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js"; import { authMiddleware, os } from "../base.js";
export const resendVerificationEmail = os.auth.resendVerificationEmail export const resendVerificationEmail = os.auth.resendVerificationEmail
@@ -47,8 +47,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
}) })
.execute(); .execute();
// Send verification email (stubbed) // Send verification email
await sendVerificationEmail(context.user.email, token); await sendVerificationEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email: context.user.email,
token,
expiryHours: 24,
});
return { success: true }; return { success: true };
}); });

View File

@@ -10,6 +10,8 @@ import type {
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { RPInfo } from "../../utils/webauthn.js"; import type { RPInfo } from "../../utils/webauthn.js";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { withTransaction } from "@reviq/db";
import { sendVerificationEmail } from "@reviq/emails";
import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { import {
COOKIE_NAMES, COOKIE_NAMES,
@@ -21,7 +23,6 @@ import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import { hashPassword, validatePassword } from "../../utils/password.js"; import { hashPassword, validatePassword } from "../../utils/password.js";
import { createSession } from "../../utils/session.js"; import { createSession } from "../../utils/session.js";
@@ -52,7 +53,8 @@ export async function signupWithPassword(
// Hash password // Hash password
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
// Create user // Create user (handle race condition if concurrent signup with same email)
try {
const user = await db const user = await db
.insertInto("users") .insertInto("users")
.values({ .values({
@@ -63,6 +65,16 @@ export async function signupWithPassword(
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return user.id; return user.id;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -97,7 +109,7 @@ export async function signupWithPasskey(
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("options") .select("options")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.where("created_at", ">", fifteenMinutesAgo) .where("created_at", ">", fifteenMinutesAgo)
.executeTakeFirst(); .executeTakeFirst();
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
// Log error for debugging but don't expose to client // Log error for debugging but don't expose to client
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
@@ -146,8 +158,9 @@ export async function signupWithPasskey(
}); });
} }
// Create user and passkey in a transaction // Create user and passkey in a transaction (handle race condition if concurrent signup)
const result = await db.transaction().execute(async (trx) => { try {
const result = await withTransaction(db, async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
.insertInto("users") .insertInto("users")
@@ -188,13 +201,23 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await trx await trx
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
return { userId: newUserId }; return { userId: newUserId };
}); });
return result.userId; return result.userId;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -241,14 +264,22 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
); );
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else { } else {
// Should never reach here due to schema validation // Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required", message: "Either password or passkeyInfo is required",
}); });
} }
// Generate verification token
const verificationToken = generateSecureBase58Token();
const verificationExpiresAt = generateExpiry(
TOKEN_DURATIONS.EMAIL_VERIFICATION,
);
// Create session and email verification in transaction
const session = await withTransaction(context.db, async (trx) => {
// Create session (7 days, trusted mode false initially, no device) // Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId: null, deviceId: null,
trustedMode: false, trustedMode: false,
@@ -256,6 +287,19 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
userAgent, userAgent,
}); });
// Store verification token (store raw token, not hash - it's already high-entropy)
await trx
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken,
expires_at: verificationExpiresAt,
})
.execute();
return newSession;
});
// Set session cookie // Set session cookie
setCookie( setCookie(
context.resHeaders, context.resHeaders,
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
COOKIE_OPTIONS.session, COOKIE_OPTIONS.session,
); );
// Generate verification token // Send verification email
const verificationToken = generateSecureBase58Token(); await sendVerificationEmail({
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); client: context.email.client,
fromAddress: context.email.fromAddress,
// Store verification token (store raw token, not hash - it's already high-entropy) baseUrl: context.email.baseUrl,
await context.db email,
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken, token: verificationToken,
expires_at: expiresAt, expiryHours: 24,
}) });
.execute();
// Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken);
return { success: true }; return { success: true };
}); });

View File

@@ -95,7 +95,7 @@ export const deleteApiToken = os.me.apiTokens.delete
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
const result = await context.db const result = await context.db
.deleteFrom("api_tokens") .deleteFrom("api_tokens")
.where("id", "=", String(input.tokenId)) .where("id", "=", input.tokenId.toString())
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -108,7 +108,7 @@ export const untrustDevice = os.me.devices.untrust
const result = await context.db const result = await context.db
.updateTable("user_devices") .updateTable("user_devices")
.set({ is_trusted: false }) .set({ is_trusted: false })
.where("id", "=", String(input.deviceId)) .where("id", "=", input.deviceId.toString())
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -38,7 +38,7 @@ export const renamePasskey = os.me.passkeys.rename
const result = await context.db const result = await context.db
.updateTable("passkeys") .updateTable("passkeys")
.set({ name }) .set({ name })
.where("id", "=", String(passkeyId)) .where("id", "=", passkeyId.toString())
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.executeTakeFirst(); .executeTakeFirst();
@@ -86,7 +86,7 @@ export const deletePasskey = os.me.passkeys.delete
const result = await trx const result = await trx
.deleteFrom("passkeys") .deleteFrom("passkeys")
.where("id", "=", String(passkeyId)) .where("id", "=", passkeyId.toString())
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -48,7 +48,7 @@ export const revokeSession = os.me.sessions.revoke
const { sessionId } = input; const { sessionId } = input;
// Prevent revoking current session (use logout instead) // Prevent revoking current session (use logout instead)
if (String(sessionId) === context.session.id) { if (sessionId.toString() === context.session.id) {
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Cannot revoke current session. Use logout instead.", message: "Cannot revoke current session. Use logout instead.",
}); });
@@ -57,7 +57,7 @@ export const revokeSession = os.me.sessions.revoke
const result = await context.db const result = await context.db
.updateTable("sessions") .updateTable("sessions")
.set({ revoked_at: new Date() }) .set({ revoked_at: new Date() })
.where("id", "=", String(sessionId)) .where("id", "=", sessionId.toString())
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.where("revoked_at", "is", null) .where("revoked_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -115,10 +115,11 @@ export async function countOwners(
): Promise<number> { ): Promise<number> {
const result = await db const result = await db
.selectFrom("org_members") .selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count")) .select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId) .where("org_id", "=", orgId)
.where("role", "=", "owner") .where("role", "=", "owner")
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return result.count; // PostgreSQL COUNT returns bigint (string), convert to number
return Number(result.count);
} }

View File

@@ -3,12 +3,12 @@
*/ */
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { sendOrgInviteEmail } from "@reviq/emails";
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
import { import {
generateExpiry, generateExpiry,
generateSecureBase58Token, generateSecureBase58Token,
} from "../../utils/crypto.js"; } from "../../utils/crypto.js";
import { sendOrgInviteEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js"; import { authMiddleware, os } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
@@ -122,7 +122,17 @@ export const invitesCreate = os.orgs.invites.create
// Send invitation email // Send invitation email
const inviterName = context.user.displayName ?? context.user.email; const inviterName = context.user.displayName ?? context.user.email;
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role); await sendOrgInviteEmail({
client: context.email.client,
fromAddress: context.email.fromAddress,
baseUrl: context.email.baseUrl,
email,
token,
orgName: org.displayName,
inviterName,
role,
expiryDays: ORG_INVITE_EXPIRY_DAYS,
});
return { success: true }; return { success: true };
}); });

View File

@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
await context.db await context.db
.updateTable("login_requests") .updateTable("login_requests")
.set({ completed_at: new Date() }) .set({ completed_at: new Date() })
.where("id", "=", String(context.loginRequestId)) .where("id", "=", context.loginRequestId.toString())
.execute(); .execute();
return { success: true }; return { success: true };

View File

@@ -1,4 +1,4 @@
import { generateSecureBase58Token } from "@reviq/utils"; import { generateSecureBase58Token } from "@reviq/server-utils";
import { base58 } from "@scure/base"; import { base58 } from "@scure/base";
// Re-export for convenience // Re-export for convenience

View File

@@ -1,419 +0,0 @@
/**
* Email sending utilities using Postmark
* Implements Workstream G: Email Service (Backend)
*/
import type { OrgRole } from "@reviq/db-schema";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { ServerClient } from "postmark";
import {
BASE_URL,
EMAIL_DEV_MODE,
EMAIL_FROM,
EMAIL_VERIFICATION_EXPIRY_HOURS,
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
ORG_INVITE_EXPIRY_DAYS,
PASSWORD_RESET_EXPIRY_HOURS,
POSTMARK_API_KEY,
} from "../constants.js";
// ===== Types =====
/**
* Email send result
*/
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}
// ===== Postmark Client =====
let postmarkClient: ServerClient | null = null;
const getPostmarkClient = (): ServerClient => {
if (!postmarkClient) {
if (!POSTMARK_API_KEY) {
throw new Error(
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
);
}
postmarkClient = new ServerClient(POSTMARK_API_KEY);
}
return postmarkClient;
};
// ===== URL Helpers =====
/**
* Build a URL with query parameters using the URL constructor
*/
const buildUrl = (path: string, params: Record<string, string>): string => {
const url = new URL(path, BASE_URL);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
};
// ===== HTML Escaping =====
/**
* Escape HTML special characters to prevent XSS
*/
const escapeHtml = (unsafe: string): string =>
unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// ===== Core Email Function =====
interface SendEmailParams {
to: string;
subject: string;
htmlBody: string;
textBody: string;
}
/**
* Send an email via Postmark (or log in dev mode)
*/
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
const { to, subject, htmlBody, textBody } = params;
// Dev mode: log instead of sending
if (EMAIL_DEV_MODE) {
console.log("=== DEV MODE EMAIL ===");
console.log(`To: ${to}`);
console.log(`Subject: ${subject}`);
console.log(`Body:\n${textBody}`);
console.log("======================");
return { success: true, messageId: "dev-mode" };
}
try {
const client = getPostmarkClient();
const result = await client.sendEmail({
From: EMAIL_FROM,
To: to,
Subject: subject,
HtmlBody: htmlBody,
TextBody: textBody,
});
return { success: true, messageId: result.MessageID };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(`Failed to send email to ${to}:`, message);
return { success: false, error: message };
}
};
// ===== Template Helpers =====
const durationFormatter = new DurationFormat("en", { style: "long" });
const formatExpiryHours = (hours: number): string =>
durationFormatter.format({ hours });
const formatExpiryMinutes = (minutes: number): string =>
durationFormatter.format({ minutes });
const formatExpiryDays = (days: number): string =>
durationFormatter.format({ days });
const roleLabels: Record<OrgRole, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
/**
* Get the correct article (a/an) for a role
*/
const getArticleForRole = (role: OrgRole): string => {
return role === "owner" || role === "admin" ? "an" : "a";
};
// ===== Email Templates =====
// Common styles
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
const containerStyles =
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
const paragraphStyles =
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
const buttonStyles =
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
// Verification Email
const buildVerificationEmailHtml = (
verifyUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Verify your email</h1>
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
const buildVerificationEmailText = (
verifyUrl: string,
expiresIn: string,
): string =>
`Verify your email
Please verify your email address by clicking the link below:
${verifyUrl}
This link expires in ${expiresIn}.
If you didn't create an account, you can safely ignore this email.
`;
// Password Reset Email
const buildPasswordResetEmailHtml = (
resetUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Reset your password</h1>
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
</div>
</body>
</html>
`;
const buildPasswordResetEmailText = (
resetUrl: string,
expiresIn: string,
): string =>
`Reset your password
We received a request to reset your password. Click the link below to choose a new password:
${resetUrl}
This link expires in ${expiresIn}.
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
`;
// Login Confirmation Email
const buildLoginConfirmationEmailHtml = (
confirmUrl: string,
expiresIn: string,
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">Confirm your login</h1>
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
</div>
</body>
</html>
`;
const buildLoginConfirmationEmailText = (
confirmUrl: string,
expiresIn: string,
): string =>
`Confirm your login
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
${confirmUrl}
This link expires in ${expiresIn}.
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
`;
// Org Invite Email
const buildOrgInviteEmailHtml = (
email: string,
orgName: string,
inviterName: string,
role: OrgRole,
inviteUrl: string,
expiresIn: string,
): string => {
const safeOrgName = escapeHtml(orgName);
const safeInviterName = escapeHtml(inviterName);
const safeEmail = escapeHtml(email);
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="${emailStyles}">
<div style="${containerStyles}">
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
</div>
</body>
</html>
`;
};
const buildOrgInviteEmailText = (
email: string,
orgName: string,
inviterName: string,
role: OrgRole,
inviteUrl: string,
expiresIn: string,
): string => {
const roleDisplay = formatRoleDisplay(role);
const article = getArticleForRole(role);
return `You've been invited to join ${orgName}
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
Click the link below to accept the invitation:
${inviteUrl}
This invitation expires in ${expiresIn}.
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
`;
};
// ===== Email Helpers =====
/**
* Send verification email to user
*/
export async function sendVerificationEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/verify", { token });
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
return sendEmail({
to: email,
subject: "Verify your email address",
htmlBody: buildVerificationEmailHtml(url, expiresIn),
textBody: buildVerificationEmailText(url, expiresIn),
});
}
/**
* Send login confirmation email (for untrusted device flow)
*/
export async function sendLoginConfirmationEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/confirm", { token });
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
return sendEmail({
to: email,
subject: "Confirm your login",
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
textBody: buildLoginConfirmationEmailText(url, expiresIn),
});
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
email: string,
token: string,
): Promise<EmailResult> {
const url = buildUrl("/auth/reset-password", { token });
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
return sendEmail({
to: email,
subject: "Reset your password",
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
textBody: buildPasswordResetEmailText(url, expiresIn),
});
}
/**
* Send org invite email
*/
export async function sendOrgInviteEmail(
email: string,
token: string,
orgName: string,
inviterName: string,
role: OrgRole,
): Promise<EmailResult> {
const url = buildUrl("/invite/accept", { token });
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
return sendEmail({
to: email,
subject: `You've been invited to join ${orgName}`,
htmlBody: buildOrgInviteEmailHtml(
email,
orgName,
inviterName,
role,
url,
expiresIn,
),
textBody: buildOrgInviteEmailText(
email,
orgName,
inviterName,
role,
url,
expiresIn,
),
});
}

View File

@@ -1,7 +1,7 @@
import { import {
hashPassword as hashPasswordUtil, hashPassword as hashPasswordUtil,
verifyPassword as verifyPasswordUtil, verifyPassword as verifyPasswordUtil,
} from "@reviq/utils"; } from "@reviq/server-utils";
import zxcvbn from "zxcvbn"; import zxcvbn from "zxcvbn";
export interface PasswordValidationResult { export interface PasswordValidationResult {

View File

@@ -1,6 +1,11 @@
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely"; import type { Kysely, Transaction } from "kysely";
import type { GeoInfo } from "./geo.js"; import type { GeoInfo } from "./geo.js";
import {
isDeviceTrusted as dbIsDeviceTrusted,
upsertUserDevice as dbUpsertUserDevice,
insertSession,
} from "@reviq/db";
import { COOKIE_DURATIONS } from "./cookies.js"; import { COOKIE_DURATIONS } from "./cookies.js";
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js"; import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
@@ -23,33 +28,26 @@ export interface SessionResult {
* Returns the raw token (to be sent in cookie) and session details * Returns the raw token (to be sent in cookie) and session details
*/ */
export async function createSession( export async function createSession(
db: Kysely<Database>, db: Kysely<Database> | Transaction<Database>,
options: CreateSessionOptions, options: CreateSessionOptions,
): Promise<SessionResult> { ): Promise<SessionResult> {
const token = generateSessionToken(); const token = generateSessionToken();
const tokenHash = await hashToken(token); const tokenHash = await hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION); const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db const result = await insertSession(db, {
.insertInto("sessions") userId: options.userId,
.values({ deviceId: options.deviceId,
user_id: options.userId, tokenHash,
device_id: options.deviceId, trustedMode: options.trustedMode,
token_hash: tokenHash, geo: options.geo,
trusted_mode: options.trustedMode, userAgent: options.userAgent,
ip_address: options.geo.ip, expiresAt,
city: options.geo.city, });
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return { return {
token, token,
sessionId: Number(result.id), sessionId: result.sessionId,
expiresAt, expiresAt,
}; };
} }
@@ -60,53 +58,22 @@ export async function createSession(
* Returns the device ID * Returns the device ID
*/ */
export async function upsertUserDevice( export async function upsertUserDevice(
db: Kysely<Database>, db: Kysely<Database> | Transaction<Database>,
userId: number, userId: number,
deviceFingerprint: string, deviceFingerprint: string,
geo: GeoInfo, geo: GeoInfo,
userAgent: string, userAgent: string,
): Promise<number> { ): Promise<number> {
const result = await db return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.onConflict((oc) =>
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
last_used_at: new Date(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
} }
/** /**
* Check if a device is trusted for a user * Check if a device is trusted for a user
*/ */
export async function isDeviceTrusted( export async function isDeviceTrusted(
db: Kysely<Database>, db: Kysely<Database> | Transaction<Database>,
userId: number, userId: number,
deviceFingerprint: string, deviceFingerprint: string,
): Promise<boolean> { ): Promise<boolean> {
const device = await db return dbIsDeviceTrusted(db, userId, deviceFingerprint);
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
} }

View File

@@ -162,7 +162,7 @@ export const verifyRegistration = async (
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("options") .select("options")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
if (!challengeRow) { if (!challengeRow) {
@@ -189,7 +189,7 @@ export const verifyRegistration = async (
// Always delete the challenge // Always delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
} }
@@ -278,7 +278,7 @@ export const verifyAuthentication = async (
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("options") .select("options")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
if (!challengeRow) { if (!challengeRow) {
@@ -321,7 +321,7 @@ export const verifyAuthentication = async (
counter: verification.authenticationInfo.newCounter.toString(), counter: verification.authenticationInfo.newCounter.toString(),
last_used_at: new Date(), last_used_at: new Date(),
}) })
.where("id", "=", String(passkey.id)) .where("id", "=", passkey.id.toString())
.execute(); .execute();
return true; return true;
@@ -329,7 +329,7 @@ export const verifyAuthentication = async (
// Always delete the challenge // Always delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
} }
}; };

View File

@@ -19,6 +19,5 @@
"isolatedDeclarations": false, "isolatedDeclarations": false,
"composite": false "composite": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["node_modules", "dist"]
} }

View File

@@ -13,7 +13,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test": "bun test" "test": "bun test src/"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",

View File

@@ -1,7 +1,7 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { ORPCError } from "@orpc/client";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface CompleteLoginFlags { interface CompleteLoginFlags {
email: string; email: string;
@@ -20,14 +20,7 @@ async function completeLogin(
console.log(`Completed login request for: ${flags.email}`); console.log(`Completed login request for: ${flags.email}`);
} catch (error) { } catch (error) {
if (error instanceof ORPCError) { console.error("Error:", formatError(error));
console.error(`Error [${String(error.code)}]:`, error.message);
} else {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
}
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js"; import { readConfig, writeConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
interface LoginFlags { interface LoginFlags {
token: string; token: string;
@@ -47,10 +48,7 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
console.log(`Logged in as ${authStatus.user.email}`); console.log(`Logged in as ${authStatus.user.email}`);
console.log("Credentials saved to ~/.config/reviq/credentials.json"); console.log("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) { } catch (error) {
console.error( console.error("Login failed:", formatError(error));
"Login failed:",
error instanceof Error ? error.message : String(error),
);
console.log("\nMake sure your API token is valid."); console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens"); console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1); this.process.exit(1);

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { getConfigPath, readConfig } from "../../utils/config.js"; import { getConfigPath, readConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
import { TOKEN_PREFIX } from "../../utils/token.js"; import { TOKEN_PREFIX } from "../../utils/token.js";
function formatDate(date: Date): string { function formatDate(date: Date): string {
@@ -14,19 +15,19 @@ function formatRelativeTime(date: Date): string {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) { if (diffDays < 0) {
return `${String(Math.abs(diffDays))} days ago`; return `${Math.abs(diffDays).toLocaleString()} days ago`;
} }
if (diffDays === 0) { if (diffDays === 0) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours <= 0) { return diffHours <= 0 ? "expired" : `in ${diffHours.toLocaleString()} hours`;
return "expired";
}
return `in ${String(diffHours)} hours`;
} }
if (diffDays === 1) { if (diffDays === 1) {
return "tomorrow"; return "tomorrow";
} }
return `in ${String(diffDays)} days`;
return `in ${diffDays.toLocaleString()} days`;
} }
async function status(this: LocalContext): Promise<void> { async function status(this: LocalContext): Promise<void> {
@@ -96,9 +97,7 @@ async function status(this: LocalContext): Promise<void> {
); );
} }
} catch (error) { } catch (error) {
console.log( console.log(` Error: ${formatError(error)}`);
` Error: ${error instanceof Error ? error.message : String(error)}`,
);
console.log( console.log(
"\n Unable to connect to API. Local credentials may be invalid.", "\n Unable to connect to API. Local credentials may be invalid.",
); );

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../context.js";
import { createDb, executeBootstrap } from "@reviq/db"; import { createDb, executeBootstrap } from "@reviq/db";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { writeConfig } from "../utils/config.js"; import { writeConfig } from "../utils/config.js";
import { formatError } from "../utils/format-error.js";
interface BootstrapFlags { interface BootstrapFlags {
email: string; email: string;
@@ -47,10 +48,7 @@ async function bootstrap(
await db.destroy(); await db.destroy();
} catch (error) { } catch (error) {
console.error( console.error("Error:", formatError(error));
"Error:",
error instanceof Error ? error.message : String(error),
);
await db.destroy(); await db.destroy();
this.process.exit(1); this.process.exit(1);
} }

View File

@@ -1,9 +1,8 @@
import type { LocalContext } from "../context.js"; import type { LocalContext } from "../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
type Shell = "bash" | "zsh" | "fish"; const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
type Shell = (typeof SUPPORTED_SHELLS)[number];
const SUPPORTED_SHELLS: readonly Shell[] = ["bash", "zsh", "fish"] as const;
function parseShell(value: string): Shell { function parseShell(value: string): Shell {
const shell = value.toLowerCase(); const shell = value.toLowerCase();

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface AddSiteFlags { interface AddSiteFlags {
org: string; org: string;
@@ -18,10 +19,7 @@ async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
console.log(`Added site ${flags.domain} to org ${flags.org}`); console.log(`Added site ${flags.domain} to org ${flags.org}`);
} catch (error) { } catch (error) {
console.error( console.error("Error:", formatError(error));
"Error:",
error instanceof Error ? error.message : String(error),
);
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface CreateOrgFlags { interface CreateOrgFlags {
slug: string; slug: string;
@@ -24,10 +25,7 @@ async function create(
console.log(`Created org: ${result.slug}`); console.log(`Created org: ${result.slug}`);
console.log(`Owner: ${flags.owner}`); console.log(`Owner: ${flags.owner}`);
} catch (error) { } catch (error) {
console.error( console.error("Error:", formatError(error));
"Error:",
error instanceof Error ? error.message : String(error),
);
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
async function list(this: LocalContext): Promise<void> { async function list(this: LocalContext): Promise<void> {
try { try {
@@ -23,12 +24,9 @@ async function list(this: LocalContext): Promise<void> {
console.log(); console.log();
} }
console.log(`Total: ${String(orgs.length)} organization(s)`); console.log(`Total: ${orgs.length.toLocaleString()} organization(s)`);
} catch (error) { } catch (error) {
console.error( console.error("Error:", formatError(error));
"Error:",
error instanceof Error ? error.message : String(error),
);
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface ConfirmEmailFlags { interface ConfirmEmailFlags {
email: string; email: string;
@@ -19,10 +20,7 @@ async function confirmEmail(
console.log(`Confirmed email for: ${flags.email}`); console.log(`Confirmed email for: ${flags.email}`);
} catch (error) { } catch (error) {
console.error( console.error("Error:", formatError(error));
"Error:",
error instanceof Error ? error.message : String(error),
);
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -1,21 +1,24 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js"; import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
type OrgRole = "owner" | "admin" | "member"; type OrgRole = "owner" | "admin" | "member";
const validRoles: OrgRole[] = ["owner", "admin", "member"]; const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
function parseRole(role: string | undefined): OrgRole | undefined { function parseRole(role: string | undefined): OrgRole | undefined {
if (!role) { if (!role) {
return undefined; return undefined;
} }
if (validRoles.includes(role as OrgRole)) {
return role as OrgRole; if (!VALID_ROLES.includes(role as OrgRole)) {
}
throw new Error( throw new Error(
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`, `Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
); );
}
return role as OrgRole;
} }
interface CreateUserFlags { interface CreateUserFlags {
@@ -45,10 +48,7 @@ async function create(
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`); console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
} }
} catch (error) { } catch (error) {
console.error( console.error("Error:", formatError(error));
"Error:",
error instanceof Error ? error.message : String(error),
);
this.process.exit(1); this.process.exit(1);
} }
} }

View File

@@ -10,6 +10,14 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>; export type ApiClient = ContractRouterClient<typeof contract>;
function buildClient(apiUrl: string, token: string): ApiClient {
const link = new RPCLink({
url: `${apiUrl}/api/v1/rpc`,
headers: { "X-API-Key": token },
});
return createORPCClient(link) as unknown as ApiClient;
}
/** /**
* Create an oRPC API client with provided credentials * Create an oRPC API client with provided credentials
*/ */
@@ -25,18 +33,10 @@ export function createApiClient(
apiUrl?: string, apiUrl?: string,
token?: string, token?: string,
): ApiClient | Promise<ApiClient> { ): ApiClient | Promise<ApiClient> {
// If both arguments are provided, create client directly
if (apiUrl !== undefined && token !== undefined) { if (apiUrl !== undefined && token !== undefined) {
const link = new RPCLink({ return buildClient(apiUrl, token);
url: `${apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": token,
},
});
return createORPCClient(link) as unknown as ApiClient;
} }
// Otherwise, read from config asynchronously
return (async (): Promise<ApiClient> => { return (async (): Promise<ApiClient> => {
const config = await readConfig(); const config = await readConfig();
if (!config) { if (!config) {
@@ -44,14 +44,6 @@ export function createApiClient(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.", "Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
); );
} }
return buildClient(config.apiUrl, config.token);
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})(); })();
} }

View File

@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
/** /**
* Get the path to the config file * Get the path to the config file
*/ */
export const getConfigPath = (): string => CONFIG_FILE; export function getConfigPath(): string {
return CONFIG_FILE;
}
/** /**
* Read the config file * Read the config file
* Returns null if the file doesn't exist or is invalid * Returns null if the file doesn't exist or is invalid
*/ */
export const readConfig = async (): Promise<Config | null> => { export async function readConfig(): Promise<Config | null> {
try { try {
const data = await readFile(CONFIG_FILE, "utf-8"); const data = await readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data) as Config; return JSON.parse(data) as Config;
} catch { } catch {
return null; return null;
} }
}; }
/** /**
* Write the config file * Write the config file
* Creates the config directory if it doesn't exist * Creates the config directory if it doesn't exist
*/ */
export const writeConfig = async (config: Config): Promise<void> => { export async function writeConfig(config: Config): Promise<void> {
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }); await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
mode: 0o600, mode: 0o600,
}); });
}; }
/** /**
* Delete the config file * Delete the config file
* Ignores errors if the file doesn't exist * Ignores errors if the file doesn't exist
*/ */
export const deleteConfig = async (): Promise<void> => { export async function deleteConfig(): Promise<void> {
try { try {
await unlink(CONFIG_FILE); await unlink(CONFIG_FILE);
} catch { } catch {
// Ignore if doesn't exist // Ignore if doesn't exist
} }
}; }

View File

@@ -0,0 +1,20 @@
import { ORPCError } from "@orpc/client";
/**
* Format an unknown error value into a string message.
* Handles ORPCError, Error instances, strings, and other types safely.
*/
export function formatError(error: unknown): string {
if (error instanceof ORPCError) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
return `[${error.code}] ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- intentional unknown coercion
return `${error}`;
}

View File

@@ -19,6 +19,5 @@
"isolatedDeclarations": false, "isolatedDeclarations": false,
"composite": false "composite": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["node_modules", "dist"]
} }

View File

@@ -15,6 +15,8 @@
"@orpc/client": "^1.13.2", "@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",
@@ -36,6 +38,7 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4", "@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3", "@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",

View File

@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
/* Geist Sans - Modern, clean typeface */ /* Geist Sans - Modern, clean typeface */
@font-face { @font-face {

View File

@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
> >
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)} {@const active = isActive(item.href, $page.url.pathname)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]", "inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active active

View File

@@ -1,7 +1,6 @@
export { default as AccountNav } from "./account-nav.svelte"; export { default as AccountNav } from "./account-nav.svelte";
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte"; export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte"; export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte"; export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
export { default as PasskeyList } from "./passkey-list.svelte"; export { default as PasskeyList } from "./passkey-list.svelte";
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte"; export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte"; import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query"; import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import ConfirmDialog from "./confirm-dialog.svelte"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte"; import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
interface Passkey { interface Passkey {
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null); let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false); let isDeleting = $state(false);
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string | null): string {
if (!date) {
return "Never";
}
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function openRename(passkey: Passkey) { function openRename(passkey: Passkey) {
selectedPasskey = passkey; selectedPasskey = passkey;
renameDialogOpen = true; renameDialogOpen = true;

View File

@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { gotoLogin } from "$lib/utils/navigation";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -12,29 +11,29 @@ interface Props {
let { children }: Props = $props(); let { children }: Props = $props();
// Check if current path is an auth page (doesn't require login) // Check if current path is a public page (doesn't require login)
const isAuthPage = $derived(page.url.pathname.startsWith("/auth")); const isPublicPage = $derived(
page.url.pathname.startsWith("/auth") ||
page.url.pathname === "/terms" ||
page.url.pathname === "/privacy",
);
// Fetch user to check if logged in (only for non-auth pages) // Fetch user to check if logged in (only for protected pages)
const userQuery = createQuery(() => ({ const userQuery = createQuery(() => ({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.me.get(), queryFn: () => api.me.get(),
enabled: !isAuthPage, enabled: !isPublicPage,
retry: false, retry: false,
})); }));
// Redirect to login if not authenticated on non-auth pages // Redirect to login if not authenticated on protected pages
$effect(() => { $effect(() => {
if (!isAuthPage && userQuery.error) { if (!isPublicPage && userQuery.error) {
goto( gotoLogin(page.url.pathname);
resolve(
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
),
);
} }
}); });
</script> </script>
{#if isAuthPage || userQuery.data || userQuery.isPending} {#if isPublicPage || userQuery.data || userQuery.isPending}
{@render children()} {@render children()}
{/if} {/if}

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -27,8 +26,8 @@ const filters = [
<div class="divide-y divide-border/50"> <div class="divide-y divide-border/50">
{#each filters as filter (filter.label)} {#each filters as filter (filter.label)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(filter.href as any)} <a href={filter.href}
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30" class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
> >
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground"> <div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { import {
@@ -33,14 +32,15 @@ const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab, ($page.url.searchParams.get("tab") as TabId) || defaultTab,
); );
function handleTabChange(tabId: string) { function handleTabChange(tabId: string): void {
const url = new URL($page.url); const url = new URL($page.url);
if (tabId === defaultTab) { if (tabId === defaultTab) {
url.searchParams.delete("tab"); url.searchParams.delete("tab");
} else { } else {
url.searchParams.set("tab", tabId); url.searchParams.set("tab", tabId);
} }
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true }); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.toString(), { replaceState: true, noScroll: true });
} }
</script> </script>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface AdUnitRow extends MetricsRow {
name: string;
}
const tableData: AdUnitRow[] = [
{ {
id: 1, id: 1,
name: "/header/leaderboard-728x90", name: "/header/leaderboard-728x90",
@@ -51,58 +55,10 @@ const tableData = [
impPercent: 9.16, impPercent: 9.16,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface CountryRow extends MetricsRow {
name: string;
code: string;
}
const tableData: CountryRow[] = [
{ {
id: 1, id: 1,
name: "United States", name: "United States",
@@ -57,54 +62,14 @@ const tableData = [
impPercent: 4.68, impPercent: 4.68,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Country">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> {@const countryRow = row as CountryRow}
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{row.code}</span> <span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
<span class="text-[13px] font-medium text-foreground">{row.name}</span> <span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
</div> </div>
</Table.Cell> {/snippet}
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell> </MetricsTable>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface DomainRow extends MetricsRow {
name: string;
}
const tableData: DomainRow[] = [
{ {
id: 1, id: 1,
name: "example.com", name: "example.com",
@@ -27,51 +31,10 @@ const tableData = [
impPercent: 18.45, impPercent: 18.45,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Domain">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <span class="text-[13px] font-medium text-foreground">{(row as DomainRow).name}</span>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Domain</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -2,4 +2,5 @@ export { default as AdUnitTable } from "./ad-unit-table.svelte";
export { default as CountryTable } from "./country-table.svelte"; export { default as CountryTable } from "./country-table.svelte";
export { default as DomainTable } from "./domain-table.svelte"; export { default as DomainTable } from "./domain-table.svelte";
export { default as KeyValueTable } from "./key-value-table.svelte"; export { default as KeyValueTable } from "./key-value-table.svelte";
export { default as MetricsTable, type MetricsRow } from "./metrics-table.svelte";
export { default as SourceTable } from "./source-table.svelte"; export { default as SourceTable } from "./source-table.svelte";

View File

@@ -1,7 +1,17 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import * as Table from "$lib/components/ui/table";
const tableData = [ interface KeyValueRow {
id: number;
key: string;
value: string;
revenue: string;
revPercent: number;
impressions: string;
impPercent: number;
}
const tableData: KeyValueRow[] = [
{ {
id: 1, id: 1,
key: "device", key: "device",

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { Snippet } from "svelte";
import * as Table from "$lib/components/ui/table";
export interface MetricsRow {
id: number;
revenue: string;
revPercent: number;
impressions: string;
impPercent: number;
}
interface Props {
data: MetricsRow[];
labelHeader: string;
labelCell: Snippet<[{ row: MetricsRow; index: number }]>;
showSortIcon?: boolean;
}
let { data, labelHeader, labelCell, showSortIcon = false }: Props = $props();
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = $derived(Math.max(...data.map((d) => d.revPercent)));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">{labelHeader}</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
{#if showSortIcon}
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
{@render labelCell({ row, index: i })}
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import * as Table from "$lib/components/ui/table"; import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
const tableData = [ interface SourceRow extends MetricsRow {
name: string;
}
const tableData: SourceRow[] = [
{ {
id: 1, id: 1,
name: "Google AdX", name: "Google AdX",
@@ -43,51 +47,10 @@ const tableData = [
impPercent: 7.28, impPercent: 7.28,
}, },
]; ];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script> </script>
<Table.Root> <MetricsTable data={tableData} labelHeader="Source">
<Table.Header> {#snippet labelCell({ row })}
<Table.Row class="border-b border-border hover:bg-transparent"> <span class="text-[13px] font-medium text-foreground">{(row as SourceRow).name}</span>
<Table.Head class="h-10 w-10 pl-5"></Table.Head> {/snippet}
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Source</Table.Head> </MetricsTable>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -6,7 +6,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
@@ -94,8 +93,8 @@ function isActive(href: string): boolean {
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden"> <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href)} {@const active = isActive(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors", "flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active active
@@ -113,8 +112,8 @@ function isActive(href: string): boolean {
<div class="hidden space-y-1 lg:block"> <div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href)} {@const active = isActive(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors", "group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active active

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
function handleNavClick() { function handleNavClick(): void {
open = false; open = false;
} }
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();
@@ -98,8 +83,8 @@ const navItems = [
item.href === "/admin" item.href === "/admin"
? $page.url.pathname === "/admin" ? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)} : $page.url.pathname.startsWith(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
onclick={handleNavClick} onclick={handleNavClick}
class={cn( class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();
@@ -90,8 +75,8 @@ const navItems = [
item.href === "/admin" item.href === "/admin"
? $page.url.pathname === "/admin" ? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)} : $page.url.pathname.startsWith(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive isActive

View File

@@ -74,8 +74,8 @@ const navItems = $derived.by(() => {
? $page.url.pathname === item.href ? $page.url.pathname === item.href
: $page.url.pathname === item.href || : $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")} $page.url.pathname.startsWith(item.href + "/")}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive isActive
@@ -163,7 +163,7 @@ const navItems = $derived.by(() => {
{#if currentSlug} {#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)} {@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a <a
href={resolve(`/dashboard/${currentSlug}/settings`)} href={resolve("/dashboard/[slug]/settings", { slug: currentSlug })}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive isSettingsActive

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -32,28 +33,11 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
// Nav items depend on whether we're in an org context // Nav items depend on whether we're in an org context
const navItems = $derived.by(() => { const navItems = $derived.by(() => {
if (currentSlug) { if (currentSlug) {
// In org context - org-specific navigation
return [ return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" }, { icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{ {
@@ -68,7 +52,6 @@ const navItems = $derived.by(() => {
}, },
]; ];
} }
// Outside org context - general navigation
return [ return [
{ icon: "home", href: "/", label: "Home" }, { icon: "home", href: "/", label: "Home" },
{ icon: "building", href: "/dashboard", label: "Organizations" }, { icon: "building", href: "/dashboard", label: "Organizations" },
@@ -77,16 +60,17 @@ const navItems = $derived.by(() => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
function handleNavClick() { function handleNavClick(): void {
open = false; open = false;
} }
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();
open = false; open = false;
goto(resolve("/auth/login")); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto("/auth/login");
} catch (error) { } catch (error) {
console.error("Failed to sign out:", error); console.error("Failed to sign out:", error);
} }
@@ -123,8 +107,8 @@ async function handleSignOut() {
{@const isActive = {@const isActive =
$page.url.pathname === item.href || $page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))} (item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
onclick={handleNavClick} onclick={handleNavClick}
class={cn( class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Check } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
})); }));
const orgs = $derived(orgsQuery.data ?? []); const orgs = $derived(orgsQuery.data ?? []);
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
function handleOrgSelect(slug: string) { function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}` as any)); goto(resolve("/dashboard/[slug]", { slug }));
} }
</script> </script>
@@ -30,8 +33,13 @@ function handleOrgSelect(slug: string) {
<button <button
{...props} {...props}
aria-label="Switch organization" aria-label="Switch organization"
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm transition-transform duration-200 hover:scale-105" class="group flex h-8 w-8 items-center justify-center transition-transform duration-200 hover:scale-105"
> >
{#if currentOrg}
<OrgAvatar org={currentOrg} size="md" />
{:else}
<!-- Default icon when no org is selected -->
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
<svg <svg
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110" class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -41,6 +49,8 @@ function handleOrgSelect(slug: string) {
> >
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" /> <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div>
{/if}
</button> </button>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
class={cn(isActive && "bg-accent")} class={cn(isActive && "bg-accent")}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if org.logoUrl} <OrgAvatar {org} size="xs" />
<img src={org.logoUrl} alt="" class="h-5 w-5 rounded" />
{:else}
<div class="flex h-5 w-5 items-center justify-center rounded bg-muted text-[10px] font-medium">
{org.displayName.charAt(0).toUpperCase()}
</div>
{/if}
<span class="flex-1 truncate">{org.displayName}</span> <span class="flex-1 truncate">{org.displayName}</span>
{#if isActive} {#if isActive}
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Check class="h-4 w-4 text-primary" />
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if} {/if}
</div> </div>
</DropdownMenu.Item> </DropdownMenu.Item>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
})); }));
const user = $derived(userQuery.data); const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Generate initials from display name or email
const initials = $derived.by(() => {
if (!user) {
return "??";
}
if (user.displayName) {
const parts = user.displayName.split(" ");
if (parts.length >= 2) {
return (
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
).toUpperCase();
}
return user.displayName.slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
async function handleSignOut() { async function handleSignOut(): Promise<void> {
try { try {
await api.auth.logout(); await api.auth.logout();
// Clear all cached queries
queryClient.clear(); queryClient.clear();
goto(resolve("/auth/login")); goto(resolve("/auth/login"));
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { Building2, Globe, Settings, Users } from "@lucide/svelte"; import { Globe, Settings, Users } from "@lucide/svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
@@ -16,36 +16,37 @@ let { title, children }: Props = $props();
// Get org context from parent layout // Get org context from parent layout
const orgContext = getContext<{ slug: string }>("orgContext"); const orgContext = getContext<{ slug: string }>("orgContext");
const slug = $derived(orgContext?.slug); const slug = $derived(orgContext?.slug ?? "");
// Settings navigation items // Settings navigation items with route patterns for type-safe resolve()
const navItems = $derived.by(() => [ const navItems = [
{ {
href: `/dashboard/${slug}/settings`, route: "/dashboard/[slug]/settings",
icon: Settings, icon: Settings,
label: "General", label: "General",
description: "Organization name, logo, and preferences", description: "Organization name, logo, and preferences",
}, },
{ {
href: `/dashboard/${slug}/settings/members`, route: "/dashboard/[slug]/settings/members",
icon: Users, icon: Users,
label: "Members", label: "Members",
description: "Manage team members and invitations", description: "Manage team members and invitations",
}, },
{ {
href: `/dashboard/${slug}/settings/sites`, route: "/dashboard/[slug]/settings/sites",
icon: Globe, icon: Globe,
label: "Sites", label: "Sites",
description: "Connected websites and domains", description: "Connected websites and domains",
}, },
]); ] as const;
// Determine active item // Determine active item
const activeHref = $derived($page.url.pathname); const activeHref = $derived($page.url.pathname);
function isActive(href: string): boolean { function isActive(route: (typeof navItems)[number]["route"]): boolean {
const href = resolve(route, { slug });
// Exact match for base settings path // Exact match for base settings path
if (href === `/dashboard/${slug}/settings`) { if (route === "/dashboard/[slug]/settings") {
return activeHref === href; return activeHref === href;
} }
// Prefix match for sub-pages // Prefix match for sub-pages
@@ -59,10 +60,10 @@ function isActive(href: string): boolean {
<nav class="w-full shrink-0 lg:w-64"> <nav class="w-full shrink-0 lg:w-64">
<!-- Mobile: horizontal scroll --> <!-- Mobile: horizontal scroll -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden"> <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)} {#each navItems as item (item.route)}
{@const active = isActive(item.href)} {@const active = isActive(item.route)}
<a <a
href={resolve(item.href as any)} href={resolve(item.route, { slug })}
class={cn( class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors", "flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active active
@@ -78,10 +79,10 @@ function isActive(href: string): boolean {
<!-- Desktop: vertical list --> <!-- Desktop: vertical list -->
<div class="hidden space-y-1 lg:block"> <div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)} {#each navItems as item (item.route)}
{@const active = isActive(item.href)} {@const active = isActive(item.route)}
<a <a
href={resolve(item.href as any)} href={resolve(item.route, { slug })}
class={cn( class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors", "group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active active

View File

@@ -1,95 +0,0 @@
<script lang="ts">
import { X } from "@lucide/svelte";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
interface Props {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "destructive" | "default";
loading?: boolean;
onconfirm: () => void;
oncancel: () => void;
}
let {
open = $bindable(false),
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
loading = false,
onconfirm,
oncancel,
}: Props = $props();
function handleCancel() {
open = false;
oncancel();
}
function handleConfirm() {
onconfirm();
}
</script>
<DialogPrimitive.Root bind:open>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogPrimitive.Content
class={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
"rounded-lg border bg-background p-6 shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
"duration-200"
)}
>
<!-- Close button -->
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
onclick={handleCancel}
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
<!-- Header -->
<div class="space-y-2">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
{description}
</DialogPrimitive.Description>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end gap-3">
<Button variant="outline" onclick={handleCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
onclick={handleConfirm}
disabled={loading}
>
{#if loading}
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{/if}
{confirmLabel}
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>

View File

@@ -1,2 +1,2 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte"; export { default as OrgAvatar } from "./org-avatar.svelte";
export { default as RoleBadge } from "./role-badge.svelte"; export { default as RoleBadge } from "./role-badge.svelte";

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import {
getOrgColor,
getOrgInitials,
type OrgLike,
} from "@reviq/frontend-utils";
import { cn } from "$lib/utils.js";
interface Props {
org: OrgLike | null | undefined;
size?: "xs" | "sm" | "md" | "lg" | "xl";
class?: string;
}
let { org, size = "md", class: className }: Props = $props();
const initials = $derived(getOrgInitials(org));
const colorClass = $derived(getOrgColor(org));
const sizeClasses = {
xs: "h-5 w-5 text-[10px] rounded",
sm: "h-6 w-6 text-[10px] rounded",
md: "h-8 w-8 text-xs rounded-lg",
lg: "h-10 w-10 text-sm rounded-lg",
xl: "h-16 w-16 text-xl rounded-xl",
} as const;
</script>
{#if org?.logoUrl}
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class={cn(sizeClasses[size], "shrink-0 object-cover", className)}
/>
{:else}
<div
class={cn(
"flex shrink-0 items-center justify-center bg-gradient-to-br font-semibold text-white",
sizeClasses[size],
colorClass,
className,
)}
>
{initials}
</div>
{/if}

View File

@@ -48,8 +48,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
</script> </script>
<script lang="ts"> <script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */ let {
let {
class: className, class: className,
variant = "default", variant = "default",
size = "default", size = "default",
@@ -67,7 +66,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href/* eslint-disable-line svelte/no-navigation-without-resolve */}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? "link" : undefined} role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}

View File

@@ -28,7 +28,7 @@ let {
onConfirm, onConfirm,
}: Props = $props(); }: Props = $props();
async function handleConfirm() { async function handleConfirm(): Promise<void> {
await onConfirm(); await onConfirm();
} }
</script> </script>
@@ -54,8 +54,8 @@ async function handleConfirm() {
<LoadingButton <LoadingButton
variant="destructive" variant="destructive"
class="w-full" class="w-full"
loading={loading} {loading}
loadingText={loadingText} {loadingText}
onclick={handleConfirm} onclick={handleConfirm}
> >
{confirmText} {confirmText}
@@ -77,7 +77,7 @@ async function handleConfirm() {
{cancelText} {cancelText}
</Button> </Button>
<LoadingButton <LoadingButton
loading={loading} {loading}
onclick={handleConfirm} onclick={handleConfirm}
> >
{confirmText} {confirmText}

View File

@@ -0,0 +1 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";

View File

@@ -1,31 +0,0 @@
/**
* Date formatting utilities for consistent display across the app
*/
/**
* Format a date for display in tables and lists
* Example: "Jan 15, 2024"
*/
export function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Format a date with time for detailed views
* Example: "Jan 15, 2024, 3:30 PM"
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

View File

@@ -0,0 +1,26 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
type SearchParams = Record<string, string>;
/**
* Build a query string from an object.
*/
function buildSearchParams(params: SearchParams): string {
const searchParams = new URLSearchParams(params);
const str = searchParams.toString();
return str ? `?${str}` : "";
}
/**
* Navigate to /auth/login with a redirect parameter.
* This is the primary use case for navigation with search params.
*
* Note: eslint-disable is required because the lint rule doesn't recognize
* resolve() inside a template literal, even though it's used correctly.
*/
export function gotoLogin(redirect: string): ReturnType<typeof goto> {
const url = `${resolve("/auth/login")}${buildSearchParams({ redirect })}`;
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used above
return goto(url);
}

View File

@@ -17,11 +17,12 @@ const orgsQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
// Not authenticated, redirect to login // Not authenticated, redirect to login
goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any)); // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent("/")}`);
} else if (orgsQuery.data) { } else if (orgsQuery.data) {
if (orgsQuery.data.length > 0) { if (orgsQuery.data.length > 0) {
// Redirect to first org's dashboard // Redirect to first org's dashboard
goto(resolve(`/dashboard/${orgsQuery.data[0].slug}` as any), { goto(resolve("/dashboard/[slug]", { slug: orgsQuery.data[0].slug }), {
replaceState: true, replaceState: true,
}); });
} else { } else {

View File

@@ -8,12 +8,13 @@ import {
Plus, Plus,
Trash2, Trash2,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatDate, formatRelativeDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -59,33 +60,6 @@ let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null); let newlyCreatedToken = $state<string | null>(null);
let tokenCopied = $state(false); let tokenCopied = $state(false);
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const diffDays = Math.floor(
(Date.now() - new Date(date).getTime()) / 86400000,
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(date);
}
async function handleCreateToken(e: Event) { async function handleCreateToken(e: Event) {
e.preventDefault(); e.preventDefault();
if (!newTokenName.trim() || isCreating) { if (!newTokenName.trim() || isCreating) {
@@ -261,9 +235,9 @@ async function handleDelete() {
<div> <div>
<p class="text-sm font-medium">{token.name}</p> <p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)} Created {formatRelativeDate(token.createdAt)}
{#if token.lastUsedAt} {#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)} · Last used {formatRelativeDate(token.lastUsedAt)}
{:else} {:else}
· Never used · Never used
{/if} {/if}

View File

@@ -8,11 +8,11 @@ import {
Star, Star,
Tablet, Tablet,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -54,31 +54,6 @@ function formatLocation(device: {
return parts.length > 0 ? parts.join(", ") : "Unknown location"; return parts.length > 0 ? parts.join(", ") : "Unknown location";
} }
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getDeviceIcon(name: string) { function getDeviceIcon(name: string) {
const nameLower = name.toLowerCase(); const nameLower = name.toLowerCase();
if ( if (

View File

@@ -2,7 +2,6 @@
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
Building2,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
@@ -10,6 +9,7 @@ import {
User, User,
XCircle, XCircle,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatLongDate, formatRole } from "@reviq/common";
import { import {
createMutation, createMutation,
createQuery, createQuery,
@@ -20,6 +20,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -48,12 +49,10 @@ const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }), mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => { onSuccess: () => {
toast.success("You've joined the organization!"); toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] }); queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] }); queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) { if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any)); goto(resolve(`/dashboard/${inviteQuery.data.org.slug}`));
} else { } else {
goto(resolve("/dashboard")); goto(resolve("/dashboard"));
} }
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }), mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => { onSuccess: () => {
toast.success("Invitation declined"); toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] }); queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
goto(resolve("/dashboard")); goto(resolve("/dashboard"));
}, },
@@ -81,24 +79,6 @@ const declineMutation = createMutation(() => ({
}, },
})); }));
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
/**
* Format date for display
*/
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
/** /**
* Check if invite is expiring soon (within 3 days) * Check if invite is expiring soon (within 3 days)
*/ */
@@ -141,17 +121,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
{#if invite.org.logoUrl} <OrgAvatar org={invite.org} size="xl" />
<img
src={invite.org.logoUrl}
alt="{invite.org.displayName} logo"
class="h-16 w-16 rounded-xl object-cover"
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-8 w-8 text-primary" />
</div>
{/if}
<div class="flex-1"> <div class="flex-1">
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle> <CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
<CardDescription class="mt-1"> <CardDescription class="mt-1">
@@ -187,7 +157,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
</div> </div>
<div> <div>
<p class="text-sm font-medium">Sent on</p> <p class="text-sm font-medium">Sent on</p>
<p class="text-sm text-muted-foreground">{formatDate(new Date(invite.createdAt))}</p> <p class="text-sm text-muted-foreground">{formatLongDate(invite.createdAt)}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -197,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div> <div>
<p class="text-sm font-medium">Expires on</p> <p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}"> <p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))} {formatLongDate(invite.expiresAt)}
</p> </p>
</div> </div>
</div> </div>
@@ -207,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Alert> <Alert>
<Clock class="h-4 w-4" /> <Clock class="h-4 w-4" />
<AlertDescription> <AlertDescription>
This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization. This invitation will expire soon. Accept it before {formatLongDate(invite.expiresAt)} to join the organization.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/if} {/if}

View File

@@ -10,11 +10,12 @@ import {
Star, Star,
Tablet, Tablet,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -56,36 +57,6 @@ function formatLocation(session: {
return parts.length > 0 ? parts.join(", ") : "Unknown location"; return parts.length > 0 ? parts.join(", ") : "Unknown location";
} }
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function parseUserAgent(userAgent: string): { function parseUserAgent(userAgent: string): {
browser: string; browser: string;
os: string; os: string;

View File

@@ -6,6 +6,7 @@ import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { gotoLogin } from "$lib/utils/navigation";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -26,11 +27,7 @@ $effect(() => {
goto(resolve("/dashboard")); goto(resolve("/dashboard"));
} }
if (userQuery.error) { if (userQuery.error) {
goto( gotoLogin(window.location.pathname);
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
} }
}); });

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte"; import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -22,7 +23,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "$lib/components/ui/table/index.js"; } from "$lib/components/ui/table/index.js";
import { formatDate } from "$lib/utils/format-date.js";
/** /**
* Admin Organizations list page * Admin Organizations list page
@@ -238,8 +238,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant="destructive" variant="destructive"
confirmLabel="Delete" confirmText="Delete"
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -3,12 +3,12 @@ import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
Building,
Globe, Globe,
Loader2, Loader2,
Plus, Plus,
Trash2, Trash2,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -16,7 +16,8 @@ import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { OrgAvatar } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -37,7 +38,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "$lib/components/ui/table"; } from "$lib/components/ui/table";
import { formatDate } from "$lib/utils/format-date.js";
/** /**
* Admin organization details page * Admin organization details page
@@ -83,7 +83,7 @@ let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state(""); let confirmDialogTitle = $state("");
let confirmDialogDescription = $state(""); let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive"); let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmDialogConfirmLabel = $state("Confirm"); let confirmDialogConfirmText = $state("Confirm");
let isConfirmLoading = $state(false); let isConfirmLoading = $state(false);
let pendingAction: (() => Promise<void>) | null = $state(null); let pendingAction: (() => Promise<void>) | null = $state(null);
@@ -159,7 +159,7 @@ function handleRemoveSite(domain: string) {
confirmDialogTitle = "Remove Site"; confirmDialogTitle = "Remove Site";
confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`; confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Remove Site"; confirmDialogConfirmText = "Remove Site";
pendingAction = async () => { pendingAction = async () => {
try { try {
await api.admin.orgs.removeSite({ slug: slug ?? "", domain }); await api.admin.orgs.removeSite({ slug: slug ?? "", domain });
@@ -181,7 +181,7 @@ function handleDelete() {
confirmDialogTitle = "Delete Organization"; confirmDialogTitle = "Delete Organization";
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`; confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Delete Organization"; confirmDialogConfirmText = "Delete Organization";
pendingAction = async () => { pendingAction = async () => {
try { try {
await api.admin.orgs.delete({ slug: slug ?? "" }); await api.admin.orgs.delete({ slug: slug ?? "" });
@@ -259,19 +259,7 @@ async function executeConfirmAction() {
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
{#if org.logoUrl} <OrgAvatar {org} size="xl" />
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class="h-16 w-16 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-lg bg-muted"
>
<Building class="h-8 w-8 text-muted-foreground" />
</div>
{/if}
<div class="flex-1"> <div class="flex-1">
<CardTitle class="text-2xl">{org.displayName}</CardTitle> <CardTitle class="text-2xl">{org.displayName}</CardTitle>
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
@@ -465,11 +453,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
confirmLabel={confirmDialogConfirmLabel} confirmText={confirmDialogConfirmText}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => {
confirmDialogOpen = false;
pendingAction = null;
}}
/> />

View File

@@ -81,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer --> <!-- Footer -->
<p class="text-center text-xs text-muted-foreground"> <p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our By continuing, you agree to our
<a href={resolve("/terms" as any)} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a> <a href={resolve("/terms")} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and and
<a href={resolve("/privacy" as any)} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a> <a href={resolve("/privacy")} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -59,7 +59,8 @@ const statusQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (statusQuery.data?.status === "completed") { if (statusQuery.data?.status === "completed") {
clearLoginFlowState(); clearLoginFlowState();
goto(resolve((statusQuery.data.redirectTo || "/") as any)); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(statusQuery.data.redirectTo || "/");
} }
}); });

View File

@@ -6,11 +6,12 @@ import {
Loader2, Loader2,
Mail, Mail,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatRelativeDate, formatRole } from "@reviq/common";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { OrgAvatar } from "$lib/components/org";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { import {
Card, Card,
@@ -19,6 +20,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { gotoLogin } from "$lib/utils/navigation";
/** /**
* Dashboard page - lists all organizations the user is a member of * Dashboard page - lists all organizations the user is a member of
@@ -40,48 +42,9 @@ const invitesQuery = createQuery(() => ({
// Redirect to login on auth error // Redirect to login on auth error
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
goto( gotoLogin(window.location.pathname);
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
} }
}); });
/**
* Format date to relative or absolute string
*/
function formatDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return "Today";
}
if (days === 1) {
return "Yesterday";
}
if (days < 7) {
return `${days} days ago`;
}
if (days < 30) {
return `${Math.floor(days / 7)} weeks ago`;
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
/**
* Format role for display
*/
function formatRole(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -101,24 +64,14 @@ function formatRole(role: string): string {
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each invitesQuery.data as invite (invite.id)} {#each invitesQuery.data as invite (invite.id)}
<a <a
href={resolve(`/account/org-invites/${invite.id}`)} href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
class="group block" class="group block"
> >
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50"> <Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2"> <CardHeader class="pb-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if invite.org.logoUrl} <OrgAvatar org={invite.org} size="lg" />
<img
src={invite.org.logoUrl}
alt="{invite.org.displayName} logo"
class="h-10 w-10 rounded-lg object-cover"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
<Building2 class="h-5 w-5 text-primary" />
</div>
{/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<CardTitle class="truncate text-base"> <CardTitle class="truncate text-base">
{invite.org.displayName} {invite.org.displayName}
@@ -133,7 +86,7 @@ function formatRole(role: string): string {
</CardHeader> </CardHeader>
<CardContent class="pt-0"> <CardContent class="pt-0">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))} From {invite.invitedBy} &middot; {formatRelativeDate(invite.createdAt)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -186,24 +139,13 @@ function formatRole(role: string): string {
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each orgsQuery.data as org (org.id)} {#each orgsQuery.data as org (org.id)}
<a <a
href={resolve(`/dashboard/${org.slug}`)} href={resolve("/dashboard/[slug]", { slug: org.slug })}
class="group block transition-transform hover:scale-[1.02]" class="group block transition-transform hover:scale-[1.02]"
> >
<Card class="h-full transition-colors group-hover:border-primary/50"> <Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Logo or placeholder --> <OrgAvatar {org} size="lg" />
{#if org.logoUrl}
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class="h-10 w-10 rounded-lg object-cover"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-5 w-5 text-primary" />
</div>
{/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<CardTitle class="truncate text-base"> <CardTitle class="truncate text-base">
{org.displayName} {org.displayName}
@@ -216,7 +158,7 @@ function formatRole(role: string): string {
</CardHeader> </CardHeader>
<CardContent class="pt-0"> <CardContent class="pt-0">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Created {formatDate(new Date(org.createdAt))} Created {formatRelativeDate(org.createdAt)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
AlertCircle, AlertCircle,
Building2,
ChevronRight, ChevronRight,
Globe, Globe,
Loader2, Loader2,
@@ -13,7 +12,7 @@ import { getContext } from "svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org"; import { OrgAvatar, RoleBadge } from "$lib/components/org";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -98,17 +97,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Header with org info --> <!-- Header with org info -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{#if orgQuery.data?.logoUrl} <OrgAvatar org={orgQuery.data} size="xl" />
<img
src={orgQuery.data.logoUrl}
alt="{orgName} logo"
class="h-16 w-16 rounded-xl object-cover"
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-8 w-8 text-primary" />
</div>
{/if}
<div> <div>
<h1 class="text-2xl font-semibold">{orgName}</h1> <h1 class="text-2xl font-semibold">{orgName}</h1>
<p class="text-sm text-muted-foreground">{slug}</p> <p class="text-sm text-muted-foreground">{slug}</p>

View File

@@ -12,7 +12,8 @@ import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { ConfirmDialog, RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -14,7 +14,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout"; import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -82,7 +82,7 @@ let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state(""); let confirmDialogTitle = $state("");
let confirmDialogDescription = $state(""); let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive"); let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmDialogConfirmLabel = $state("Confirm"); let confirmDialogConfirmText = $state("Confirm");
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve()); let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
let isConfirmLoading = $state(false); let isConfirmLoading = $state(false);
@@ -119,7 +119,7 @@ function handleLeave() {
confirmDialogDescription = confirmDialogDescription =
"Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin."; "Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin.";
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Leave Organization"; confirmDialogConfirmText = "Leave Organization";
confirmAction = async () => { confirmAction = async () => {
try { try {
await api.orgs.leave({ slug }); await api.orgs.leave({ slug });
@@ -142,7 +142,7 @@ function handleDelete() {
confirmDialogTitle = "Delete Organization"; confirmDialogTitle = "Delete Organization";
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`; confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Delete Organization"; confirmDialogConfirmText = "Delete Organization";
confirmAction = async () => { confirmAction = async () => {
try { try {
await api.orgs.delete({ slug }); await api.orgs.delete({ slug });
@@ -306,8 +306,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle} title={confirmDialogTitle}
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
confirmLabel={confirmDialogConfirmLabel} confirmText={confirmDialogConfirmText}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -12,7 +12,8 @@ import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout"; import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog, RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
description={confirmDialogDescription} description={confirmDialogDescription}
variant={confirmDialogVariant} variant={confirmDialogVariant}
loading={isConfirmLoading} loading={isConfirmLoading}
onconfirm={executeConfirmAction} onConfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/> />

View File

@@ -46,9 +46,8 @@ async function acceptInvite(): Promise<void> {
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to login with return URL // Redirect to login with return URL
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`; const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
goto( // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any), goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent(returnUrl)}`);
);
return; return;
} }

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<svelte:head>
<title>Privacy Policy | Publisher Dashboard</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-6 py-16">
<article class="prose prose-neutral dark:prose-invert">
<h1>Privacy Policy</h1>
<p class="lead">Last updated: January 2025</p>
<h2>1. Information We Collect</h2>
<p>
We collect information you provide directly to us, such as your email address,
name, and organization details when you create an account.
</p>
<h2>2. How We Use Your Information</h2>
<p>
We use the information we collect to provide, maintain, and improve our services,
and to communicate with you about your account and updates.
</p>
<h2>3. Data Security</h2>
<p>
We implement appropriate security measures to protect your personal information
against unauthorized access, alteration, or destruction.
</p>
<h2>4. Data Retention</h2>
<p>
We retain your information for as long as your account is active or as needed
to provide you services and comply with legal obligations.
</p>
<h2>5. Contact</h2>
<p>
If you have any questions about this Privacy Policy, please contact us.
</p>
</article>
<div class="mt-12">
<a
href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Back to login
</a>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More