Compare commits
40 Commits
fix-export
...
c60041a1bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
c60041a1bb
|
|||
|
e43c006bb1
|
|||
|
8e65c2e698
|
|||
|
b085a315be
|
|||
|
1ed41e5c4c
|
|||
|
84644c8bfb
|
|||
|
5ecf12a1a1
|
|||
|
c2b815dd6a
|
|||
|
67930d90d5
|
|||
|
58ffa68f4c
|
|||
|
5a2e0297e5
|
|||
|
c9de0b1ac5
|
|||
|
0f50291490
|
|||
|
9c6694cad4
|
|||
|
f9f1dc7403
|
|||
|
b27a977809
|
|||
|
7edc4ba8a9
|
|||
|
16f827e8f0
|
|||
|
947c73dbdc
|
|||
|
2baf10b0cd
|
|||
|
8b081d5ba8
|
|||
|
01f1e1c9e3
|
|||
|
26d10d452f
|
|||
|
8b63eb3538
|
|||
|
587e151fbd
|
|||
|
94b6de5970
|
|||
|
6fa4da1abb
|
|||
|
92f7e1df09
|
|||
|
b2fba6e150
|
|||
|
ebc85af62c
|
|||
|
6b8dd27898
|
|||
|
61fdd3329f
|
|||
|
848d9e9af1
|
|||
|
44a480179b
|
|||
|
628b01f4d8
|
|||
| 8939deefbe | |||
|
76a5e40900
|
|||
|
b1d07626f3
|
|||
|
99539bbdcb
|
|||
|
eedd664db8
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal 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")
|
||||
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal 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())
|
||||
8
.ast-grep/rules/no-countall-number.yml
Normal file
8
.ast-grep/rules/no-countall-number.yml
Normal 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()
|
||||
7
.ast-grep/rules/no-string-function.yml
Normal file
7
.ast-grep/rules/no-string-function.yml
Normal 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
34
.gitea/workflows/ci.yaml
Normal 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
|
||||
74
CLAUDE.md
74
CLAUDE.md
@@ -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);
|
||||
```
|
||||
|
||||
17
README.md
17
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
1954
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
1954
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,16 +275,17 @@ async function createPasswordReset(
|
||||
return token;
|
||||
}
|
||||
|
||||
// Test setup
|
||||
beforeAll(async () => {
|
||||
describeE2E("auth", () => {
|
||||
// Test setup
|
||||
beforeAll(async () => {
|
||||
await initTestDb();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.signup tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.signup tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.signup", () => {
|
||||
describe("auth.signup", () => {
|
||||
test("creates user with valid password", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const ctx = createAPIContext(db);
|
||||
@@ -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,18 +547,18 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.createLoginRequest tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.createLoginRequest tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.createLoginRequest", () => {
|
||||
describe("auth.createLoginRequest", () => {
|
||||
test("returns auth methods for existing user with password", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
await createTestUser(db, {
|
||||
@@ -671,13 +686,13 @@ describe("auth.createLoginRequest", () => {
|
||||
expect(fingerprint).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.loginPassword tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.loginPassword tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.loginPassword", () => {
|
||||
describe("auth.loginPassword", () => {
|
||||
test("completes login immediately for trusted device", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -863,13 +878,13 @@ describe("auth.loginPassword", () => {
|
||||
).rejects.toThrow("Invalid email or password");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.loginPasswordConfirm tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.loginPasswordConfirm tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.loginPasswordConfirm", () => {
|
||||
describe("auth.loginPasswordConfirm", () => {
|
||||
test("marks login request as completed with valid token", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -964,13 +979,13 @@ describe("auth.loginPasswordConfirm", () => {
|
||||
).rejects.toThrow("Invalid or expired confirmation link");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.loginIfRequestIsCompleted tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.loginIfRequestIsCompleted tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.loginIfRequestIsCompleted", () => {
|
||||
describe("auth.loginIfRequestIsCompleted", () => {
|
||||
test("returns pending for incomplete login request", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -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({
|
||||
@@ -1165,13 +1182,13 @@ describe("auth.loginIfRequestIsCompleted", () => {
|
||||
expect(result.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.verifyEmail tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.verifyEmail tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.verifyEmail", () => {
|
||||
describe("auth.verifyEmail", () => {
|
||||
test("verifies email with valid token", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -1247,13 +1264,13 @@ describe("auth.verifyEmail", () => {
|
||||
expect(verifications.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.resendVerificationEmail tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.resendVerificationEmail tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.resendVerificationEmail", () => {
|
||||
describe("auth.resendVerificationEmail", () => {
|
||||
test("creates new verification token for unverified user", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -1344,17 +1361,19 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.forgotPassword tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.forgotPassword tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.forgotPassword", () => {
|
||||
describe("auth.forgotPassword", () => {
|
||||
test("creates password reset token for existing user", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -1449,13 +1468,13 @@ describe("auth.forgotPassword", () => {
|
||||
expect(resets.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.resetPassword tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.resetPassword tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.resetPassword", () => {
|
||||
describe("auth.resetPassword", () => {
|
||||
test("resets password with valid token", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -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);
|
||||
|
||||
@@ -1604,13 +1622,13 @@ describe("auth.resetPassword", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// auth.logout tests
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// auth.logout tests
|
||||
// =============================================================================
|
||||
|
||||
describe("auth.logout", () => {
|
||||
describe("auth.logout", () => {
|
||||
test("revokes current session", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -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();
|
||||
@@ -1656,13 +1674,13 @@ describe("auth.logout", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// End-to-end login scenarios from docs/initial-app.md
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// End-to-end login scenarios from docs/initial-app.md
|
||||
// =============================================================================
|
||||
|
||||
describe("End-to-end login scenarios", () => {
|
||||
describe("End-to-end login scenarios", () => {
|
||||
test("Scenario: Password login with trusted device (immediate completion)", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
// Setup: User with password and trusted device
|
||||
@@ -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 },
|
||||
@@ -2104,4 +2126,63 @@ describe("End-to-end login scenarios", () => {
|
||||
expect(loginRequest?.completed_at).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// loginRequestMiddleware tests (base.ts)
|
||||
// =============================================================================
|
||||
|
||||
describe("loginRequestMiddleware", () => {
|
||||
test("rejects request with no login request cookie", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
// No login request token in context
|
||||
const ctx = createAPIContext(db);
|
||||
|
||||
await expect(
|
||||
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||
context: ctx,
|
||||
}),
|
||||
).rejects.toThrow("No login request found");
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects request with invalid login request token", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
// Invalid token that doesn't exist in DB
|
||||
const ctx = createAPIContext(db, {
|
||||
loginRequestToken: "invalid-login-request-token",
|
||||
});
|
||||
|
||||
await expect(
|
||||
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||
context: ctx,
|
||||
}),
|
||||
).rejects.toThrow("Login request expired or not found");
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects request with expired login request", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
email: "expiredloginreq@example.com",
|
||||
});
|
||||
|
||||
// Create an expired login request
|
||||
const { token: loginToken } = await createLoginRequest(
|
||||
db,
|
||||
user.id,
|
||||
user.email,
|
||||
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
|
||||
);
|
||||
|
||||
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
|
||||
|
||||
await expect(
|
||||
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||
context: ctx,
|
||||
}),
|
||||
).rejects.toThrow("Login request expired or not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
}); // Close outer describeE2E
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1847
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
1847
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,15 +212,16 @@ async function authenticate(
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
describeE2E("webauthn", () => {
|
||||
beforeAll(async () => {
|
||||
await initTestDb();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
afterAll(async () => {
|
||||
await destroySharedDb();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration flow", () => {
|
||||
describe("registration flow", () => {
|
||||
test("creates registration options with challenge stored in DB via router", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -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();
|
||||
@@ -419,9 +434,9 @@ describe("registration flow", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication flow", () => {
|
||||
describe("authentication flow", () => {
|
||||
test("creates authentication options with user's passkeys via router", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -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();
|
||||
@@ -636,9 +654,9 @@ describe("authentication flow", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("security tests", () => {
|
||||
describe("security tests", () => {
|
||||
test("rejects replayed credentials (counter check) via router", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -772,9 +790,9 @@ describe("security tests", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("full passkey lifecycle", () => {
|
||||
describe("full passkey lifecycle", () => {
|
||||
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, { email: "lifecycle@test.com" });
|
||||
@@ -834,9 +852,9 @@ describe("full passkey lifecycle", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("passkey management", () => {
|
||||
describe("passkey management", () => {
|
||||
test("lists passkeys with correct data via router", async () => {
|
||||
await withTestTransaction(getSharedDb(), async (db) => {
|
||||
const user = await createTestUser(db, {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1193,4 +1225,5 @@ describe("passkey management", () => {
|
||||
expect(firstPasskey.transports).toContain("hybrid");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}); // Close outer describe.skipIf
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateSecureBase58Token } from "@reviq/utils";
|
||||
import { generateSecureBase58Token } from "@reviq/server-utils";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// ===== 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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,5 @@
|
||||
"isolatedDeclarations": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
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 {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
20
apps/cli/src/utils/format-error.ts
Normal file
20
apps/cli/src/utils/format-error.ts
Normal 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}`;
|
||||
}
|
||||
@@ -19,6 +19,5 @@
|
||||
"isolatedDeclarations": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Geist Sans - Modern, clean typeface */
|
||||
@font-face {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
@@ -48,8 +48,7 @@ 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 {
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "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}
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
26
apps/publisher-dashboard/src/lib/utils/navigation.ts
Normal file
26
apps/publisher-dashboard/src/lib/utils/navigation.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || "/");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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} · {formatDate(new Date(invite.createdAt))}
|
||||
From {invite.invitedBy} · {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
52
apps/publisher-dashboard/src/routes/privacy/+page.svelte
Normal file
52
apps/publisher-dashboard/src/routes/privacy/+page.svelte
Normal 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
Reference in New Issue
Block a user