Compare commits
59 Commits
730021a5ea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
8da4379583
|
|||
|
1f6d5a4a9f
|
|||
|
d8397dfb38
|
|||
|
73ef3df01f
|
|||
|
25c8bab741
|
|||
|
b48012c1f6
|
|||
|
bd4053f952
|
|||
|
ce5a27d014
|
|||
|
665092464a
|
|||
|
b78064caeb
|
|||
|
c60041a1bb
|
|||
|
40d743c8c2
|
|||
|
e43c006bb1
|
|||
|
8e65c2e698
|
|||
|
b085a315be
|
|||
|
1ed41e5c4c
|
|||
|
84644c8bfb
|
|||
|
5ecf12a1a1
|
|||
|
c2b815dd6a
|
|||
|
67930d90d5
|
|||
|
58ffa68f4c
|
|||
|
5a2e0297e5
|
|||
|
c9de0b1ac5
|
|||
|
0f50291490
|
|||
|
9c6694cad4
|
|||
|
f9f1dc7403
|
|||
|
b27a977809
|
|||
|
7edc4ba8a9
|
|||
|
16f827e8f0
|
|||
|
947c73dbdc
|
|||
|
2baf10b0cd
|
|||
|
8b081d5ba8
|
|||
|
01f1e1c9e3
|
|||
|
26d10d452f
|
|||
|
8b63eb3538
|
|||
|
587e151fbd
|
|||
|
94b6de5970
|
|||
|
6fa4da1abb
|
|||
|
92f7e1df09
|
|||
|
b2fba6e150
|
|||
|
ebc85af62c
|
|||
|
6b8dd27898
|
|||
|
61fdd3329f
|
|||
|
848d9e9af1
|
|||
|
44a480179b
|
|||
|
628b01f4d8
|
|||
| 8939deefbe | |||
|
4d9fbdeed5
|
|||
|
76a5e40900
|
|||
|
b1d07626f3
|
|||
|
99539bbdcb
|
|||
|
9a119da96e
|
|||
|
7358129802
|
|||
|
eedd664db8
|
|||
|
a02e1f0862
|
|||
|
2fb42c0fa5
|
|||
|
3d42324750
|
|||
|
ac4b8dc99a
|
|||
|
cf71cb63d7
|
@@ -0,0 +1,16 @@
|
||||
id: no-countall-number
|
||||
snapshots:
|
||||
countAll<number>():
|
||||
fixed: countAll()
|
||||
labels:
|
||||
- source: countAll<number>()
|
||||
style: primary
|
||||
start: 0
|
||||
end: 18
|
||||
eb.fn.countAll<number>().as("count"):
|
||||
fixed: eb.fn.countAll().as("count")
|
||||
labels:
|
||||
- source: eb.fn.countAll<number>()
|
||||
style: primary
|
||||
start: 0
|
||||
end: 24
|
||||
@@ -0,0 +1,20 @@
|
||||
id: no-string-function
|
||||
snapshots:
|
||||
String(123):
|
||||
labels:
|
||||
- source: String(123)
|
||||
style: primary
|
||||
start: 0
|
||||
end: 11
|
||||
String(Date.now()):
|
||||
labels:
|
||||
- source: String(Date.now())
|
||||
style: primary
|
||||
start: 0
|
||||
end: 18
|
||||
String(value):
|
||||
labels:
|
||||
- source: String(value)
|
||||
style: primary
|
||||
start: 0
|
||||
end: 13
|
||||
@@ -3,7 +3,7 @@ snapshots:
|
||||
? |
|
||||
import { z } from "zod";
|
||||
: fixed: |
|
||||
import * as z from "zod"
|
||||
import * as z from "zod";
|
||||
labels:
|
||||
- source: import { z } from "zod";
|
||||
style: primary
|
||||
@@ -12,7 +12,7 @@ snapshots:
|
||||
? |
|
||||
import { z, ZodError } from "zod";
|
||||
: fixed: |
|
||||
import * as z from "zod"
|
||||
import * as z from "zod";
|
||||
labels:
|
||||
- source: import { z, ZodError } from "zod";
|
||||
style: primary
|
||||
|
||||
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
id: no-countall-number
|
||||
valid:
|
||||
# Plain countAll() is fine
|
||||
- eb.fn.countAll().as("count")
|
||||
# Other type arguments are fine
|
||||
- eb.fn.countAll<string>().as("count")
|
||||
invalid:
|
||||
# countAll<number>() should be flagged
|
||||
- eb.fn.countAll<number>().as("count")
|
||||
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
id: no-string-function
|
||||
valid:
|
||||
# toString() is fine
|
||||
- value.toString()
|
||||
- (123).toString()
|
||||
- date.toLocaleString()
|
||||
# Other functions named String are fine
|
||||
- myString(value)
|
||||
invalid:
|
||||
# String() function should be flagged
|
||||
- String(value)
|
||||
- String(123)
|
||||
- String(Date.now())
|
||||
8
.ast-grep/rules/no-countall-number.yml
Normal file
8
.ast-grep/rules/no-countall-number.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
id: no-countall-number
|
||||
language: typescript
|
||||
severity: error
|
||||
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
|
||||
note: "Use Number() to convert the result if you need a number type."
|
||||
rule:
|
||||
pattern: $OBJ.countAll<number>()
|
||||
fix: $OBJ.countAll()
|
||||
7
.ast-grep/rules/no-string-function.yml
Normal file
7
.ast-grep/rules/no-string-function.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
id: no-string-function
|
||||
language: typescript
|
||||
severity: error
|
||||
message: "Don't use String() - use .toString() or .toLocaleString() instead."
|
||||
note: "String() can have unexpected behavior. Use .toString() for general conversion or .toLocaleString() for locale-aware formatting."
|
||||
rule:
|
||||
pattern: String($VAL)
|
||||
77
.claude/skills/gitea/SKILL.md
Normal file
77
.claude/skills/gitea/SKILL.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: gitea
|
||||
description: Create pull requests on Gitea using the tea CLI. Use when the user asks to "create a PR", "open a pull request", "make a PR", "submit PR", or any variation involving pull requests for this repository.
|
||||
---
|
||||
|
||||
# Gitea Pull Requests
|
||||
|
||||
This project uses Gitea (git.rev.iq) for hosting and the `tea` CLI for creating pull requests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The `tea` CLI is installed via devenv (pinned to 0.10.1 to avoid TTY bugs in 0.11.x)
|
||||
- Login is configured via `~/.config/tea/config.yml`
|
||||
|
||||
## Creating a Pull Request
|
||||
|
||||
When asked to create a PR, follow these steps:
|
||||
|
||||
### 1. Check current state
|
||||
|
||||
```bash
|
||||
git status
|
||||
git log --oneline -5
|
||||
git diff master...HEAD --stat
|
||||
```
|
||||
|
||||
### 2. Ensure changes are committed and pushed
|
||||
|
||||
If there are uncommitted changes, commit them first. Then push:
|
||||
|
||||
```bash
|
||||
git push -u origin <branch-name>
|
||||
```
|
||||
|
||||
### 3. Create the PR using tea
|
||||
|
||||
```bash
|
||||
tea pr create \
|
||||
-r igm/publisher-dashboard \
|
||||
--title "PR title here" \
|
||||
--description "## Summary
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)" \
|
||||
--head <source-branch> \
|
||||
--base master
|
||||
```
|
||||
|
||||
**Important flags:**
|
||||
- `-r igm/publisher-dashboard` - Always specify the repo explicitly (required due to SSH remote detection issues)
|
||||
- `--head` - The source branch (your feature branch)
|
||||
- `--base` - The target branch (usually `master`)
|
||||
|
||||
### 4. Return the PR URL
|
||||
|
||||
The command outputs the PR URL. Always share this with the user.
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
# #1 Update packages to export from dist/ (open)
|
||||
|
||||
@igm created 2024-01-11 **master** <- **fix-exports**
|
||||
|
||||
--------
|
||||
|
||||
• No Conflicts
|
||||
• Maintainers are allowed to edit
|
||||
|
||||
https://git.rev.iq/igm/publisher-dashboard/pulls/1
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If tea fails with TTY errors, ensure you're using tea 0.10.1 (configured in `nix/tea.nix`)
|
||||
- The repo flag `-r igm/publisher-dashboard` is required because the SSH remote isn't auto-detected
|
||||
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
|
||||
81
CLAUDE.md
81
CLAUDE.md
@@ -1,5 +1,20 @@
|
||||
# Claude Code Notes
|
||||
|
||||
## Running Tests
|
||||
|
||||
Use `bun run test:cov` to run all tests with coverage. This runs both unit tests and e2e tests that require a database connection.
|
||||
|
||||
- `bun run test:cov` - Run all tests with coverage (preferred)
|
||||
- `bun run test:unit:cov` - Run only unit tests with coverage (no database required)
|
||||
|
||||
## Database Scripts
|
||||
|
||||
Use the wrapper scripts instead of running dbmate directly:
|
||||
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
|
||||
- `./scripts/db-migrate` - Run migrations and dump clean schema
|
||||
|
||||
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
|
||||
|
||||
## Development Server
|
||||
|
||||
Before starting the dev server, check if it's already running:
|
||||
@@ -7,10 +22,64 @@ Before starting the dev server, check if it's already running:
|
||||
- The dev server runs on port 6827 (may fall back to 6828 if port is in use)
|
||||
- Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
|
||||
|
||||
## macOS sed Syntax
|
||||
## Pull Requests
|
||||
|
||||
macOS uses BSD sed which differs from GNU sed:
|
||||
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
|
||||
- GNU sed (Linux): `sed -i 's/old/new/g' file`
|
||||
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
|
||||
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
|
||||
This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
|
||||
- Use the `/gitea` skill when creating PRs
|
||||
- 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)
|
||||
|
||||
## sed Syntax (GNU coreutils)
|
||||
|
||||
This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
|
||||
- In-place edit: `sed -i 's/old/new/g' file`
|
||||
- Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|g' file`
|
||||
- For multiple files: `for f in *.txt; do sed -i 's/old/new/g' "$f"; done`
|
||||
- Do NOT use BSD sed syntax (`sed -i ''`) - we have GNU sed available
|
||||
|
||||
## SvelteKit resolve() Usage
|
||||
|
||||
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
|
||||
|
||||
### Static routes - use resolve() directly
|
||||
```svelte
|
||||
href={resolve("/auth/login")}
|
||||
href={resolve("/dashboard")}
|
||||
```
|
||||
|
||||
### Dynamic routes - use two-argument form
|
||||
```svelte
|
||||
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
|
||||
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
|
||||
```
|
||||
|
||||
### Login redirects - use gotoLogin helper
|
||||
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
|
||||
```typescript
|
||||
import { gotoLogin } from "$lib/utils/navigation";
|
||||
|
||||
gotoLogin(page.url.pathname);
|
||||
```
|
||||
This helper uses resolve() internally and handles the query string correctly.
|
||||
|
||||
### Navigation arrays - use `as const` with route patterns
|
||||
For type-safe navigation arrays, define routes as literal strings with `as const`:
|
||||
```typescript
|
||||
const navItems = [
|
||||
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
|
||||
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
|
||||
] as const;
|
||||
```
|
||||
Then use resolve with params:
|
||||
```svelte
|
||||
{#each navItems as item (item.route)}
|
||||
<a href={resolve(item.route, { slug })}>
|
||||
{/each}
|
||||
```
|
||||
|
||||
### Runtime strings - skip resolve, use eslint-disable
|
||||
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
|
||||
```typescript
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(redirectUrl);
|
||||
```
|
||||
|
||||
19
README.md
19
README.md
@@ -26,9 +26,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
|
||||
|
||||
### Shared Packages
|
||||
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||
- `@reviq/common` - Shared utilities for frontend and backend
|
||||
- `@reviq/db` - Database client and queries
|
||||
- `@reviq/db-schema` - Database schema and codegen
|
||||
- `@reviq/utils` - Shared utilities
|
||||
- `@reviq/frontend-utils` - Frontend-specific utilities
|
||||
- `@reviq/server-utils` - Server/CLI utilities
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -40,10 +42,12 @@ publisher-dashboard/
|
||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||
├── packages/
|
||||
│ ├── api-contract/ # Shared oRPC contract
|
||||
│ ├── common/ # Shared utilities (frontend + backend)
|
||||
│ ├── db/ # Database client
|
||||
│ ├── db-schema/ # DB schema & codegen
|
||||
│ ├── testing/ # Test utilities
|
||||
│ └── utils/ # Shared utilities
|
||||
│ ├── frontend-utils/ # Frontend utilities
|
||||
│ ├── server-utils/ # Server/CLI utilities
|
||||
│ └── testing/ # Test utilities
|
||||
└── db/ # Database migrations
|
||||
```
|
||||
|
||||
@@ -51,7 +55,7 @@ publisher-dashboard/
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) v1.1.42+
|
||||
- [Bun](https://bun.sh/) v1.3.5+
|
||||
- [devenv](https://devenv.sh/) for development environment management
|
||||
|
||||
### Environment Variables
|
||||
@@ -109,8 +113,13 @@ bun run dev
|
||||
| `bun run typecheck` | Run TypeScript type checking |
|
||||
| `bun run lint` | Run Biome and ESLint |
|
||||
| `bun run lint:fix` | Fix linting issues |
|
||||
| `bun run test` | Run tests |
|
||||
| `bun run test` | Run all tests (requires database) |
|
||||
| `bun run test:unit` | Run unit tests only (no database required) |
|
||||
| `bun run test:cov` | Run all tests with coverage report |
|
||||
| `bun run test:unit:cov` | Run unit tests with coverage (no database) |
|
||||
| `bun run db:codegen` | Generate database types |
|
||||
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
|
||||
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
|
||||
|
||||
## CLI
|
||||
|
||||
|
||||
@@ -9,19 +9,17 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --cache",
|
||||
"clean": "rm -rf dist .eslintcache",
|
||||
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
|
||||
"test:unit": "bun test src/__tests__/unit",
|
||||
"test": "bun test --coverage src/utils"
|
||||
"test": "bun test src/ --no-parallel"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.9.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@orpc/experimental-pino": "^1.13.2",
|
||||
"@orpc/server": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@reviq/utils": "workspace:*",
|
||||
"@reviq/emails": "workspace:*",
|
||||
"@reviq/server-utils": "workspace:*",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
@@ -34,12 +32,11 @@
|
||||
"devDependencies": {
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@reviq/test-helpers": "workspace:*",
|
||||
"@reviq/virtual-authenticator": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"eslint": "catalog:",
|
||||
"pg": "^8.16.3",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
1954
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
1954
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
return [
|
||||
`http://localhost:${String(DEFAULT_PORT)}`,
|
||||
`http://localhost:${DEFAULT_PORT.toString()}`,
|
||||
"http://localhost:6827",
|
||||
"http://localhost:6828",
|
||||
];
|
||||
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
|
||||
/** Base URL for generating email links */
|
||||
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
|
||||
|
||||
/** Dev mode: log emails instead of sending (default: true) */
|
||||
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
|
||||
|
||||
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
|
||||
/** Postmark API key (optional - uses logging client if not set) */
|
||||
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
|
||||
|
||||
// ===== Token Expiration Times =====
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { EmailClient } from "@reviq/emails";
|
||||
import type { Kysely } from "kysely";
|
||||
|
||||
/**
|
||||
* Email configuration for the API
|
||||
*/
|
||||
export interface EmailConfig {
|
||||
client: EmailClient;
|
||||
fromAddress: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API context available to all handlers
|
||||
*/
|
||||
@@ -23,6 +33,8 @@ export interface APIContext {
|
||||
resHeaders: Headers;
|
||||
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||
clientIP?: string | null;
|
||||
/** Email client and configuration */
|
||||
email: EmailConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext {
|
||||
/** User with superuser privileges */
|
||||
user: SessionUser & { isSuperuser: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization info in context
|
||||
*/
|
||||
export interface OrgInfo {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* User's membership in an org
|
||||
*/
|
||||
export interface OrgMembership {
|
||||
id: number;
|
||||
role: "owner" | "admin" | "member";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Org member context for org-scoped procedures
|
||||
* Requires user to be a member of the org
|
||||
*/
|
||||
export interface OrgMemberContext extends AuthenticatedContext {
|
||||
/** The organization */
|
||||
org: OrgInfo;
|
||||
/** User's membership in the org */
|
||||
membership: OrgMembership;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ import type { APIContext } from "./context.js";
|
||||
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { createDb } from "@reviq/db";
|
||||
import { createLoggingEmailClient, createPostmarkClient } from "@reviq/emails";
|
||||
import pino from "pino";
|
||||
import {
|
||||
BASE_URL,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_RP_NAME,
|
||||
EMAIL_FROM,
|
||||
getAllowedOrigins,
|
||||
POSTMARK_API_KEY,
|
||||
} from "./constants.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
@@ -24,6 +28,16 @@ if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL environment variable is required");
|
||||
}
|
||||
const db = createDb(databaseUrl);
|
||||
|
||||
// Create email client - use Postmark if API key is set, otherwise log to console
|
||||
const emailClient = POSTMARK_API_KEY
|
||||
? createPostmarkClient(POSTMARK_API_KEY)
|
||||
: createLoggingEmailClient();
|
||||
|
||||
if (!POSTMARK_API_KEY) {
|
||||
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
|
||||
}
|
||||
|
||||
const handler = new RPCHandler(router, {
|
||||
plugins: [
|
||||
new LoggingHandlerPlugin({
|
||||
@@ -45,7 +59,7 @@ Bun.serve({
|
||||
if (url.pathname.startsWith("/api/v1/rpc")) {
|
||||
// Build context for the request
|
||||
const origin =
|
||||
request.headers.get("origin") ?? `http://localhost:${String(port)}`;
|
||||
request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
|
||||
|
||||
// Create response headers for setting cookies
|
||||
const resHeaders = new Headers();
|
||||
@@ -62,6 +76,11 @@ Bun.serve({
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
clientIP,
|
||||
email: {
|
||||
client: emailClient,
|
||||
fromAddress: EMAIL_FROM,
|
||||
baseUrl: BASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
const { response } = await handler.handle(request, {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Authentication middleware for oRPC server
|
||||
*
|
||||
* Handles authentication via:
|
||||
* - Session cookie (rev.session_token) - for browser clients
|
||||
* - API key header (x-api-key) - for CLI and programmatic access
|
||||
*/
|
||||
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
|
||||
/**
|
||||
* Create the auth middleware function
|
||||
* This returns a middleware handler that can be used with oRPC procedures
|
||||
*/
|
||||
export const createAuthMiddleware = () => {
|
||||
return async ({
|
||||
context,
|
||||
next,
|
||||
}: {
|
||||
context: APIContext;
|
||||
next: (opts: {
|
||||
context: Omit<AuthenticatedContext, keyof APIContext>;
|
||||
}) => Promise<unknown>;
|
||||
}) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require superuser access
|
||||
*/
|
||||
export const createSuperuserMiddleware = () => {
|
||||
return async ({
|
||||
context,
|
||||
next,
|
||||
}: {
|
||||
context: AuthenticatedContext;
|
||||
next: () => Promise<unknown>;
|
||||
}) => {
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
138
apps/api-server/src/middlewares/auth.ts
Normal file
138
apps/api-server/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Auth middleware - validates session/API token and adds user to context
|
||||
*/
|
||||
|
||||
import type { AuthInfo, Session, SessionUser } from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
import { os } from "./os.js";
|
||||
|
||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
8
apps/api-server/src/middlewares/index.ts
Normal file
8
apps/api-server/src/middlewares/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Middleware exports
|
||||
*/
|
||||
|
||||
export { authMiddleware } from "./auth.js";
|
||||
export { loginRequestMiddleware } from "./login-request.js";
|
||||
export { os } from "./os.js";
|
||||
export { superuserMiddleware } from "./superuser.js";
|
||||
64
apps/api-server/src/middlewares/login-request.ts
Normal file
64
apps/api-server/src/middlewares/login-request.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Login request middleware - validates login request token from cookie
|
||||
*/
|
||||
|
||||
import type { SessionUser } from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { os } from "./os.js";
|
||||
|
||||
export const loginRequestMiddleware = os.middleware(
|
||||
async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Read login request token from cookie
|
||||
const loginRequestToken = getCookie(
|
||||
reqHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
);
|
||||
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No login request found",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
.select([
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.expires_at",
|
||||
"users.email",
|
||||
"users.display_name",
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: result.user_id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
emailVerifiedAt: result.email_verified_at,
|
||||
isSuperuser: result.is_superuser,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
loginRequestId: Number(result.id),
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
10
apps/api-server/src/middlewares/os.ts
Normal file
10
apps/api-server/src/middlewares/os.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Base implementer with typed APIContext
|
||||
* All procedures and middlewares should derive from this
|
||||
*/
|
||||
|
||||
import type { APIContext } from "../context.js";
|
||||
import { implement } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
|
||||
export const os = implement(contract).$context<APIContext>();
|
||||
19
apps/api-server/src/middlewares/superuser.ts
Normal file
19
apps/api-server/src/middlewares/superuser.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Superuser middleware - authenticates and requires superuser access
|
||||
*
|
||||
* This middleware chains authMiddleware first, then checks for superuser.
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware } from "./auth.js";
|
||||
|
||||
export const superuserMiddleware = authMiddleware.concat(
|
||||
async ({ context, next }) => {
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
},
|
||||
);
|
||||
@@ -3,49 +3,49 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const email = input.email.toLowerCase();
|
||||
export const adminAuthCompleteLogin =
|
||||
superuserProcedure.admin.auth.completeLogin.handler(
|
||||
async ({ input, context }) => {
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
// First check if any login request exists for this email
|
||||
const anyRequest = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.where("email", "=", email)
|
||||
.orderBy("created_at", "desc")
|
||||
.select(["id", "completed_at", "expires_at"])
|
||||
.executeTakeFirst();
|
||||
// First check if any login request exists for this email
|
||||
const anyRequest = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.where("email", "=", email)
|
||||
.orderBy("created_at", "desc")
|
||||
.select(["id", "completed_at", "expires_at"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!anyRequest) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: `No login request found for ${email}`,
|
||||
});
|
||||
}
|
||||
if (!anyRequest) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: `No login request found for ${email}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already completed
|
||||
if (anyRequest.completed_at) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request already completed",
|
||||
});
|
||||
}
|
||||
// Check if already completed
|
||||
if (anyRequest.completed_at) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request already completed",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Login request expired (15 min limit). Start a new login flow.",
|
||||
});
|
||||
}
|
||||
// Check if expired
|
||||
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Login request expired (15 min limit). Start a new login flow.",
|
||||
});
|
||||
}
|
||||
|
||||
// Complete the login request
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", anyRequest.id)
|
||||
.execute();
|
||||
// Complete the login request
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", anyRequest.id)
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsCreate = os.admin.orgs.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsCreate = superuserProcedure.admin.orgs.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, ownerEmail } = input;
|
||||
|
||||
// Find owner user by email (outside transaction - read-only)
|
||||
@@ -55,4 +53,5 @@ export const adminOrgsCreate = os.admin.orgs.create
|
||||
});
|
||||
|
||||
return { slug };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsDelete = os.admin.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsDelete = superuserProcedure.admin.orgs.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Delete org and related records in transaction
|
||||
@@ -35,4 +33,5 @@ export const adminOrgsDelete = os.admin.orgs.delete
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsGet = os.admin.orgs.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsGet = superuserProcedure.admin.orgs.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", input.slug)
|
||||
@@ -19,4 +17,5 @@ export const adminOrgsGet = os.admin.orgs.get
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return toOrgResponse(org);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* admin.orgs.list - List all organizations
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsList = os.admin.orgs.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const adminOrgsList = superuserProcedure.admin.orgs.list.handler(
|
||||
async ({ context }) => {
|
||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||
return orgs.map(toOrgResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,37 +4,35 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toSiteResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
export const adminOrgsListSites =
|
||||
superuserProcedure.admin.orgs.listSites.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
const sites = await context.db
|
||||
.selectFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.selectAll()
|
||||
.execute();
|
||||
const sites = await context.db
|
||||
.selectFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
return sites.map(toSiteResponse);
|
||||
});
|
||||
return sites.map(toSiteResponse);
|
||||
},
|
||||
);
|
||||
|
||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
// Use transaction to prevent race condition on site creation
|
||||
@@ -70,32 +68,33 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
export const adminOrgsRemoveSite =
|
||||
superuserProcedure.admin.orgs.removeSite.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", slug)
|
||||
.select(["id"])
|
||||
.executeTakeFirst();
|
||||
if (!org) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
|
||||
const result = await context.db
|
||||
.deleteFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.where("domain", "=", domain)
|
||||
.executeTakeFirst();
|
||||
const result = await context.db
|
||||
.deleteFrom("org_sites")
|
||||
.where("org_id", "=", org.id)
|
||||
.where("domain", "=", domain)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Site not found" });
|
||||
}
|
||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||
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 { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsUpdate = os.admin.orgs.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsUpdate = superuserProcedure.admin.orgs.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Check if there are actual updates to make
|
||||
@@ -49,4 +47,5 @@ export const adminOrgsUpdate = os.admin.orgs.update
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
email_verified_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
export const adminUsersConfirmEmail =
|
||||
superuserProcedure.admin.users.confirmEmail.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
email_verified_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
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 { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersCreate = os.admin.users.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersCreate = superuserProcedure.admin.users.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { email, name, orgSlug, orgRole } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
@@ -62,4 +60,5 @@ export const adminUsersCreate = os.admin.users.create
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersGet = os.admin.users.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersGet = superuserProcedure.admin.users.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
@@ -19,4 +17,5 @@ export const adminUsersGet = os.admin.users.get
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return toUserResponse(user);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* admin.users.list - List all users
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersList = os.admin.users.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const adminUsersList = superuserProcedure.admin.users.list.handler(
|
||||
async ({ context }) => {
|
||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||
return users.map(toUserResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersUpdate = os.admin.users.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersUpdate = superuserProcedure.admin.users.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { email, isSuperuser } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
@@ -47,4 +45,5 @@ export const adminUsersUpdate = os.admin.users.update
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
* This prevents attackers from determining which emails are registered
|
||||
*/
|
||||
|
||||
import { withTransaction } from "@reviq/db";
|
||||
import { sendPasswordResetEmail } from "@reviq/emails";
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
||||
import { os } from "../base.js";
|
||||
|
||||
export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
@@ -30,29 +31,39 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
|
||||
// If user exists, create password reset token and send email
|
||||
if (user) {
|
||||
// Delete any existing password reset tokens for this user (security measure)
|
||||
await context.db
|
||||
.deleteFrom("password_resets")
|
||||
.where("user_id", "=", user.id)
|
||||
.execute();
|
||||
|
||||
// Generate secure base58 token
|
||||
const token = generateSecureBase58Token();
|
||||
|
||||
// Create password reset record with 1 hour expiry
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
||||
|
||||
await context.db
|
||||
.insertInto("password_resets")
|
||||
.values({
|
||||
user_id: user.id,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.execute();
|
||||
// Delete old tokens and insert new one in transaction
|
||||
await withTransaction(context.db, async (trx) => {
|
||||
// Delete any existing password reset tokens for this user (security measure)
|
||||
await trx
|
||||
.deleteFrom("password_resets")
|
||||
.where("user_id", "=", user.id)
|
||||
.execute();
|
||||
|
||||
// Send password reset email (stubbed)
|
||||
await sendPasswordResetEmail(user.email, token);
|
||||
await trx
|
||||
.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)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
||||
*/
|
||||
|
||||
import { withTransaction } from "@reviq/db";
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
COOKIE_OPTIONS,
|
||||
@@ -89,37 +90,41 @@ export const loginIfRequestIsCompleted =
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
// Upsert user device
|
||||
const deviceId = await upsertUserDevice(
|
||||
// Create session in transaction (atomic: device upsert + session + login_request delete)
|
||||
const { session, deviceTrusted } = await withTransaction(
|
||||
context.db,
|
||||
userId,
|
||||
deviceFingerprint,
|
||||
geo,
|
||||
userAgent,
|
||||
async (trx) => {
|
||||
// Upsert user device
|
||||
const deviceId = await upsertUserDevice(
|
||||
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
|
||||
setCookie(
|
||||
context.resHeaders,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendLoginConfirmationEmail } from "@reviq/emails";
|
||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
||||
import { verifyPassword } from "../../utils/password.js";
|
||||
import { isDeviceTrusted } from "../../utils/session.js";
|
||||
import { os } from "../base.js";
|
||||
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
} else {
|
||||
// Device is untrusted - send confirmation email with existing token
|
||||
// The same base58 token is used for both cookie lookup and email confirmation
|
||||
await sendLoginConfirmationEmail(result.email, result.token);
|
||||
await sendLoginConfirmationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: result.email,
|
||||
token: result.token,
|
||||
expiryMinutes: 15,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
/**
|
||||
* Logout handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Revokes the current session by setting revoked_at to now()
|
||||
* - Clears the session cookie from the response
|
||||
*/
|
||||
export const logout = os.auth.logout
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const logout = authedProcedure.auth.logout.handler(
|
||||
async ({ context }) => {
|
||||
// Revoke the current session
|
||||
await context.db
|
||||
.updateTable("sessions")
|
||||
@@ -25,4 +24,5 @@ export const logout = os.auth.logout
|
||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,17 +10,16 @@
|
||||
* 5. Send verification email (stubbed)
|
||||
*/
|
||||
|
||||
import { sendVerificationEmail } from "@reviq/emails";
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const resendVerificationEmail =
|
||||
authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
|
||||
// Check if email is already verified
|
||||
if (context.user.emailVerifiedAt !== null) {
|
||||
// Email already verified, return early
|
||||
@@ -47,8 +46,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(context.user.email, token);
|
||||
// Send verification email
|
||||
await sendVerificationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: context.user.email,
|
||||
token,
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { Kysely } from "kysely";
|
||||
import type { RPInfo } from "../../utils/webauthn.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { withTransaction } from "@reviq/db";
|
||||
import { sendVerificationEmail } from "@reviq/emails";
|
||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||
import { createSession } from "../../utils/session.js";
|
||||
@@ -52,17 +53,28 @@ export async function signupWithPassword(
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
// Create user (handle race condition if concurrent signup with same email)
|
||||
try {
|
||||
const user = await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
})
|
||||
.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
|
||||
.selectFrom("webauthn_challenges")
|
||||
.select("options")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.where("created_at", ">", fifteenMinutesAgo)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
|
||||
// Delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
|
||||
// Log error for debugging but don't expose to client
|
||||
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
|
||||
// Delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
@@ -146,55 +158,66 @@ export async function signupWithPasskey(
|
||||
});
|
||||
}
|
||||
|
||||
// Create user and passkey in a transaction
|
||||
const result = await db.transaction().execute(async (trx) => {
|
||||
// Create user
|
||||
const user = await trx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||
try {
|
||||
const result = await withTransaction(db, async (trx) => {
|
||||
// Create user
|
||||
const user = await trx
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email,
|
||||
password_hash: null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const newUserId = user.id;
|
||||
const newUserId = user.id;
|
||||
|
||||
// Get friendly name from AAGUID
|
||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||
const passkeyName = guidName ?? "Default";
|
||||
// Get friendly name from AAGUID
|
||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||
const passkeyName = guidName ?? "Default";
|
||||
|
||||
// Store the passkey
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
// Store the passkey
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
|
||||
await trx
|
||||
.insertInto("passkeys")
|
||||
.values({
|
||||
user_id: newUserId,
|
||||
credential_id: Buffer.from(credential.id, "base64url"),
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
webauthn_user_id: options.user.id,
|
||||
counter: BigInt(credential.counter),
|
||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||
backup_eligible: registrationInfo.credentialBackedUp,
|
||||
backup_status: credentialBackedUp,
|
||||
transports: JSON.stringify(response.response.transports ?? []),
|
||||
rpid: rpInfo.rpID,
|
||||
name: passkeyName,
|
||||
})
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("passkeys")
|
||||
.values({
|
||||
user_id: newUserId,
|
||||
credential_id: Buffer.from(credential.id, "base64url"),
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
webauthn_user_id: options.user.id,
|
||||
counter: BigInt(credential.counter),
|
||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||
backup_eligible: registrationInfo.credentialBackedUp,
|
||||
backup_status: credentialBackedUp,
|
||||
transports: JSON.stringify(response.response.transports ?? []),
|
||||
rpid: rpInfo.rpID,
|
||||
name: passkeyName,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Delete the challenge
|
||||
await trx
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.execute();
|
||||
// Delete the challenge
|
||||
await trx
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", challengeId.toString())
|
||||
.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);
|
||||
} else {
|
||||
// Should never reach here due to schema validation
|
||||
// Unreachable - schema validation requires password or passkeyInfo
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Either password or passkeyInfo is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Create session (7 days, trusted mode false initially, no device)
|
||||
const session = await createSession(context.db, {
|
||||
userId,
|
||||
deviceId: null,
|
||||
trustedMode: false,
|
||||
geo,
|
||||
userAgent,
|
||||
// Generate verification token
|
||||
const verificationToken = generateSecureBase58Token();
|
||||
const verificationExpiresAt = generateExpiry(
|
||||
TOKEN_DURATIONS.EMAIL_VERIFICATION,
|
||||
);
|
||||
|
||||
// Create session and email verification in transaction
|
||||
const session = await withTransaction(context.db, async (trx) => {
|
||||
// Create session (7 days, trusted mode false initially, no device)
|
||||
const 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
|
||||
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
COOKIE_OPTIONS.session,
|
||||
);
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = generateSecureBase58Token();
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||
|
||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||
await context.db
|
||||
.insertInto("email_verifications")
|
||||
.values({
|
||||
user_id: userId,
|
||||
token: verificationToken,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(email, verificationToken);
|
||||
// Send verification email
|
||||
await sendVerificationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email,
|
||||
token: verificationToken,
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -8,227 +8,22 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
LoginRequestContext,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
import { implement, ORPCError } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
loginRequestMiddleware,
|
||||
os,
|
||||
superuserMiddleware,
|
||||
} from "../middlewares/index.js";
|
||||
|
||||
/**
|
||||
* Base implementer with typed APIContext
|
||||
* All procedures should be derived from this
|
||||
*/
|
||||
export const os = implement(contract).$context<APIContext>();
|
||||
// Re-export middlewares and os
|
||||
export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
|
||||
|
||||
/**
|
||||
* Auth middleware - validates session/API token and adds user to context
|
||||
* Use with os.use(authMiddleware) to create authenticated procedures
|
||||
*/
|
||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Login request middleware - validates login request token from cookie
|
||||
*/
|
||||
export const loginRequestMiddleware = os.middleware(
|
||||
async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Read login request token from cookie
|
||||
const loginRequestToken = getCookie(
|
||||
reqHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
);
|
||||
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No login request found",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
.select([
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.expires_at",
|
||||
"users.email",
|
||||
"users.display_name",
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: result.user_id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
emailVerifiedAt: result.email_verified_at,
|
||||
isSuperuser: result.is_superuser,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
loginRequestId: Number(result.id),
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Superuser middleware - requires admin access (must be used after authMiddleware)
|
||||
*/
|
||||
export const superuserMiddleware = os.middleware(async ({ context, next }) => {
|
||||
// This middleware should be used after authMiddleware
|
||||
const ctx = context as AuthenticatedContext;
|
||||
if (!ctx.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
// Pre-configured procedures with middleware applied
|
||||
export const authedProcedure = os.use(authMiddleware);
|
||||
export const superuserProcedure = os.use(superuserMiddleware);
|
||||
export const loginRequestProcedure = os.use(loginRequestMiddleware);
|
||||
|
||||
// Type exports for use in procedure files
|
||||
export type { APIContext, AuthenticatedContext, LoginRequestContext };
|
||||
|
||||
7
apps/api-server/src/procedures/me/_base.ts
Normal file
7
apps/api-server/src/procedures/me/_base.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Base route for me procedures with auth middleware applied
|
||||
*/
|
||||
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
export const meRoute = authedProcedure.me;
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
hashToken,
|
||||
TOKEN_PREFIX,
|
||||
} from "../../utils/crypto.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/** Token expiration: 365 days */
|
||||
const TOKEN_EXPIRATION_DAYS = 365;
|
||||
@@ -18,9 +18,8 @@ const TOKEN_EXPIRATION_DAYS = 365;
|
||||
* List all API tokens for the current user
|
||||
* Returns token metadata (not the actual token values)
|
||||
*/
|
||||
export const listApiTokens = os.me.apiTokens.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listApiTokens = meRoute.apiTokens.list.handler(
|
||||
async ({ context }) => {
|
||||
const tokens = await context.db
|
||||
.selectFrom("api_tokens")
|
||||
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
|
||||
@@ -35,15 +34,15 @@ export const listApiTokens = os.me.apiTokens.list
|
||||
createdAt: token.created_at.toISOString(),
|
||||
expiresAt: token.expires_at.toISOString(),
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new API token
|
||||
* Requires superuser status and trusted session
|
||||
*/
|
||||
export const createApiToken = os.me.apiTokens.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const createApiToken = meRoute.apiTokens.create.handler(
|
||||
async ({ input, context }) => {
|
||||
// Require superuser status
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
@@ -85,17 +84,17 @@ export const createApiToken = os.me.apiTokens.create
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an API token
|
||||
*/
|
||||
export const deleteApiToken = os.me.apiTokens.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const deleteApiToken = meRoute.apiTokens.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.deleteFrom("api_tokens")
|
||||
.where("id", "=", String(input.tokenId))
|
||||
.where("id", "=", input.tokenId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -106,4 +105,5 @@ export const deleteApiToken = os.me.apiTokens.delete
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,40 +2,38 @@
|
||||
* Get current user auth status
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const meAuthStatus = os.me.authStatus
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"full_name",
|
||||
"phone_number",
|
||||
"avatar_url",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
"password_hash",
|
||||
])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"full_name",
|
||||
"phone_number",
|
||||
"avatar_url",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
"password_hash",
|
||||
])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
fullName: user.full_name,
|
||||
phoneNumber: user.phone_number,
|
||||
avatarUrl: user.avatar_url,
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
hasPassword: user.password_hash !== null,
|
||||
},
|
||||
auth: context.auth,
|
||||
};
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
fullName: user.full_name,
|
||||
phoneNumber: user.phone_number,
|
||||
avatarUrl: user.avatar_url,
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
hasPassword: user.password_hash !== null,
|
||||
},
|
||||
auth: context.auth,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||
import { verifyPassword } from "../../utils/password.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Delete account handler
|
||||
@@ -14,39 +14,37 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||
* - Clears session cookie
|
||||
*/
|
||||
export const meDelete = os.me.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { password } = input;
|
||||
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
|
||||
const { password } = input;
|
||||
|
||||
// Fetch user with password hash
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select(["password_hash"])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
// Fetch user with password hash
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select(["password_hash"])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Verify password (required for account deletion)
|
||||
if (!user.password_hash) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Cannot delete account without a password. Please set a password first.",
|
||||
});
|
||||
}
|
||||
// Verify password (required for account deletion)
|
||||
if (!user.password_hash) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Cannot delete account without a password. Please set a password first.",
|
||||
});
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
||||
}
|
||||
const valid = await verifyPassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
||||
}
|
||||
|
||||
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
||||
await context.db
|
||||
.deleteFrom("users")
|
||||
.where("id", "=", context.user.id)
|
||||
.execute();
|
||||
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
||||
await context.db
|
||||
.deleteFrom("users")
|
||||
.where("id", "=", context.user.id)
|
||||
.execute();
|
||||
|
||||
// Clear session cookie
|
||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
// Clear session cookie
|
||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
|
||||
/**
|
||||
@@ -13,9 +13,8 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const getDeviceInfo = os.me.devices.getInfo
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const getDeviceInfo = meRoute.devices.getInfo.handler(
|
||||
async ({ context }) => {
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
|
||||
const device = await context.db
|
||||
@@ -39,7 +38,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
||||
lastUsedAt: device.last_used_at,
|
||||
isTrusted: device.is_trusted,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Trust device handler
|
||||
@@ -48,9 +48,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const trustDevice = os.me.devices.trust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const trustDevice = meRoute.devices.trust.handler(
|
||||
async ({ input, context }) => {
|
||||
const { name } = input;
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
|
||||
@@ -66,16 +65,16 @@ export const trustDevice = os.me.devices.trust
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* List trusted devices handler
|
||||
* - Requires authentication
|
||||
* - Returns all trusted devices for the current user
|
||||
*/
|
||||
export const listTrustedDevices = os.me.devices.listTrusted
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listTrustedDevices = meRoute.devices.listTrusted.handler(
|
||||
async ({ context }) => {
|
||||
const devices = await context.db
|
||||
.selectFrom("user_devices")
|
||||
.selectAll()
|
||||
@@ -94,7 +93,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
||||
lastUsedAt: d.last_used_at,
|
||||
isTrusted: d.is_trusted,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Untrust device handler
|
||||
@@ -102,13 +102,12 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
||||
* - Marks device as untrusted by ID
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const untrustDevice = os.me.devices.untrust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const untrustDevice = meRoute.devices.untrust.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("user_devices")
|
||||
.set({ is_trusted: false })
|
||||
.where("id", "=", String(input.deviceId))
|
||||
.where("id", "=", input.deviceId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -117,16 +116,16 @@ export const untrustDevice = os.me.devices.untrust
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke all trusted devices handler
|
||||
* - Requires authentication
|
||||
* - Marks all devices as untrusted
|
||||
*/
|
||||
export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const revokeAllTrustedDevices = meRoute.devices.revokeAll.handler(
|
||||
async ({ context }) => {
|
||||
await context.db
|
||||
.updateTable("user_devices")
|
||||
.set({ is_trusted: false })
|
||||
@@ -134,4 +133,5 @@ export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,37 +2,35 @@
|
||||
* Get current user profile
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const meGet = os.me.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"full_name",
|
||||
"phone_number",
|
||||
"avatar_url",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
"password_hash",
|
||||
])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
export const meGet = meRoute.get.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"full_name",
|
||||
"phone_number",
|
||||
"avatar_url",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
"password_hash",
|
||||
])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
fullName: user.full_name,
|
||||
phoneNumber: user.phone_number,
|
||||
avatarUrl: user.avatar_url,
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
hasPassword: user.password_hash !== null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
fullName: user.full_name,
|
||||
phoneNumber: user.phone_number,
|
||||
avatarUrl: user.avatar_url,
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
needsSetup: user.display_name === null,
|
||||
isSuperuser: user.is_superuser,
|
||||
hasPassword: user.password_hash !== null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,64 +3,61 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List pending invites for the current user
|
||||
* Only returns invites where the user's email matches and email is verified
|
||||
*/
|
||||
export const listInvites = os.me.invites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
// Only show invites if email is verified
|
||||
if (!context.user.emailVerifiedAt) {
|
||||
return [];
|
||||
}
|
||||
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
|
||||
// Only show invites if email is verified
|
||||
if (!context.user.emailVerifiedAt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get non-expired invites matching user's email
|
||||
const invites = await context.db
|
||||
.selectFrom("org_invites")
|
||||
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
||||
.innerJoin("users", "users.id", "org_invites.invited_by")
|
||||
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
||||
.where("org_invites.expires_at", ">", new Date())
|
||||
.select([
|
||||
"org_invites.id",
|
||||
"org_invites.role",
|
||||
"org_invites.created_at",
|
||||
"org_invites.expires_at",
|
||||
"orgs.id as org_id",
|
||||
"orgs.slug as org_slug",
|
||||
"orgs.display_name as org_display_name",
|
||||
"orgs.logo_url as org_logo_url",
|
||||
"users.display_name as inviter_name",
|
||||
"users.email as inviter_email",
|
||||
])
|
||||
.orderBy("org_invites.created_at", "desc")
|
||||
.execute();
|
||||
// Get non-expired invites matching user's email
|
||||
const invites = await context.db
|
||||
.selectFrom("org_invites")
|
||||
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
|
||||
.innerJoin("users", "users.id", "org_invites.invited_by")
|
||||
.where("org_invites.email", "=", context.user.email.toLowerCase())
|
||||
.where("org_invites.expires_at", ">", new Date())
|
||||
.select([
|
||||
"org_invites.id",
|
||||
"org_invites.role",
|
||||
"org_invites.created_at",
|
||||
"org_invites.expires_at",
|
||||
"orgs.id as org_id",
|
||||
"orgs.slug as org_slug",
|
||||
"orgs.display_name as org_display_name",
|
||||
"orgs.logo_url as org_logo_url",
|
||||
"users.display_name as inviter_name",
|
||||
"users.email as inviter_email",
|
||||
])
|
||||
.orderBy("org_invites.created_at", "desc")
|
||||
.execute();
|
||||
|
||||
return invites.map((i) => ({
|
||||
id: i.id,
|
||||
org: {
|
||||
id: i.org_id,
|
||||
slug: i.org_slug,
|
||||
displayName: i.org_display_name,
|
||||
logoUrl: i.org_logo_url,
|
||||
},
|
||||
role: i.role,
|
||||
invitedBy: i.inviter_name ?? i.inviter_email,
|
||||
createdAt: i.created_at,
|
||||
expiresAt: i.expires_at,
|
||||
}));
|
||||
});
|
||||
return invites.map((i) => ({
|
||||
id: i.id,
|
||||
org: {
|
||||
id: i.org_id,
|
||||
slug: i.org_slug,
|
||||
displayName: i.org_display_name,
|
||||
logoUrl: i.org_logo_url,
|
||||
},
|
||||
role: i.role,
|
||||
invitedBy: i.inviter_name ?? i.inviter_email,
|
||||
createdAt: i.created_at,
|
||||
expiresAt: i.expires_at,
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a specific invite by ID
|
||||
* Only returns if the invite belongs to the current user's email
|
||||
*/
|
||||
export const getInvite = os.me.invites.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const getInvite = meRoute.invites.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Only show invite if email is verified
|
||||
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
|
||||
createdAt: invite.created_at,
|
||||
expiresAt: invite.expires_at,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept an invite by ID
|
||||
* Adds user to org and deletes the invite
|
||||
*/
|
||||
export const acceptInvite = os.me.invites.accept
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const acceptInvite = meRoute.invites.accept.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Only allow accepting if email is verified
|
||||
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Decline an invite
|
||||
* Deletes the invite if it belongs to the current user's email
|
||||
*/
|
||||
export const declineInvite = os.me.invites.decline
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const declineInvite = meRoute.invites.decline.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Delete the invite only if it matches user's email
|
||||
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List passkeys handler
|
||||
* - Requires authentication
|
||||
* - Returns all passkeys for the current user
|
||||
*/
|
||||
export const listPasskeys = os.me.passkeys.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listPasskeys = meRoute.passkeys.list.handler(
|
||||
async ({ context }) => {
|
||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||
|
||||
return passkeys.map((p) => ({
|
||||
@@ -22,7 +21,8 @@ export const listPasskeys = os.me.passkeys.list
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: p.lastUsedAt,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Rename passkey handler
|
||||
@@ -30,15 +30,14 @@ export const listPasskeys = os.me.passkeys.list
|
||||
* - Updates passkey name
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
*/
|
||||
export const renamePasskey = os.me.passkeys.rename
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const renamePasskey = meRoute.passkeys.rename.handler(
|
||||
async ({ input, context }) => {
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("passkeys")
|
||||
.set({ name })
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("id", "=", passkeyId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -47,7 +46,8 @@ export const renamePasskey = os.me.passkeys.rename
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete passkey handler
|
||||
@@ -57,9 +57,8 @@ export const renamePasskey = os.me.passkeys.rename
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||
*/
|
||||
export const deletePasskey = os.me.passkeys.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const deletePasskey = meRoute.passkeys.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { passkeyId } = input;
|
||||
|
||||
// Use transaction to prevent race condition when checking last passkey
|
||||
@@ -86,7 +85,7 @@ export const deletePasskey = os.me.passkeys.delete
|
||||
|
||||
const result = await trx
|
||||
.deleteFrom("passkeys")
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("id", "=", passkeyId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -96,4 +95,5 @@ export const deletePasskey = os.me.passkeys.delete
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List sessions handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Returns all sessions for the current user
|
||||
* - Includes isCurrent flag to identify active session
|
||||
*/
|
||||
export const listSessions = os.me.sessions.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listSessions = meRoute.sessions.list.handler(
|
||||
async ({ context }) => {
|
||||
const sessions = await context.db
|
||||
.selectFrom("sessions")
|
||||
.selectAll()
|
||||
@@ -33,7 +32,8 @@ export const listSessions = os.me.sessions.list
|
||||
isCurrent: s.id === context.session.id,
|
||||
revokedAt: s.revoked_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke session handler
|
||||
@@ -42,13 +42,12 @@ export const listSessions = os.me.sessions.list
|
||||
* @throws NOT_FOUND if session doesn't exist
|
||||
* @throws BAD_REQUEST if trying to revoke current session
|
||||
*/
|
||||
export const revokeSession = os.me.sessions.revoke
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const revokeSession = meRoute.sessions.revoke.handler(
|
||||
async ({ input, context }) => {
|
||||
const { sessionId } = input;
|
||||
|
||||
// Prevent revoking current session (use logout instead)
|
||||
if (String(sessionId) === context.session.id) {
|
||||
if (sessionId.toString() === context.session.id) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Cannot revoke current session. Use logout instead.",
|
||||
});
|
||||
@@ -57,7 +56,7 @@ export const revokeSession = os.me.sessions.revoke
|
||||
const result = await context.db
|
||||
.updateTable("sessions")
|
||||
.set({ revoked_at: new Date() })
|
||||
.where("id", "=", String(sessionId))
|
||||
.where("id", "=", sessionId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.where("revoked_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
@@ -67,16 +66,16 @@ export const revokeSession = os.me.sessions.revoke
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke all sessions handler
|
||||
* - Requires authentication
|
||||
* - Revokes all sessions except current
|
||||
*/
|
||||
export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const revokeAllSessions = meRoute.sessions.revokeAll.handler(
|
||||
async ({ context }) => {
|
||||
// Revoke all sessions except current
|
||||
await context.db
|
||||
.updateTable("sessions")
|
||||
@@ -87,4 +86,5 @@ export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
validatePassword,
|
||||
verifyPassword,
|
||||
} from "../../utils/password.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Set password handler
|
||||
@@ -16,9 +16,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - If user has existing password, currentPassword is required
|
||||
* - Validates new password strength using zxcvbn
|
||||
*/
|
||||
export const setPassword = os.me.setPassword
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const setPassword = meRoute.setPassword.handler(
|
||||
async ({ input, context }) => {
|
||||
const { currentPassword, newPassword } = input;
|
||||
|
||||
// Fetch current password hash
|
||||
@@ -60,4 +59,5 @@ export const setPassword = os.me.setPassword
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Setup user profile (initial setup after signup)
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const setupProfile = os.me.setupProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const setupProfile = meRoute.setupProfile.handler(
|
||||
async ({ input, context }) => {
|
||||
const { displayName, fullName, phoneNumber } = input;
|
||||
|
||||
await context.db
|
||||
@@ -21,4 +20,5 @@ export const setupProfile = os.me.setupProfile
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { ProfileUpdate } from "./helpers.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Update profile handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
||||
* - Automatically sets updated_at timestamp
|
||||
*/
|
||||
export const updateProfile = os.me.updateProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const updateProfile = meRoute.updateProfile.handler(
|
||||
async ({ input, context }) => {
|
||||
const updates: Partial<ProfileUpdate> = {};
|
||||
if (input.displayName !== undefined) {
|
||||
updates.display_name = input.displayName;
|
||||
@@ -38,4 +37,5 @@ export const updateProfile = os.me.updateProfile
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all orgs the current user is a member of
|
||||
*/
|
||||
export const orgsList = os.orgs.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const orgsList = authedProcedure.orgs.list.handler(
|
||||
async ({ context }) => {
|
||||
const orgs = await context.db
|
||||
.selectFrom("org_members")
|
||||
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||
@@ -33,15 +32,15 @@ export const orgsList = os.orgs.list
|
||||
logoUrl: o.logo_url,
|
||||
createdAt: o.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new org
|
||||
* The creating user becomes the owner
|
||||
*/
|
||||
export const orgsCreate = os.orgs.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsCreate = authedProcedure.orgs.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName } = input;
|
||||
|
||||
try {
|
||||
@@ -75,15 +74,15 @@ export const orgsCreate = os.orgs.create
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single org by slug
|
||||
* Requires membership
|
||||
*/
|
||||
export const orgsGet = os.orgs.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsGet = authedProcedure.orgs.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -97,4 +96,5 @@ export const orgsGet = os.orgs.get
|
||||
logoUrl: org.logoUrl,
|
||||
createdAt: org.createdAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,25 +5,11 @@
|
||||
|
||||
import type { DB, OrgRole } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { OrgInfo, OrgMembership } from "../../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
/** Org info returned from lookup */
|
||||
export interface OrgInfo {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** User's membership in an org */
|
||||
export interface OrgMembership {
|
||||
id: number;
|
||||
role: OrgRole;
|
||||
createdAt: Date;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { OrgInfo, OrgMembership };
|
||||
|
||||
// ===== Role Hierarchy =====
|
||||
|
||||
@@ -115,10 +101,11 @@ export async function countOwners(
|
||||
): Promise<number> {
|
||||
const result = await db
|
||||
.selectFrom("org_members")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.select((eb) => eb.fn.countAll().as("count"))
|
||||
.where("org_id", "=", orgId)
|
||||
.where("role", "=", "owner")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.count;
|
||||
// PostgreSQL COUNT returns bigint (string), convert to number
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendOrgInviteEmail } from "@reviq/emails";
|
||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List pending invites for an org
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const invitesList = os.orgs.invites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesList = authedProcedure.orgs.invites.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify admin+ role
|
||||
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
|
||||
createdAt: i.created_at,
|
||||
expiresAt: i.expires_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create an invite for a new member
|
||||
* Requires admin or owner role
|
||||
* Only owners can invite new owners (privilege escalation prevention)
|
||||
*/
|
||||
export const invitesCreate = os.orgs.invites.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesCreate = authedProcedure.orgs.invites.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, email: rawEmail, role } = input;
|
||||
const email = rawEmail.toLowerCase();
|
||||
|
||||
@@ -122,18 +121,28 @@ export const invitesCreate = os.orgs.invites.create
|
||||
|
||||
// Send invitation email
|
||||
const inviterName = context.user.displayName ?? context.user.email;
|
||||
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
|
||||
await sendOrgInviteEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email,
|
||||
token,
|
||||
orgName: org.displayName,
|
||||
inviterName,
|
||||
role,
|
||||
expiryDays: ORG_INVITE_EXPIRY_DAYS,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a pending invite
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const invitesCancel = os.orgs.invites.cancel
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, inviteId } = input;
|
||||
|
||||
// Lookup org and verify admin+ role
|
||||
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept an invitation
|
||||
* Token-based lookup, requires auth but no org membership
|
||||
* Handles race condition if user is already a member
|
||||
*/
|
||||
export const invitesAccept = os.orgs.invites.accept
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
|
||||
async ({ input, context }) => {
|
||||
const { token } = input;
|
||||
|
||||
// Find the invite by token (must not be expired)
|
||||
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
* Update org details
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const orgsUpdate = os.orgs.update
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsUpdate = authedProcedure.orgs.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Lookup org and verify membership with admin+ role
|
||||
@@ -41,16 +40,16 @@ export const orgsUpdate = os.orgs.update
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an org
|
||||
* Requires owner role
|
||||
* FK CASCADE handles deleting members, invites, and sites
|
||||
*/
|
||||
export const orgsDelete = os.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsDelete = authedProcedure.orgs.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify ownership
|
||||
@@ -61,16 +60,16 @@ export const orgsDelete = os.orgs.delete
|
||||
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Leave an org
|
||||
* Cannot leave if you're the only owner
|
||||
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||
*/
|
||||
export const orgsLeave = os.orgs.leave
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsLeave = authedProcedure.orgs.leave.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and get membership
|
||||
@@ -98,4 +97,5 @@ export const orgsLeave = os.orgs.leave
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
* List all members of an org
|
||||
* Any member can view the member list
|
||||
*/
|
||||
export const membersList = os.orgs.members.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersList = authedProcedure.orgs.members.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -48,65 +47,70 @@ export const membersList = os.orgs.members.list
|
||||
role: m.role,
|
||||
createdAt: m.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
* Only owners can change roles
|
||||
* Uses transaction to prevent race condition when demoting owners
|
||||
*/
|
||||
export const membersUpdateRole = os.orgs.members.updateRole
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, userId, role: newRole } = input;
|
||||
export const membersUpdateRole =
|
||||
authedProcedure.orgs.members.updateRole.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, userId, role: newRole } = input;
|
||||
|
||||
// Lookup org and verify ownership
|
||||
const org = await lookupOrgBySlug(context.db, slug);
|
||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||
requireRole(membership, "owner");
|
||||
// Lookup org and verify ownership
|
||||
const org = await lookupOrgBySlug(context.db, slug);
|
||||
const membership = await getMembership(
|
||||
context.db,
|
||||
org.id,
|
||||
context.user.id,
|
||||
);
|
||||
requireRole(membership, "owner");
|
||||
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Get the target member's current membership
|
||||
const targetMember = await trx
|
||||
.selectFrom("org_members")
|
||||
.select(["id", "role"])
|
||||
.where("org_id", "=", org.id)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Get the target member's current membership
|
||||
const targetMember = await trx
|
||||
.selectFrom("org_members")
|
||||
.select(["id", "role"])
|
||||
.where("org_id", "=", org.id)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!targetMember) {
|
||||
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",
|
||||
});
|
||||
if (!targetMember) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
|
||||
}
|
||||
}
|
||||
|
||||
// Update the role
|
||||
await trx
|
||||
.updateTable("org_members")
|
||||
.set({ role: newRole })
|
||||
.where("id", "=", targetMember.id)
|
||||
.execute();
|
||||
});
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* Owners can remove anyone, admins can only remove members
|
||||
* Uses transaction to prevent race condition when removing owners
|
||||
*/
|
||||
export const membersRemove = os.orgs.members.remove
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersRemove = authedProcedure.orgs.members.remove.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, userId } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
* Org sites procedures - list
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all sites for an org
|
||||
* Any member can view the site list
|
||||
*/
|
||||
export const sitesList = os.orgs.sites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const sitesList = authedProcedure.orgs.sites.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -31,4 +30,5 @@ export const sitesList = os.orgs.sites.list
|
||||
domain: s.domain,
|
||||
createdAt: s.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", String(context.loginRequestId))
|
||||
.where("id", "=", context.loginRequestId.toString())
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Authentication utilities for token handling
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import { hashToken } from "./crypto.js";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
email: string;
|
||||
isSuperuser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request using session token or API key
|
||||
* Returns the authenticated user or null if not authenticated
|
||||
*/
|
||||
export const authenticateRequest = async (
|
||||
db: Kysely<Database>,
|
||||
sessionToken?: string,
|
||||
apiKey?: string,
|
||||
): Promise<AuthenticatedUser | null> => {
|
||||
// Try session cookie first, then API key
|
||||
const token = sessionToken ?? apiKey;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
|
||||
// Check sessions table
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.innerJoin("users", "users.id", "sessions.user_id")
|
||||
.where("sessions.token_hash", "=", tokenHash)
|
||||
.where("sessions.expires_at", ">", new Date())
|
||||
.where("sessions.revoked_at", "is", null)
|
||||
.select(["users.id", "users.email", "users.is_superuser"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
id: session.id,
|
||||
email: session.email,
|
||||
isSuperuser: session.is_superuser,
|
||||
};
|
||||
}
|
||||
|
||||
// Check API tokens table
|
||||
const apiToken = await db
|
||||
.selectFrom("api_tokens")
|
||||
.innerJoin("users", "users.id", "api_tokens.user_id")
|
||||
.where("api_tokens.token_hash", "=", tokenHash)
|
||||
.where("api_tokens.expires_at", ">", new Date())
|
||||
.select(["users.id", "users.email", "users.is_superuser"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (apiToken) {
|
||||
// Update last_used_at
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
id: apiToken.id,
|
||||
email: apiToken.email,
|
||||
isSuperuser: apiToken.is_superuser,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateSecureBase58Token } from "@reviq/utils";
|
||||
import { generateSecureBase58Token } from "@reviq/server-utils";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
/**
|
||||
* Email sending utilities using Postmark
|
||||
* Implements Workstream G: Email Service (Backend)
|
||||
*/
|
||||
|
||||
import type { OrgRole } from "@reviq/db-schema";
|
||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||
import { ServerClient } from "postmark";
|
||||
import {
|
||||
BASE_URL,
|
||||
EMAIL_DEV_MODE,
|
||||
EMAIL_FROM,
|
||||
EMAIL_VERIFICATION_EXPIRY_HOURS,
|
||||
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
|
||||
ORG_INVITE_EXPIRY_DAYS,
|
||||
PASSWORD_RESET_EXPIRY_HOURS,
|
||||
POSTMARK_API_KEY,
|
||||
} from "../constants.js";
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
/**
|
||||
* Email send result
|
||||
*/
|
||||
export interface EmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== Postmark Client =====
|
||||
|
||||
let postmarkClient: ServerClient | null = null;
|
||||
|
||||
const getPostmarkClient = (): ServerClient => {
|
||||
if (!postmarkClient) {
|
||||
if (!POSTMARK_API_KEY) {
|
||||
throw new Error(
|
||||
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
|
||||
);
|
||||
}
|
||||
postmarkClient = new ServerClient(POSTMARK_API_KEY);
|
||||
}
|
||||
return postmarkClient;
|
||||
};
|
||||
|
||||
// ===== URL Helpers =====
|
||||
|
||||
/**
|
||||
* Build a URL with query parameters using the URL constructor
|
||||
*/
|
||||
const buildUrl = (path: string, params: Record<string, string>): string => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
// ===== HTML Escaping =====
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
*/
|
||||
const escapeHtml = (unsafe: string): string =>
|
||||
unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// ===== Core Email Function =====
|
||||
|
||||
interface SendEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email via Postmark (or log in dev mode)
|
||||
*/
|
||||
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
|
||||
const { to, subject, htmlBody, textBody } = params;
|
||||
|
||||
// Dev mode: log instead of sending
|
||||
if (EMAIL_DEV_MODE) {
|
||||
console.log("=== DEV MODE EMAIL ===");
|
||||
console.log(`To: ${to}`);
|
||||
console.log(`Subject: ${subject}`);
|
||||
console.log(`Body:\n${textBody}`);
|
||||
console.log("======================");
|
||||
return { success: true, messageId: "dev-mode" };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getPostmarkClient();
|
||||
const result = await client.sendEmail({
|
||||
From: EMAIL_FROM,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
HtmlBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
});
|
||||
return { success: true, messageId: result.MessageID };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`Failed to send email to ${to}:`, message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Template Helpers =====
|
||||
|
||||
const durationFormatter = new DurationFormat("en", { style: "long" });
|
||||
|
||||
const formatExpiryHours = (hours: number): string =>
|
||||
durationFormatter.format({ hours });
|
||||
|
||||
const formatExpiryMinutes = (minutes: number): string =>
|
||||
durationFormatter.format({ minutes });
|
||||
|
||||
const formatExpiryDays = (days: number): string =>
|
||||
durationFormatter.format({ days });
|
||||
|
||||
const roleLabels: Record<OrgRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
member: "Member",
|
||||
};
|
||||
|
||||
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
|
||||
|
||||
/**
|
||||
* Get the correct article (a/an) for a role
|
||||
*/
|
||||
const getArticleForRole = (role: OrgRole): string => {
|
||||
return role === "owner" || role === "admin" ? "an" : "a";
|
||||
};
|
||||
|
||||
// ===== Email Templates =====
|
||||
|
||||
// Common styles
|
||||
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
|
||||
const containerStyles =
|
||||
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
|
||||
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
|
||||
const paragraphStyles =
|
||||
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
|
||||
const buttonStyles =
|
||||
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
|
||||
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
|
||||
|
||||
// Verification Email
|
||||
const buildVerificationEmailHtml = (
|
||||
verifyUrl: string,
|
||||
expiresIn: string,
|
||||
): string => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">Verify your email</h1>
|
||||
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
|
||||
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
|
||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const buildVerificationEmailText = (
|
||||
verifyUrl: string,
|
||||
expiresIn: string,
|
||||
): string =>
|
||||
`Verify your email
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
|
||||
${verifyUrl}
|
||||
|
||||
This link expires in ${expiresIn}.
|
||||
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
`;
|
||||
|
||||
// Password Reset Email
|
||||
const buildPasswordResetEmailHtml = (
|
||||
resetUrl: string,
|
||||
expiresIn: string,
|
||||
): string => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">Reset your password</h1>
|
||||
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
|
||||
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
|
||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const buildPasswordResetEmailText = (
|
||||
resetUrl: string,
|
||||
expiresIn: string,
|
||||
): string =>
|
||||
`Reset your password
|
||||
|
||||
We received a request to reset your password. Click the link below to choose a new password:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link expires in ${expiresIn}.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
`;
|
||||
|
||||
// Login Confirmation Email
|
||||
const buildLoginConfirmationEmailHtml = (
|
||||
confirmUrl: string,
|
||||
expiresIn: string,
|
||||
): string => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">Confirm your login</h1>
|
||||
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
|
||||
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
|
||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const buildLoginConfirmationEmailText = (
|
||||
confirmUrl: string,
|
||||
expiresIn: string,
|
||||
): string =>
|
||||
`Confirm your login
|
||||
|
||||
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
|
||||
|
||||
${confirmUrl}
|
||||
|
||||
This link expires in ${expiresIn}.
|
||||
|
||||
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
|
||||
`;
|
||||
|
||||
// Org Invite Email
|
||||
const buildOrgInviteEmailHtml = (
|
||||
email: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
role: OrgRole,
|
||||
inviteUrl: string,
|
||||
expiresIn: string,
|
||||
): string => {
|
||||
const safeOrgName = escapeHtml(orgName);
|
||||
const safeInviterName = escapeHtml(inviterName);
|
||||
const safeEmail = escapeHtml(email);
|
||||
const roleDisplay = formatRoleDisplay(role);
|
||||
const article = getArticleForRole(role);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
|
||||
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
|
||||
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
|
||||
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const buildOrgInviteEmailText = (
|
||||
email: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
role: OrgRole,
|
||||
inviteUrl: string,
|
||||
expiresIn: string,
|
||||
): string => {
|
||||
const roleDisplay = formatRoleDisplay(role);
|
||||
const article = getArticleForRole(role);
|
||||
|
||||
return `You've been invited to join ${orgName}
|
||||
|
||||
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
|
||||
|
||||
Click the link below to accept the invitation:
|
||||
|
||||
${inviteUrl}
|
||||
|
||||
This invitation expires in ${expiresIn}.
|
||||
|
||||
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
|
||||
`;
|
||||
};
|
||||
|
||||
// ===== Email Helpers =====
|
||||
|
||||
/**
|
||||
* Send verification email to user
|
||||
*/
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/auth/verify", { token });
|
||||
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Verify your email address",
|
||||
htmlBody: buildVerificationEmailHtml(url, expiresIn),
|
||||
textBody: buildVerificationEmailText(url, expiresIn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login confirmation email (for untrusted device flow)
|
||||
*/
|
||||
export async function sendLoginConfirmationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/auth/confirm", { token });
|
||||
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Confirm your login",
|
||||
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
|
||||
textBody: buildLoginConfirmationEmailText(url, expiresIn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/auth/reset-password", { token });
|
||||
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
|
||||
textBody: buildPasswordResetEmailText(url, expiresIn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send org invite email
|
||||
*/
|
||||
export async function sendOrgInviteEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
role: OrgRole,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/invite/accept", { token });
|
||||
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: `You've been invited to join ${orgName}`,
|
||||
htmlBody: buildOrgInviteEmailHtml(
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
url,
|
||||
expiresIn,
|
||||
),
|
||||
textBody: buildOrgInviteEmailText(
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
url,
|
||||
expiresIn,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
hashPassword as hashPasswordUtil,
|
||||
verifyPassword as verifyPasswordUtil,
|
||||
} from "@reviq/utils";
|
||||
} from "@reviq/server-utils";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { Kysely, Transaction } from "kysely";
|
||||
import type { GeoInfo } from "./geo.js";
|
||||
import {
|
||||
isDeviceTrusted as dbIsDeviceTrusted,
|
||||
upsertUserDevice as dbUpsertUserDevice,
|
||||
insertSession,
|
||||
} from "@reviq/db";
|
||||
import { COOKIE_DURATIONS } from "./cookies.js";
|
||||
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
|
||||
|
||||
@@ -23,33 +28,26 @@ export interface SessionResult {
|
||||
* Returns the raw token (to be sent in cookie) and session details
|
||||
*/
|
||||
export async function createSession(
|
||||
db: Kysely<Database>,
|
||||
db: Kysely<Database> | Transaction<Database>,
|
||||
options: CreateSessionOptions,
|
||||
): Promise<SessionResult> {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = await hashToken(token);
|
||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
||||
|
||||
const result = await db
|
||||
.insertInto("sessions")
|
||||
.values({
|
||||
user_id: options.userId,
|
||||
device_id: options.deviceId,
|
||||
token_hash: tokenHash,
|
||||
trusted_mode: options.trustedMode,
|
||||
ip_address: options.geo.ip,
|
||||
city: options.geo.city,
|
||||
region: options.geo.region,
|
||||
country: options.geo.country,
|
||||
user_agent: options.userAgent,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
const result = await insertSession(db, {
|
||||
userId: options.userId,
|
||||
deviceId: options.deviceId,
|
||||
tokenHash,
|
||||
trustedMode: options.trustedMode,
|
||||
geo: options.geo,
|
||||
userAgent: options.userAgent,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
sessionId: Number(result.id),
|
||||
sessionId: result.sessionId,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -60,53 +58,22 @@ export async function createSession(
|
||||
* Returns the device ID
|
||||
*/
|
||||
export async function upsertUserDevice(
|
||||
db: Kysely<Database>,
|
||||
db: Kysely<Database> | Transaction<Database>,
|
||||
userId: number,
|
||||
deviceFingerprint: string,
|
||||
geo: GeoInfo,
|
||||
userAgent: string,
|
||||
): Promise<number> {
|
||||
const result = await db
|
||||
.insertInto("user_devices")
|
||||
.values({
|
||||
user_id: userId,
|
||||
device_fingerprint: deviceFingerprint,
|
||||
user_agent: userAgent,
|
||||
ip_address: geo.ip,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
country: geo.country,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
|
||||
ip_address: geo.ip,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
country: geo.country,
|
||||
user_agent: userAgent,
|
||||
last_used_at: new Date(),
|
||||
}),
|
||||
)
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Number(result.id);
|
||||
return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device is trusted for a user
|
||||
*/
|
||||
export async function isDeviceTrusted(
|
||||
db: Kysely<Database>,
|
||||
db: Kysely<Database> | Transaction<Database>,
|
||||
userId: number,
|
||||
deviceFingerprint: string,
|
||||
): Promise<boolean> {
|
||||
const device = await db
|
||||
.selectFrom("user_devices")
|
||||
.select(["is_trusted"])
|
||||
.where("user_id", "=", userId)
|
||||
.where("device_fingerprint", "=", deviceFingerprint)
|
||||
.executeTakeFirst();
|
||||
|
||||
return device?.is_trusted ?? false;
|
||||
return dbIsDeviceTrusted(db, userId, deviceFingerprint);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const verifyRegistration = async (
|
||||
const challengeRow = await db
|
||||
.selectFrom("webauthn_challenges")
|
||||
.select("options")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!challengeRow) {
|
||||
@@ -189,7 +189,7 @@ export const verifyRegistration = async (
|
||||
// Always delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ export const verifyAuthentication = async (
|
||||
const challengeRow = await db
|
||||
.selectFrom("webauthn_challenges")
|
||||
.select("options")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!challengeRow) {
|
||||
@@ -321,7 +321,7 @@ export const verifyAuthentication = async (
|
||||
counter: verification.authenticationInfo.newCounter.toString(),
|
||||
last_used_at: new Date(),
|
||||
})
|
||||
.where("id", "=", String(passkey.id))
|
||||
.where("id", "=", passkey.id.toString())
|
||||
.execute();
|
||||
|
||||
return true;
|
||||
@@ -329,7 +329,7 @@ export const verifyAuthentication = async (
|
||||
// Always delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,5 @@
|
||||
"isolatedDeclarations": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --cache",
|
||||
"clean": "rm -rf dist .eslintcache",
|
||||
"test": "bun test"
|
||||
"test": "bun test src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
interface CompleteLoginFlags {
|
||||
email: string;
|
||||
@@ -20,14 +20,7 @@ async function completeLogin(
|
||||
|
||||
console.log(`Completed login request for: ${flags.email}`);
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) {
|
||||
console.error(`Error [${String(error.code)}]:`, error.message);
|
||||
} else {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { readConfig, writeConfig } from "../../utils/config.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
interface LoginFlags {
|
||||
token: string;
|
||||
@@ -47,10 +48,7 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
||||
console.log(`Logged in as ${authStatus.user.email}`);
|
||||
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Login failed:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Login failed:", formatError(error));
|
||||
console.log("\nMake sure your API token is valid.");
|
||||
console.log("You can create a new token at: /account/api-tokens");
|
||||
this.process.exit(1);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { getConfigPath, readConfig } from "../../utils/config.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
import { TOKEN_PREFIX } from "../../utils/token.js";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
@@ -14,19 +15,21 @@ function formatRelativeTime(date: Date): string {
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) {
|
||||
return `${String(Math.abs(diffDays))} days ago`;
|
||||
return `${Math.abs(diffDays).toLocaleString()} days ago`;
|
||||
}
|
||||
|
||||
if (diffDays === 0) {
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
if (diffHours <= 0) {
|
||||
return "expired";
|
||||
}
|
||||
return `in ${String(diffHours)} hours`;
|
||||
return diffHours <= 0
|
||||
? "expired"
|
||||
: `in ${diffHours.toLocaleString()} hours`;
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return "tomorrow";
|
||||
}
|
||||
return `in ${String(diffDays)} days`;
|
||||
|
||||
return `in ${diffDays.toLocaleString()} days`;
|
||||
}
|
||||
|
||||
async function status(this: LocalContext): Promise<void> {
|
||||
@@ -96,9 +99,7 @@ async function status(this: LocalContext): Promise<void> {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
` Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
console.log(` Error: ${formatError(error)}`);
|
||||
console.log(
|
||||
"\n Unable to connect to API. Local credentials may be invalid.",
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../context.js";
|
||||
import { createDb, executeBootstrap } from "@reviq/db";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { writeConfig } from "../utils/config.js";
|
||||
import { formatError } from "../utils/format-error.js";
|
||||
|
||||
interface BootstrapFlags {
|
||||
email: string;
|
||||
@@ -47,10 +48,7 @@ async function bootstrap(
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
await db.destroy();
|
||||
this.process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { LocalContext } from "../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
|
||||
type Shell = "bash" | "zsh" | "fish";
|
||||
|
||||
const SUPPORTED_SHELLS: readonly Shell[] = ["bash", "zsh", "fish"] as const;
|
||||
const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
|
||||
type Shell = (typeof SUPPORTED_SHELLS)[number];
|
||||
|
||||
function parseShell(value: string): Shell {
|
||||
const shell = value.toLowerCase();
|
||||
@@ -45,7 +44,6 @@ function completions(
|
||||
_flags: Record<string, never>,
|
||||
shell: Shell,
|
||||
): void {
|
||||
// biome-ignore lint/nursery/noUnnecessaryConditions: switch on union type is valid
|
||||
switch (shell) {
|
||||
case "bash":
|
||||
console.log("To enable bash completions for reviq, run:\n");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
interface AddSiteFlags {
|
||||
org: string;
|
||||
@@ -18,10 +19,7 @@ async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
|
||||
|
||||
console.log(`Added site ${flags.domain} to org ${flags.org}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
interface CreateOrgFlags {
|
||||
slug: string;
|
||||
@@ -24,10 +25,7 @@ async function create(
|
||||
console.log(`Created org: ${result.slug}`);
|
||||
console.log(`Owner: ${flags.owner}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
async function list(this: LocalContext): Promise<void> {
|
||||
try {
|
||||
@@ -23,12 +24,9 @@ async function list(this: LocalContext): Promise<void> {
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`Total: ${String(orgs.length)} organization(s)`);
|
||||
console.log(`Total: ${orgs.length.toLocaleString()} organization(s)`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
interface ConfirmEmailFlags {
|
||||
email: string;
|
||||
@@ -19,10 +20,7 @@ async function confirmEmail(
|
||||
|
||||
console.log(`Confirmed email for: ${flags.email}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
type OrgRole = "owner" | "admin" | "member";
|
||||
|
||||
const validRoles: OrgRole[] = ["owner", "admin", "member"];
|
||||
const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
|
||||
|
||||
function parseRole(role: string | undefined): OrgRole | undefined {
|
||||
if (!role) {
|
||||
return undefined;
|
||||
}
|
||||
if (validRoles.includes(role as OrgRole)) {
|
||||
return role as OrgRole;
|
||||
|
||||
if (!VALID_ROLES.includes(role as OrgRole)) {
|
||||
throw new Error(
|
||||
`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
|
||||
);
|
||||
|
||||
return role as OrgRole;
|
||||
}
|
||||
|
||||
interface CreateUserFlags {
|
||||
@@ -45,10 +48,7 @@ async function create(
|
||||
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ import { readConfig } from "./config.js";
|
||||
|
||||
export type ApiClient = ContractRouterClient<typeof contract>;
|
||||
|
||||
function buildClient(apiUrl: string, token: string): ApiClient {
|
||||
const link = new RPCLink({
|
||||
url: `${apiUrl}/api/v1/rpc`,
|
||||
headers: { "X-API-Key": token },
|
||||
});
|
||||
return createORPCClient(link) as unknown as ApiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an oRPC API client with provided credentials
|
||||
*/
|
||||
@@ -25,18 +33,10 @@ export function createApiClient(
|
||||
apiUrl?: string,
|
||||
token?: string,
|
||||
): ApiClient | Promise<ApiClient> {
|
||||
// If both arguments are provided, create client directly
|
||||
if (apiUrl !== undefined && token !== undefined) {
|
||||
const link = new RPCLink({
|
||||
url: `${apiUrl}/api/v1/rpc`,
|
||||
headers: {
|
||||
"X-API-Key": token,
|
||||
},
|
||||
});
|
||||
return createORPCClient(link) as unknown as ApiClient;
|
||||
return buildClient(apiUrl, token);
|
||||
}
|
||||
|
||||
// Otherwise, read from config asynchronously
|
||||
return (async (): Promise<ApiClient> => {
|
||||
const config = await readConfig();
|
||||
if (!config) {
|
||||
@@ -44,14 +44,6 @@ export function createApiClient(
|
||||
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
||||
);
|
||||
}
|
||||
|
||||
const link = new RPCLink({
|
||||
url: `${config.apiUrl}/api/v1/rpc`,
|
||||
headers: {
|
||||
"X-API-Key": config.token,
|
||||
},
|
||||
});
|
||||
|
||||
return createORPCClient(link) as unknown as ApiClient;
|
||||
return buildClient(config.apiUrl, config.token);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
|
||||
/**
|
||||
* Get the path to the config file
|
||||
*/
|
||||
export const getConfigPath = (): string => CONFIG_FILE;
|
||||
export function getConfigPath(): string {
|
||||
return CONFIG_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the config file
|
||||
* Returns null if the file doesn't exist or is invalid
|
||||
*/
|
||||
export const readConfig = async (): Promise<Config | null> => {
|
||||
export async function readConfig(): Promise<Config | null> {
|
||||
try {
|
||||
const data = await readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data) as Config;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the config file
|
||||
* Creates the config directory if it doesn't exist
|
||||
*/
|
||||
export const writeConfig = async (config: Config): Promise<void> => {
|
||||
export async function writeConfig(config: Config): Promise<void> {
|
||||
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the config file
|
||||
* Ignores errors if the file doesn't exist
|
||||
*/
|
||||
export const deleteConfig = async (): Promise<void> => {
|
||||
export async function deleteConfig(): Promise<void> {
|
||||
try {
|
||||
await unlink(CONFIG_FILE);
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
20
apps/cli/src/utils/format-error.ts
Normal file
20
apps/cli/src/utils/format-error.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
|
||||
/**
|
||||
* Format an unknown error value into a string message.
|
||||
* Handles ORPCError, Error instances, strings, and other types safely.
|
||||
*/
|
||||
export function formatError(error: unknown): string {
|
||||
if (error instanceof ORPCError) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
|
||||
return `[${error.code}] ${error.message}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- intentional unknown coercion
|
||||
return `${error}`;
|
||||
}
|
||||
@@ -19,6 +19,5 @@
|
||||
"isolatedDeclarations": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [".svelte-kit/**", "build/**"],
|
||||
},
|
||||
...configs.fast,
|
||||
...svelte.configs["flat/recommended"],
|
||||
{
|
||||
files: ["**/*.svelte", "**/*.svelte.ts"],
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"@orpc/client": "^1.13.2",
|
||||
"@orpc/contract": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/common": "workspace:*",
|
||||
"@reviq/frontend-utils": "workspace:*",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/svelte-query": "^6.0.14",
|
||||
"@tanstack/svelte-query-devtools": "^6.0.3",
|
||||
@@ -36,12 +38,16 @@
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.49.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@typescript-eslint/parser": "^8.52.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"svelte": "^5.28.2",
|
||||
"svelte-check": "^4.2.1",
|
||||
"svelte-eslint-parser": "^1.4.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Geist Sans - Modern, clean typeface */
|
||||
@font-face {
|
||||
|
||||
@@ -56,10 +56,10 @@ function isActive(href: string, pathname: string): boolean {
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#each navItems as item}
|
||||
{#each navItems as item (item.href)}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href={item.href}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={item.href}
|
||||
class={cn(
|
||||
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||
active
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlertTriangle } from "@lucide/svelte";
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { api } from "$lib/api/client";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -51,7 +52,7 @@ async function handleDelete(e: Event) {
|
||||
open = false;
|
||||
|
||||
// Redirect to login
|
||||
goto("/auth/login");
|
||||
goto(resolve("/auth/login"));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to delete account";
|
||||
isDeleting = false;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { default as AccountNav } from "./account-nav.svelte";
|
||||
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
|
||||
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
|
||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
|
||||
export { default as PasskeyList } from "./passkey-list.svelte";
|
||||
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Key, Pencil, Trash2 } from "@lucide/svelte";
|
||||
import { formatDate, formatRelativeTime } from "@reviq/common";
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import ConfirmDialog from "./confirm-dialog.svelte";
|
||||
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
|
||||
|
||||
interface Passkey {
|
||||
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
|
||||
let selectedPasskey = $state<Passkey | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string | null): string {
|
||||
if (!date) {
|
||||
return "Never";
|
||||
}
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
}
|
||||
return formatDate(d);
|
||||
}
|
||||
|
||||
function openRename(passkey: Passkey) {
|
||||
selectedPasskey = passkey;
|
||||
renameDialogOpen = true;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { api } from "$lib/api/client";
|
||||
import { gotoLogin } from "$lib/utils/navigation";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -11,25 +11,29 @@ interface Props {
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Check if current path is an auth page (doesn't require login)
|
||||
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
|
||||
// Check if current path is a public page (doesn't require login)
|
||||
const isPublicPage = $derived(
|
||||
page.url.pathname.startsWith("/auth") ||
|
||||
page.url.pathname === "/terms" ||
|
||||
page.url.pathname === "/privacy",
|
||||
);
|
||||
|
||||
// Fetch user to check if logged in (only for non-auth pages)
|
||||
// Fetch user to check if logged in (only for protected pages)
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
enabled: !isAuthPage,
|
||||
enabled: !isPublicPage,
|
||||
retry: false,
|
||||
}));
|
||||
|
||||
// Redirect to login if not authenticated on non-auth pages
|
||||
// Redirect to login if not authenticated on protected pages
|
||||
$effect(() => {
|
||||
if (!isAuthPage && userQuery.error) {
|
||||
goto(`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}`);
|
||||
if (!isPublicPage && userQuery.error) {
|
||||
gotoLogin(page.url.pathname);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isAuthPage || userQuery.data || userQuery.isPending}
|
||||
{#if isPublicPage || userQuery.data || userQuery.isPending}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
@@ -32,7 +32,7 @@ const config = $derived(strengthConfig[score]);
|
||||
<div class="space-y-2">
|
||||
<!-- Strength bars -->
|
||||
<div class="flex gap-1">
|
||||
{#each Array(4) as _, i}
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div
|
||||
class="h-1 flex-1 rounded-full transition-colors {i < score
|
||||
? config.color
|
||||
@@ -52,7 +52,7 @@ const config = $derived(strengthConfig[score]);
|
||||
{#if result.feedback.warning}
|
||||
<p class="text-destructive">{result.feedback.warning}</p>
|
||||
{/if}
|
||||
{#each result.feedback.suggestions as suggestion}
|
||||
{#each result.feedback.suggestions as suggestion, i (i)}
|
||||
<p>{suggestion}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -25,9 +25,9 @@ const filters = [
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-border/50">
|
||||
{#each filters as filter}
|
||||
<a
|
||||
href={filter.href}
|
||||
{#each filters as filter (filter.label)}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={filter.href}
|
||||
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
||||
|
||||
@@ -46,7 +46,7 @@ function hourToPercent(hour: number): number {
|
||||
<div class="flex">
|
||||
<!-- Y-axis labels -->
|
||||
<div class="flex w-10 flex-col justify-between pr-2" style="height: 210px">
|
||||
{#each hours as hour}
|
||||
{#each hours as hour (hour)}
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -55,14 +55,14 @@ function hourToPercent(hour: number): number {
|
||||
<div class="relative flex-1">
|
||||
<!-- Grid lines -->
|
||||
<div class="absolute inset-0 flex flex-col justify-between" style="height: 210px">
|
||||
{#each hours as _}
|
||||
{#each hours as hour (hour)}
|
||||
<div class="h-px w-full bg-border"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bars container -->
|
||||
<div class="relative grid grid-cols-7 gap-4 px-2" style="height: 210px">
|
||||
{#each days as _, dayIndex}
|
||||
{#each days as day, dayIndex (day)}
|
||||
{@const thisMonth = thisMonthData[dayIndex]}
|
||||
{@const lastMonth = lastMonthData[dayIndex]}
|
||||
<div class="relative flex justify-center">
|
||||
@@ -104,7 +104,7 @@ function hourToPercent(hour: number): number {
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<div class="mt-2 grid grid-cols-7 gap-4 px-2">
|
||||
{#each days as day}
|
||||
{#each days as day (day)}
|
||||
<div class="text-center text-[11px] text-muted-foreground">{day}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -32,13 +32,14 @@ const activeTab = $derived(
|
||||
($page.url.searchParams.get("tab") as TabId) || defaultTab,
|
||||
);
|
||||
|
||||
function handleTabChange(tabId: string) {
|
||||
function handleTabChange(tabId: string): void {
|
||||
const url = new URL($page.url);
|
||||
if (tabId === defaultTab) {
|
||||
url.searchParams.delete("tab");
|
||||
} else {
|
||||
url.searchParams.set("tab", tabId);
|
||||
}
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(url.toString(), { replaceState: true, noScroll: true });
|
||||
}
|
||||
</script>
|
||||
@@ -60,7 +61,7 @@ function handleTabChange(tabId: string) {
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<div class="flex items-center gap-0.5" role="tablist">
|
||||
{#each tabs as tab}
|
||||
{#each tabs as tab (tab.id)}
|
||||
{@const isActive = activeTab === tab.id}
|
||||
<button
|
||||
role="tab"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||
|
||||
const tableData = [
|
||||
interface AdUnitRow extends MetricsRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const tableData: AdUnitRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "/header/leaderboard-728x90",
|
||||
@@ -51,58 +55,10 @@ const tableData = [
|
||||
impPercent: 9.16,
|
||||
},
|
||||
];
|
||||
|
||||
function getBarWidth(value: number, max: number): number {
|
||||
return (value / max) * 100;
|
||||
}
|
||||
|
||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
||||
</script>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head>
|
||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
% of revenue
|
||||
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</Table.Head>
|
||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each tableData as row, i}
|
||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||
<Table.Cell class="w-10 py-3 pl-5">
|
||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||
{i + 1}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="py-3">
|
||||
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
||||
<Table.Cell class="w-32 py-3">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
<MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
|
||||
{#snippet labelCell({ row })}
|
||||
<code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
|
||||
{/snippet}
|
||||
</MetricsTable>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||
|
||||
const tableData = [
|
||||
interface CountryRow extends MetricsRow {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const tableData: CountryRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "United States",
|
||||
@@ -57,54 +62,14 @@ const tableData = [
|
||||
impPercent: 4.68,
|
||||
},
|
||||
];
|
||||
|
||||
function getBarWidth(value: number, max: number): number {
|
||||
return (value / max) * 100;
|
||||
}
|
||||
|
||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
||||
</script>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
|
||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each tableData as row, i}
|
||||
<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>
|
||||
<MetricsTable data={tableData} labelHeader="Country">
|
||||
{#snippet labelCell({ row })}
|
||||
{@const countryRow = row as CountryRow}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
|
||||
<span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
</MetricsTable>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user