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";
: fixed: |
import * as z from "zod"
import * as z from "zod";
labels:
- source: import { z } from "zod";
style: primary
@@ -12,7 +12,7 @@ snapshots:
? |
import { z, ZodError } from "zod";
: fixed: |
import * as z from "zod"
import * as z from "zod";
labels:
- source: import { z, ZodError } from "zod";
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
## 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
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)
- 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:
- In-place edit requires empty string for backup: `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`
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
- In-place edit: `sed -i 's/old/new/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`
- 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
- `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/common` - Shared utilities for frontend and backend
- `@reviq/db` - Database client and queries
- `@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
@@ -40,10 +42,12 @@ publisher-dashboard/
│ └── publisher-dashboard/ # SvelteKit frontend
├── packages/
│ ├── api-contract/ # Shared oRPC contract
│ ├── common/ # Shared utilities (frontend + backend)
│ ├── db/ # Database client
│ ├── db-schema/ # DB schema & codegen
│ ├── testing/ # Test utilities
── utils/ # Shared utilities
│ ├── frontend-utils/ # Frontend utilities
── server-utils/ # Server/CLI utilities
│ └── testing/ # Test utilities
└── db/ # Database migrations
```
@@ -109,8 +113,13 @@ bun run dev
| `bun run typecheck` | Run TypeScript type checking |
| `bun run lint` | Run Biome and ESLint |
| `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 |
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
## CLI

View File

@@ -9,19 +9,17 @@
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
"test": "bun test src/ --no-parallel"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*",
"@reviq/emails": "workspace:*",
"@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0",
@@ -34,12 +32,11 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3",
"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 { beforeAll, describe, expect, test } from "bun:test";
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 { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.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 */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -94,6 +101,11 @@ function createAPIContext(
rpName: TEST_RP.rpName,
reqHeaders,
resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
};
}
@@ -141,7 +153,7 @@ async function createSession(
userId: number,
options?: { deviceId?: bigint },
): 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 expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -149,7 +161,7 @@ async function createSession(
.insertInto("sessions")
.values({
user_id: userId,
device_id: options?.deviceId ? String(options.deviceId) : null,
device_id: options?.deviceId ? options.deviceId.toString() : null,
token_hash: tokenHashValue,
trusted_mode: false,
expires_at: expiresAt,
@@ -173,7 +185,7 @@ async function createLoginRequest(
expiresAt?: Date;
},
): Promise<{ token: string; id: number }> {
const token = `login_test-${String(Date.now())}${String(Math.random())}`;
const token = `login_test-${uniqueTestId()}`;
const expiresAt =
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
@@ -223,7 +235,7 @@ async function createEmailVerification(
userId: number,
options?: { expiresAt?: Date },
): Promise<string> {
const token = `verify-${String(Date.now())}${String(Math.random())}`;
const token = `verify-${uniqueTestId()}`;
const expiresAt =
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
@@ -247,7 +259,7 @@ async function createPasswordReset(
userId: number,
options?: { expiresAt?: Date; usedAt?: Date | null },
): 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);
await db
@@ -263,6 +275,7 @@ async function createPasswordReset(
return token;
}
describeE2E("auth", () => {
// Test setup
beforeAll(async () => {
await initTestDb();
@@ -391,7 +404,9 @@ describe("auth.signup", () => {
// nested transactions.
test("creates user with passkey", async () => {
const db = getSharedDb();
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db);
// Step 1: Create registration options
@@ -449,7 +464,7 @@ describe("auth.signup", () => {
const challenges = await db
.selectFrom("webauthn_challenges")
.selectAll()
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
expect(challenges.length).toBe(0);
});
@@ -475,7 +490,7 @@ describe("auth.signup", () => {
await db
.updateTable("webauthn_challenges")
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
// Step 4: Try to signup with expired challenge
@@ -532,7 +547,7 @@ describe("auth.signup", () => {
const challenges = await db
.selectFrom("webauthn_challenges")
.selectAll()
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.execute();
expect(challenges.length).toBe(0);
});
@@ -1064,7 +1079,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
const loginRequest = await db
.selectFrom("login_requests")
.selectAll()
.where("id", "=", String(loginRequestId))
.where("id", "=", loginRequestId.toString())
.executeTakeFirst();
expect(loginRequest).toBeUndefined();
@@ -1111,7 +1126,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for fake/non-existent token", async () => {
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(
router.auth.loginIfRequestIsCompleted,
undefined,
@@ -1142,7 +1159,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
});
// Create login request without device fingerprint
const token = `login_test-${String(Date.now())}`;
const token = `login_test-${uniqueTestId()}`;
await db
.insertInto("login_requests")
.values({
@@ -1344,7 +1361,9 @@ describe("auth.resendVerificationEmail", () => {
const ctx = createAPIContext(db); // No session
await expect(
call(router.auth.resendVerificationEmail, undefined, { context: ctx }),
call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
}),
).rejects.toThrow();
});
});
@@ -1503,7 +1522,6 @@ describe("auth.resetPassword", () => {
// Create some sessions
await createSession(db, user.id);
await createSession(db, user.id);
const token = await createPasswordReset(db, user.id);
@@ -1633,7 +1651,7 @@ describe("auth.logout", () => {
const session = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("id", "=", String(sessionId))
.where("id", "=", sessionId.toString())
.executeTakeFirst();
expect(session?.revoked_at).not.toBeNull();
@@ -1875,7 +1893,10 @@ describe("End-to-end login scenarios", () => {
const ctx2 = createAPIContext(db);
await call(
router.auth.resetPassword,
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" },
{
token: assertDefined(reset).token,
newPassword: "NewSecureP@ss123!",
},
{ context: ctx2 },
);
@@ -1967,7 +1988,7 @@ describe("End-to-end login scenarios", () => {
// Clean up registration session
await db
.deleteFrom("sessions")
.where("id", "=", String(regSessionId))
.where("id", "=", regSessionId.toString())
.execute();
// Step 1: Create login request
@@ -1991,7 +2012,8 @@ describe("End-to-end login scenarios", () => {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: ctx2 },
@@ -2105,3 +2127,62 @@ describe("End-to-end login scenarios", () => {
});
});
});
// =============================================================================
// 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 { afterAll, beforeAll, describe, expect, test } from "bun:test";
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 { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.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 */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -48,6 +52,11 @@ function createAPIContext(
rpName: TEST_RP.rpName,
reqHeaders,
resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
};
}
@@ -58,7 +67,7 @@ async function createSession(
db: Kysely<Database>,
userId: number,
): Promise<string> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -85,7 +94,7 @@ async function createLoginRequest(
userId: number,
email: 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 result = await db
@@ -130,6 +139,11 @@ function createLoginRequestContext(
rpName: TEST_RP.rpName,
reqHeaders,
resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
};
}
@@ -198,6 +212,7 @@ async function authenticate(
);
}
describeE2E("webauthn", () => {
beforeAll(async () => {
await initTestDb();
});
@@ -233,7 +248,7 @@ describe("registration flow", () => {
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("id")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.executeTakeFirst();
expect(challengeRow).toBeDefined();
@@ -379,7 +394,7 @@ describe("registration flow", () => {
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("id")
.where("id", "=", String(challengeId))
.where("id", "=", challengeId.toString())
.executeTakeFirst();
expect(challengeRow).toBeUndefined();
@@ -483,7 +498,8 @@ describe("authentication flow", () => {
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: loginCtx },
@@ -525,7 +541,8 @@ describe("authentication flow", () => {
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: loginCtx },
@@ -563,7 +580,8 @@ describe("authentication flow", () => {
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: loginCtx },
@@ -579,7 +597,7 @@ describe("authentication flow", () => {
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("id")
.where("id", "=", String(authChallengeId))
.where("id", "=", authChallengeId.toString())
.executeTakeFirst();
expect(challengeRow).toBeUndefined();
@@ -864,7 +882,9 @@ describe("passkey management", () => {
expect(passkeys).toHaveLength(2);
// 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) {
throw new Error("Expected iCloud Keychain passkey to exist");
}
@@ -1003,7 +1023,9 @@ describe("passkey management", () => {
email: "delete-with-password@test.com",
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);
@@ -1019,7 +1041,9 @@ describe("passkey management", () => {
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
// 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);
});
@@ -1052,7 +1076,9 @@ describe("passkey management", () => {
);
// 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);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.id).not.toBe(firstPasskeyId);
@@ -1066,7 +1092,9 @@ describe("passkey management", () => {
email: "delete-last@test.com",
// 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);
@@ -1139,9 +1167,13 @@ describe("passkey management", () => {
}
// 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,
});
},
);
expect(user2PasskeysAfter).toHaveLength(1);
});
@@ -1194,3 +1226,4 @@ describe("passkey management", () => {
});
});
});
}); // Close outer describe.skipIf

View File

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

View File

@@ -3,8 +3,18 @@
*/
import type { Database } from "@reviq/db-schema";
import type { EmailClient } from "@reviq/emails";
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
*/
@@ -23,6 +33,8 @@ export interface APIContext {
resHeaders: Headers;
/** Client IP address from direct connection (fallback when no proxy headers) */
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 { RPCHandler } from "@orpc/server/fetch";
import { createDb } from "@reviq/db";
import {
createLoggingEmailClient,
createPostmarkClient,
} from "@reviq/emails";
import pino from "pino";
import {
BASE_URL,
DEFAULT_PORT,
DEFAULT_RP_NAME,
EMAIL_FROM,
POSTMARK_API_KEY,
getAllowedOrigins,
} from "./constants.js";
import { router } from "./router.js";
@@ -24,6 +31,16 @@ if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is required");
}
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, {
plugins: [
new LoggingHandlerPlugin({
@@ -45,7 +62,7 @@ Bun.serve({
if (url.pathname.startsWith("/api/v1/rpc")) {
// Build context for the request
const origin =
request.headers.get("origin") ?? `http://localhost:${String(port)}`;
request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
// Create response headers for setting cookies
const resHeaders = new Headers();
@@ -62,6 +79,11 @@ Bun.serve({
reqHeaders: request.headers,
resHeaders,
clientIP,
email: {
client: emailClient,
fromAddress: EMAIL_FROM,
baseUrl: BASE_URL,
},
};
const { response } = await handler.handle(request, {

View File

@@ -6,12 +6,13 @@
* 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 {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendPasswordResetEmail } from "../../utils/email.js";
import { os } from "../base.js";
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) {
// 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
const token = generateSecureBase58Token();
// Create password reset record with 1 hour expiry
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")
.values({
user_id: user.id,
@@ -50,9 +53,17 @@ export const forgotPassword = os.auth.forgotPassword.handler(
expires_at: expiresAt,
})
.execute();
});
// Send password reset email (stubbed)
await sendPasswordResetEmail(user.email, token);
// Send password reset email
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)

View File

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

View File

@@ -4,8 +4,8 @@
*/
import { ORPCError } from "@orpc/server";
import { sendLoginConfirmationEmail } from "@reviq/emails";
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
import { sendLoginConfirmationEmail } from "../../utils/email.js";
import { verifyPassword } from "../../utils/password.js";
import { isDeviceTrusted } from "../../utils/session.js";
import { os } from "../base.js";
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
} else {
// Device is untrusted - send confirmation email with existing token
// 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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,10 +115,11 @@ export async function countOwners(
): Promise<number> {
const result = await db
.selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count"))
.select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId)
.where("role", "=", "owner")
.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 { sendOrgInviteEmail } from "@reviq/emails";
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
import {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendOrgInviteEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
@@ -122,7 +122,17 @@ export const invitesCreate = os.orgs.invites.create
// Send invitation 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 };
});

View File

@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
await context.db
.updateTable("login_requests")
.set({ completed_at: new Date() })
.where("id", "=", String(context.loginRequestId))
.where("id", "=", context.loginRequestId.toString())
.execute();
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";
// 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 {
hashPassword as hashPasswordUtil,
verifyPassword as verifyPasswordUtil,
} from "@reviq/utils";
} from "@reviq/server-utils";
import zxcvbn from "zxcvbn";
export interface PasswordValidationResult {

View File

@@ -1,6 +1,11 @@
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 {
isDeviceTrusted as dbIsDeviceTrusted,
upsertUserDevice as dbUpsertUserDevice,
insertSession,
} from "@reviq/db";
import { COOKIE_DURATIONS } from "./cookies.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
*/
export async function createSession(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
options: CreateSessionOptions,
): Promise<SessionResult> {
const token = generateSessionToken();
const tokenHash = await hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: tokenHash,
trusted_mode: options.trustedMode,
ip_address: options.geo.ip,
city: options.geo.city,
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
const result = await insertSession(db, {
userId: options.userId,
deviceId: options.deviceId,
tokenHash,
trustedMode: options.trustedMode,
geo: options.geo,
userAgent: options.userAgent,
expiresAt,
});
return {
token,
sessionId: Number(result.id),
sessionId: result.sessionId,
expiresAt,
};
}
@@ -60,53 +58,22 @@ export async function createSession(
* Returns the device ID
*/
export async function upsertUserDevice(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
geo: GeoInfo,
userAgent: string,
): Promise<number> {
const result = await db
.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);
return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
): Promise<boolean> {
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
return dbIsDeviceTrusted(db, userId, deviceFingerprint);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
interface LoginFlags {
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("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) {
console.error(
"Login failed:",
error instanceof Error ? error.message : String(error),
);
console.error("Login failed:", formatError(error));
console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1);

View File

@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { getConfigPath, readConfig } from "../../utils/config.js";
import { formatError } from "../../utils/format-error.js";
import { TOKEN_PREFIX } from "../../utils/token.js";
function formatDate(date: Date): string {
@@ -14,19 +15,19 @@ function formatRelativeTime(date: Date): string {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return `${String(Math.abs(diffDays))} days ago`;
return `${Math.abs(diffDays).toLocaleString()} days ago`;
}
if (diffDays === 0) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours <= 0) {
return "expired";
}
return `in ${String(diffHours)} hours`;
return diffHours <= 0 ? "expired" : `in ${diffHours.toLocaleString()} hours`;
}
if (diffDays === 1) {
return "tomorrow";
}
return `in ${String(diffDays)} days`;
return `in ${diffDays.toLocaleString()} days`;
}
async function status(this: LocalContext): Promise<void> {
@@ -96,9 +97,7 @@ async function status(this: LocalContext): Promise<void> {
);
}
} catch (error) {
console.log(
` Error: ${error instanceof Error ? error.message : String(error)}`,
);
console.log(` Error: ${formatError(error)}`);
console.log(
"\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 { buildCommand } from "@stricli/core";
import { writeConfig } from "../utils/config.js";
import { formatError } from "../utils/format-error.js";
interface BootstrapFlags {
email: string;
@@ -47,10 +48,7 @@ async function bootstrap(
await db.destroy();
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
await db.destroy();
this.process.exit(1);
}

View File

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

View File

@@ -1,6 +1,7 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
interface AddSiteFlags {
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}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,26 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { createApiClient } from "../../utils/api-client.js";
import { formatError } from "../../utils/format-error.js";
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 {
if (!role) {
return undefined;
}
if (validRoles.includes(role as OrgRole)) {
return role as OrgRole;
}
if (!VALID_ROLES.includes(role as OrgRole)) {
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 {
email: string;
name?: string;
@@ -45,10 +48,7 @@ async function create(
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
}
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error("Error:", formatError(error));
this.process.exit(1);
}
}

View File

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

View File

@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
/**
* Get the path to the config file
*/
export const getConfigPath = (): string => CONFIG_FILE;
export function getConfigPath(): string {
return CONFIG_FILE;
}
/**
* Read the config file
* 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 {
const data = await readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data) as Config;
} catch {
return null;
}
};
}
/**
* Write the config file
* 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 writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
mode: 0o600,
});
};
}
/**
* Delete the config file
* Ignores errors if the file doesn't exist
*/
export const deleteConfig = async (): Promise<void> => {
export async function deleteConfig(): Promise<void> {
try {
await unlink(CONFIG_FILE);
} catch {
// 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,
"composite": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*"]
}

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js";
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
>
{#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
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]",
active

View File

@@ -1,7 +1,6 @@
export { default as AccountNav } from "./account-nav.svelte";
export { default as AddPasskeyDialog } from "./add-passkey-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 PasskeyList } from "./passkey-list.svelte";
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
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";
interface Passkey {
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null);
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) {
selectedPasskey = passkey;
renameDialogOpen = true;

View File

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

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js";
interface Props {
@@ -27,8 +26,8 @@ const filters = [
<div class="divide-y divide-border/50">
{#each filters as filter (filter.label)}
<a
href={resolve(filter.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={filter.href}
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">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
import {
@@ -33,14 +32,15 @@ const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab,
);
function handleTabChange(tabId: string) {
function handleTabChange(tabId: string): void {
const url = new URL($page.url);
if (tabId === defaultTab) {
url.searchParams.delete("tab");
} else {
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>

View File

@@ -1,7 +1,11 @@
<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,
name: "/header/leaderboard-728x90",
@@ -51,58 +55,10 @@ const tableData = [
impPercent: 9.16,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.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">Ad unit</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
<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>
<MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
{#snippet labelCell({ row })}
<code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
{/snippet}
</MetricsTable>

View File

@@ -1,7 +1,12 @@
<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,
name: "United States",
@@ -57,54 +62,14 @@ const tableData = [
impPercent: 4.68,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.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">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">
<MetricsTable data={tableData} labelHeader="Country">
{#snippet labelCell({ row })}
{@const countryRow = row as CountryRow}
<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="text-[13px] font-medium text-foreground">{row.name}</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">{countryRow.name}</span>
</div>
</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>
{/snippet}
</MetricsTable>

View File

@@ -1,7 +1,11 @@
<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,
name: "example.com",
@@ -27,51 +31,10 @@ const tableData = [
impPercent: 18.45,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.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">Domain</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">
<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>
<MetricsTable data={tableData} labelHeader="Domain">
{#snippet labelCell({ row })}
<span class="text-[13px] font-medium text-foreground">{(row as DomainRow).name}</span>
{/snippet}
</MetricsTable>

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 DomainTable } from "./domain-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";

View File

@@ -1,7 +1,17 @@
<script lang="ts">
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,
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">
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,
name: "Google AdX",
@@ -43,51 +47,10 @@ const tableData = [
impPercent: 7.28,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.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">Source</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">
<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>
<MetricsTable data={tableData} labelHeader="Source">
{#snippet labelCell({ row })}
<span class="text-[13px] font-medium text-foreground">{(row as SourceRow).name}</span>
{/snippet}
</MetricsTable>

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
@@ -98,8 +83,8 @@ const navItems = [
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"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">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
@@ -90,8 +75,8 @@ const navItems = [
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -32,28 +33,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
// Nav items depend on whether we're in an org context
const navItems = $derived.by(() => {
if (currentSlug) {
// In org context - org-specific navigation
return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{
@@ -68,7 +52,6 @@ const navItems = $derived.by(() => {
},
];
}
// Outside org context - general navigation
return [
{ icon: "home", href: "/", label: "Home" },
{ icon: "building", href: "/dashboard", label: "Organizations" },
@@ -77,16 +60,17 @@ const navItems = $derived.by(() => {
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
open = false;
goto(resolve("/auth/login"));
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto("/auth/login");
} catch (error) {
console.error("Failed to sign out:", error);
}
@@ -123,8 +107,8 @@ async function handleSignOut() {
{@const isActive =
$page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"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">
import { Check } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js";
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
}));
const orgs = $derived(orgsQuery.data ?? []);
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}` as any));
goto(resolve("/dashboard/[slug]", { slug }));
}
</script>
@@ -30,8 +33,13 @@ function handleOrgSelect(slug: string) {
<button
{...props}
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
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
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" />
</svg>
</div>
{/if}
</button>
{/snippet}
</DropdownMenu.Trigger>
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
class={cn(isActive && "bg-accent")}
>
<div class="flex items-center gap-2">
{#if org.logoUrl}
<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}
<OrgAvatar {org} size="xs" />
<span class="flex-1 truncate">{org.displayName}</span>
{#if isActive}
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<Check class="h-4 w-4 text-primary" />
{/if}
</div>
</DropdownMenu.Item>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
// Clear all cached queries
queryClient.clear();
goto(resolve("/auth/login"));
} catch (error) {

View File

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

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

View File

@@ -28,7 +28,7 @@ let {
onConfirm,
}: Props = $props();
async function handleConfirm() {
async function handleConfirm(): Promise<void> {
await onConfirm();
}
</script>
@@ -54,8 +54,8 @@ async function handleConfirm() {
<LoadingButton
variant="destructive"
class="w-full"
loading={loading}
loadingText={loadingText}
{loading}
{loadingText}
onclick={handleConfirm}
>
{confirmText}
@@ -77,7 +77,7 @@ async function handleConfirm() {
{cancelText}
</Button>
<LoadingButton
loading={loading}
{loading}
onclick={handleConfirm}
>
{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(() => {
if (orgsQuery.error) {
// 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) {
if (orgsQuery.data.length > 0) {
// 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,
});
} else {

View File

@@ -8,12 +8,13 @@ import {
Plus,
Trash2,
} from "@lucide/svelte";
import { formatDate, formatRelativeDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
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 { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
@@ -59,33 +60,6 @@ let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null);
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) {
e.preventDefault();
if (!newTokenName.trim() || isCreating) {
@@ -261,9 +235,9 @@ async function handleDelete() {
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
Created {formatRelativeDate(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
· Last used {formatRelativeDate(token.lastUsedAt)}
{:else}
· Never used
{/if}

View File

@@ -8,11 +8,11 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
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 { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
@@ -54,31 +54,6 @@ function formatLocation(device: {
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) {
const nameLower = name.toLowerCase();
if (

View File

@@ -2,7 +2,6 @@
import {
AlertCircle,
ArrowLeft,
Building2,
Calendar,
CheckCircle2,
Clock,
@@ -10,6 +9,7 @@ import {
User,
XCircle,
} from "@lucide/svelte";
import { formatLongDate, formatRole } from "@reviq/common";
import {
createMutation,
createQuery,
@@ -20,6 +20,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import {
@@ -48,12 +49,10 @@ const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => {
toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}`));
} else {
goto(resolve("/dashboard"));
}
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => {
toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
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)
*/
@@ -141,17 +121,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if invite.org.logoUrl}
<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}
<OrgAvatar org={invite.org} size="xl" />
<div class="flex-1">
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
<CardDescription class="mt-1">
@@ -187,7 +157,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
</div>
<div>
<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 class="flex items-center gap-3">
@@ -197,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div>
<p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))}
{formatLongDate(invite.expiresAt)}
</p>
</div>
</div>
@@ -207,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Alert>
<Clock class="h-4 w-4" />
<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>
</Alert>
{/if}

View File

@@ -10,11 +10,12 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
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 { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
@@ -56,36 +57,6 @@ function formatLocation(session: {
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): {
browser: string;
os: string;

View File

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

View File

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

View File

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

View File

@@ -81,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
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
<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>
</div>
</div>

View File

@@ -59,7 +59,8 @@ const statusQuery = createQuery(() => ({
$effect(() => {
if (statusQuery.data?.status === "completed") {
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,
Mail,
} from "@lucide/svelte";
import { formatRelativeDate, formatRole } from "@reviq/common";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
import { OrgAvatar } from "$lib/components/org";
import { Badge } from "$lib/components/ui/badge";
import {
Card,
@@ -19,6 +20,7 @@ import {
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { gotoLogin } from "$lib/utils/navigation";
/**
* Dashboard page - lists all organizations the user is a member of
@@ -40,48 +42,9 @@ const invitesQuery = createQuery(() => ({
// Redirect to login on auth error
$effect(() => {
if (orgsQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
gotoLogin(window.location.pathname);
}
});
/**
* 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>
<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">
{#each invitesQuery.data as invite (invite.id)}
<a
href={resolve(`/account/org-invites/${invite.id}`)}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
class="group block"
>
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3">
{#if invite.org.logoUrl}
<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}
<OrgAvatar org={invite.org} size="lg" />
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{invite.org.displayName}
@@ -133,7 +86,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))}
From {invite.invitedBy} &middot; {formatRelativeDate(invite.createdAt)}
</p>
</CardContent>
</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">
{#each orgsQuery.data as org (org.id)}
<a
href={resolve(`/dashboard/${org.slug}`)}
href={resolve("/dashboard/[slug]", { slug: org.slug })}
class="group block transition-transform hover:scale-[1.02]"
>
<Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-3">
<div class="flex items-start gap-3">
<!-- Logo or placeholder -->
{#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}
<OrgAvatar {org} size="lg" />
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{org.displayName}
@@ -216,7 +158,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
Created {formatDate(new Date(org.createdAt))}
Created {formatRelativeDate(org.createdAt)}
</p>
</CardContent>
</Card>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import {
AlertCircle,
Building2,
ChevronRight,
Globe,
Loader2,
@@ -13,7 +12,7 @@ import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
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 {
Card,
@@ -98,17 +97,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Header with org info -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
{#if orgQuery.data?.logoUrl}
<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}
<OrgAvatar org={orgQuery.data} size="xl" />
<div>
<h1 class="text-2xl font-semibold">{orgName}</h1>
<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 { api } from "$lib/api/client";
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 {
Card,
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
description={confirmDialogDescription}
variant={confirmDialogVariant}
loading={isConfirmLoading}
onconfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
onConfirm={executeConfirmAction}
/>

View File

@@ -14,7 +14,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
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 { Button } from "$lib/components/ui/button";
import {
@@ -82,7 +82,7 @@ let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state("");
let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmDialogConfirmLabel = $state("Confirm");
let confirmDialogConfirmText = $state("Confirm");
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
let isConfirmLoading = $state(false);
@@ -119,7 +119,7 @@ function handleLeave() {
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.";
confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Leave Organization";
confirmDialogConfirmText = "Leave Organization";
confirmAction = async () => {
try {
await api.orgs.leave({ slug });
@@ -142,7 +142,7 @@ function handleDelete() {
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.`;
confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Delete Organization";
confirmDialogConfirmText = "Delete Organization";
confirmAction = async () => {
try {
await api.orgs.delete({ slug });
@@ -306,8 +306,7 @@ async function executeConfirmAction() {
title={confirmDialogTitle}
description={confirmDialogDescription}
variant={confirmDialogVariant}
confirmLabel={confirmDialogConfirmLabel}
confirmText={confirmDialogConfirmText}
loading={isConfirmLoading}
onconfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
onConfirm={executeConfirmAction}
/>

View File

@@ -12,7 +12,8 @@ import { getContext } from "svelte";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
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 {
Card,
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
description={confirmDialogDescription}
variant={confirmDialogVariant}
loading={isConfirmLoading}
onconfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
onConfirm={executeConfirmAction}
/>

View File

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