Compare commits
50 Commits
fix-export
...
1f6d5a4a9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
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";
|
import { z } from "zod";
|
||||||
: fixed: |
|
: fixed: |
|
||||||
import * as z from "zod"
|
import * as z from "zod";
|
||||||
labels:
|
labels:
|
||||||
- source: import { z } from "zod";
|
- source: import { z } from "zod";
|
||||||
style: primary
|
style: primary
|
||||||
@@ -12,7 +12,7 @@ snapshots:
|
|||||||
? |
|
? |
|
||||||
import { z, ZodError } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
: fixed: |
|
: fixed: |
|
||||||
import * as z from "zod"
|
import * as z from "zod";
|
||||||
labels:
|
labels:
|
||||||
- source: import { z, ZodError } from "zod";
|
- source: import { z, ZodError } from "zod";
|
||||||
style: primary
|
style: primary
|
||||||
|
|||||||
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
|
# Claude Code Notes
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Use `bun run test:cov` to run all tests with coverage. This runs both unit tests and e2e tests that require a database connection.
|
||||||
|
|
||||||
|
- `bun run test:cov` - Run all tests with coverage (preferred)
|
||||||
|
- `bun run test:unit:cov` - Run only unit tests with coverage (no database required)
|
||||||
|
|
||||||
|
## Database Scripts
|
||||||
|
|
||||||
|
Use the wrapper scripts instead of running dbmate directly:
|
||||||
|
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
|
||||||
|
- `./scripts/db-migrate` - Run migrations and dump clean schema
|
||||||
|
|
||||||
|
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
|
||||||
|
|
||||||
## Development Server
|
## Development Server
|
||||||
|
|
||||||
Before starting the dev server, check if it's already running:
|
Before starting the dev server, check if it's already running:
|
||||||
@@ -14,10 +29,57 @@ This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
|
|||||||
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
||||||
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
||||||
|
|
||||||
## macOS sed Syntax
|
## sed Syntax (GNU coreutils)
|
||||||
|
|
||||||
macOS uses BSD sed which differs from GNU sed:
|
This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
|
||||||
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
|
- In-place edit: `sed -i 's/old/new/g' file`
|
||||||
- GNU sed (Linux): `sed -i 's/old/new/g' file`
|
- Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|g' file`
|
||||||
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
|
- For multiple files: `for f in *.txt; do sed -i 's/old/new/g' "$f"; done`
|
||||||
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
|
- Do NOT use BSD sed syntax (`sed -i ''`) - we have GNU sed available
|
||||||
|
|
||||||
|
## SvelteKit resolve() Usage
|
||||||
|
|
||||||
|
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
|
||||||
|
|
||||||
|
### Static routes - use resolve() directly
|
||||||
|
```svelte
|
||||||
|
href={resolve("/auth/login")}
|
||||||
|
href={resolve("/dashboard")}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic routes - use two-argument form
|
||||||
|
```svelte
|
||||||
|
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
|
||||||
|
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login redirects - use gotoLogin helper
|
||||||
|
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
|
||||||
|
```typescript
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
|
gotoLogin(page.url.pathname);
|
||||||
|
```
|
||||||
|
This helper uses resolve() internally and handles the query string correctly.
|
||||||
|
|
||||||
|
### Navigation arrays - use `as const` with route patterns
|
||||||
|
For type-safe navigation arrays, define routes as literal strings with `as const`:
|
||||||
|
```typescript
|
||||||
|
const navItems = [
|
||||||
|
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
|
||||||
|
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
|
||||||
|
] as const;
|
||||||
|
```
|
||||||
|
Then use resolve with params:
|
||||||
|
```svelte
|
||||||
|
{#each navItems as item (item.route)}
|
||||||
|
<a href={resolve(item.route, { slug })}>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime strings - skip resolve, use eslint-disable
|
||||||
|
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
|
||||||
|
```typescript
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto(redirectUrl);
|
||||||
|
```
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -26,9 +26,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
|
|||||||
|
|
||||||
### Shared Packages
|
### Shared Packages
|
||||||
- `@reviq/api-contract` - Shared API contract (oRPC)
|
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||||
|
- `@reviq/common` - Shared utilities for frontend and backend
|
||||||
- `@reviq/db` - Database client and queries
|
- `@reviq/db` - Database client and queries
|
||||||
- `@reviq/db-schema` - Database schema and codegen
|
- `@reviq/db-schema` - Database schema and codegen
|
||||||
- `@reviq/utils` - Shared utilities
|
- `@reviq/frontend-utils` - Frontend-specific utilities
|
||||||
|
- `@reviq/server-utils` - Server/CLI utilities
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -40,10 +42,12 @@ publisher-dashboard/
|
|||||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── api-contract/ # Shared oRPC contract
|
│ ├── api-contract/ # Shared oRPC contract
|
||||||
|
│ ├── common/ # Shared utilities (frontend + backend)
|
||||||
│ ├── db/ # Database client
|
│ ├── db/ # Database client
|
||||||
│ ├── db-schema/ # DB schema & codegen
|
│ ├── db-schema/ # DB schema & codegen
|
||||||
│ ├── testing/ # Test utilities
|
│ ├── frontend-utils/ # Frontend utilities
|
||||||
│ └── utils/ # Shared utilities
|
│ ├── server-utils/ # Server/CLI utilities
|
||||||
|
│ └── testing/ # Test utilities
|
||||||
└── db/ # Database migrations
|
└── db/ # Database migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -109,8 +113,13 @@ bun run dev
|
|||||||
| `bun run typecheck` | Run TypeScript type checking |
|
| `bun run typecheck` | Run TypeScript type checking |
|
||||||
| `bun run lint` | Run Biome and ESLint |
|
| `bun run lint` | Run Biome and ESLint |
|
||||||
| `bun run lint:fix` | Fix linting issues |
|
| `bun run lint:fix` | Fix linting issues |
|
||||||
| `bun run test` | Run tests |
|
| `bun run test` | Run all tests (requires database) |
|
||||||
|
| `bun run test:unit` | Run unit tests only (no database required) |
|
||||||
|
| `bun run test:cov` | Run all tests with coverage report |
|
||||||
|
| `bun run test:unit:cov` | Run unit tests with coverage (no database) |
|
||||||
| `bun run db:codegen` | Generate database types |
|
| `bun run db:codegen` | Generate database types |
|
||||||
|
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
|
||||||
|
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,17 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
|
"test": "bun test src/ --no-parallel"
|
||||||
"test:unit": "bun test src/__tests__/unit",
|
|
||||||
"test": "bun test --coverage src/utils"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-durationformat": "^0.9.2",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@orpc/experimental-pino": "^1.13.2",
|
"@orpc/experimental-pino": "^1.13.2",
|
||||||
"@orpc/server": "^1.13.2",
|
"@orpc/server": "^1.13.2",
|
||||||
"@reviq/api-contract": "workspace:*",
|
"@reviq/api-contract": "workspace:*",
|
||||||
"@reviq/db": "workspace:*",
|
"@reviq/db": "workspace:*",
|
||||||
"@reviq/db-schema": "workspace:*",
|
"@reviq/db-schema": "workspace:*",
|
||||||
"@reviq/utils": "workspace:*",
|
"@reviq/emails": "workspace:*",
|
||||||
|
"@reviq/server-utils": "workspace:*",
|
||||||
"@scure/base": "^2.0.0",
|
"@scure/base": "^2.0.0",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@simplewebauthn/types": "^12.0.0",
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
@@ -34,12 +32,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@reviq/test-helpers": "workspace:*",
|
||||||
"@reviq/virtual-authenticator": "workspace:*",
|
"@reviq/virtual-authenticator": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
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
File diff suppressed because it is too large
Load Diff
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
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ export const getAllowedOrigins = (): string[] => {
|
|||||||
|
|
||||||
// Default to localhost origins for development
|
// Default to localhost origins for development
|
||||||
return [
|
return [
|
||||||
`http://localhost:${String(DEFAULT_PORT)}`,
|
`http://localhost:${DEFAULT_PORT.toString()}`,
|
||||||
"http://localhost:6827",
|
"http://localhost:6827",
|
||||||
"http://localhost:6828",
|
"http://localhost:6828",
|
||||||
];
|
];
|
||||||
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
|
|||||||
/** Base URL for generating email links */
|
/** Base URL for generating email links */
|
||||||
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
|
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
|
||||||
|
|
||||||
/** Dev mode: log emails instead of sending (default: true) */
|
/** Postmark API key (optional - uses logging client if not set) */
|
||||||
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
|
|
||||||
|
|
||||||
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
|
|
||||||
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
|
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
|
||||||
|
|
||||||
// ===== Token Expiration Times =====
|
// ===== Token Expiration Times =====
|
||||||
|
|||||||
@@ -3,8 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Database } from "@reviq/db-schema";
|
import type { Database } from "@reviq/db-schema";
|
||||||
|
import type { EmailClient } from "@reviq/emails";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email configuration for the API
|
||||||
|
*/
|
||||||
|
export interface EmailConfig {
|
||||||
|
client: EmailClient;
|
||||||
|
fromAddress: string;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base API context available to all handlers
|
* Base API context available to all handlers
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +33,8 @@ export interface APIContext {
|
|||||||
resHeaders: Headers;
|
resHeaders: Headers;
|
||||||
/** Client IP address from direct connection (fallback when no proxy headers) */
|
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||||
clientIP?: string | null;
|
clientIP?: string | null;
|
||||||
|
/** Email client and configuration */
|
||||||
|
email: EmailConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
|
|||||||
/** User with superuser privileges */
|
/** User with superuser privileges */
|
||||||
user: SessionUser & { isSuperuser: true };
|
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 { LoggingHandlerPlugin } from "@orpc/experimental-pino";
|
||||||
import { RPCHandler } from "@orpc/server/fetch";
|
import { RPCHandler } from "@orpc/server/fetch";
|
||||||
import { createDb } from "@reviq/db";
|
import { createDb } from "@reviq/db";
|
||||||
|
import { createLoggingEmailClient, createPostmarkClient } from "@reviq/emails";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import {
|
import {
|
||||||
|
BASE_URL,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
DEFAULT_RP_NAME,
|
DEFAULT_RP_NAME,
|
||||||
|
EMAIL_FROM,
|
||||||
getAllowedOrigins,
|
getAllowedOrigins,
|
||||||
|
POSTMARK_API_KEY,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
@@ -24,6 +28,16 @@ if (!databaseUrl) {
|
|||||||
throw new Error("DATABASE_URL environment variable is required");
|
throw new Error("DATABASE_URL environment variable is required");
|
||||||
}
|
}
|
||||||
const db = createDb(databaseUrl);
|
const db = createDb(databaseUrl);
|
||||||
|
|
||||||
|
// Create email client - use Postmark if API key is set, otherwise log to console
|
||||||
|
const emailClient = POSTMARK_API_KEY
|
||||||
|
? createPostmarkClient(POSTMARK_API_KEY)
|
||||||
|
: createLoggingEmailClient();
|
||||||
|
|
||||||
|
if (!POSTMARK_API_KEY) {
|
||||||
|
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
|
||||||
|
}
|
||||||
|
|
||||||
const handler = new RPCHandler(router, {
|
const handler = new RPCHandler(router, {
|
||||||
plugins: [
|
plugins: [
|
||||||
new LoggingHandlerPlugin({
|
new LoggingHandlerPlugin({
|
||||||
@@ -45,7 +59,7 @@ Bun.serve({
|
|||||||
if (url.pathname.startsWith("/api/v1/rpc")) {
|
if (url.pathname.startsWith("/api/v1/rpc")) {
|
||||||
// Build context for the request
|
// Build context for the request
|
||||||
const origin =
|
const origin =
|
||||||
request.headers.get("origin") ?? `http://localhost:${String(port)}`;
|
request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
|
||||||
|
|
||||||
// Create response headers for setting cookies
|
// Create response headers for setting cookies
|
||||||
const resHeaders = new Headers();
|
const resHeaders = new Headers();
|
||||||
@@ -62,6 +76,11 @@ Bun.serve({
|
|||||||
reqHeaders: request.headers,
|
reqHeaders: request.headers,
|
||||||
resHeaders,
|
resHeaders,
|
||||||
clientIP,
|
clientIP,
|
||||||
|
email: {
|
||||||
|
client: emailClient,
|
||||||
|
fromAddress: EMAIL_FROM,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { response } = await handler.handle(request, {
|
const { response } = await handler.handle(request, {
|
||||||
|
|||||||
@@ -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,49 +3,49 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
export const adminAuthCompleteLogin =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.auth.completeLogin.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const email = input.email.toLowerCase();
|
||||||
const email = input.email.toLowerCase();
|
|
||||||
|
|
||||||
// First check if any login request exists for this email
|
// First check if any login request exists for this email
|
||||||
const anyRequest = await context.db
|
const anyRequest = await context.db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.where("email", "=", email)
|
.where("email", "=", email)
|
||||||
.orderBy("created_at", "desc")
|
.orderBy("created_at", "desc")
|
||||||
.select(["id", "completed_at", "expires_at"])
|
.select(["id", "completed_at", "expires_at"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!anyRequest) {
|
if (!anyRequest) {
|
||||||
throw new ORPCError("NOT_FOUND", {
|
throw new ORPCError("NOT_FOUND", {
|
||||||
message: `No login request found for ${email}`,
|
message: `No login request found for ${email}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already completed
|
// Check if already completed
|
||||||
if (anyRequest.completed_at) {
|
if (anyRequest.completed_at) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Login request already completed",
|
message: "Login request already completed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
// Check if expired
|
||||||
if (new Date(anyRequest.expires_at) < new Date()) {
|
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"Login request expired (15 min limit). Start a new login flow.",
|
"Login request expired (15 min limit). Start a new login flow.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete the login request
|
// Complete the login request
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", anyRequest.id)
|
.where("id", "=", anyRequest.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminOrgsCreate = os.admin.orgs.create
|
export const adminOrgsCreate = superuserProcedure.admin.orgs.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName, ownerEmail } = input;
|
const { slug, displayName, ownerEmail } = input;
|
||||||
|
|
||||||
// Find owner user by email (outside transaction - read-only)
|
// Find owner user by email (outside transaction - read-only)
|
||||||
@@ -55,4 +53,5 @@ export const adminOrgsCreate = os.admin.orgs.create
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { slug };
|
return { slug };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminOrgsDelete = os.admin.orgs.delete
|
export const adminOrgsDelete = superuserProcedure.admin.orgs.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Delete org and related records in transaction
|
// Delete org and related records in transaction
|
||||||
@@ -35,4 +33,5 @@ export const adminOrgsDelete = os.admin.orgs.delete
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toOrgResponse } from "../helpers.js";
|
import { toOrgResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsGet = os.admin.orgs.get
|
export const adminOrgsGet = superuserProcedure.admin.orgs.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", input.slug)
|
.where("slug", "=", input.slug)
|
||||||
@@ -19,4 +17,5 @@ export const adminOrgsGet = os.admin.orgs.get
|
|||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
return toOrgResponse(org);
|
return toOrgResponse(org);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
* admin.orgs.list - List all organizations
|
* admin.orgs.list - List all organizations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toOrgResponse } from "../helpers.js";
|
import { toOrgResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsList = os.admin.orgs.list
|
export const adminOrgsList = superuserProcedure.admin.orgs.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||||
return orgs.map(toOrgResponse);
|
return orgs.map(toOrgResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,37 +4,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toSiteResponse } from "../helpers.js";
|
import { toSiteResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
export const adminOrgsListSites =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.orgs.listSites.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const { slug } = input;
|
||||||
const { slug } = input;
|
|
||||||
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", slug)
|
.where("slug", "=", slug)
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sites = await context.db
|
const sites = await context.db
|
||||||
.selectFrom("org_sites")
|
.selectFrom("org_sites")
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return sites.map(toSiteResponse);
|
return sites.map(toSiteResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, domain } = input;
|
const { slug, domain } = input;
|
||||||
|
|
||||||
// Use transaction to prevent race condition on site creation
|
// Use transaction to prevent race condition on site creation
|
||||||
@@ -70,32 +68,33 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
export const adminOrgsRemoveSite =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.orgs.removeSite.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const { slug, domain } = input;
|
||||||
const { slug, domain } = input;
|
|
||||||
|
|
||||||
const org = await context.db
|
const org = await context.db
|
||||||
.selectFrom("orgs")
|
.selectFrom("orgs")
|
||||||
.where("slug", "=", slug)
|
.where("slug", "=", slug)
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.deleteFrom("org_sites")
|
.deleteFrom("org_sites")
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.where("domain", "=", domain)
|
.where("domain", "=", domain)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminOrgsUpdate = os.admin.orgs.update
|
export const adminOrgsUpdate = superuserProcedure.admin.orgs.update.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName, logoUrl } = input;
|
const { slug, displayName, logoUrl } = input;
|
||||||
|
|
||||||
// Check if there are actual updates to make
|
// Check if there are actual updates to make
|
||||||
@@ -49,4 +47,5 @@ export const adminOrgsUpdate = os.admin.orgs.update
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
export const adminUsersConfirmEmail =
|
||||||
.use(authMiddleware)
|
superuserProcedure.admin.users.confirmEmail.handler(
|
||||||
.use(superuserMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
const result = await context.db
|
||||||
const result = await context.db
|
.updateTable("users")
|
||||||
.updateTable("users")
|
.set({
|
||||||
.set({
|
email_verified_at: new Date(),
|
||||||
email_verified_at: new Date(),
|
updated_at: new Date(),
|
||||||
updated_at: new Date(),
|
})
|
||||||
})
|
.where("email", "=", input.email.toLowerCase())
|
||||||
.where("email", "=", input.email.toLowerCase())
|
.executeTakeFirst();
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersCreate = os.admin.users.create
|
export const adminUsersCreate = superuserProcedure.admin.users.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { email, name, orgSlug, orgRole } = input;
|
const { email, name, orgSlug, orgRole } = input;
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
@@ -62,4 +60,5 @@ export const adminUsersCreate = os.admin.users.create
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toUserResponse } from "../helpers.js";
|
import { toUserResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminUsersGet = os.admin.users.get
|
export const adminUsersGet = superuserProcedure.admin.users.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const user = await context.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.where("email", "=", input.email.toLowerCase())
|
.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" });
|
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||||
}
|
}
|
||||||
return toUserResponse(user);
|
return toUserResponse(user);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
* admin.users.list - List all users
|
* admin.users.list - List all users
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
import { toUserResponse } from "../helpers.js";
|
import { toUserResponse } from "../helpers.js";
|
||||||
|
|
||||||
export const adminUsersList = os.admin.users.list
|
export const adminUsersList = superuserProcedure.admin.users.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||||
return users.map(toUserResponse);
|
return users.map(toUserResponse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
import { superuserProcedure } from "../../base.js";
|
||||||
|
|
||||||
export const adminUsersUpdate = os.admin.users.update
|
export const adminUsersUpdate = superuserProcedure.admin.users.update.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.use(superuserMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { email, isSuperuser } = input;
|
const { email, isSuperuser } = input;
|
||||||
const normalizedEmail = email.toLowerCase();
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
@@ -47,4 +45,5 @@ export const adminUsersUpdate = os.admin.users.update
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
* This prevents attackers from determining which emails are registered
|
* This prevents attackers from determining which emails are registered
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { withTransaction } from "@reviq/db";
|
||||||
|
import { sendPasswordResetEmail } from "@reviq/emails";
|
||||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||||
import {
|
import {
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
|
||||||
import { os } from "../base.js";
|
import { os } from "../base.js";
|
||||||
|
|
||||||
export const forgotPassword = os.auth.forgotPassword.handler(
|
export const forgotPassword = os.auth.forgotPassword.handler(
|
||||||
@@ -30,29 +31,39 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
|||||||
|
|
||||||
// If user exists, create password reset token and send email
|
// If user exists, create password reset token and send email
|
||||||
if (user) {
|
if (user) {
|
||||||
// Delete any existing password reset tokens for this user (security measure)
|
|
||||||
await context.db
|
|
||||||
.deleteFrom("password_resets")
|
|
||||||
.where("user_id", "=", user.id)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Generate secure base58 token
|
// Generate secure base58 token
|
||||||
const token = generateSecureBase58Token();
|
const token = generateSecureBase58Token();
|
||||||
|
|
||||||
// Create password reset record with 1 hour expiry
|
// Create password reset record with 1 hour expiry
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
||||||
|
|
||||||
await context.db
|
// Delete old tokens and insert new one in transaction
|
||||||
.insertInto("password_resets")
|
await withTransaction(context.db, async (trx) => {
|
||||||
.values({
|
// Delete any existing password reset tokens for this user (security measure)
|
||||||
user_id: user.id,
|
await trx
|
||||||
token,
|
.deleteFrom("password_resets")
|
||||||
expires_at: expiresAt,
|
.where("user_id", "=", user.id)
|
||||||
})
|
.execute();
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send password reset email (stubbed)
|
await trx
|
||||||
await sendPasswordResetEmail(user.email, token);
|
.insertInto("password_resets")
|
||||||
|
.values({
|
||||||
|
user_id: user.id,
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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)
|
// Always return success (anti-enumeration)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { withTransaction } from "@reviq/db";
|
||||||
import {
|
import {
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
COOKIE_OPTIONS,
|
COOKIE_OPTIONS,
|
||||||
@@ -89,37 +90,41 @@ export const loginIfRequestIsCompleted =
|
|||||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||||
const userAgent = getUserAgent(context.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
// Upsert user device
|
// Create session in transaction (atomic: device upsert + session + login_request delete)
|
||||||
const deviceId = await upsertUserDevice(
|
const { session, deviceTrusted } = await withTransaction(
|
||||||
context.db,
|
context.db,
|
||||||
userId,
|
async (trx) => {
|
||||||
deviceFingerprint,
|
// Upsert user device
|
||||||
geo,
|
const deviceId = await upsertUserDevice(
|
||||||
userAgent,
|
trx,
|
||||||
|
userId,
|
||||||
|
deviceFingerprint,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if device is already trusted
|
||||||
|
const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
|
||||||
|
|
||||||
|
// Create session with trusted mode = true (email-confirmed login)
|
||||||
|
const newSession = await createSession(trx, {
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
trustedMode: true,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the login request (it's been consumed)
|
||||||
|
await trx
|
||||||
|
.deleteFrom("login_requests")
|
||||||
|
.where("id", "=", loginRequest.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return { session: newSession, deviceTrusted: trusted };
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if device is already trusted
|
|
||||||
const deviceTrusted = await isDeviceTrusted(
|
|
||||||
context.db,
|
|
||||||
userId,
|
|
||||||
deviceFingerprint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create session with trusted mode = true (email-confirmed login)
|
|
||||||
const session = await createSession(context.db, {
|
|
||||||
userId,
|
|
||||||
deviceId,
|
|
||||||
trustedMode: true,
|
|
||||||
geo,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete the login request (it's been consumed)
|
|
||||||
await context.db
|
|
||||||
.deleteFrom("login_requests")
|
|
||||||
.where("id", "=", loginRequest.id)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
context.resHeaders,
|
context.resHeaders,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { sendLoginConfirmationEmail } from "@reviq/emails";
|
||||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||||
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
|
||||||
import { verifyPassword } from "../../utils/password.js";
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
import { isDeviceTrusted } from "../../utils/session.js";
|
import { isDeviceTrusted } from "../../utils/session.js";
|
||||||
import { os } from "../base.js";
|
import { os } from "../base.js";
|
||||||
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
} else {
|
} else {
|
||||||
// Device is untrusted - send confirmation email with existing token
|
// Device is untrusted - send confirmation email with existing token
|
||||||
// The same base58 token is used for both cookie lookup and email confirmation
|
// The same base58 token is used for both cookie lookup and email confirmation
|
||||||
await sendLoginConfirmationEmail(result.email, result.token);
|
await sendLoginConfirmationEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email: result.email,
|
||||||
|
token: result.token,
|
||||||
|
expiryMinutes: 15,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout handler
|
* Logout handler
|
||||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Revokes the current session by setting revoked_at to now()
|
* - Revokes the current session by setting revoked_at to now()
|
||||||
* - Clears the session cookie from the response
|
* - Clears the session cookie from the response
|
||||||
*/
|
*/
|
||||||
export const logout = os.auth.logout
|
export const logout = authedProcedure.auth.logout.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
// Revoke the current session
|
// Revoke the current session
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
@@ -25,4 +24,5 @@ export const logout = os.auth.logout
|
|||||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,17 +10,16 @@
|
|||||||
* 5. Send verification email (stubbed)
|
* 5. Send verification email (stubbed)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { sendVerificationEmail } from "@reviq/emails";
|
||||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||||
import {
|
import {
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendVerificationEmail } from "../../utils/email.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
|
||||||
|
|
||||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
export const resendVerificationEmail =
|
||||||
.use(authMiddleware)
|
authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
// Check if email is already verified
|
// Check if email is already verified
|
||||||
if (context.user.emailVerifiedAt !== null) {
|
if (context.user.emailVerifiedAt !== null) {
|
||||||
// Email already verified, return early
|
// Email already verified, return early
|
||||||
@@ -47,8 +46,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
|||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Send verification email (stubbed)
|
// Send verification email
|
||||||
await sendVerificationEmail(context.user.email, token);
|
await sendVerificationEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email: context.user.email,
|
||||||
|
token,
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import type { RPInfo } from "../../utils/webauthn.js";
|
import type { RPInfo } from "../../utils/webauthn.js";
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { withTransaction } from "@reviq/db";
|
||||||
|
import { sendVerificationEmail } from "@reviq/emails";
|
||||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||||
import {
|
import {
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
@@ -21,7 +23,6 @@ import {
|
|||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendVerificationEmail } from "../../utils/email.js";
|
|
||||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||||
import { createSession } from "../../utils/session.js";
|
import { createSession } from "../../utils/session.js";
|
||||||
@@ -52,17 +53,28 @@ export async function signupWithPassword(
|
|||||||
// Hash password
|
// Hash password
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
// Create user
|
// Create user (handle race condition if concurrent signup with same email)
|
||||||
const user = await db
|
try {
|
||||||
.insertInto("users")
|
const user = await db
|
||||||
.values({
|
.insertInto("users")
|
||||||
email,
|
.values({
|
||||||
password_hash: passwordHash,
|
email,
|
||||||
})
|
password_hash: passwordHash,
|
||||||
.returning(["id"])
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return user.id;
|
return user.id;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,7 +109,7 @@ export async function signupWithPasskey(
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("options")
|
.select("options")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.where("created_at", ">", fifteenMinutesAgo)
|
.where("created_at", ">", fifteenMinutesAgo)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
|
|||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Log error for debugging but don't expose to client
|
// Log error for debugging but don't expose to client
|
||||||
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
|
|||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
@@ -146,55 +158,66 @@ export async function signupWithPasskey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user and passkey in a transaction
|
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||||
const result = await db.transaction().execute(async (trx) => {
|
try {
|
||||||
// Create user
|
const result = await withTransaction(db, async (trx) => {
|
||||||
const user = await trx
|
// Create user
|
||||||
.insertInto("users")
|
const user = await trx
|
||||||
.values({
|
.insertInto("users")
|
||||||
email,
|
.values({
|
||||||
password_hash: null,
|
email,
|
||||||
})
|
password_hash: null,
|
||||||
.returning(["id"])
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const newUserId = user.id;
|
const newUserId = user.id;
|
||||||
|
|
||||||
// Get friendly name from AAGUID
|
// Get friendly name from AAGUID
|
||||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||||
const passkeyName = guidName ?? "Default";
|
const passkeyName = guidName ?? "Default";
|
||||||
|
|
||||||
// Store the passkey
|
// Store the passkey
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||||
registrationInfo;
|
registrationInfo;
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insertInto("passkeys")
|
.insertInto("passkeys")
|
||||||
.values({
|
.values({
|
||||||
user_id: newUserId,
|
user_id: newUserId,
|
||||||
credential_id: Buffer.from(credential.id, "base64url"),
|
credential_id: Buffer.from(credential.id, "base64url"),
|
||||||
public_key: Buffer.from(credential.publicKey),
|
public_key: Buffer.from(credential.publicKey),
|
||||||
webauthn_user_id: options.user.id,
|
webauthn_user_id: options.user.id,
|
||||||
counter: BigInt(credential.counter),
|
counter: BigInt(credential.counter),
|
||||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||||
backup_eligible: registrationInfo.credentialBackedUp,
|
backup_eligible: registrationInfo.credentialBackedUp,
|
||||||
backup_status: credentialBackedUp,
|
backup_status: credentialBackedUp,
|
||||||
transports: JSON.stringify(response.response.transports ?? []),
|
transports: JSON.stringify(response.response.transports ?? []),
|
||||||
rpid: rpInfo.rpID,
|
rpid: rpInfo.rpID,
|
||||||
name: passkeyName,
|
name: passkeyName,
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await trx
|
await trx
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { userId: newUserId };
|
return { userId: newUserId };
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.userId;
|
return result.userId;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,19 +264,40 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
);
|
);
|
||||||
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
||||||
} else {
|
} else {
|
||||||
// Should never reach here due to schema validation
|
// Unreachable - schema validation requires password or passkeyInfo
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Either password or passkeyInfo is required",
|
message: "Either password or passkeyInfo is required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session (7 days, trusted mode false initially, no device)
|
// Generate verification token
|
||||||
const session = await createSession(context.db, {
|
const verificationToken = generateSecureBase58Token();
|
||||||
userId,
|
const verificationExpiresAt = generateExpiry(
|
||||||
deviceId: null,
|
TOKEN_DURATIONS.EMAIL_VERIFICATION,
|
||||||
trustedMode: false,
|
);
|
||||||
geo,
|
|
||||||
userAgent,
|
// 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 newSession = await createSession(trx, {
|
||||||
|
userId,
|
||||||
|
deviceId: null,
|
||||||
|
trustedMode: false,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||||
|
await trx
|
||||||
|
.insertInto("email_verifications")
|
||||||
|
.values({
|
||||||
|
user_id: userId,
|
||||||
|
token: verificationToken,
|
||||||
|
expires_at: verificationExpiresAt,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return newSession;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
COOKIE_OPTIONS.session,
|
COOKIE_OPTIONS.session,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate verification token
|
// Send verification email
|
||||||
const verificationToken = generateSecureBase58Token();
|
await sendVerificationEmail({
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
baseUrl: context.email.baseUrl,
|
||||||
await context.db
|
email,
|
||||||
.insertInto("email_verifications")
|
token: verificationToken,
|
||||||
.values({
|
expiryHours: 24,
|
||||||
user_id: userId,
|
});
|
||||||
token: verificationToken,
|
|
||||||
expires_at: expiresAt,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send verification email (stubbed)
|
|
||||||
await sendVerificationEmail(email, verificationToken);
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,227 +8,22 @@
|
|||||||
import type {
|
import type {
|
||||||
APIContext,
|
APIContext,
|
||||||
AuthenticatedContext,
|
AuthenticatedContext,
|
||||||
AuthInfo,
|
|
||||||
LoginRequestContext,
|
LoginRequestContext,
|
||||||
Session,
|
|
||||||
SessionUser,
|
|
||||||
} from "../context.js";
|
} from "../context.js";
|
||||||
import { implement, ORPCError } from "@orpc/server";
|
import {
|
||||||
import { contract } from "@reviq/api-contract";
|
authMiddleware,
|
||||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
loginRequestMiddleware,
|
||||||
import { hashToken } from "../utils/crypto.js";
|
os,
|
||||||
|
superuserMiddleware,
|
||||||
|
} from "../middlewares/index.js";
|
||||||
|
|
||||||
/**
|
// Re-export middlewares and os
|
||||||
* Base implementer with typed APIContext
|
export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
|
||||||
* All procedures should be derived from this
|
|
||||||
*/
|
|
||||||
export const os = implement(contract).$context<APIContext>();
|
|
||||||
|
|
||||||
/**
|
// Pre-configured procedures with middleware applied
|
||||||
* Auth middleware - validates session/API token and adds user to context
|
export const authedProcedure = os.use(authMiddleware);
|
||||||
* Use with os.use(authMiddleware) to create authenticated procedures
|
export const superuserProcedure = os.use(superuserMiddleware);
|
||||||
*/
|
export const loginRequestProcedure = os.use(loginRequestMiddleware);
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type exports for use in procedure files
|
// Type exports for use in procedure files
|
||||||
export type { APIContext, AuthenticatedContext, LoginRequestContext };
|
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,
|
hashToken,
|
||||||
TOKEN_PREFIX,
|
TOKEN_PREFIX,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/** Token expiration: 365 days */
|
/** Token expiration: 365 days */
|
||||||
const TOKEN_EXPIRATION_DAYS = 365;
|
const TOKEN_EXPIRATION_DAYS = 365;
|
||||||
@@ -18,9 +18,8 @@ const TOKEN_EXPIRATION_DAYS = 365;
|
|||||||
* List all API tokens for the current user
|
* List all API tokens for the current user
|
||||||
* Returns token metadata (not the actual token values)
|
* Returns token metadata (not the actual token values)
|
||||||
*/
|
*/
|
||||||
export const listApiTokens = os.me.apiTokens.list
|
export const listApiTokens = meRoute.apiTokens.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const tokens = await context.db
|
const tokens = await context.db
|
||||||
.selectFrom("api_tokens")
|
.selectFrom("api_tokens")
|
||||||
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
|
.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(),
|
createdAt: token.created_at.toISOString(),
|
||||||
expiresAt: token.expires_at.toISOString(),
|
expiresAt: token.expires_at.toISOString(),
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new API token
|
* Create a new API token
|
||||||
* Requires superuser status and trusted session
|
* Requires superuser status and trusted session
|
||||||
*/
|
*/
|
||||||
export const createApiToken = os.me.apiTokens.create
|
export const createApiToken = meRoute.apiTokens.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
// Require superuser status
|
// Require superuser status
|
||||||
if (!context.user.isSuperuser) {
|
if (!context.user.isSuperuser) {
|
||||||
throw new ORPCError("FORBIDDEN", {
|
throw new ORPCError("FORBIDDEN", {
|
||||||
@@ -85,17 +84,17 @@ export const createApiToken = os.me.apiTokens.create
|
|||||||
token,
|
token,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an API token
|
* Delete an API token
|
||||||
*/
|
*/
|
||||||
export const deleteApiToken = os.me.apiTokens.delete
|
export const deleteApiToken = meRoute.apiTokens.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.deleteFrom("api_tokens")
|
.deleteFrom("api_tokens")
|
||||||
.where("id", "=", String(input.tokenId))
|
.where("id", "=", input.tokenId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -106,4 +105,5 @@ export const deleteApiToken = os.me.apiTokens.delete
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,40 +2,38 @@
|
|||||||
* Get current user auth status
|
* Get current user auth status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const meAuthStatus = os.me.authStatus
|
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
const user = await context.db
|
||||||
.handler(async ({ context }) => {
|
.selectFrom("users")
|
||||||
const user = await context.db
|
.select([
|
||||||
.selectFrom("users")
|
"id",
|
||||||
.select([
|
"email",
|
||||||
"id",
|
"display_name",
|
||||||
"email",
|
"full_name",
|
||||||
"display_name",
|
"phone_number",
|
||||||
"full_name",
|
"avatar_url",
|
||||||
"phone_number",
|
"email_verified_at",
|
||||||
"avatar_url",
|
"is_superuser",
|
||||||
"email_verified_at",
|
"password_hash",
|
||||||
"is_superuser",
|
])
|
||||||
"password_hash",
|
.where("id", "=", context.user.id)
|
||||||
])
|
.executeTakeFirstOrThrow();
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
hasPassword: user.password_hash !== null,
|
hasPassword: user.password_hash !== null,
|
||||||
},
|
},
|
||||||
auth: context.auth,
|
auth: context.auth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
import { verifyPassword } from "../../utils/password.js";
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete account handler
|
* Delete account handler
|
||||||
@@ -14,39 +14,37 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||||
* - Clears session cookie
|
* - Clears session cookie
|
||||||
*/
|
*/
|
||||||
export const meDelete = os.me.delete
|
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
|
||||||
.use(authMiddleware)
|
const { password } = input;
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { password } = input;
|
|
||||||
|
|
||||||
// Fetch user with password hash
|
// Fetch user with password hash
|
||||||
const user = await context.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.select(["password_hash"])
|
.select(["password_hash"])
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
// Verify password (required for account deletion)
|
// Verify password (required for account deletion)
|
||||||
if (!user.password_hash) {
|
if (!user.password_hash) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"Cannot delete account without a password. Please set a password first.",
|
"Cannot delete account without a password. Please set a password first.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.password_hash);
|
const valid = await verifyPassword(password, user.password_hash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
||||||
await context.db
|
await context.db
|
||||||
.deleteFrom("users")
|
.deleteFrom("users")
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Clear session cookie
|
// Clear session cookie
|
||||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.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 BAD_REQUEST if no device fingerprint found
|
||||||
* @throws NOT_FOUND if device doesn't exist
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
*/
|
*/
|
||||||
export const getDeviceInfo = os.me.devices.getInfo
|
export const getDeviceInfo = meRoute.devices.getInfo.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||||
|
|
||||||
const device = await context.db
|
const device = await context.db
|
||||||
@@ -39,7 +38,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
|||||||
lastUsedAt: device.last_used_at,
|
lastUsedAt: device.last_used_at,
|
||||||
isTrusted: device.is_trusted,
|
isTrusted: device.is_trusted,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trust device handler
|
* Trust device handler
|
||||||
@@ -48,9 +48,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
|||||||
* @throws BAD_REQUEST if no device fingerprint found
|
* @throws BAD_REQUEST if no device fingerprint found
|
||||||
* @throws NOT_FOUND if device doesn't exist
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
*/
|
*/
|
||||||
export const trustDevice = os.me.devices.trust
|
export const trustDevice = meRoute.devices.trust.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { name } = input;
|
const { name } = input;
|
||||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||||
|
|
||||||
@@ -66,16 +65,16 @@ export const trustDevice = os.me.devices.trust
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List trusted devices handler
|
* List trusted devices handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Returns all trusted devices for the current user
|
* - Returns all trusted devices for the current user
|
||||||
*/
|
*/
|
||||||
export const listTrustedDevices = os.me.devices.listTrusted
|
export const listTrustedDevices = meRoute.devices.listTrusted.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const devices = await context.db
|
const devices = await context.db
|
||||||
.selectFrom("user_devices")
|
.selectFrom("user_devices")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -94,7 +93,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
|||||||
lastUsedAt: d.last_used_at,
|
lastUsedAt: d.last_used_at,
|
||||||
isTrusted: d.is_trusted,
|
isTrusted: d.is_trusted,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Untrust device handler
|
* Untrust device handler
|
||||||
@@ -102,13 +102,12 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
|||||||
* - Marks device as untrusted by ID
|
* - Marks device as untrusted by ID
|
||||||
* @throws NOT_FOUND if device doesn't exist
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
*/
|
*/
|
||||||
export const untrustDevice = os.me.devices.untrust
|
export const untrustDevice = meRoute.devices.untrust.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("user_devices")
|
.updateTable("user_devices")
|
||||||
.set({ is_trusted: false })
|
.set({ is_trusted: false })
|
||||||
.where("id", "=", String(input.deviceId))
|
.where("id", "=", input.deviceId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -117,16 +116,16 @@ export const untrustDevice = os.me.devices.untrust
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke all trusted devices handler
|
* Revoke all trusted devices handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Marks all devices as untrusted
|
* - Marks all devices as untrusted
|
||||||
*/
|
*/
|
||||||
export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
export const revokeAllTrustedDevices = meRoute.devices.revokeAll.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("user_devices")
|
.updateTable("user_devices")
|
||||||
.set({ is_trusted: false })
|
.set({ is_trusted: false })
|
||||||
@@ -134,4 +133,5 @@ export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,37 +2,35 @@
|
|||||||
* Get current user profile
|
* Get current user profile
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const meGet = os.me.get
|
export const meGet = meRoute.get.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
const user = await context.db
|
||||||
.handler(async ({ context }) => {
|
.selectFrom("users")
|
||||||
const user = await context.db
|
.select([
|
||||||
.selectFrom("users")
|
"id",
|
||||||
.select([
|
"email",
|
||||||
"id",
|
"display_name",
|
||||||
"email",
|
"full_name",
|
||||||
"display_name",
|
"phone_number",
|
||||||
"full_name",
|
"avatar_url",
|
||||||
"phone_number",
|
"email_verified_at",
|
||||||
"avatar_url",
|
"is_superuser",
|
||||||
"email_verified_at",
|
"password_hash",
|
||||||
"is_superuser",
|
])
|
||||||
"password_hash",
|
.where("id", "=", context.user.id)
|
||||||
])
|
.executeTakeFirstOrThrow();
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
hasPassword: user.password_hash !== null,
|
hasPassword: user.password_hash !== null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,64 +3,61 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List pending invites for the current user
|
* List pending invites for the current user
|
||||||
* Only returns invites where the user's email matches and email is verified
|
* Only returns invites where the user's email matches and email is verified
|
||||||
*/
|
*/
|
||||||
export const listInvites = os.me.invites.list
|
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
|
||||||
.use(authMiddleware)
|
// Only show invites if email is verified
|
||||||
.handler(async ({ context }) => {
|
if (!context.user.emailVerifiedAt) {
|
||||||
// Only show invites if email is verified
|
return [];
|
||||||
if (!context.user.emailVerifiedAt) {
|
}
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get non-expired invites matching user's email
|
// Get non-expired invites matching user's email
|
||||||
const invites = await context.db
|
const invites = await context.db
|
||||||
.selectFrom("org_invites")
|
.selectFrom("org_invites")
|
||||||
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
||||||
.innerJoin("users", "users.id", "org_invites.invited_by")
|
.innerJoin("users", "users.id", "org_invites.invited_by")
|
||||||
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
||||||
.where("org_invites.expires_at", ">", new Date())
|
.where("org_invites.expires_at", ">", new Date())
|
||||||
.select([
|
.select([
|
||||||
"org_invites.id",
|
"org_invites.id",
|
||||||
"org_invites.role",
|
"org_invites.role",
|
||||||
"org_invites.created_at",
|
"org_invites.created_at",
|
||||||
"org_invites.expires_at",
|
"org_invites.expires_at",
|
||||||
"orgs.id as org_id",
|
"orgs.id as org_id",
|
||||||
"orgs.slug as org_slug",
|
"orgs.slug as org_slug",
|
||||||
"orgs.display_name as org_display_name",
|
"orgs.display_name as org_display_name",
|
||||||
"orgs.logo_url as org_logo_url",
|
"orgs.logo_url as org_logo_url",
|
||||||
"users.display_name as inviter_name",
|
"users.display_name as inviter_name",
|
||||||
"users.email as inviter_email",
|
"users.email as inviter_email",
|
||||||
])
|
])
|
||||||
.orderBy("org_invites.created_at", "desc")
|
.orderBy("org_invites.created_at", "desc")
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return invites.map((i) => ({
|
return invites.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
org: {
|
org: {
|
||||||
id: i.org_id,
|
id: i.org_id,
|
||||||
slug: i.org_slug,
|
slug: i.org_slug,
|
||||||
displayName: i.org_display_name,
|
displayName: i.org_display_name,
|
||||||
logoUrl: i.org_logo_url,
|
logoUrl: i.org_logo_url,
|
||||||
},
|
},
|
||||||
role: i.role,
|
role: i.role,
|
||||||
invitedBy: i.inviter_name ?? i.inviter_email,
|
invitedBy: i.inviter_name ?? i.inviter_email,
|
||||||
createdAt: i.created_at,
|
createdAt: i.created_at,
|
||||||
expiresAt: i.expires_at,
|
expiresAt: i.expires_at,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific invite by ID
|
* Get a specific invite by ID
|
||||||
* Only returns if the invite belongs to the current user's email
|
* Only returns if the invite belongs to the current user's email
|
||||||
*/
|
*/
|
||||||
export const getInvite = os.me.invites.get
|
export const getInvite = meRoute.invites.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Only show invite if email is verified
|
// Only show invite if email is verified
|
||||||
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
|
|||||||
createdAt: invite.created_at,
|
createdAt: invite.created_at,
|
||||||
expiresAt: invite.expires_at,
|
expiresAt: invite.expires_at,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept an invite by ID
|
* Accept an invite by ID
|
||||||
* Adds user to org and deletes the invite
|
* Adds user to org and deletes the invite
|
||||||
*/
|
*/
|
||||||
export const acceptInvite = os.me.invites.accept
|
export const acceptInvite = meRoute.invites.accept.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Only allow accepting if email is verified
|
// Only allow accepting if email is verified
|
||||||
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decline an invite
|
* Decline an invite
|
||||||
* Deletes the invite if it belongs to the current user's email
|
* Deletes the invite if it belongs to the current user's email
|
||||||
*/
|
*/
|
||||||
export const declineInvite = os.me.invites.decline
|
export const declineInvite = meRoute.invites.decline.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { inviteId } = input;
|
const { inviteId } = input;
|
||||||
|
|
||||||
// Delete the invite only if it matches user's email
|
// Delete the invite only if it matches user's email
|
||||||
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,16 +4,15 @@
|
|||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List passkeys handler
|
* List passkeys handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Returns all passkeys for the current user
|
* - Returns all passkeys for the current user
|
||||||
*/
|
*/
|
||||||
export const listPasskeys = os.me.passkeys.list
|
export const listPasskeys = meRoute.passkeys.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||||
|
|
||||||
return passkeys.map((p) => ({
|
return passkeys.map((p) => ({
|
||||||
@@ -22,7 +21,8 @@ export const listPasskeys = os.me.passkeys.list
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
lastUsedAt: p.lastUsedAt,
|
lastUsedAt: p.lastUsedAt,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename passkey handler
|
* Rename passkey handler
|
||||||
@@ -30,15 +30,14 @@ export const listPasskeys = os.me.passkeys.list
|
|||||||
* - Updates passkey name
|
* - Updates passkey name
|
||||||
* @throws NOT_FOUND if passkey doesn't exist
|
* @throws NOT_FOUND if passkey doesn't exist
|
||||||
*/
|
*/
|
||||||
export const renamePasskey = os.me.passkeys.rename
|
export const renamePasskey = meRoute.passkeys.rename.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { passkeyId, name } = input;
|
const { passkeyId, name } = input;
|
||||||
|
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("passkeys")
|
.updateTable("passkeys")
|
||||||
.set({ name })
|
.set({ name })
|
||||||
.where("id", "=", String(passkeyId))
|
.where("id", "=", passkeyId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -47,7 +46,8 @@ export const renamePasskey = os.me.passkeys.rename
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete passkey handler
|
* Delete passkey handler
|
||||||
@@ -57,9 +57,8 @@ export const renamePasskey = os.me.passkeys.rename
|
|||||||
* @throws NOT_FOUND if passkey doesn't exist
|
* @throws NOT_FOUND if passkey doesn't exist
|
||||||
* @throws BAD_REQUEST if trying to delete last passkey without password
|
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||||
*/
|
*/
|
||||||
export const deletePasskey = os.me.passkeys.delete
|
export const deletePasskey = meRoute.passkeys.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { passkeyId } = input;
|
const { passkeyId } = input;
|
||||||
|
|
||||||
// Use transaction to prevent race condition when checking last passkey
|
// 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
|
const result = await trx
|
||||||
.deleteFrom("passkeys")
|
.deleteFrom("passkeys")
|
||||||
.where("id", "=", String(passkeyId))
|
.where("id", "=", passkeyId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -96,4 +95,5 @@ export const deletePasskey = os.me.passkeys.delete
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List sessions handler
|
* List sessions handler
|
||||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - Returns all sessions for the current user
|
* - Returns all sessions for the current user
|
||||||
* - Includes isCurrent flag to identify active session
|
* - Includes isCurrent flag to identify active session
|
||||||
*/
|
*/
|
||||||
export const listSessions = os.me.sessions.list
|
export const listSessions = meRoute.sessions.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const sessions = await context.db
|
const sessions = await context.db
|
||||||
.selectFrom("sessions")
|
.selectFrom("sessions")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -33,7 +32,8 @@ export const listSessions = os.me.sessions.list
|
|||||||
isCurrent: s.id === context.session.id,
|
isCurrent: s.id === context.session.id,
|
||||||
revokedAt: s.revoked_at,
|
revokedAt: s.revoked_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke session handler
|
* Revoke session handler
|
||||||
@@ -42,13 +42,12 @@ export const listSessions = os.me.sessions.list
|
|||||||
* @throws NOT_FOUND if session doesn't exist
|
* @throws NOT_FOUND if session doesn't exist
|
||||||
* @throws BAD_REQUEST if trying to revoke current session
|
* @throws BAD_REQUEST if trying to revoke current session
|
||||||
*/
|
*/
|
||||||
export const revokeSession = os.me.sessions.revoke
|
export const revokeSession = meRoute.sessions.revoke.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { sessionId } = input;
|
const { sessionId } = input;
|
||||||
|
|
||||||
// Prevent revoking current session (use logout instead)
|
// Prevent revoking current session (use logout instead)
|
||||||
if (String(sessionId) === context.session.id) {
|
if (sessionId.toString() === context.session.id) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Cannot revoke current session. Use logout instead.",
|
message: "Cannot revoke current session. Use logout instead.",
|
||||||
});
|
});
|
||||||
@@ -57,7 +56,7 @@ export const revokeSession = os.me.sessions.revoke
|
|||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
.set({ revoked_at: new Date() })
|
.set({ revoked_at: new Date() })
|
||||||
.where("id", "=", String(sessionId))
|
.where("id", "=", sessionId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.where("revoked_at", "is", null)
|
.where("revoked_at", "is", null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -67,16 +66,16 @@ export const revokeSession = os.me.sessions.revoke
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke all sessions handler
|
* Revoke all sessions handler
|
||||||
* - Requires authentication
|
* - Requires authentication
|
||||||
* - Revokes all sessions except current
|
* - Revokes all sessions except current
|
||||||
*/
|
*/
|
||||||
export const revokeAllSessions = os.me.sessions.revokeAll
|
export const revokeAllSessions = meRoute.sessions.revokeAll.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
// Revoke all sessions except current
|
// Revoke all sessions except current
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
@@ -87,4 +86,5 @@ export const revokeAllSessions = os.me.sessions.revokeAll
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
validatePassword,
|
validatePassword,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from "../../utils/password.js";
|
} from "../../utils/password.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set password handler
|
* Set password handler
|
||||||
@@ -16,9 +16,8 @@ import { authMiddleware, os } from "../base.js";
|
|||||||
* - If user has existing password, currentPassword is required
|
* - If user has existing password, currentPassword is required
|
||||||
* - Validates new password strength using zxcvbn
|
* - Validates new password strength using zxcvbn
|
||||||
*/
|
*/
|
||||||
export const setPassword = os.me.setPassword
|
export const setPassword = meRoute.setPassword.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { currentPassword, newPassword } = input;
|
const { currentPassword, newPassword } = input;
|
||||||
|
|
||||||
// Fetch current password hash
|
// Fetch current password hash
|
||||||
@@ -60,4 +59,5 @@ export const setPassword = os.me.setPassword
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
* Setup user profile (initial setup after signup)
|
* Setup user profile (initial setup after signup)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
export const setupProfile = os.me.setupProfile
|
export const setupProfile = meRoute.setupProfile.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { displayName, fullName, phoneNumber } = input;
|
const { displayName, fullName, phoneNumber } = input;
|
||||||
|
|
||||||
await context.db
|
await context.db
|
||||||
@@ -21,4 +20,5 @@ export const setupProfile = os.me.setupProfile
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ProfileUpdate } from "./helpers.js";
|
import type { ProfileUpdate } from "./helpers.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { meRoute } from "./_base.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update profile handler
|
* 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
|
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
||||||
* - Automatically sets updated_at timestamp
|
* - Automatically sets updated_at timestamp
|
||||||
*/
|
*/
|
||||||
export const updateProfile = os.me.updateProfile
|
export const updateProfile = meRoute.updateProfile.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const updates: Partial<ProfileUpdate> = {};
|
const updates: Partial<ProfileUpdate> = {};
|
||||||
if (input.displayName !== undefined) {
|
if (input.displayName !== undefined) {
|
||||||
updates.display_name = input.displayName;
|
updates.display_name = input.displayName;
|
||||||
@@ -38,4 +37,5 @@ export const updateProfile = os.me.updateProfile
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all orgs the current user is a member of
|
* List all orgs the current user is a member of
|
||||||
*/
|
*/
|
||||||
export const orgsList = os.orgs.list
|
export const orgsList = authedProcedure.orgs.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ context }) => {
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const orgs = await context.db
|
const orgs = await context.db
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||||
@@ -33,15 +32,15 @@ export const orgsList = os.orgs.list
|
|||||||
logoUrl: o.logo_url,
|
logoUrl: o.logo_url,
|
||||||
createdAt: o.created_at,
|
createdAt: o.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new org
|
* Create a new org
|
||||||
* The creating user becomes the owner
|
* The creating user becomes the owner
|
||||||
*/
|
*/
|
||||||
export const orgsCreate = os.orgs.create
|
export const orgsCreate = authedProcedure.orgs.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName } = input;
|
const { slug, displayName } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -75,15 +74,15 @@ export const orgsCreate = os.orgs.create
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single org by slug
|
* Get a single org by slug
|
||||||
* Requires membership
|
* Requires membership
|
||||||
*/
|
*/
|
||||||
export const orgsGet = os.orgs.get
|
export const orgsGet = authedProcedure.orgs.get.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -97,4 +96,5 @@ export const orgsGet = os.orgs.get
|
|||||||
logoUrl: org.logoUrl,
|
logoUrl: org.logoUrl,
|
||||||
createdAt: org.createdAt,
|
createdAt: org.createdAt,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,25 +5,11 @@
|
|||||||
|
|
||||||
import type { DB, OrgRole } from "@reviq/db-schema";
|
import type { DB, OrgRole } from "@reviq/db-schema";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
|
import type { OrgInfo, OrgMembership } from "../../context.js";
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
|
||||||
// ===== Types =====
|
// Re-export types for convenience
|
||||||
|
export type { OrgInfo, OrgMembership };
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Role Hierarchy =====
|
// ===== Role Hierarchy =====
|
||||||
|
|
||||||
@@ -115,10 +101,11 @@ export async function countOwners(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
.select((eb) => eb.fn.countAll().as("count"))
|
||||||
.where("org_id", "=", orgId)
|
.where("org_id", "=", orgId)
|
||||||
.where("role", "=", "owner")
|
.where("role", "=", "owner")
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return result.count;
|
// PostgreSQL COUNT returns bigint (string), convert to number
|
||||||
|
return Number(result.count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { sendOrgInviteEmail } from "@reviq/emails";
|
||||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import { authMiddleware, os } from "../base.js";
|
|
||||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List pending invites for an org
|
* List pending invites for an org
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
*/
|
*/
|
||||||
export const invitesList = os.orgs.invites.list
|
export const invitesList = authedProcedure.orgs.invites.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify admin+ role
|
// Lookup org and verify admin+ role
|
||||||
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
|
|||||||
createdAt: i.created_at,
|
createdAt: i.created_at,
|
||||||
expiresAt: i.expires_at,
|
expiresAt: i.expires_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an invite for a new member
|
* Create an invite for a new member
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
* Only owners can invite new owners (privilege escalation prevention)
|
* Only owners can invite new owners (privilege escalation prevention)
|
||||||
*/
|
*/
|
||||||
export const invitesCreate = os.orgs.invites.create
|
export const invitesCreate = authedProcedure.orgs.invites.create.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, email: rawEmail, role } = input;
|
const { slug, email: rawEmail, role } = input;
|
||||||
const email = rawEmail.toLowerCase();
|
const email = rawEmail.toLowerCase();
|
||||||
|
|
||||||
@@ -122,18 +121,28 @@ export const invitesCreate = os.orgs.invites.create
|
|||||||
|
|
||||||
// Send invitation email
|
// Send invitation email
|
||||||
const inviterName = context.user.displayName ?? context.user.email;
|
const inviterName = context.user.displayName ?? context.user.email;
|
||||||
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
|
await sendOrgInviteEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
orgName: org.displayName,
|
||||||
|
inviterName,
|
||||||
|
role,
|
||||||
|
expiryDays: ORG_INVITE_EXPIRY_DAYS,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a pending invite
|
* Cancel a pending invite
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
*/
|
*/
|
||||||
export const invitesCancel = os.orgs.invites.cancel
|
export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, inviteId } = input;
|
const { slug, inviteId } = input;
|
||||||
|
|
||||||
// Lookup org and verify admin+ role
|
// Lookup org and verify admin+ role
|
||||||
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept an invitation
|
* Accept an invitation
|
||||||
* Token-based lookup, requires auth but no org membership
|
* Token-based lookup, requires auth but no org membership
|
||||||
* Handles race condition if user is already a member
|
* Handles race condition if user is already a member
|
||||||
*/
|
*/
|
||||||
export const invitesAccept = os.orgs.invites.accept
|
export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { token } = input;
|
const { token } = input;
|
||||||
|
|
||||||
// Find the invite by token (must not be expired)
|
// Find the invite by token (must not be expired)
|
||||||
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import {
|
import {
|
||||||
countOwners,
|
countOwners,
|
||||||
getMembership,
|
getMembership,
|
||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
* Update org details
|
* Update org details
|
||||||
* Requires admin or owner role
|
* Requires admin or owner role
|
||||||
*/
|
*/
|
||||||
export const orgsUpdate = os.orgs.update
|
export const orgsUpdate = authedProcedure.orgs.update.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, displayName, logoUrl } = input;
|
const { slug, displayName, logoUrl } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership with admin+ role
|
// Lookup org and verify membership with admin+ role
|
||||||
@@ -41,16 +40,16 @@ export const orgsUpdate = os.orgs.update
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an org
|
* Delete an org
|
||||||
* Requires owner role
|
* Requires owner role
|
||||||
* FK CASCADE handles deleting members, invites, and sites
|
* FK CASCADE handles deleting members, invites, and sites
|
||||||
*/
|
*/
|
||||||
export const orgsDelete = os.orgs.delete
|
export const orgsDelete = authedProcedure.orgs.delete.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify ownership
|
// 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();
|
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leave an org
|
* Leave an org
|
||||||
* Cannot leave if you're the only owner
|
* Cannot leave if you're the only owner
|
||||||
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||||
*/
|
*/
|
||||||
export const orgsLeave = os.orgs.leave
|
export const orgsLeave = authedProcedure.orgs.leave.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and get membership
|
// Lookup org and get membership
|
||||||
@@ -98,4 +97,5 @@ export const orgsLeave = os.orgs.leave
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import {
|
import {
|
||||||
countOwners,
|
countOwners,
|
||||||
getMembership,
|
getMembership,
|
||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
* List all members of an org
|
* List all members of an org
|
||||||
* Any member can view the member list
|
* Any member can view the member list
|
||||||
*/
|
*/
|
||||||
export const membersList = os.orgs.members.list
|
export const membersList = authedProcedure.orgs.members.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -48,65 +47,70 @@ export const membersList = os.orgs.members.list
|
|||||||
role: m.role,
|
role: m.role,
|
||||||
createdAt: m.created_at,
|
createdAt: m.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a member's role
|
* Update a member's role
|
||||||
* Only owners can change roles
|
* Only owners can change roles
|
||||||
* Uses transaction to prevent race condition when demoting owners
|
* Uses transaction to prevent race condition when demoting owners
|
||||||
*/
|
*/
|
||||||
export const membersUpdateRole = os.orgs.members.updateRole
|
export const membersUpdateRole =
|
||||||
.use(authMiddleware)
|
authedProcedure.orgs.members.updateRole.handler(
|
||||||
.handler(async ({ input, context }) => {
|
async ({ input, context }) => {
|
||||||
const { slug, userId, role: newRole } = input;
|
const { slug, userId, role: newRole } = input;
|
||||||
|
|
||||||
// Lookup org and verify ownership
|
// Lookup org and verify ownership
|
||||||
const org = await lookupOrgBySlug(context.db, slug);
|
const org = await lookupOrgBySlug(context.db, slug);
|
||||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
const membership = await getMembership(
|
||||||
requireRole(membership, "owner");
|
context.db,
|
||||||
|
org.id,
|
||||||
|
context.user.id,
|
||||||
|
);
|
||||||
|
requireRole(membership, "owner");
|
||||||
|
|
||||||
await context.db.transaction().execute(async (trx) => {
|
await context.db.transaction().execute(async (trx) => {
|
||||||
// Get the target member's current membership
|
// Get the target member's current membership
|
||||||
const targetMember = await trx
|
const targetMember = await trx
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.select(["id", "role"])
|
.select(["id", "role"])
|
||||||
.where("org_id", "=", org.id)
|
.where("org_id", "=", org.id)
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!targetMember) {
|
if (!targetMember) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
||||||
}
|
|
||||||
|
|
||||||
// If demoting an owner, check if they're the last one
|
|
||||||
if (targetMember.role === "owner" && newRole !== "owner") {
|
|
||||||
const ownerCount = await countOwners(trx, org.id);
|
|
||||||
if (ownerCount === 1) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Cannot demote the only owner",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update the role
|
// If demoting an owner, check if they're the last one
|
||||||
await trx
|
if (targetMember.role === "owner" && newRole !== "owner") {
|
||||||
.updateTable("org_members")
|
const ownerCount = await countOwners(trx, org.id);
|
||||||
.set({ role: newRole })
|
if (ownerCount === 1) {
|
||||||
.where("id", "=", targetMember.id)
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
.execute();
|
message: "Cannot demote the only owner",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
// Update the role
|
||||||
});
|
await trx
|
||||||
|
.updateTable("org_members")
|
||||||
|
.set({ role: newRole })
|
||||||
|
.where("id", "=", targetMember.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a member from an org
|
* Remove a member from an org
|
||||||
* Owners can remove anyone, admins can only remove members
|
* Owners can remove anyone, admins can only remove members
|
||||||
* Uses transaction to prevent race condition when removing owners
|
* Uses transaction to prevent race condition when removing owners
|
||||||
*/
|
*/
|
||||||
export const membersRemove = os.orgs.members.remove
|
export const membersRemove = authedProcedure.orgs.members.remove.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug, userId } = input;
|
const { slug, userId } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
* Org sites procedures - list
|
* Org sites procedures - list
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authedProcedure } from "../base.js";
|
||||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all sites for an org
|
* List all sites for an org
|
||||||
* Any member can view the site list
|
* Any member can view the site list
|
||||||
*/
|
*/
|
||||||
export const sitesList = os.orgs.sites.list
|
export const sitesList = authedProcedure.orgs.sites.list.handler(
|
||||||
.use(authMiddleware)
|
async ({ input, context }) => {
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { slug } = input;
|
const { slug } = input;
|
||||||
|
|
||||||
// Lookup org and verify membership
|
// Lookup org and verify membership
|
||||||
@@ -31,4 +30,5 @@ export const sitesList = os.orgs.sites.list
|
|||||||
domain: s.domain,
|
domain: s.domain,
|
||||||
createdAt: s.created_at,
|
createdAt: s.created_at,
|
||||||
}));
|
}));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
|
|||||||
await context.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", String(context.loginRequestId))
|
.where("id", "=", context.loginRequestId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -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";
|
import { base58 } from "@scure/base";
|
||||||
|
|
||||||
// Re-export for convenience
|
// 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 {
|
import {
|
||||||
hashPassword as hashPasswordUtil,
|
hashPassword as hashPasswordUtil,
|
||||||
verifyPassword as verifyPasswordUtil,
|
verifyPassword as verifyPasswordUtil,
|
||||||
} from "@reviq/utils";
|
} from "@reviq/server-utils";
|
||||||
import zxcvbn from "zxcvbn";
|
import zxcvbn from "zxcvbn";
|
||||||
|
|
||||||
export interface PasswordValidationResult {
|
export interface PasswordValidationResult {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Database } from "@reviq/db-schema";
|
import type { Database } from "@reviq/db-schema";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely, Transaction } from "kysely";
|
||||||
import type { GeoInfo } from "./geo.js";
|
import type { GeoInfo } from "./geo.js";
|
||||||
|
import {
|
||||||
|
isDeviceTrusted as dbIsDeviceTrusted,
|
||||||
|
upsertUserDevice as dbUpsertUserDevice,
|
||||||
|
insertSession,
|
||||||
|
} from "@reviq/db";
|
||||||
import { COOKIE_DURATIONS } from "./cookies.js";
|
import { COOKIE_DURATIONS } from "./cookies.js";
|
||||||
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
|
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
|
||||||
|
|
||||||
@@ -23,33 +28,26 @@ export interface SessionResult {
|
|||||||
* Returns the raw token (to be sent in cookie) and session details
|
* Returns the raw token (to be sent in cookie) and session details
|
||||||
*/
|
*/
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
db: Kysely<Database>,
|
db: Kysely<Database> | Transaction<Database>,
|
||||||
options: CreateSessionOptions,
|
options: CreateSessionOptions,
|
||||||
): Promise<SessionResult> {
|
): Promise<SessionResult> {
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const tokenHash = await hashToken(token);
|
const tokenHash = await hashToken(token);
|
||||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
||||||
|
|
||||||
const result = await db
|
const result = await insertSession(db, {
|
||||||
.insertInto("sessions")
|
userId: options.userId,
|
||||||
.values({
|
deviceId: options.deviceId,
|
||||||
user_id: options.userId,
|
tokenHash,
|
||||||
device_id: options.deviceId,
|
trustedMode: options.trustedMode,
|
||||||
token_hash: tokenHash,
|
geo: options.geo,
|
||||||
trusted_mode: options.trustedMode,
|
userAgent: options.userAgent,
|
||||||
ip_address: options.geo.ip,
|
expiresAt,
|
||||||
city: options.geo.city,
|
});
|
||||||
region: options.geo.region,
|
|
||||||
country: options.geo.country,
|
|
||||||
user_agent: options.userAgent,
|
|
||||||
expires_at: expiresAt,
|
|
||||||
})
|
|
||||||
.returning(["id"])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
sessionId: Number(result.id),
|
sessionId: result.sessionId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,53 +58,22 @@ export async function createSession(
|
|||||||
* Returns the device ID
|
* Returns the device ID
|
||||||
*/
|
*/
|
||||||
export async function upsertUserDevice(
|
export async function upsertUserDevice(
|
||||||
db: Kysely<Database>,
|
db: Kysely<Database> | Transaction<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
deviceFingerprint: string,
|
deviceFingerprint: string,
|
||||||
geo: GeoInfo,
|
geo: GeoInfo,
|
||||||
userAgent: string,
|
userAgent: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const result = await db
|
return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
|
||||||
.insertInto("user_devices")
|
|
||||||
.values({
|
|
||||||
user_id: userId,
|
|
||||||
device_fingerprint: deviceFingerprint,
|
|
||||||
user_agent: userAgent,
|
|
||||||
ip_address: geo.ip,
|
|
||||||
city: geo.city,
|
|
||||||
region: geo.region,
|
|
||||||
country: geo.country,
|
|
||||||
})
|
|
||||||
.onConflict((oc) =>
|
|
||||||
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
|
|
||||||
ip_address: geo.ip,
|
|
||||||
city: geo.city,
|
|
||||||
region: geo.region,
|
|
||||||
country: geo.country,
|
|
||||||
user_agent: userAgent,
|
|
||||||
last_used_at: new Date(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.returning(["id"])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return Number(result.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a device is trusted for a user
|
* Check if a device is trusted for a user
|
||||||
*/
|
*/
|
||||||
export async function isDeviceTrusted(
|
export async function isDeviceTrusted(
|
||||||
db: Kysely<Database>,
|
db: Kysely<Database> | Transaction<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
deviceFingerprint: string,
|
deviceFingerprint: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const device = await db
|
return dbIsDeviceTrusted(db, userId, deviceFingerprint);
|
||||||
.selectFrom("user_devices")
|
|
||||||
.select(["is_trusted"])
|
|
||||||
.where("user_id", "=", userId)
|
|
||||||
.where("device_fingerprint", "=", deviceFingerprint)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return device?.is_trusted ?? false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const verifyRegistration = async (
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("options")
|
.select("options")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!challengeRow) {
|
if (!challengeRow) {
|
||||||
@@ -189,7 +189,7 @@ export const verifyRegistration = async (
|
|||||||
// Always delete the challenge
|
// Always delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ export const verifyAuthentication = async (
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("options")
|
.select("options")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!challengeRow) {
|
if (!challengeRow) {
|
||||||
@@ -321,7 +321,7 @@ export const verifyAuthentication = async (
|
|||||||
counter: verification.authenticationInfo.newCounter.toString(),
|
counter: verification.authenticationInfo.newCounter.toString(),
|
||||||
last_used_at: new Date(),
|
last_used_at: new Date(),
|
||||||
})
|
})
|
||||||
.where("id", "=", String(passkey.id))
|
.where("id", "=", passkey.id.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -329,7 +329,7 @@ export const verifyAuthentication = async (
|
|||||||
// Always delete the challenge
|
// Always delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,5 @@
|
|||||||
"isolatedDeclarations": false,
|
"isolatedDeclarations": false,
|
||||||
"composite": false
|
"composite": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"]
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test": "bun test"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { ORPCError } from "@orpc/client";
|
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface CompleteLoginFlags {
|
interface CompleteLoginFlags {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -20,14 +20,7 @@ async function completeLogin(
|
|||||||
|
|
||||||
console.log(`Completed login request for: ${flags.email}`);
|
console.log(`Completed login request for: ${flags.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ORPCError) {
|
console.error("Error:", formatError(error));
|
||||||
console.error(`Error [${String(error.code)}]:`, error.message);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
import { readConfig, writeConfig } from "../../utils/config.js";
|
import { readConfig, writeConfig } from "../../utils/config.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface LoginFlags {
|
interface LoginFlags {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -47,10 +48,7 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
|||||||
console.log(`Logged in as ${authStatus.user.email}`);
|
console.log(`Logged in as ${authStatus.user.email}`);
|
||||||
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Login failed:", formatError(error));
|
||||||
"Login failed:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
console.log("\nMake sure your API token is valid.");
|
console.log("\nMake sure your API token is valid.");
|
||||||
console.log("You can create a new token at: /account/api-tokens");
|
console.log("You can create a new token at: /account/api-tokens");
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
import { getConfigPath, readConfig } from "../../utils/config.js";
|
import { getConfigPath, readConfig } from "../../utils/config.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
import { TOKEN_PREFIX } from "../../utils/token.js";
|
import { TOKEN_PREFIX } from "../../utils/token.js";
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
function formatDate(date: Date): string {
|
||||||
@@ -14,19 +15,21 @@ function formatRelativeTime(date: Date): string {
|
|||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays < 0) {
|
if (diffDays < 0) {
|
||||||
return `${String(Math.abs(diffDays))} days ago`;
|
return `${Math.abs(diffDays).toLocaleString()} days ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffDays === 0) {
|
if (diffDays === 0) {
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
if (diffHours <= 0) {
|
return diffHours <= 0
|
||||||
return "expired";
|
? "expired"
|
||||||
}
|
: `in ${diffHours.toLocaleString()} hours`;
|
||||||
return `in ${String(diffHours)} hours`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffDays === 1) {
|
if (diffDays === 1) {
|
||||||
return "tomorrow";
|
return "tomorrow";
|
||||||
}
|
}
|
||||||
return `in ${String(diffDays)} days`;
|
|
||||||
|
return `in ${diffDays.toLocaleString()} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function status(this: LocalContext): Promise<void> {
|
async function status(this: LocalContext): Promise<void> {
|
||||||
@@ -96,9 +99,7 @@ async function status(this: LocalContext): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(
|
console.log(` Error: ${formatError(error)}`);
|
||||||
` Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
console.log(
|
console.log(
|
||||||
"\n Unable to connect to API. Local credentials may be invalid.",
|
"\n Unable to connect to API. Local credentials may be invalid.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../context.js";
|
|||||||
import { createDb, executeBootstrap } from "@reviq/db";
|
import { createDb, executeBootstrap } from "@reviq/db";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { writeConfig } from "../utils/config.js";
|
import { writeConfig } from "../utils/config.js";
|
||||||
|
import { formatError } from "../utils/format-error.js";
|
||||||
|
|
||||||
interface BootstrapFlags {
|
interface BootstrapFlags {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -47,10 +48,7 @@ async function bootstrap(
|
|||||||
|
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { LocalContext } from "../context.js";
|
import type { LocalContext } from "../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
|
|
||||||
type Shell = "bash" | "zsh" | "fish";
|
const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
|
||||||
|
type Shell = (typeof SUPPORTED_SHELLS)[number];
|
||||||
const SUPPORTED_SHELLS: readonly Shell[] = ["bash", "zsh", "fish"] as const;
|
|
||||||
|
|
||||||
function parseShell(value: string): Shell {
|
function parseShell(value: string): Shell {
|
||||||
const shell = value.toLowerCase();
|
const shell = value.toLowerCase();
|
||||||
@@ -45,7 +44,6 @@ function completions(
|
|||||||
_flags: Record<string, never>,
|
_flags: Record<string, never>,
|
||||||
shell: Shell,
|
shell: Shell,
|
||||||
): void {
|
): void {
|
||||||
// biome-ignore lint/nursery/noUnnecessaryConditions: switch on union type is valid
|
|
||||||
switch (shell) {
|
switch (shell) {
|
||||||
case "bash":
|
case "bash":
|
||||||
console.log("To enable bash completions for reviq, run:\n");
|
console.log("To enable bash completions for reviq, run:\n");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface AddSiteFlags {
|
interface AddSiteFlags {
|
||||||
org: string;
|
org: string;
|
||||||
@@ -18,10 +19,7 @@ async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
|
|||||||
|
|
||||||
console.log(`Added site ${flags.domain} to org ${flags.org}`);
|
console.log(`Added site ${flags.domain} to org ${flags.org}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface CreateOrgFlags {
|
interface CreateOrgFlags {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -24,10 +25,7 @@ async function create(
|
|||||||
console.log(`Created org: ${result.slug}`);
|
console.log(`Created org: ${result.slug}`);
|
||||||
console.log(`Owner: ${flags.owner}`);
|
console.log(`Owner: ${flags.owner}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
async function list(this: LocalContext): Promise<void> {
|
async function list(this: LocalContext): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -23,12 +24,9 @@ async function list(this: LocalContext): Promise<void> {
|
|||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Total: ${String(orgs.length)} organization(s)`);
|
console.log(`Total: ${orgs.length.toLocaleString()} organization(s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface ConfirmEmailFlags {
|
interface ConfirmEmailFlags {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -19,10 +20,7 @@ async function confirmEmail(
|
|||||||
|
|
||||||
console.log(`Confirmed email for: ${flags.email}`);
|
console.log(`Confirmed email for: ${flags.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
type OrgRole = "owner" | "admin" | "member";
|
type OrgRole = "owner" | "admin" | "member";
|
||||||
|
|
||||||
const validRoles: OrgRole[] = ["owner", "admin", "member"];
|
const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
|
||||||
|
|
||||||
function parseRole(role: string | undefined): OrgRole | undefined {
|
function parseRole(role: string | undefined): OrgRole | undefined {
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (validRoles.includes(role as OrgRole)) {
|
|
||||||
return role as OrgRole;
|
if (!VALID_ROLES.includes(role as OrgRole)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error(
|
|
||||||
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
|
return role as OrgRole;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateUserFlags {
|
interface CreateUserFlags {
|
||||||
@@ -45,10 +48,7 @@ async function create(
|
|||||||
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
|
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { readConfig } from "./config.js";
|
|||||||
|
|
||||||
export type ApiClient = ContractRouterClient<typeof contract>;
|
export type ApiClient = ContractRouterClient<typeof contract>;
|
||||||
|
|
||||||
|
function buildClient(apiUrl: string, token: string): ApiClient {
|
||||||
|
const link = new RPCLink({
|
||||||
|
url: `${apiUrl}/api/v1/rpc`,
|
||||||
|
headers: { "X-API-Key": token },
|
||||||
|
});
|
||||||
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an oRPC API client with provided credentials
|
* Create an oRPC API client with provided credentials
|
||||||
*/
|
*/
|
||||||
@@ -25,18 +33,10 @@ export function createApiClient(
|
|||||||
apiUrl?: string,
|
apiUrl?: string,
|
||||||
token?: string,
|
token?: string,
|
||||||
): ApiClient | Promise<ApiClient> {
|
): ApiClient | Promise<ApiClient> {
|
||||||
// If both arguments are provided, create client directly
|
|
||||||
if (apiUrl !== undefined && token !== undefined) {
|
if (apiUrl !== undefined && token !== undefined) {
|
||||||
const link = new RPCLink({
|
return buildClient(apiUrl, token);
|
||||||
url: `${apiUrl}/api/v1/rpc`,
|
|
||||||
headers: {
|
|
||||||
"X-API-Key": token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return createORPCClient(link) as unknown as ApiClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, read from config asynchronously
|
|
||||||
return (async (): Promise<ApiClient> => {
|
return (async (): Promise<ApiClient> => {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -44,14 +44,6 @@ export function createApiClient(
|
|||||||
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return buildClient(config.apiUrl, config.token);
|
||||||
const link = new RPCLink({
|
|
||||||
url: `${config.apiUrl}/api/v1/rpc`,
|
|
||||||
headers: {
|
|
||||||
"X-API-Key": config.token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return createORPCClient(link) as unknown as ApiClient;
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
|
|||||||
/**
|
/**
|
||||||
* Get the path to the config file
|
* Get the path to the config file
|
||||||
*/
|
*/
|
||||||
export const getConfigPath = (): string => CONFIG_FILE;
|
export function getConfigPath(): string {
|
||||||
|
return CONFIG_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the config file
|
* Read the config file
|
||||||
* Returns null if the file doesn't exist or is invalid
|
* Returns null if the file doesn't exist or is invalid
|
||||||
*/
|
*/
|
||||||
export const readConfig = async (): Promise<Config | null> => {
|
export async function readConfig(): Promise<Config | null> {
|
||||||
try {
|
try {
|
||||||
const data = await readFile(CONFIG_FILE, "utf-8");
|
const data = await readFile(CONFIG_FILE, "utf-8");
|
||||||
return JSON.parse(data) as Config;
|
return JSON.parse(data) as Config;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the config file
|
* Write the config file
|
||||||
* Creates the config directory if it doesn't exist
|
* Creates the config directory if it doesn't exist
|
||||||
*/
|
*/
|
||||||
export const writeConfig = async (config: Config): Promise<void> => {
|
export async function writeConfig(config: Config): Promise<void> {
|
||||||
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the config file
|
* Delete the config file
|
||||||
* Ignores errors if the file doesn't exist
|
* Ignores errors if the file doesn't exist
|
||||||
*/
|
*/
|
||||||
export const deleteConfig = async (): Promise<void> => {
|
export async function deleteConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await unlink(CONFIG_FILE);
|
await unlink(CONFIG_FILE);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if doesn't exist
|
// Ignore if doesn't exist
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
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,
|
"isolatedDeclarations": false,
|
||||||
"composite": false
|
"composite": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"]
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"@orpc/client": "^1.13.2",
|
"@orpc/client": "^1.13.2",
|
||||||
"@orpc/contract": "^1.13.2",
|
"@orpc/contract": "^1.13.2",
|
||||||
"@reviq/api-contract": "workspace:*",
|
"@reviq/api-contract": "workspace:*",
|
||||||
|
"@reviq/common": "workspace:*",
|
||||||
|
"@reviq/frontend-utils": "workspace:*",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@tanstack/svelte-query": "^6.0.14",
|
"@tanstack/svelte-query": "^6.0.14",
|
||||||
"@tanstack/svelte-query-devtools": "^6.0.3",
|
"@tanstack/svelte-query-devtools": "^6.0.3",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.49.4",
|
"@sveltejs/kit": "^2.49.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
/* Geist Sans - Modern, clean typeface */
|
/* Geist Sans - Modern, clean typeface */
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
|
|||||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||||
import UserIcon from "@lucide/svelte/icons/user";
|
import UserIcon from "@lucide/svelte/icons/user";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
|
|||||||
>
|
>
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.href)}
|
||||||
{@const active = isActive(item.href, $page.url.pathname)}
|
{@const active = isActive(item.href, $page.url.pathname)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export { default as AccountNav } from "./account-nav.svelte";
|
export { default as AccountNav } from "./account-nav.svelte";
|
||||||
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
|
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
|
||||||
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
|
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
|
||||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
|
||||||
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
|
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
|
||||||
export { default as PasskeyList } from "./passkey-list.svelte";
|
export { default as PasskeyList } from "./passkey-list.svelte";
|
||||||
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";
|
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Key, Pencil, Trash2 } from "@lucide/svelte";
|
import { Key, Pencil, Trash2 } from "@lucide/svelte";
|
||||||
|
import { formatDate, formatRelativeTime } from "@reviq/common";
|
||||||
import { useQueryClient } from "@tanstack/svelte-query";
|
import { useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import ConfirmDialog from "./confirm-dialog.svelte";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
|
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
|
||||||
|
|
||||||
interface Passkey {
|
interface Passkey {
|
||||||
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
|
|||||||
let selectedPasskey = $state<Passkey | null>(null);
|
let selectedPasskey = $state<Passkey | null>(null);
|
||||||
let isDeleting = $state(false);
|
let isDeleting = $state(false);
|
||||||
|
|
||||||
function formatDate(date: Date | string): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(date: Date | string | null): string {
|
|
||||||
if (!date) {
|
|
||||||
return "Never";
|
|
||||||
}
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
if (diffDays === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (diffDays < 7) {
|
|
||||||
return `${diffDays} days ago`;
|
|
||||||
}
|
|
||||||
if (diffDays < 30) {
|
|
||||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
|
||||||
}
|
|
||||||
return formatDate(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRename(passkey: Passkey) {
|
function openRename(passkey: Passkey) {
|
||||||
selectedPasskey = passkey;
|
selectedPasskey = passkey;
|
||||||
renameDialogOpen = true;
|
renameDialogOpen = true;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -12,29 +11,29 @@ interface Props {
|
|||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
// Check if current path is an auth page (doesn't require login)
|
// Check if current path is a public page (doesn't require login)
|
||||||
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
|
const isPublicPage = $derived(
|
||||||
|
page.url.pathname.startsWith("/auth") ||
|
||||||
|
page.url.pathname === "/terms" ||
|
||||||
|
page.url.pathname === "/privacy",
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch user to check if logged in (only for non-auth pages)
|
// Fetch user to check if logged in (only for protected pages)
|
||||||
const userQuery = createQuery(() => ({
|
const userQuery = createQuery(() => ({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: () => api.me.get(),
|
queryFn: () => api.me.get(),
|
||||||
enabled: !isAuthPage,
|
enabled: !isPublicPage,
|
||||||
retry: false,
|
retry: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Redirect to login if not authenticated on non-auth pages
|
// Redirect to login if not authenticated on protected pages
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isAuthPage && userQuery.error) {
|
if (!isPublicPage && userQuery.error) {
|
||||||
goto(
|
gotoLogin(page.url.pathname);
|
||||||
resolve(
|
|
||||||
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isAuthPage || userQuery.data || userQuery.isPending}
|
{#if isPublicPage || userQuery.data || userQuery.isPending}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,8 +26,8 @@ const filters = [
|
|||||||
|
|
||||||
<div class="divide-y divide-border/50">
|
<div class="divide-y divide-border/50">
|
||||||
{#each filters as filter (filter.label)}
|
{#each filters as filter (filter.label)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(filter.href as any)}
|
<a href={filter.href}
|
||||||
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -33,14 +32,15 @@ const activeTab = $derived(
|
|||||||
($page.url.searchParams.get("tab") as TabId) || defaultTab,
|
($page.url.searchParams.get("tab") as TabId) || defaultTab,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleTabChange(tabId: string) {
|
function handleTabChange(tabId: string): void {
|
||||||
const url = new URL($page.url);
|
const url = new URL($page.url);
|
||||||
if (tabId === defaultTab) {
|
if (tabId === defaultTab) {
|
||||||
url.searchParams.delete("tab");
|
url.searchParams.delete("tab");
|
||||||
} else {
|
} else {
|
||||||
url.searchParams.set("tab", tabId);
|
url.searchParams.set("tab", tabId);
|
||||||
}
|
}
|
||||||
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true });
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto(url.toString(), { replaceState: true, noScroll: true });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface AdUnitRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: AdUnitRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "/header/leaderboard-728x90",
|
name: "/header/leaderboard-728x90",
|
||||||
@@ -51,58 +55,10 @@ const tableData = [
|
|||||||
impPercent: 9.16,
|
impPercent: 9.16,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
<code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head>
|
</MetricsTable>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
% of revenue
|
|
||||||
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</Table.Head>
|
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface CountryRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: CountryRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "United States",
|
name: "United States",
|
||||||
@@ -57,54 +62,14 @@ const tableData = [
|
|||||||
impPercent: 4.68,
|
impPercent: 4.68,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Country">
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
{@const countryRow = row as CountryRow}
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
<div class="flex items-center gap-2">
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
<span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
</div>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
</MetricsTable>
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface DomainRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: DomainRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "example.com",
|
name: "example.com",
|
||||||
@@ -27,51 +31,10 @@ const tableData = [
|
|||||||
impPercent: 18.45,
|
impPercent: 18.45,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Domain">
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
<span class="text-[13px] font-medium text-foreground">{(row as DomainRow).name}</span>
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Domain</Table.Head>
|
</MetricsTable>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ export { default as AdUnitTable } from "./ad-unit-table.svelte";
|
|||||||
export { default as CountryTable } from "./country-table.svelte";
|
export { default as CountryTable } from "./country-table.svelte";
|
||||||
export { default as DomainTable } from "./domain-table.svelte";
|
export { default as DomainTable } from "./domain-table.svelte";
|
||||||
export { default as KeyValueTable } from "./key-value-table.svelte";
|
export { default as KeyValueTable } from "./key-value-table.svelte";
|
||||||
|
export {
|
||||||
|
default as MetricsTable,
|
||||||
|
type MetricsRow,
|
||||||
|
} from "./metrics-table.svelte";
|
||||||
export { default as SourceTable } from "./source-table.svelte";
|
export { default as SourceTable } from "./source-table.svelte";
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import * as Table from "$lib/components/ui/table";
|
||||||
|
|
||||||
const tableData = [
|
interface KeyValueRow {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
revenue: string;
|
||||||
|
revPercent: number;
|
||||||
|
impressions: string;
|
||||||
|
impPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: KeyValueRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
key: "device",
|
key: "device",
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface SourceRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: SourceRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Google AdX",
|
name: "Google AdX",
|
||||||
@@ -43,51 +47,10 @@ const tableData = [
|
|||||||
impPercent: 7.28,
|
impPercent: 7.28,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Source">
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
<span class="text-[13px] font-medium text-foreground">{(row as SourceRow).name}</span>
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Source</Table.Head>
|
</MetricsTable>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user