Compare commits
51 Commits
fix-export
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
8da4379583
|
|||
|
1f6d5a4a9f
|
|||
|
d8397dfb38
|
|||
|
73ef3df01f
|
|||
|
25c8bab741
|
|||
|
b48012c1f6
|
|||
|
bd4053f952
|
|||
|
ce5a27d014
|
|||
|
665092464a
|
|||
|
b78064caeb
|
|||
|
c60041a1bb
|
|||
|
40d743c8c2
|
|||
|
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);
|
||||
```
|
||||
|
||||
19
README.md
19
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
|
||||
```
|
||||
|
||||
@@ -51,7 +55,7 @@ publisher-dashboard/
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) v1.1.42+
|
||||
- [Bun](https://bun.sh/) v1.3.5+
|
||||
- [devenv](https://devenv.sh/) for development environment management
|
||||
|
||||
### Environment Variables
|
||||
@@ -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 { createLoggingEmailClient } from "@reviq/emails";
|
||||
import {
|
||||
createTestUser,
|
||||
describeE2E,
|
||||
getSharedDb,
|
||||
initTestDb,
|
||||
TEST_RP,
|
||||
uniqueTestId,
|
||||
withTestTransaction,
|
||||
} from "@reviq/test-helpers";
|
||||
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 { createLoggingEmailClient } from "@reviq/emails";
|
||||
import {
|
||||
createTestUser,
|
||||
describeE2E,
|
||||
destroySharedDb,
|
||||
getSharedDb,
|
||||
initTestDb,
|
||||
KNOWN_AAGUIDS,
|
||||
TEST_RP,
|
||||
uniqueTestId,
|
||||
withTestTransaction,
|
||||
} from "@reviq/test-helpers";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
|
||||
/** User with superuser privileges */
|
||||
user: SessionUser & { isSuperuser: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization info in context
|
||||
*/
|
||||
export interface OrgInfo {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* User's membership in an org
|
||||
*/
|
||||
export interface OrgMembership {
|
||||
id: number;
|
||||
role: "owner" | "admin" | "member";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Org member context for org-scoped procedures
|
||||
* Requires user to be a member of the org
|
||||
*/
|
||||
export interface OrgMemberContext extends AuthenticatedContext {
|
||||
/** The organization */
|
||||
org: OrgInfo;
|
||||
/** User's membership in the org */
|
||||
membership: OrgMembership;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ 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,
|
||||
getAllowedOrigins,
|
||||
POSTMARK_API_KEY,
|
||||
} from "./constants.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
@@ -24,6 +28,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 +59,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 +76,11 @@ Bun.serve({
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
clientIP,
|
||||
email: {
|
||||
client: emailClient,
|
||||
fromAddress: EMAIL_FROM,
|
||||
baseUrl: BASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
const { response } = await handler.handle(request, {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Authentication middleware for oRPC server
|
||||
*
|
||||
* Handles authentication via:
|
||||
* - Session cookie (rev.session_token) - for browser clients
|
||||
* - API key header (x-api-key) - for CLI and programmatic access
|
||||
*/
|
||||
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
|
||||
/**
|
||||
* Create the auth middleware function
|
||||
* This returns a middleware handler that can be used with oRPC procedures
|
||||
*/
|
||||
export const createAuthMiddleware = () => {
|
||||
return async ({
|
||||
context,
|
||||
next,
|
||||
}: {
|
||||
context: APIContext;
|
||||
next: (opts: {
|
||||
context: Omit<AuthenticatedContext, keyof APIContext>;
|
||||
}) => Promise<unknown>;
|
||||
}) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require superuser access
|
||||
*/
|
||||
export const createSuperuserMiddleware = () => {
|
||||
return async ({
|
||||
context,
|
||||
next,
|
||||
}: {
|
||||
context: AuthenticatedContext;
|
||||
next: () => Promise<unknown>;
|
||||
}) => {
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
138
apps/api-server/src/middlewares/auth.ts
Normal file
138
apps/api-server/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Auth middleware - validates session/API token and adds user to context
|
||||
*/
|
||||
|
||||
import type { AuthInfo, Session, SessionUser } from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
import { os } from "./os.js";
|
||||
|
||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
8
apps/api-server/src/middlewares/index.ts
Normal file
8
apps/api-server/src/middlewares/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Middleware exports
|
||||
*/
|
||||
|
||||
export { authMiddleware } from "./auth.js";
|
||||
export { loginRequestMiddleware } from "./login-request.js";
|
||||
export { os } from "./os.js";
|
||||
export { superuserMiddleware } from "./superuser.js";
|
||||
64
apps/api-server/src/middlewares/login-request.ts
Normal file
64
apps/api-server/src/middlewares/login-request.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Login request middleware - validates login request token from cookie
|
||||
*/
|
||||
|
||||
import type { SessionUser } from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { os } from "./os.js";
|
||||
|
||||
export const loginRequestMiddleware = os.middleware(
|
||||
async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Read login request token from cookie
|
||||
const loginRequestToken = getCookie(
|
||||
reqHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
);
|
||||
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No login request found",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
.select([
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.expires_at",
|
||||
"users.email",
|
||||
"users.display_name",
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: result.user_id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
emailVerifiedAt: result.email_verified_at,
|
||||
isSuperuser: result.is_superuser,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
loginRequestId: Number(result.id),
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
10
apps/api-server/src/middlewares/os.ts
Normal file
10
apps/api-server/src/middlewares/os.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Base implementer with typed APIContext
|
||||
* All procedures and middlewares should derive from this
|
||||
*/
|
||||
|
||||
import type { APIContext } from "../context.js";
|
||||
import { implement } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
|
||||
export const os = implement(contract).$context<APIContext>();
|
||||
19
apps/api-server/src/middlewares/superuser.ts
Normal file
19
apps/api-server/src/middlewares/superuser.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Superuser middleware - authenticates and requires superuser access
|
||||
*
|
||||
* This middleware chains authMiddleware first, then checks for superuser.
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware } from "./auth.js";
|
||||
|
||||
export const superuserMiddleware = authMiddleware.concat(
|
||||
async ({ context, next }) => {
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
},
|
||||
);
|
||||
@@ -3,12 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminAuthCompleteLogin =
|
||||
superuserProcedure.admin.auth.completeLogin.handler(
|
||||
async ({ input, context }) => {
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
// First check if any login request exists for this email
|
||||
@@ -48,4 +47,5 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsCreate = os.admin.orgs.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsCreate = superuserProcedure.admin.orgs.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, ownerEmail } = input;
|
||||
|
||||
// Find owner user by email (outside transaction - read-only)
|
||||
@@ -55,4 +53,5 @@ export const adminOrgsCreate = os.admin.orgs.create
|
||||
});
|
||||
|
||||
return { slug };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsDelete = os.admin.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsDelete = superuserProcedure.admin.orgs.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Delete org and related records in transaction
|
||||
@@ -35,4 +33,5 @@ export const adminOrgsDelete = os.admin.orgs.delete
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsGet = os.admin.orgs.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsGet = superuserProcedure.admin.orgs.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", input.slug)
|
||||
@@ -19,4 +17,5 @@ export const adminOrgsGet = os.admin.orgs.get
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return toOrgResponse(org);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* admin.orgs.list - List all organizations
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsList = os.admin.orgs.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const adminOrgsList = superuserProcedure.admin.orgs.list.handler(
|
||||
async ({ context }) => {
|
||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||
return orgs.map(toOrgResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toSiteResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsListSites =
|
||||
superuserProcedure.admin.orgs.listSites.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
const org = await context.db
|
||||
@@ -29,12 +28,11 @@ export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.execute();
|
||||
|
||||
return sites.map(toSiteResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
// Use transaction to prevent race condition on site creation
|
||||
@@ -70,12 +68,12 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsRemoveSite =
|
||||
superuserProcedure.admin.orgs.removeSite.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
const org = await context.db
|
||||
@@ -98,4 +96,5 @@ export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsUpdate = os.admin.orgs.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsUpdate = superuserProcedure.admin.orgs.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Check if there are actual updates to make
|
||||
@@ -49,4 +47,5 @@ export const adminOrgsUpdate = os.admin.orgs.update
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersConfirmEmail =
|
||||
superuserProcedure.admin.users.confirmEmail.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
@@ -23,4 +22,5 @@ export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersCreate = os.admin.users.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersCreate = superuserProcedure.admin.users.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { email, name, orgSlug, orgRole } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
@@ -62,4 +60,5 @@ export const adminUsersCreate = os.admin.users.create
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersGet = os.admin.users.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersGet = superuserProcedure.admin.users.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
@@ -19,4 +17,5 @@ export const adminUsersGet = os.admin.users.get
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return toUserResponse(user);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* admin.users.list - List all users
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersList = os.admin.users.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const adminUsersList = superuserProcedure.admin.users.list.handler(
|
||||
async ({ context }) => {
|
||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||
return users.map(toUserResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersUpdate = os.admin.users.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersUpdate = superuserProcedure.admin.users.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { email, isSuperuser } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
@@ -47,4 +45,5 @@ export const adminUsersUpdate = os.admin.users.update
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,15 @@ 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 };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
/**
|
||||
* Logout handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Revokes the current session by setting revoked_at to now()
|
||||
* - Clears the session cookie from the response
|
||||
*/
|
||||
export const logout = os.auth.logout
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const logout = authedProcedure.auth.logout.handler(
|
||||
async ({ context }) => {
|
||||
// Revoke the current session
|
||||
await context.db
|
||||
.updateTable("sessions")
|
||||
@@ -25,4 +24,5 @@ export const logout = os.auth.logout
|
||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,17 +10,16 @@
|
||||
* 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";
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const resendVerificationEmail =
|
||||
authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
|
||||
// Check if email is already verified
|
||||
if (context.user.emailVerifiedAt !== null) {
|
||||
// Email already verified, return early
|
||||
@@ -47,8 +46,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 };
|
||||
});
|
||||
|
||||
@@ -8,227 +8,22 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
LoginRequestContext,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
import { implement, ORPCError } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
loginRequestMiddleware,
|
||||
os,
|
||||
superuserMiddleware,
|
||||
} from "../middlewares/index.js";
|
||||
|
||||
/**
|
||||
* Base implementer with typed APIContext
|
||||
* All procedures should be derived from this
|
||||
*/
|
||||
export const os = implement(contract).$context<APIContext>();
|
||||
// Re-export middlewares and os
|
||||
export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
|
||||
|
||||
/**
|
||||
* Auth middleware - validates session/API token and adds user to context
|
||||
* Use with os.use(authMiddleware) to create authenticated procedures
|
||||
*/
|
||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Login request middleware - validates login request token from cookie
|
||||
*/
|
||||
export const loginRequestMiddleware = os.middleware(
|
||||
async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Read login request token from cookie
|
||||
const loginRequestToken = getCookie(
|
||||
reqHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
);
|
||||
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No login request found",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
.select([
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.expires_at",
|
||||
"users.email",
|
||||
"users.display_name",
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: result.user_id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
emailVerifiedAt: result.email_verified_at,
|
||||
isSuperuser: result.is_superuser,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
loginRequestId: Number(result.id),
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Superuser middleware - requires admin access (must be used after authMiddleware)
|
||||
*/
|
||||
export const superuserMiddleware = os.middleware(async ({ context, next }) => {
|
||||
// This middleware should be used after authMiddleware
|
||||
const ctx = context as AuthenticatedContext;
|
||||
if (!ctx.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
// Pre-configured procedures with middleware applied
|
||||
export const authedProcedure = os.use(authMiddleware);
|
||||
export const superuserProcedure = os.use(superuserMiddleware);
|
||||
export const loginRequestProcedure = os.use(loginRequestMiddleware);
|
||||
|
||||
// Type exports for use in procedure files
|
||||
export type { APIContext, AuthenticatedContext, LoginRequestContext };
|
||||
|
||||
7
apps/api-server/src/procedures/me/_base.ts
Normal file
7
apps/api-server/src/procedures/me/_base.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Base route for me procedures with auth middleware applied
|
||||
*/
|
||||
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
export const meRoute = authedProcedure.me;
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
hashToken,
|
||||
TOKEN_PREFIX,
|
||||
} from "../../utils/crypto.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/** Token expiration: 365 days */
|
||||
const TOKEN_EXPIRATION_DAYS = 365;
|
||||
@@ -18,9 +18,8 @@ const TOKEN_EXPIRATION_DAYS = 365;
|
||||
* List all API tokens for the current user
|
||||
* Returns token metadata (not the actual token values)
|
||||
*/
|
||||
export const listApiTokens = os.me.apiTokens.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listApiTokens = meRoute.apiTokens.list.handler(
|
||||
async ({ context }) => {
|
||||
const tokens = await context.db
|
||||
.selectFrom("api_tokens")
|
||||
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
|
||||
@@ -35,15 +34,15 @@ export const listApiTokens = os.me.apiTokens.list
|
||||
createdAt: token.created_at.toISOString(),
|
||||
expiresAt: token.expires_at.toISOString(),
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new API token
|
||||
* Requires superuser status and trusted session
|
||||
*/
|
||||
export const createApiToken = os.me.apiTokens.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const createApiToken = meRoute.apiTokens.create.handler(
|
||||
async ({ input, context }) => {
|
||||
// Require superuser status
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
@@ -85,17 +84,17 @@ export const createApiToken = os.me.apiTokens.create
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an API token
|
||||
*/
|
||||
export const deleteApiToken = os.me.apiTokens.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const deleteApiToken = meRoute.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();
|
||||
|
||||
@@ -106,4 +105,5 @@ export const deleteApiToken = os.me.apiTokens.delete
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* Get current user auth status
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const meAuthStatus = os.me.authStatus
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
@@ -38,4 +36,4 @@ export const meAuthStatus = os.me.authStatus
|
||||
},
|
||||
auth: context.auth,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||
import { verifyPassword } from "../../utils/password.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Delete account handler
|
||||
@@ -14,9 +14,7 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||
* - Clears session cookie
|
||||
*/
|
||||
export const meDelete = os.me.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
|
||||
const { password } = input;
|
||||
|
||||
// Fetch user with password hash
|
||||
@@ -49,4 +47,4 @@ export const meDelete = os.me.delete
|
||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
|
||||
/**
|
||||
@@ -13,9 +13,8 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const getDeviceInfo = os.me.devices.getInfo
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const getDeviceInfo = meRoute.devices.getInfo.handler(
|
||||
async ({ context }) => {
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
|
||||
const device = await context.db
|
||||
@@ -39,7 +38,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
||||
lastUsedAt: device.last_used_at,
|
||||
isTrusted: device.is_trusted,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Trust device handler
|
||||
@@ -48,9 +48,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const trustDevice = os.me.devices.trust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const trustDevice = meRoute.devices.trust.handler(
|
||||
async ({ input, context }) => {
|
||||
const { name } = input;
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
|
||||
@@ -66,16 +65,16 @@ export const trustDevice = os.me.devices.trust
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* List trusted devices handler
|
||||
* - Requires authentication
|
||||
* - Returns all trusted devices for the current user
|
||||
*/
|
||||
export const listTrustedDevices = os.me.devices.listTrusted
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listTrustedDevices = meRoute.devices.listTrusted.handler(
|
||||
async ({ context }) => {
|
||||
const devices = await context.db
|
||||
.selectFrom("user_devices")
|
||||
.selectAll()
|
||||
@@ -94,7 +93,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
||||
lastUsedAt: d.last_used_at,
|
||||
isTrusted: d.is_trusted,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Untrust device handler
|
||||
@@ -102,13 +102,12 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
||||
* - Marks device as untrusted by ID
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const untrustDevice = os.me.devices.untrust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const untrustDevice = meRoute.devices.untrust.handler(
|
||||
async ({ input, context }) => {
|
||||
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();
|
||||
|
||||
@@ -117,16 +116,16 @@ export const untrustDevice = os.me.devices.untrust
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke all trusted devices handler
|
||||
* - Requires authentication
|
||||
* - Marks all devices as untrusted
|
||||
*/
|
||||
export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const revokeAllTrustedDevices = meRoute.devices.revokeAll.handler(
|
||||
async ({ context }) => {
|
||||
await context.db
|
||||
.updateTable("user_devices")
|
||||
.set({ is_trusted: false })
|
||||
@@ -134,4 +133,5 @@ export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* Get current user profile
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const meGet = os.me.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const meGet = meRoute.get.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
@@ -35,4 +33,4 @@ export const meGet = os.me.get
|
||||
isSuperuser: user.is_superuser,
|
||||
hasPassword: user.password_hash !== null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List pending invites for the current user
|
||||
* Only returns invites where the user's email matches and email is verified
|
||||
*/
|
||||
export const listInvites = os.me.invites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
|
||||
// Only show invites if email is verified
|
||||
if (!context.user.emailVerifiedAt) {
|
||||
return [];
|
||||
@@ -52,15 +50,14 @@ export const listInvites = os.me.invites.list
|
||||
createdAt: i.created_at,
|
||||
expiresAt: i.expires_at,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a specific invite by ID
|
||||
* Only returns if the invite belongs to the current user's email
|
||||
*/
|
||||
export const getInvite = os.me.invites.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const getInvite = meRoute.invites.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Only show invite if email is verified
|
||||
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
|
||||
createdAt: invite.created_at,
|
||||
expiresAt: invite.expires_at,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept an invite by ID
|
||||
* Adds user to org and deletes the invite
|
||||
*/
|
||||
export const acceptInvite = os.me.invites.accept
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const acceptInvite = meRoute.invites.accept.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Only allow accepting if email is verified
|
||||
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Decline an invite
|
||||
* Deletes the invite if it belongs to the current user's email
|
||||
*/
|
||||
export const declineInvite = os.me.invites.decline
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const declineInvite = meRoute.invites.decline.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Delete the invite only if it matches user's email
|
||||
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List passkeys handler
|
||||
* - Requires authentication
|
||||
* - Returns all passkeys for the current user
|
||||
*/
|
||||
export const listPasskeys = os.me.passkeys.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listPasskeys = meRoute.passkeys.list.handler(
|
||||
async ({ context }) => {
|
||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||
|
||||
return passkeys.map((p) => ({
|
||||
@@ -22,7 +21,8 @@ export const listPasskeys = os.me.passkeys.list
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: p.lastUsedAt,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Rename passkey handler
|
||||
@@ -30,15 +30,14 @@ export const listPasskeys = os.me.passkeys.list
|
||||
* - Updates passkey name
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
*/
|
||||
export const renamePasskey = os.me.passkeys.rename
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const renamePasskey = meRoute.passkeys.rename.handler(
|
||||
async ({ input, context }) => {
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("passkeys")
|
||||
.set({ name })
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("id", "=", passkeyId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -47,7 +46,8 @@ export const renamePasskey = os.me.passkeys.rename
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete passkey handler
|
||||
@@ -57,9 +57,8 @@ export const renamePasskey = os.me.passkeys.rename
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||
*/
|
||||
export const deletePasskey = os.me.passkeys.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const deletePasskey = meRoute.passkeys.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { passkeyId } = input;
|
||||
|
||||
// Use transaction to prevent race condition when checking last passkey
|
||||
@@ -86,7 +85,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();
|
||||
|
||||
@@ -96,4 +95,5 @@ export const deletePasskey = os.me.passkeys.delete
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List sessions handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Returns all sessions for the current user
|
||||
* - Includes isCurrent flag to identify active session
|
||||
*/
|
||||
export const listSessions = os.me.sessions.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listSessions = meRoute.sessions.list.handler(
|
||||
async ({ context }) => {
|
||||
const sessions = await context.db
|
||||
.selectFrom("sessions")
|
||||
.selectAll()
|
||||
@@ -33,7 +32,8 @@ export const listSessions = os.me.sessions.list
|
||||
isCurrent: s.id === context.session.id,
|
||||
revokedAt: s.revoked_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke session handler
|
||||
@@ -42,13 +42,12 @@ export const listSessions = os.me.sessions.list
|
||||
* @throws NOT_FOUND if session doesn't exist
|
||||
* @throws BAD_REQUEST if trying to revoke current session
|
||||
*/
|
||||
export const revokeSession = os.me.sessions.revoke
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const revokeSession = meRoute.sessions.revoke.handler(
|
||||
async ({ input, context }) => {
|
||||
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 +56,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();
|
||||
@@ -67,16 +66,16 @@ export const revokeSession = os.me.sessions.revoke
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke all sessions handler
|
||||
* - Requires authentication
|
||||
* - Revokes all sessions except current
|
||||
*/
|
||||
export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const revokeAllSessions = meRoute.sessions.revokeAll.handler(
|
||||
async ({ context }) => {
|
||||
// Revoke all sessions except current
|
||||
await context.db
|
||||
.updateTable("sessions")
|
||||
@@ -87,4 +86,5 @@ export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
validatePassword,
|
||||
verifyPassword,
|
||||
} from "../../utils/password.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Set password handler
|
||||
@@ -16,9 +16,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - If user has existing password, currentPassword is required
|
||||
* - Validates new password strength using zxcvbn
|
||||
*/
|
||||
export const setPassword = os.me.setPassword
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const setPassword = meRoute.setPassword.handler(
|
||||
async ({ input, context }) => {
|
||||
const { currentPassword, newPassword } = input;
|
||||
|
||||
// Fetch current password hash
|
||||
@@ -60,4 +59,5 @@ export const setPassword = os.me.setPassword
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Setup user profile (initial setup after signup)
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const setupProfile = os.me.setupProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const setupProfile = meRoute.setupProfile.handler(
|
||||
async ({ input, context }) => {
|
||||
const { displayName, fullName, phoneNumber } = input;
|
||||
|
||||
await context.db
|
||||
@@ -21,4 +20,5 @@ export const setupProfile = os.me.setupProfile
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { ProfileUpdate } from "./helpers.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Update profile handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
||||
* - Automatically sets updated_at timestamp
|
||||
*/
|
||||
export const updateProfile = os.me.updateProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const updateProfile = meRoute.updateProfile.handler(
|
||||
async ({ input, context }) => {
|
||||
const updates: Partial<ProfileUpdate> = {};
|
||||
if (input.displayName !== undefined) {
|
||||
updates.display_name = input.displayName;
|
||||
@@ -38,4 +37,5 @@ export const updateProfile = os.me.updateProfile
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all orgs the current user is a member of
|
||||
*/
|
||||
export const orgsList = os.orgs.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const orgsList = authedProcedure.orgs.list.handler(
|
||||
async ({ context }) => {
|
||||
const orgs = await context.db
|
||||
.selectFrom("org_members")
|
||||
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||
@@ -33,15 +32,15 @@ export const orgsList = os.orgs.list
|
||||
logoUrl: o.logo_url,
|
||||
createdAt: o.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new org
|
||||
* The creating user becomes the owner
|
||||
*/
|
||||
export const orgsCreate = os.orgs.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsCreate = authedProcedure.orgs.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName } = input;
|
||||
|
||||
try {
|
||||
@@ -75,15 +74,15 @@ export const orgsCreate = os.orgs.create
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single org by slug
|
||||
* Requires membership
|
||||
*/
|
||||
export const orgsGet = os.orgs.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsGet = authedProcedure.orgs.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -97,4 +96,5 @@ export const orgsGet = os.orgs.get
|
||||
logoUrl: org.logoUrl,
|
||||
createdAt: org.createdAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,25 +5,11 @@
|
||||
|
||||
import type { DB, OrgRole } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { OrgInfo, OrgMembership } from "../../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
/** Org info returned from lookup */
|
||||
export interface OrgInfo {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** User's membership in an org */
|
||||
export interface OrgMembership {
|
||||
id: number;
|
||||
role: OrgRole;
|
||||
createdAt: Date;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { OrgInfo, OrgMembership };
|
||||
|
||||
// ===== Role Hierarchy =====
|
||||
|
||||
@@ -115,10 +101,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,22 +3,21 @@
|
||||
*/
|
||||
|
||||
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 { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List pending invites for an org
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const invitesList = os.orgs.invites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesList = authedProcedure.orgs.invites.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify admin+ role
|
||||
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
|
||||
createdAt: i.created_at,
|
||||
expiresAt: i.expires_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create an invite for a new member
|
||||
* Requires admin or owner role
|
||||
* Only owners can invite new owners (privilege escalation prevention)
|
||||
*/
|
||||
export const invitesCreate = os.orgs.invites.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesCreate = authedProcedure.orgs.invites.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, email: rawEmail, role } = input;
|
||||
const email = rawEmail.toLowerCase();
|
||||
|
||||
@@ -122,18 +121,28 @@ 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 };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a pending invite
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const invitesCancel = os.orgs.invites.cancel
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, inviteId } = input;
|
||||
|
||||
// Lookup org and verify admin+ role
|
||||
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept an invitation
|
||||
* Token-based lookup, requires auth but no org membership
|
||||
* Handles race condition if user is already a member
|
||||
*/
|
||||
export const invitesAccept = os.orgs.invites.accept
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
|
||||
async ({ input, context }) => {
|
||||
const { token } = input;
|
||||
|
||||
// Find the invite by token (must not be expired)
|
||||
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
* Update org details
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const orgsUpdate = os.orgs.update
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsUpdate = authedProcedure.orgs.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Lookup org and verify membership with admin+ role
|
||||
@@ -41,16 +40,16 @@ export const orgsUpdate = os.orgs.update
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an org
|
||||
* Requires owner role
|
||||
* FK CASCADE handles deleting members, invites, and sites
|
||||
*/
|
||||
export const orgsDelete = os.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsDelete = authedProcedure.orgs.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify ownership
|
||||
@@ -61,16 +60,16 @@ export const orgsDelete = os.orgs.delete
|
||||
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Leave an org
|
||||
* Cannot leave if you're the only owner
|
||||
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||
*/
|
||||
export const orgsLeave = os.orgs.leave
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsLeave = authedProcedure.orgs.leave.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and get membership
|
||||
@@ -98,4 +97,5 @@ export const orgsLeave = os.orgs.leave
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
* List all members of an org
|
||||
* Any member can view the member list
|
||||
*/
|
||||
export const membersList = os.orgs.members.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersList = authedProcedure.orgs.members.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -48,21 +47,26 @@ export const membersList = os.orgs.members.list
|
||||
role: m.role,
|
||||
createdAt: m.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
* Only owners can change roles
|
||||
* Uses transaction to prevent race condition when demoting owners
|
||||
*/
|
||||
export const membersUpdateRole = os.orgs.members.updateRole
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersUpdateRole =
|
||||
authedProcedure.orgs.members.updateRole.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, userId, role: newRole } = input;
|
||||
|
||||
// Lookup org and verify ownership
|
||||
const org = await lookupOrgBySlug(context.db, slug);
|
||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||
const membership = await getMembership(
|
||||
context.db,
|
||||
org.id,
|
||||
context.user.id,
|
||||
);
|
||||
requireRole(membership, "owner");
|
||||
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
@@ -97,16 +101,16 @@ export const membersUpdateRole = os.orgs.members.updateRole
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove a member from an org
|
||||
* Owners can remove anyone, admins can only remove members
|
||||
* Uses transaction to prevent race condition when removing owners
|
||||
*/
|
||||
export const membersRemove = os.orgs.members.remove
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersRemove = authedProcedure.orgs.members.remove.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, userId } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
* Org sites procedures - list
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all sites for an org
|
||||
* Any member can view the site list
|
||||
*/
|
||||
export const sitesList = os.orgs.sites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const sitesList = authedProcedure.orgs.sites.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -31,4 +30,5 @@ export const sitesList = os.orgs.sites.list
|
||||
domain: s.domain,
|
||||
createdAt: s.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,75 +0,0 @@
|
||||
/**
|
||||
* Authentication utilities for token handling
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import { hashToken } from "./crypto.js";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
email: string;
|
||||
isSuperuser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request using session token or API key
|
||||
* Returns the authenticated user or null if not authenticated
|
||||
*/
|
||||
export const authenticateRequest = async (
|
||||
db: Kysely<Database>,
|
||||
sessionToken?: string,
|
||||
apiKey?: string,
|
||||
): Promise<AuthenticatedUser | null> => {
|
||||
// Try session cookie first, then API key
|
||||
const token = sessionToken ?? apiKey;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
|
||||
// Check sessions table
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.innerJoin("users", "users.id", "sessions.user_id")
|
||||
.where("sessions.token_hash", "=", tokenHash)
|
||||
.where("sessions.expires_at", ">", new Date())
|
||||
.where("sessions.revoked_at", "is", null)
|
||||
.select(["users.id", "users.email", "users.is_superuser"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
id: session.id,
|
||||
email: session.email,
|
||||
isSuperuser: session.is_superuser,
|
||||
};
|
||||
}
|
||||
|
||||
// Check API tokens table
|
||||
const apiToken = await db
|
||||
.selectFrom("api_tokens")
|
||||
.innerJoin("users", "users.id", "api_tokens.user_id")
|
||||
.where("api_tokens.token_hash", "=", tokenHash)
|
||||
.where("api_tokens.expires_at", ">", new Date())
|
||||
.select(["users.id", "users.email", "users.is_superuser"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (apiToken) {
|
||||
// Update last_used_at
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
id: apiToken.id,
|
||||
email: apiToken.email,
|
||||
isSuperuser: apiToken.is_superuser,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -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,21 @@ 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 +99,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();
|
||||
@@ -45,7 +44,6 @@ function completions(
|
||||
_flags: Record<string, never>,
|
||||
shell: Shell,
|
||||
): void {
|
||||
// biome-ignore lint/nursery/noUnnecessaryConditions: switch on union type is valid
|
||||
switch (shell) {
|
||||
case "bash":
|
||||
console.log("To enable bash completions for reviq, run:\n");
|
||||
|
||||
@@ -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,8 @@ 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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user