Compare commits
76 Commits
bd5bb5f5ef
...
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
|
|||
|
730021a5ea
|
|||
|
c698a85cc1
|
|||
|
462799ca3d
|
|||
|
dcb48a5d5e
|
|||
|
8f3a1f2962
|
|||
|
a7d6beaf5a
|
|||
|
48ffba6c5f
|
|||
|
1b46fc0ecc
|
|||
|
587d17c39c
|
|||
|
|
cca901a9b9 | ||
|
|
42badf3c52 | ||
|
|
bd53a60497 | ||
|
|
d486e2444e | ||
|
|
319edf70db | ||
|
|
74b26818ca | ||
|
|
b93f5e0b69 | ||
|
|
fb68f341dd |
@@ -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
|
||||
77
CLAUDE.md
77
CLAUDE.md
@@ -1,8 +1,85 @@
|
||||
# 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:
|
||||
- Use `lsof -i :6827` or check for existing background tasks
|
||||
- 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`
|
||||
|
||||
## Pull Requests
|
||||
|
||||
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);
|
||||
```
|
||||
|
||||
152
README.md
152
README.md
@@ -1,9 +1,61 @@
|
||||
# Reviq Publisher Dashboard
|
||||
|
||||
A modern publisher dashboard for managing organizations, members, and sites. Built as a monorepo with SvelteKit frontend and oRPC API server.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend (`apps/publisher-dashboard`)
|
||||
- **SvelteKit** with Svelte 5 (runes)
|
||||
- **Tailwind CSS v4** for styling
|
||||
- **TanStack Query** for data fetching
|
||||
- **bits-ui** for accessible UI primitives
|
||||
- **Lucide** for icons
|
||||
- **WebAuthn/Passkeys** for passwordless authentication
|
||||
|
||||
### Backend (`apps/api-server`)
|
||||
- **Bun** runtime
|
||||
- **oRPC** for type-safe API (contract-first)
|
||||
- **Kysely** for type-safe SQL queries
|
||||
- **PostgreSQL** database
|
||||
- **Postmark** for transactional emails
|
||||
|
||||
### CLI (`apps/cli`)
|
||||
- **Stricli** for command parsing
|
||||
- API token-based authentication
|
||||
- User, organization, and site management commands
|
||||
|
||||
### 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/frontend-utils` - Frontend-specific utilities
|
||||
- `@reviq/server-utils` - Server/CLI utilities
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
publisher-dashboard/
|
||||
├── apps/
|
||||
│ ├── api-server/ # Backend API server
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||
├── packages/
|
||||
│ ├── api-contract/ # Shared oRPC contract
|
||||
│ ├── common/ # Shared utilities (frontend + backend)
|
||||
│ ├── db/ # Database client
|
||||
│ ├── db-schema/ # DB schema & codegen
|
||||
│ ├── frontend-utils/ # Frontend utilities
|
||||
│ ├── server-utils/ # Server/CLI utilities
|
||||
│ └── testing/ # Test utilities
|
||||
└── db/ # Database migrations
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) v1.3.5+
|
||||
- [devenv](https://devenv.sh/) for development environment management
|
||||
|
||||
### Environment Variables
|
||||
@@ -29,9 +81,109 @@ devenv up
|
||||
This starts:
|
||||
- PostgreSQL database
|
||||
- Publisher dashboard dev server (port 6827)
|
||||
- API server
|
||||
- Package build watcher
|
||||
|
||||
The database is automatically initialized with:
|
||||
- Database: `reviq-dashboard`
|
||||
- User: `reviq`
|
||||
- Password: `reviq`
|
||||
|
||||
### Manual Development
|
||||
|
||||
If not using devenv, start services individually:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build packages first
|
||||
bun run build:packages
|
||||
|
||||
# Start dev server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `bun run dev` | Start all dev servers |
|
||||
| `bun run build` | Build all packages and apps |
|
||||
| `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 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
|
||||
|
||||
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
|
||||
|
||||
Quick start:
|
||||
|
||||
```bash
|
||||
# Build the CLI
|
||||
bun run --cwd apps/cli build
|
||||
|
||||
# Login with an API token
|
||||
./apps/cli/dist/reviq auth login --token <your-token>
|
||||
|
||||
# Check status
|
||||
./apps/cli/dist/reviq auth status
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
- Passwordless login with passkeys (WebAuthn)
|
||||
- Email verification
|
||||
- Session management with device tracking
|
||||
|
||||
### Organizations
|
||||
- Create and manage organizations
|
||||
- Member management with roles (owner, admin, member)
|
||||
- Invite members via email
|
||||
- Organization settings
|
||||
|
||||
### Dashboard
|
||||
- Organization switcher
|
||||
- Performance metrics
|
||||
- Reports (coming soon)
|
||||
- Site management (coming soon)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Routes
|
||||
|
||||
```
|
||||
/ # Landing page
|
||||
/login # Login page
|
||||
/dashboard # Organization list
|
||||
/dashboard/[slug] # Organization home
|
||||
/dashboard/[slug]/performance # Performance metrics
|
||||
/dashboard/[slug]/reports # Reports (placeholder)
|
||||
/dashboard/[slug]/settings # Organization settings
|
||||
├── /members # Member management
|
||||
└── /sites # Sites (placeholder)
|
||||
/account # User account settings
|
||||
├── /security # Security settings
|
||||
└── /sessions # Active sessions
|
||||
/admin # Admin panel
|
||||
```
|
||||
|
||||
### API Structure
|
||||
|
||||
The API uses oRPC with a contract-first approach. Routes are defined in `@reviq/api-contract` and implemented in `apps/api-server`.
|
||||
|
||||
Key API namespaces:
|
||||
- `auth` - Authentication (passkeys, sessions)
|
||||
- `me` - Current user profile
|
||||
- `orgs` - Organization management
|
||||
- `orgs.members` - Member management
|
||||
- `orgs.invites` - Invitation management
|
||||
|
||||
@@ -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
2188
apps/api-server/src/__tests__/e2e/auth.test.ts
Normal file
2188
apps/api-server/src/__tests__/e2e/auth.test.ts
Normal file
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
|
||||
*/
|
||||
@@ -21,6 +31,10 @@ export interface APIContext {
|
||||
reqHeaders: Headers;
|
||||
/** Response headers (for setting cookies) */
|
||||
resHeaders: Headers;
|
||||
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||
clientIP?: string | null;
|
||||
/** Email client and configuration */
|
||||
email: EmailConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,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({
|
||||
@@ -39,17 +53,21 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME;
|
||||
|
||||
Bun.serve({
|
||||
port,
|
||||
async fetch(request) {
|
||||
async fetch(request, server) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
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();
|
||||
|
||||
// Get client IP from Bun's server (fallback for when no proxy headers)
|
||||
const socketInfo = server.requestIP(request);
|
||||
const clientIP = socketInfo?.address ?? null;
|
||||
|
||||
const context: APIContext = {
|
||||
db,
|
||||
origin,
|
||||
@@ -57,6 +75,12 @@ Bun.serve({
|
||||
rpName,
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
clientIP,
|
||||
email: {
|
||||
client: emailClient,
|
||||
fromAddress: EMAIL_FROM,
|
||||
baseUrl: BASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
const { response } = await handler.handle(request, {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Authentication middleware for oRPC server
|
||||
*
|
||||
* Handles authentication via:
|
||||
* - Session cookie (rev.session_token) - for browser clients
|
||||
* - API key header (x-api-key) - for CLI and programmatic access
|
||||
*/
|
||||
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
|
||||
/**
|
||||
* Create the auth middleware function
|
||||
* This returns a middleware handler that can be used with oRPC procedures
|
||||
*/
|
||||
export const createAuthMiddleware = () => {
|
||||
return async ({
|
||||
context,
|
||||
next,
|
||||
}: {
|
||||
context: APIContext;
|
||||
next: (opts: {
|
||||
context: Omit<AuthenticatedContext, keyof APIContext>;
|
||||
}) => Promise<unknown>;
|
||||
}) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require superuser access
|
||||
*/
|
||||
export const createSuperuserMiddleware = () => {
|
||||
return async ({
|
||||
context,
|
||||
next,
|
||||
}: {
|
||||
context: AuthenticatedContext;
|
||||
next: () => Promise<unknown>;
|
||||
}) => {
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
138
apps/api-server/src/middlewares/auth.ts
Normal file
138
apps/api-server/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Auth middleware - validates session/API token and adds user to context
|
||||
*/
|
||||
|
||||
import type { AuthInfo, Session, SessionUser } from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
import { os } from "./os.js";
|
||||
|
||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
8
apps/api-server/src/middlewares/index.ts
Normal file
8
apps/api-server/src/middlewares/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Middleware exports
|
||||
*/
|
||||
|
||||
export { authMiddleware } from "./auth.js";
|
||||
export { loginRequestMiddleware } from "./login-request.js";
|
||||
export { os } from "./os.js";
|
||||
export { superuserMiddleware } from "./superuser.js";
|
||||
64
apps/api-server/src/middlewares/login-request.ts
Normal file
64
apps/api-server/src/middlewares/login-request.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Login request middleware - validates login request token from cookie
|
||||
*/
|
||||
|
||||
import type { SessionUser } from "../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { os } from "./os.js";
|
||||
|
||||
export const loginRequestMiddleware = os.middleware(
|
||||
async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Read login request token from cookie
|
||||
const loginRequestToken = getCookie(
|
||||
reqHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
);
|
||||
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No login request found",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
.select([
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.expires_at",
|
||||
"users.email",
|
||||
"users.display_name",
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: result.user_id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
emailVerifiedAt: result.email_verified_at,
|
||||
isSuperuser: result.is_superuser,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
loginRequestId: Number(result.id),
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
10
apps/api-server/src/middlewares/os.ts
Normal file
10
apps/api-server/src/middlewares/os.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Base implementer with typed APIContext
|
||||
* All procedures and middlewares should derive from this
|
||||
*/
|
||||
|
||||
import type { APIContext } from "../context.js";
|
||||
import { implement } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
|
||||
export const os = implement(contract).$context<APIContext>();
|
||||
19
apps/api-server/src/middlewares/superuser.ts
Normal file
19
apps/api-server/src/middlewares/superuser.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Superuser middleware - authenticates and requires superuser access
|
||||
*
|
||||
* This middleware chains authMiddleware first, then checks for superuser.
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware } from "./auth.js";
|
||||
|
||||
export const superuserMiddleware = authMiddleware.concat(
|
||||
async ({ context, next }) => {
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
},
|
||||
);
|
||||
@@ -3,12 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminAuthCompleteLogin =
|
||||
superuserProcedure.admin.auth.completeLogin.handler(
|
||||
async ({ input, context }) => {
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
// First check if any login request exists for this email
|
||||
@@ -48,4 +47,5 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsCreate = os.admin.orgs.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsCreate = superuserProcedure.admin.orgs.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, ownerEmail } = input;
|
||||
|
||||
// Find owner user by email (outside transaction - read-only)
|
||||
@@ -55,4 +53,5 @@ export const adminOrgsCreate = os.admin.orgs.create
|
||||
});
|
||||
|
||||
return { slug };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsDelete = os.admin.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsDelete = superuserProcedure.admin.orgs.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Delete org and related records in transaction
|
||||
@@ -35,4 +33,5 @@ export const adminOrgsDelete = os.admin.orgs.delete
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsGet = os.admin.orgs.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsGet = superuserProcedure.admin.orgs.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const org = await context.db
|
||||
.selectFrom("orgs")
|
||||
.where("slug", "=", input.slug)
|
||||
@@ -19,4 +17,5 @@ export const adminOrgsGet = os.admin.orgs.get
|
||||
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
|
||||
}
|
||||
return toOrgResponse(org);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* admin.orgs.list - List all organizations
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toOrgResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsList = os.admin.orgs.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const adminOrgsList = superuserProcedure.admin.orgs.list.handler(
|
||||
async ({ context }) => {
|
||||
const orgs = await context.db.selectFrom("orgs").selectAll().execute();
|
||||
return orgs.map(toOrgResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toSiteResponse } from "../helpers.js";
|
||||
|
||||
export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsListSites =
|
||||
superuserProcedure.admin.orgs.listSites.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
const org = await context.db
|
||||
@@ -29,12 +28,11 @@ export const adminOrgsListSites = os.admin.orgs.listSites
|
||||
.execute();
|
||||
|
||||
return sites.map(toSiteResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsAddSite = superuserProcedure.admin.orgs.addSite.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
// Use transaction to prevent race condition on site creation
|
||||
@@ -70,12 +68,12 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsRemoveSite =
|
||||
superuserProcedure.admin.orgs.removeSite.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, domain } = input;
|
||||
|
||||
const org = await context.db
|
||||
@@ -98,4 +96,5 @@ export const adminOrgsRemoveSite = os.admin.orgs.removeSite
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminOrgsUpdate = os.admin.orgs.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminOrgsUpdate = superuserProcedure.admin.orgs.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Check if there are actual updates to make
|
||||
@@ -49,4 +47,5 @@ export const adminOrgsUpdate = os.admin.orgs.update
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersConfirmEmail =
|
||||
superuserProcedure.admin.users.confirmEmail.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("users")
|
||||
.set({
|
||||
@@ -23,4 +22,5 @@ export const adminUsersConfirmEmail = os.admin.users.confirmEmail
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersCreate = os.admin.users.create
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersCreate = superuserProcedure.admin.users.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { email, name, orgSlug, orgRole } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
@@ -62,4 +60,5 @@ export const adminUsersCreate = os.admin.users.create
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersGet = os.admin.users.get
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersGet = superuserProcedure.admin.users.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
@@ -19,4 +17,5 @@ export const adminUsersGet = os.admin.users.get
|
||||
throw new ORPCError("NOT_FOUND", { message: "User not found" });
|
||||
}
|
||||
return toUserResponse(user);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* admin.users.list - List all users
|
||||
*/
|
||||
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
import { toUserResponse } from "../helpers.js";
|
||||
|
||||
export const adminUsersList = os.admin.users.list
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const adminUsersList = superuserProcedure.admin.users.list.handler(
|
||||
async ({ context }) => {
|
||||
const users = await context.db.selectFrom("users").selectAll().execute();
|
||||
return users.map(toUserResponse);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os, superuserMiddleware } from "../../base.js";
|
||||
import { superuserProcedure } from "../../base.js";
|
||||
|
||||
export const adminUsersUpdate = os.admin.users.update
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const adminUsersUpdate = superuserProcedure.admin.users.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { email, isSuperuser } = input;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
@@ -47,4 +45,5 @@ export const adminUsersUpdate = os.admin.users.update
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
const hasPassword = user.password_hash !== null;
|
||||
|
||||
// Get geo info and user agent
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
// Create login request with secure token
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
* This prevents attackers from determining which emails are registered
|
||||
*/
|
||||
|
||||
import { withTransaction } from "@reviq/db";
|
||||
import { sendPasswordResetEmail } from "@reviq/emails";
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
||||
import { os } from "../base.js";
|
||||
|
||||
export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
@@ -30,19 +31,21 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
|
||||
// If user exists, create password reset token and send email
|
||||
if (user) {
|
||||
// Delete any existing password reset tokens for this user (security measure)
|
||||
await context.db
|
||||
.deleteFrom("password_resets")
|
||||
.where("user_id", "=", user.id)
|
||||
.execute();
|
||||
|
||||
// Generate secure base58 token
|
||||
const token = generateSecureBase58Token();
|
||||
|
||||
// Create password reset record with 1 hour expiry
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
||||
|
||||
await context.db
|
||||
// Delete old tokens and insert new one in transaction
|
||||
await withTransaction(context.db, async (trx) => {
|
||||
// Delete any existing password reset tokens for this user (security measure)
|
||||
await trx
|
||||
.deleteFrom("password_resets")
|
||||
.where("user_id", "=", user.id)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.insertInto("password_resets")
|
||||
.values({
|
||||
user_id: user.id,
|
||||
@@ -50,9 +53,17 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
// Send password reset email (stubbed)
|
||||
await sendPasswordResetEmail(user.email, token);
|
||||
// Send password reset email
|
||||
await sendPasswordResetEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: user.email,
|
||||
token,
|
||||
expiryHours: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Always return success (anti-enumeration)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
||||
*/
|
||||
|
||||
import { withTransaction } from "@reviq/db";
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
COOKIE_OPTIONS,
|
||||
@@ -86,12 +87,16 @@ export const loginIfRequestIsCompleted =
|
||||
}
|
||||
|
||||
// Get current request info
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
// Create session in transaction (atomic: device upsert + session + login_request delete)
|
||||
const { session, deviceTrusted } = await withTransaction(
|
||||
context.db,
|
||||
async (trx) => {
|
||||
// Upsert user device
|
||||
const deviceId = await upsertUserDevice(
|
||||
context.db,
|
||||
trx,
|
||||
userId,
|
||||
deviceFingerprint,
|
||||
geo,
|
||||
@@ -99,14 +104,10 @@ export const loginIfRequestIsCompleted =
|
||||
);
|
||||
|
||||
// Check if device is already trusted
|
||||
const deviceTrusted = await isDeviceTrusted(
|
||||
context.db,
|
||||
userId,
|
||||
deviceFingerprint,
|
||||
);
|
||||
const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
|
||||
|
||||
// Create session with trusted mode = true (email-confirmed login)
|
||||
const session = await createSession(context.db, {
|
||||
const newSession = await createSession(trx, {
|
||||
userId,
|
||||
deviceId,
|
||||
trustedMode: true,
|
||||
@@ -115,11 +116,15 @@ export const loginIfRequestIsCompleted =
|
||||
});
|
||||
|
||||
// Delete the login request (it's been consumed)
|
||||
await context.db
|
||||
await trx
|
||||
.deleteFrom("login_requests")
|
||||
.where("id", "=", loginRequest.id)
|
||||
.execute();
|
||||
|
||||
return { session: newSession, deviceTrusted: trusted };
|
||||
},
|
||||
);
|
||||
|
||||
// Set session cookie
|
||||
setCookie(
|
||||
context.resHeaders,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendLoginConfirmationEmail } from "@reviq/emails";
|
||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
||||
import { verifyPassword } from "../../utils/password.js";
|
||||
import { isDeviceTrusted } from "../../utils/session.js";
|
||||
import { os } from "../base.js";
|
||||
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
} else {
|
||||
// Device is untrusted - send confirmation email with existing token
|
||||
// The same base58 token is used for both cookie lookup and email confirmation
|
||||
await sendLoginConfirmationEmail(result.email, result.token);
|
||||
await sendLoginConfirmationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: result.email,
|
||||
token: result.token,
|
||||
expiryMinutes: 15,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
/**
|
||||
* Logout handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Revokes the current session by setting revoked_at to now()
|
||||
* - Clears the session cookie from the response
|
||||
*/
|
||||
export const logout = os.auth.logout
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const logout = authedProcedure.auth.logout.handler(
|
||||
async ({ context }) => {
|
||||
// Revoke the current session
|
||||
await context.db
|
||||
.updateTable("sessions")
|
||||
@@ -25,4 +24,5 @@ export const logout = os.auth.logout
|
||||
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,17 +10,16 @@
|
||||
* 5. Send verification email (stubbed)
|
||||
*/
|
||||
|
||||
import { sendVerificationEmail } from "@reviq/emails";
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const resendVerificationEmail =
|
||||
authedProcedure.auth.resendVerificationEmail.handler(async ({ context }) => {
|
||||
// Check if email is already verified
|
||||
if (context.user.emailVerifiedAt !== null) {
|
||||
// Email already verified, return early
|
||||
@@ -47,8 +46,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(context.user.email, token);
|
||||
// Send verification email
|
||||
await sendVerificationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: context.user.email,
|
||||
token,
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { Kysely } from "kysely";
|
||||
import type { RPInfo } from "../../utils/webauthn.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { withTransaction } from "@reviq/db";
|
||||
import { sendVerificationEmail } from "@reviq/emails";
|
||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||
import { createSession } from "../../utils/session.js";
|
||||
@@ -52,7 +53,8 @@ export async function signupWithPassword(
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
// Create user (handle race condition if concurrent signup with same email)
|
||||
try {
|
||||
const user = await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
@@ -63,6 +65,16 @@ export async function signupWithPassword(
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return user.id;
|
||||
} catch (error) {
|
||||
// Handle duplicate email (unique constraint violation)
|
||||
// Use generic error to prevent email enumeration
|
||||
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Unable to create account",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +109,7 @@ export async function signupWithPasskey(
|
||||
const challengeRow = await db
|
||||
.selectFrom("webauthn_challenges")
|
||||
.select("options")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.where("created_at", ">", fifteenMinutesAgo)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
|
||||
// Delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
|
||||
// Log error for debugging but don't expose to client
|
||||
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
|
||||
// Delete the challenge
|
||||
await db
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
@@ -146,8 +158,9 @@ export async function signupWithPasskey(
|
||||
});
|
||||
}
|
||||
|
||||
// Create user and passkey in a transaction
|
||||
const result = await db.transaction().execute(async (trx) => {
|
||||
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||
try {
|
||||
const result = await withTransaction(db, async (trx) => {
|
||||
// Create user
|
||||
const user = await trx
|
||||
.insertInto("users")
|
||||
@@ -188,13 +201,23 @@ export async function signupWithPasskey(
|
||||
// Delete the challenge
|
||||
await trx
|
||||
.deleteFrom("webauthn_challenges")
|
||||
.where("id", "=", String(challengeId))
|
||||
.where("id", "=", challengeId.toString())
|
||||
.execute();
|
||||
|
||||
return { userId: newUserId };
|
||||
});
|
||||
|
||||
return result.userId;
|
||||
} catch (error) {
|
||||
// Handle duplicate email (unique constraint violation)
|
||||
// Use generic error to prevent email enumeration
|
||||
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Unable to create account",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,7 +248,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
}
|
||||
|
||||
// Get geo info and user agent for session creation
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
let userId: number;
|
||||
@@ -241,14 +264,22 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
);
|
||||
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
||||
} else {
|
||||
// Should never reach here due to schema validation
|
||||
// Unreachable - schema validation requires password or passkeyInfo
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Either password or passkeyInfo is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = generateSecureBase58Token();
|
||||
const verificationExpiresAt = generateExpiry(
|
||||
TOKEN_DURATIONS.EMAIL_VERIFICATION,
|
||||
);
|
||||
|
||||
// Create session and email verification in transaction
|
||||
const session = await withTransaction(context.db, async (trx) => {
|
||||
// Create session (7 days, trusted mode false initially, no device)
|
||||
const session = await createSession(context.db, {
|
||||
const newSession = await createSession(trx, {
|
||||
userId,
|
||||
deviceId: null,
|
||||
trustedMode: false,
|
||||
@@ -256,6 +287,19 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
userAgent,
|
||||
});
|
||||
|
||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||
await trx
|
||||
.insertInto("email_verifications")
|
||||
.values({
|
||||
user_id: userId,
|
||||
token: verificationToken,
|
||||
expires_at: verificationExpiresAt,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return newSession;
|
||||
});
|
||||
|
||||
// Set session cookie
|
||||
setCookie(
|
||||
context.resHeaders,
|
||||
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
COOKIE_OPTIONS.session,
|
||||
);
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = generateSecureBase58Token();
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||
|
||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||
await context.db
|
||||
.insertInto("email_verifications")
|
||||
.values({
|
||||
user_id: userId,
|
||||
// Send verification email
|
||||
await sendVerificationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email,
|
||||
token: verificationToken,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(email, verificationToken);
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -8,227 +8,22 @@
|
||||
import type {
|
||||
APIContext,
|
||||
AuthenticatedContext,
|
||||
AuthInfo,
|
||||
LoginRequestContext,
|
||||
Session,
|
||||
SessionUser,
|
||||
} from "../context.js";
|
||||
import { implement, ORPCError } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
import { COOKIE_NAMES, getCookie } from "../utils/cookies.js";
|
||||
import { hashToken } from "../utils/crypto.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
loginRequestMiddleware,
|
||||
os,
|
||||
superuserMiddleware,
|
||||
} from "../middlewares/index.js";
|
||||
|
||||
/**
|
||||
* Base implementer with typed APIContext
|
||||
* All procedures should be derived from this
|
||||
*/
|
||||
export const os = implement(contract).$context<APIContext>();
|
||||
// Re-export middlewares and os
|
||||
export { authMiddleware, loginRequestMiddleware, os, superuserMiddleware };
|
||||
|
||||
/**
|
||||
* Auth middleware - validates session/API token and adds user to context
|
||||
* Use with os.use(authMiddleware) to create authenticated procedures
|
||||
*/
|
||||
export const authMiddleware = os.middleware(async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Try session cookie first
|
||||
let tokenHash: string | undefined;
|
||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||
if (sessionToken) {
|
||||
tokenHash = await hashToken(sessionToken);
|
||||
}
|
||||
|
||||
// Fall back to API key header (for CLI)
|
||||
const apiKey = reqHeaders.get("x-api-key");
|
||||
if (!tokenHash && apiKey) {
|
||||
tokenHash = await hashToken(apiKey);
|
||||
}
|
||||
|
||||
if (!tokenHash) {
|
||||
throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" });
|
||||
}
|
||||
|
||||
// Look up session (check not expired and not revoked)
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Fall back to API token if no session found
|
||||
const apiToken = !session
|
||||
? await db
|
||||
.selectFrom("api_tokens")
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.where("expires_at", ">", new Date())
|
||||
.selectAll()
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
const userId = session?.user_id ?? apiToken?.user_id;
|
||||
if (!userId) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_used_at for API tokens
|
||||
if (apiToken) {
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("id", "=", apiToken.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
const user = await db
|
||||
.selectFrom("users")
|
||||
.where("id", "=", userId)
|
||||
.select([
|
||||
"id",
|
||||
"email",
|
||||
"display_name",
|
||||
"email_verified_at",
|
||||
"is_superuser",
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerifiedAt: user.email_verified_at,
|
||||
isSuperuser: user.is_superuser,
|
||||
};
|
||||
|
||||
// Build session and auth info based on authentication method
|
||||
let sessionInfo: Session;
|
||||
let authInfo: AuthInfo;
|
||||
|
||||
if (session) {
|
||||
sessionInfo = {
|
||||
id: session.id,
|
||||
trustedMode: session.trusted_mode,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "session",
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expires_at,
|
||||
createdAt: session.created_at,
|
||||
};
|
||||
} else if (apiToken) {
|
||||
sessionInfo = {
|
||||
// For API token auth, create a synthetic session object
|
||||
id: "0",
|
||||
trustedMode: true,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
authInfo = {
|
||||
method: "api_token",
|
||||
tokenId: apiToken.id,
|
||||
tokenName: apiToken.name,
|
||||
expiresAt: apiToken.expires_at,
|
||||
lastUsedAt: apiToken.last_used_at,
|
||||
createdAt: apiToken.created_at,
|
||||
};
|
||||
} else {
|
||||
// This should never happen since we checked userId above
|
||||
throw new ORPCError("UNAUTHORIZED", {
|
||||
message: "Invalid authentication state",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: sessionUser,
|
||||
session: sessionInfo,
|
||||
auth: authInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Login request middleware - validates login request token from cookie
|
||||
*/
|
||||
export const loginRequestMiddleware = os.middleware(
|
||||
async ({ context, next }) => {
|
||||
const { db, reqHeaders } = context;
|
||||
|
||||
// Read login request token from cookie
|
||||
const loginRequestToken = getCookie(
|
||||
reqHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
);
|
||||
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "No login request found",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
.select([
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.expires_at",
|
||||
"users.email",
|
||||
"users.display_name",
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: result.user_id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
emailVerifiedAt: result.email_verified_at,
|
||||
isSuperuser: result.is_superuser,
|
||||
};
|
||||
|
||||
return next({
|
||||
context: {
|
||||
loginRequestId: Number(result.id),
|
||||
user: sessionUser,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Superuser middleware - requires admin access (must be used after authMiddleware)
|
||||
*/
|
||||
export const superuserMiddleware = os.middleware(async ({ context, next }) => {
|
||||
// This middleware should be used after authMiddleware
|
||||
const ctx = context as AuthenticatedContext;
|
||||
if (!ctx.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Superuser access required",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
// Pre-configured procedures with middleware applied
|
||||
export const authedProcedure = os.use(authMiddleware);
|
||||
export const superuserProcedure = os.use(superuserMiddleware);
|
||||
export const loginRequestProcedure = os.use(loginRequestMiddleware);
|
||||
|
||||
// Type exports for use in procedure files
|
||||
export type { APIContext, AuthenticatedContext, LoginRequestContext };
|
||||
|
||||
7
apps/api-server/src/procedures/me/_base.ts
Normal file
7
apps/api-server/src/procedures/me/_base.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Base route for me procedures with auth middleware applied
|
||||
*/
|
||||
|
||||
import { authedProcedure } from "../base.js";
|
||||
|
||||
export const meRoute = authedProcedure.me;
|
||||
@@ -2,6 +2,7 @@
|
||||
* Me routes - consolidated exports for os.router()
|
||||
*/
|
||||
|
||||
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
|
||||
import { meAuthStatus } from "./auth-status.js";
|
||||
import { meDelete } from "./delete.js";
|
||||
import {
|
||||
@@ -54,4 +55,9 @@ export const meRoutes = {
|
||||
untrust: untrustDevice,
|
||||
revokeAll: revokeAllTrustedDevices,
|
||||
},
|
||||
apiTokens: {
|
||||
list: listApiTokens,
|
||||
create: createApiToken,
|
||||
delete: deleteApiToken,
|
||||
},
|
||||
};
|
||||
|
||||
109
apps/api-server/src/procedures/me/api-tokens.ts
Normal file
109
apps/api-server/src/procedures/me/api-tokens.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* API token management procedures
|
||||
* Allows users to create and manage API tokens for CLI/programmatic access
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import {
|
||||
generateSecureBase58Token,
|
||||
hashToken,
|
||||
TOKEN_PREFIX,
|
||||
} from "../../utils/crypto.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/** Token expiration: 365 days */
|
||||
const TOKEN_EXPIRATION_DAYS = 365;
|
||||
|
||||
/**
|
||||
* List all API tokens for the current user
|
||||
* Returns token metadata (not the actual token values)
|
||||
*/
|
||||
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"])
|
||||
.where("user_id", "=", context.user.id)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute();
|
||||
|
||||
return tokens.map((token) => ({
|
||||
id: Number(token.id),
|
||||
name: token.name,
|
||||
lastUsedAt: token.last_used_at?.toISOString() ?? null,
|
||||
createdAt: token.created_at.toISOString(),
|
||||
expiresAt: token.expires_at.toISOString(),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new API token
|
||||
* Requires superuser status and trusted session
|
||||
*/
|
||||
export const createApiToken = meRoute.apiTokens.create.handler(
|
||||
async ({ input, context }) => {
|
||||
// Require superuser status
|
||||
if (!context.user.isSuperuser) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message: "Only superusers can create API tokens.",
|
||||
});
|
||||
}
|
||||
|
||||
// Require trusted session for creating API tokens
|
||||
if (!context.session.trustedMode) {
|
||||
throw new ORPCError("FORBIDDEN", {
|
||||
message:
|
||||
"Creating API tokens requires a trusted session. Please re-authenticate.",
|
||||
});
|
||||
}
|
||||
|
||||
const { name } = input;
|
||||
|
||||
// Generate a new API token
|
||||
const token = generateSecureBase58Token(TOKEN_PREFIX);
|
||||
const tokenHash = await hashToken(token);
|
||||
|
||||
// Calculate expiration
|
||||
const expiresAt = new Date(
|
||||
Date.now() + TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Insert into api_tokens table
|
||||
await context.db
|
||||
.insertInto("api_tokens")
|
||||
.values({
|
||||
user_id: context.user.id,
|
||||
token_hash: tokenHash,
|
||||
name,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an API token
|
||||
*/
|
||||
export const deleteApiToken = meRoute.apiTokens.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.deleteFrom("api_tokens")
|
||||
.where("id", "=", input.tokenId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (result.numDeletedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "API token not found",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
@@ -2,11 +2,9 @@
|
||||
* Get current user auth status
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const meAuthStatus = os.me.authStatus
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const meAuthStatus = meRoute.authStatus.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||
import { verifyPassword } from "../../utils/password.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Delete account handler
|
||||
@@ -14,9 +14,7 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||
* - Clears session cookie
|
||||
*/
|
||||
export const meDelete = os.me.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const meDelete = meRoute.delete.handler(async ({ input, context }) => {
|
||||
const { password } = input;
|
||||
|
||||
// Fetch user with password hash
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
|
||||
/**
|
||||
@@ -13,9 +13,8 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const getDeviceInfo = os.me.devices.getInfo
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const getDeviceInfo = meRoute.devices.getInfo.handler(
|
||||
async ({ context }) => {
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
|
||||
const device = await context.db
|
||||
@@ -39,7 +38,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
||||
lastUsedAt: device.last_used_at,
|
||||
isTrusted: device.is_trusted,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Trust device handler
|
||||
@@ -48,9 +48,8 @@ export const getDeviceInfo = os.me.devices.getInfo
|
||||
* @throws BAD_REQUEST if no device fingerprint found
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const trustDevice = os.me.devices.trust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const trustDevice = meRoute.devices.trust.handler(
|
||||
async ({ input, context }) => {
|
||||
const { name } = input;
|
||||
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||
|
||||
@@ -66,16 +65,16 @@ export const trustDevice = os.me.devices.trust
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* List trusted devices handler
|
||||
* - Requires authentication
|
||||
* - Returns all trusted devices for the current user
|
||||
*/
|
||||
export const listTrustedDevices = os.me.devices.listTrusted
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listTrustedDevices = meRoute.devices.listTrusted.handler(
|
||||
async ({ context }) => {
|
||||
const devices = await context.db
|
||||
.selectFrom("user_devices")
|
||||
.selectAll()
|
||||
@@ -94,7 +93,8 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
||||
lastUsedAt: d.last_used_at,
|
||||
isTrusted: d.is_trusted,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Untrust device handler
|
||||
@@ -102,13 +102,12 @@ export const listTrustedDevices = os.me.devices.listTrusted
|
||||
* - Marks device as untrusted by ID
|
||||
* @throws NOT_FOUND if device doesn't exist
|
||||
*/
|
||||
export const untrustDevice = os.me.devices.untrust
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const untrustDevice = meRoute.devices.untrust.handler(
|
||||
async ({ input, context }) => {
|
||||
const result = await context.db
|
||||
.updateTable("user_devices")
|
||||
.set({ is_trusted: false })
|
||||
.where("id", "=", String(input.deviceId))
|
||||
.where("id", "=", input.deviceId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -117,16 +116,16 @@ export const untrustDevice = os.me.devices.untrust
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke all trusted devices handler
|
||||
* - Requires authentication
|
||||
* - Marks all devices as untrusted
|
||||
*/
|
||||
export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const revokeAllTrustedDevices = meRoute.devices.revokeAll.handler(
|
||||
async ({ context }) => {
|
||||
await context.db
|
||||
.updateTable("user_devices")
|
||||
.set({ is_trusted: false })
|
||||
@@ -134,4 +133,5 @@ export const revokeAllTrustedDevices = os.me.devices.revokeAll
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* Get current user profile
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const meGet = os.me.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const meGet = meRoute.get.handler(async ({ context }) => {
|
||||
const user = await context.db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List pending invites for the current user
|
||||
* Only returns invites where the user's email matches and email is verified
|
||||
*/
|
||||
export const listInvites = os.me.invites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listInvites = meRoute.invites.list.handler(async ({ context }) => {
|
||||
// Only show invites if email is verified
|
||||
if (!context.user.emailVerifiedAt) {
|
||||
return [];
|
||||
@@ -58,9 +56,8 @@ export const listInvites = os.me.invites.list
|
||||
* Get a specific invite by ID
|
||||
* Only returns if the invite belongs to the current user's email
|
||||
*/
|
||||
export const getInvite = os.me.invites.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const getInvite = meRoute.invites.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Only show invite if email is verified
|
||||
@@ -111,15 +108,15 @@ export const getInvite = os.me.invites.get
|
||||
createdAt: invite.created_at,
|
||||
expiresAt: invite.expires_at,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept an invite by ID
|
||||
* Adds user to org and deletes the invite
|
||||
*/
|
||||
export const acceptInvite = os.me.invites.accept
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const acceptInvite = meRoute.invites.accept.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Only allow accepting if email is verified
|
||||
@@ -183,15 +180,15 @@ export const acceptInvite = os.me.invites.accept
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Decline an invite
|
||||
* Deletes the invite if it belongs to the current user's email
|
||||
*/
|
||||
export const declineInvite = os.me.invites.decline
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const declineInvite = meRoute.invites.decline.handler(
|
||||
async ({ input, context }) => {
|
||||
const { inviteId } = input;
|
||||
|
||||
// Delete the invite only if it matches user's email
|
||||
@@ -208,4 +205,5 @@ export const declineInvite = os.me.invites.decline
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List passkeys handler
|
||||
* - Requires authentication
|
||||
* - Returns all passkeys for the current user
|
||||
*/
|
||||
export const listPasskeys = os.me.passkeys.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listPasskeys = meRoute.passkeys.list.handler(
|
||||
async ({ context }) => {
|
||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||
|
||||
return passkeys.map((p) => ({
|
||||
@@ -22,7 +21,8 @@ export const listPasskeys = os.me.passkeys.list
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: p.lastUsedAt,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Rename passkey handler
|
||||
@@ -30,15 +30,14 @@ export const listPasskeys = os.me.passkeys.list
|
||||
* - Updates passkey name
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
*/
|
||||
export const renamePasskey = os.me.passkeys.rename
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const renamePasskey = meRoute.passkeys.rename.handler(
|
||||
async ({ input, context }) => {
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("passkeys")
|
||||
.set({ name })
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("id", "=", passkeyId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -47,7 +46,8 @@ export const renamePasskey = os.me.passkeys.rename
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete passkey handler
|
||||
@@ -57,9 +57,8 @@ export const renamePasskey = os.me.passkeys.rename
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||
*/
|
||||
export const deletePasskey = os.me.passkeys.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const deletePasskey = meRoute.passkeys.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { passkeyId } = input;
|
||||
|
||||
// Use transaction to prevent race condition when checking last passkey
|
||||
@@ -86,7 +85,7 @@ export const deletePasskey = os.me.passkeys.delete
|
||||
|
||||
const result = await trx
|
||||
.deleteFrom("passkeys")
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("id", "=", passkeyId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -96,4 +95,5 @@ export const deletePasskey = os.me.passkeys.delete
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* List sessions handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Returns all sessions for the current user
|
||||
* - Includes isCurrent flag to identify active session
|
||||
*/
|
||||
export const listSessions = os.me.sessions.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const listSessions = meRoute.sessions.list.handler(
|
||||
async ({ context }) => {
|
||||
const sessions = await context.db
|
||||
.selectFrom("sessions")
|
||||
.selectAll()
|
||||
@@ -33,7 +32,8 @@ export const listSessions = os.me.sessions.list
|
||||
isCurrent: s.id === context.session.id,
|
||||
revokedAt: s.revoked_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke session handler
|
||||
@@ -42,13 +42,12 @@ export const listSessions = os.me.sessions.list
|
||||
* @throws NOT_FOUND if session doesn't exist
|
||||
* @throws BAD_REQUEST if trying to revoke current session
|
||||
*/
|
||||
export const revokeSession = os.me.sessions.revoke
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const revokeSession = meRoute.sessions.revoke.handler(
|
||||
async ({ input, context }) => {
|
||||
const { sessionId } = input;
|
||||
|
||||
// Prevent revoking current session (use logout instead)
|
||||
if (String(sessionId) === context.session.id) {
|
||||
if (sessionId.toString() === context.session.id) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Cannot revoke current session. Use logout instead.",
|
||||
});
|
||||
@@ -57,7 +56,7 @@ export const revokeSession = os.me.sessions.revoke
|
||||
const result = await context.db
|
||||
.updateTable("sessions")
|
||||
.set({ revoked_at: new Date() })
|
||||
.where("id", "=", String(sessionId))
|
||||
.where("id", "=", sessionId.toString())
|
||||
.where("user_id", "=", context.user.id)
|
||||
.where("revoked_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
@@ -67,16 +66,16 @@ export const revokeSession = os.me.sessions.revoke
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke all sessions handler
|
||||
* - Requires authentication
|
||||
* - Revokes all sessions except current
|
||||
*/
|
||||
export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const revokeAllSessions = meRoute.sessions.revokeAll.handler(
|
||||
async ({ context }) => {
|
||||
// Revoke all sessions except current
|
||||
await context.db
|
||||
.updateTable("sessions")
|
||||
@@ -87,4 +86,5 @@ export const revokeAllSessions = os.me.sessions.revokeAll
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
validatePassword,
|
||||
verifyPassword,
|
||||
} from "../../utils/password.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Set password handler
|
||||
@@ -16,9 +16,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - If user has existing password, currentPassword is required
|
||||
* - Validates new password strength using zxcvbn
|
||||
*/
|
||||
export const setPassword = os.me.setPassword
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const setPassword = meRoute.setPassword.handler(
|
||||
async ({ input, context }) => {
|
||||
const { currentPassword, newPassword } = input;
|
||||
|
||||
// Fetch current password hash
|
||||
@@ -60,4 +59,5 @@ export const setPassword = os.me.setPassword
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Setup user profile (initial setup after signup)
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
export const setupProfile = os.me.setupProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const setupProfile = meRoute.setupProfile.handler(
|
||||
async ({ input, context }) => {
|
||||
const { displayName, fullName, phoneNumber } = input;
|
||||
|
||||
await context.db
|
||||
@@ -21,4 +20,5 @@ export const setupProfile = os.me.setupProfile
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { ProfileUpdate } from "./helpers.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { meRoute } from "./_base.js";
|
||||
|
||||
/**
|
||||
* Update profile handler
|
||||
@@ -11,9 +11,8 @@ import { authMiddleware, os } from "../base.js";
|
||||
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
||||
* - Automatically sets updated_at timestamp
|
||||
*/
|
||||
export const updateProfile = os.me.updateProfile
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const updateProfile = meRoute.updateProfile.handler(
|
||||
async ({ input, context }) => {
|
||||
const updates: Partial<ProfileUpdate> = {};
|
||||
if (input.displayName !== undefined) {
|
||||
updates.display_name = input.displayName;
|
||||
@@ -38,4 +37,5 @@ export const updateProfile = os.me.updateProfile
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all orgs the current user is a member of
|
||||
*/
|
||||
export const orgsList = os.orgs.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
export const orgsList = authedProcedure.orgs.list.handler(
|
||||
async ({ context }) => {
|
||||
const orgs = await context.db
|
||||
.selectFrom("org_members")
|
||||
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||
@@ -33,15 +32,15 @@ export const orgsList = os.orgs.list
|
||||
logoUrl: o.logo_url,
|
||||
createdAt: o.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new org
|
||||
* The creating user becomes the owner
|
||||
*/
|
||||
export const orgsCreate = os.orgs.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsCreate = authedProcedure.orgs.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName } = input;
|
||||
|
||||
try {
|
||||
@@ -75,15 +74,15 @@ export const orgsCreate = os.orgs.create
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single org by slug
|
||||
* Requires membership
|
||||
*/
|
||||
export const orgsGet = os.orgs.get
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsGet = authedProcedure.orgs.get.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -97,4 +96,5 @@ export const orgsGet = os.orgs.get
|
||||
logoUrl: org.logoUrl,
|
||||
createdAt: org.createdAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,25 +5,11 @@
|
||||
|
||||
import type { DB, OrgRole } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { OrgInfo, OrgMembership } from "../../context.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
/** Org info returned from lookup */
|
||||
export interface OrgInfo {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** User's membership in an org */
|
||||
export interface OrgMembership {
|
||||
id: number;
|
||||
role: OrgRole;
|
||||
createdAt: Date;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { OrgInfo, OrgMembership };
|
||||
|
||||
// ===== Role Hierarchy =====
|
||||
|
||||
@@ -115,10 +101,11 @@ export async function countOwners(
|
||||
): Promise<number> {
|
||||
const result = await db
|
||||
.selectFrom("org_members")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.select((eb) => eb.fn.countAll().as("count"))
|
||||
.where("org_id", "=", orgId)
|
||||
.where("role", "=", "owner")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.count;
|
||||
// PostgreSQL COUNT returns bigint (string), convert to number
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendOrgInviteEmail } from "@reviq/emails";
|
||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List pending invites for an org
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const invitesList = os.orgs.invites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesList = authedProcedure.orgs.invites.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify admin+ role
|
||||
@@ -52,16 +51,16 @@ export const invitesList = os.orgs.invites.list
|
||||
createdAt: i.created_at,
|
||||
expiresAt: i.expires_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create an invite for a new member
|
||||
* Requires admin or owner role
|
||||
* Only owners can invite new owners (privilege escalation prevention)
|
||||
*/
|
||||
export const invitesCreate = os.orgs.invites.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesCreate = authedProcedure.orgs.invites.create.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, email: rawEmail, role } = input;
|
||||
const email = rawEmail.toLowerCase();
|
||||
|
||||
@@ -122,18 +121,28 @@ export const invitesCreate = os.orgs.invites.create
|
||||
|
||||
// Send invitation email
|
||||
const inviterName = context.user.displayName ?? context.user.email;
|
||||
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
|
||||
await sendOrgInviteEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email,
|
||||
token,
|
||||
orgName: org.displayName,
|
||||
inviterName,
|
||||
role,
|
||||
expiryDays: ORG_INVITE_EXPIRY_DAYS,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a pending invite
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const invitesCancel = os.orgs.invites.cancel
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesCancel = authedProcedure.orgs.invites.cancel.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, inviteId } = input;
|
||||
|
||||
// Lookup org and verify admin+ role
|
||||
@@ -153,16 +162,16 @@ export const invitesCancel = os.orgs.invites.cancel
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept an invitation
|
||||
* Token-based lookup, requires auth but no org membership
|
||||
* Handles race condition if user is already a member
|
||||
*/
|
||||
export const invitesAccept = os.orgs.invites.accept
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const invitesAccept = authedProcedure.orgs.invites.accept.handler(
|
||||
async ({ input, context }) => {
|
||||
const { token } = input;
|
||||
|
||||
// Find the invite by token (must not be expired)
|
||||
@@ -225,4 +234,5 @@ export const invitesAccept = os.orgs.invites.accept
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
* Update org details
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const orgsUpdate = os.orgs.update
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsUpdate = authedProcedure.orgs.update.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Lookup org and verify membership with admin+ role
|
||||
@@ -41,16 +40,16 @@ export const orgsUpdate = os.orgs.update
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an org
|
||||
* Requires owner role
|
||||
* FK CASCADE handles deleting members, invites, and sites
|
||||
*/
|
||||
export const orgsDelete = os.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsDelete = authedProcedure.orgs.delete.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify ownership
|
||||
@@ -61,16 +60,16 @@ export const orgsDelete = os.orgs.delete
|
||||
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Leave an org
|
||||
* Cannot leave if you're the only owner
|
||||
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||
*/
|
||||
export const orgsLeave = os.orgs.leave
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const orgsLeave = authedProcedure.orgs.leave.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and get membership
|
||||
@@ -98,4 +97,5 @@ export const orgsLeave = os.orgs.leave
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
* List all members of an org
|
||||
* Any member can view the member list
|
||||
*/
|
||||
export const membersList = os.orgs.members.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersList = authedProcedure.orgs.members.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -48,21 +47,26 @@ export const membersList = os.orgs.members.list
|
||||
role: m.role,
|
||||
createdAt: m.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
* Only owners can change roles
|
||||
* Uses transaction to prevent race condition when demoting owners
|
||||
*/
|
||||
export const membersUpdateRole = os.orgs.members.updateRole
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersUpdateRole =
|
||||
authedProcedure.orgs.members.updateRole.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, userId, role: newRole } = input;
|
||||
|
||||
// Lookup org and verify ownership
|
||||
const org = await lookupOrgBySlug(context.db, slug);
|
||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||
const membership = await getMembership(
|
||||
context.db,
|
||||
org.id,
|
||||
context.user.id,
|
||||
);
|
||||
requireRole(membership, "owner");
|
||||
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
@@ -97,16 +101,16 @@ export const membersUpdateRole = os.orgs.members.updateRole
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove a member from an org
|
||||
* Owners can remove anyone, admins can only remove members
|
||||
* Uses transaction to prevent race condition when removing owners
|
||||
*/
|
||||
export const membersRemove = os.orgs.members.remove
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const membersRemove = authedProcedure.orgs.members.remove.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug, userId } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -159,4 +163,5 @@ export const membersRemove = os.orgs.members.remove
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
* Org sites procedures - list
|
||||
*/
|
||||
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { authedProcedure } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all sites for an org
|
||||
* Any member can view the site list
|
||||
*/
|
||||
export const sitesList = os.orgs.sites.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
export const sitesList = authedProcedure.orgs.sites.list.handler(
|
||||
async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and verify membership
|
||||
@@ -31,4 +30,5 @@ export const sitesList = os.orgs.sites.list
|
||||
domain: s.domain,
|
||||
createdAt: s.created_at,
|
||||
}));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", String(context.loginRequestId))
|
||||
.where("id", "=", context.loginRequestId.toString())
|
||||
.execute();
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Authentication utilities for token handling
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import { hashToken } from "./crypto.js";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
email: string;
|
||||
isSuperuser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request using session token or API key
|
||||
* Returns the authenticated user or null if not authenticated
|
||||
*/
|
||||
export const authenticateRequest = async (
|
||||
db: Kysely<Database>,
|
||||
sessionToken?: string,
|
||||
apiKey?: string,
|
||||
): Promise<AuthenticatedUser | null> => {
|
||||
// Try session cookie first, then API key
|
||||
const token = sessionToken ?? apiKey;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
|
||||
// Check sessions table
|
||||
const session = await db
|
||||
.selectFrom("sessions")
|
||||
.innerJoin("users", "users.id", "sessions.user_id")
|
||||
.where("sessions.token_hash", "=", tokenHash)
|
||||
.where("sessions.expires_at", ">", new Date())
|
||||
.where("sessions.revoked_at", "is", null)
|
||||
.select(["users.id", "users.email", "users.is_superuser"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
id: session.id,
|
||||
email: session.email,
|
||||
isSuperuser: session.is_superuser,
|
||||
};
|
||||
}
|
||||
|
||||
// Check API tokens table
|
||||
const apiToken = await db
|
||||
.selectFrom("api_tokens")
|
||||
.innerJoin("users", "users.id", "api_tokens.user_id")
|
||||
.where("api_tokens.token_hash", "=", tokenHash)
|
||||
.where("api_tokens.expires_at", ">", new Date())
|
||||
.select(["users.id", "users.email", "users.is_superuser"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (apiToken) {
|
||||
// Update last_used_at
|
||||
await db
|
||||
.updateTable("api_tokens")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("token_hash", "=", tokenHash)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
id: apiToken.id,
|
||||
email: apiToken.email,
|
||||
isSuperuser: apiToken.is_superuser,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateSecureBase58Token } from "@reviq/utils";
|
||||
import { generateSecureBase58Token } from "@reviq/server-utils";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
/**
|
||||
* Email sending utilities using Postmark
|
||||
* Implements Workstream G: Email Service (Backend)
|
||||
*/
|
||||
|
||||
import type { OrgRole } from "@reviq/db-schema";
|
||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||
import { ServerClient } from "postmark";
|
||||
import {
|
||||
BASE_URL,
|
||||
EMAIL_DEV_MODE,
|
||||
EMAIL_FROM,
|
||||
EMAIL_VERIFICATION_EXPIRY_HOURS,
|
||||
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
|
||||
ORG_INVITE_EXPIRY_DAYS,
|
||||
PASSWORD_RESET_EXPIRY_HOURS,
|
||||
POSTMARK_API_KEY,
|
||||
} from "../constants.js";
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
/**
|
||||
* Email send result
|
||||
*/
|
||||
export interface EmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== Postmark Client =====
|
||||
|
||||
let postmarkClient: ServerClient | null = null;
|
||||
|
||||
const getPostmarkClient = (): ServerClient => {
|
||||
if (!postmarkClient) {
|
||||
if (!POSTMARK_API_KEY) {
|
||||
throw new Error(
|
||||
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
|
||||
);
|
||||
}
|
||||
postmarkClient = new ServerClient(POSTMARK_API_KEY);
|
||||
}
|
||||
return postmarkClient;
|
||||
};
|
||||
|
||||
// ===== URL Helpers =====
|
||||
|
||||
/**
|
||||
* Build a URL with query parameters using the URL constructor
|
||||
*/
|
||||
const buildUrl = (path: string, params: Record<string, string>): string => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
// ===== HTML Escaping =====
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
*/
|
||||
const escapeHtml = (unsafe: string): string =>
|
||||
unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// ===== Core Email Function =====
|
||||
|
||||
interface SendEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email via Postmark (or log in dev mode)
|
||||
*/
|
||||
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
|
||||
const { to, subject, htmlBody, textBody } = params;
|
||||
|
||||
// Dev mode: log instead of sending
|
||||
if (EMAIL_DEV_MODE) {
|
||||
console.log("=== DEV MODE EMAIL ===");
|
||||
console.log(`To: ${to}`);
|
||||
console.log(`Subject: ${subject}`);
|
||||
console.log(`Body:\n${textBody}`);
|
||||
console.log("======================");
|
||||
return { success: true, messageId: "dev-mode" };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getPostmarkClient();
|
||||
const result = await client.sendEmail({
|
||||
From: EMAIL_FROM,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
HtmlBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
});
|
||||
return { success: true, messageId: result.MessageID };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`Failed to send email to ${to}:`, message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Template Helpers =====
|
||||
|
||||
const durationFormatter = new DurationFormat("en", { style: "long" });
|
||||
|
||||
const formatExpiryHours = (hours: number): string =>
|
||||
durationFormatter.format({ hours });
|
||||
|
||||
const formatExpiryMinutes = (minutes: number): string =>
|
||||
durationFormatter.format({ minutes });
|
||||
|
||||
const formatExpiryDays = (days: number): string =>
|
||||
durationFormatter.format({ days });
|
||||
|
||||
const roleLabels: Record<OrgRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
member: "Member",
|
||||
};
|
||||
|
||||
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
|
||||
|
||||
/**
|
||||
* Get the correct article (a/an) for a role
|
||||
*/
|
||||
const getArticleForRole = (role: OrgRole): string => {
|
||||
return role === "owner" || role === "admin" ? "an" : "a";
|
||||
};
|
||||
|
||||
// ===== Email Templates =====
|
||||
|
||||
// Common styles
|
||||
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
|
||||
const containerStyles =
|
||||
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
|
||||
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
|
||||
const paragraphStyles =
|
||||
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
|
||||
const buttonStyles =
|
||||
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
|
||||
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
|
||||
|
||||
// Verification Email
|
||||
const buildVerificationEmailHtml = (
|
||||
verifyUrl: string,
|
||||
expiresIn: string,
|
||||
): string => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">Verify your email</h1>
|
||||
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
|
||||
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
|
||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const buildVerificationEmailText = (
|
||||
verifyUrl: string,
|
||||
expiresIn: string,
|
||||
): string =>
|
||||
`Verify your email
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
|
||||
${verifyUrl}
|
||||
|
||||
This link expires in ${expiresIn}.
|
||||
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
`;
|
||||
|
||||
// Password Reset Email
|
||||
const buildPasswordResetEmailHtml = (
|
||||
resetUrl: string,
|
||||
expiresIn: string,
|
||||
): string => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">Reset your password</h1>
|
||||
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
|
||||
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
|
||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const buildPasswordResetEmailText = (
|
||||
resetUrl: string,
|
||||
expiresIn: string,
|
||||
): string =>
|
||||
`Reset your password
|
||||
|
||||
We received a request to reset your password. Click the link below to choose a new password:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link expires in ${expiresIn}.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
`;
|
||||
|
||||
// Login Confirmation Email
|
||||
const buildLoginConfirmationEmailHtml = (
|
||||
confirmUrl: string,
|
||||
expiresIn: string,
|
||||
): string => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">Confirm your login</h1>
|
||||
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
|
||||
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
|
||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const buildLoginConfirmationEmailText = (
|
||||
confirmUrl: string,
|
||||
expiresIn: string,
|
||||
): string =>
|
||||
`Confirm your login
|
||||
|
||||
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
|
||||
|
||||
${confirmUrl}
|
||||
|
||||
This link expires in ${expiresIn}.
|
||||
|
||||
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
|
||||
`;
|
||||
|
||||
// Org Invite Email
|
||||
const buildOrgInviteEmailHtml = (
|
||||
email: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
role: OrgRole,
|
||||
inviteUrl: string,
|
||||
expiresIn: string,
|
||||
): string => {
|
||||
const safeOrgName = escapeHtml(orgName);
|
||||
const safeInviterName = escapeHtml(inviterName);
|
||||
const safeEmail = escapeHtml(email);
|
||||
const roleDisplay = formatRoleDisplay(role);
|
||||
const article = getArticleForRole(role);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="${emailStyles}">
|
||||
<div style="${containerStyles}">
|
||||
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
|
||||
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
|
||||
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
|
||||
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
|
||||
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const buildOrgInviteEmailText = (
|
||||
email: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
role: OrgRole,
|
||||
inviteUrl: string,
|
||||
expiresIn: string,
|
||||
): string => {
|
||||
const roleDisplay = formatRoleDisplay(role);
|
||||
const article = getArticleForRole(role);
|
||||
|
||||
return `You've been invited to join ${orgName}
|
||||
|
||||
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
|
||||
|
||||
Click the link below to accept the invitation:
|
||||
|
||||
${inviteUrl}
|
||||
|
||||
This invitation expires in ${expiresIn}.
|
||||
|
||||
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
|
||||
`;
|
||||
};
|
||||
|
||||
// ===== Email Helpers =====
|
||||
|
||||
/**
|
||||
* Send verification email to user
|
||||
*/
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/auth/verify", { token });
|
||||
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Verify your email address",
|
||||
htmlBody: buildVerificationEmailHtml(url, expiresIn),
|
||||
textBody: buildVerificationEmailText(url, expiresIn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login confirmation email (for untrusted device flow)
|
||||
*/
|
||||
export async function sendLoginConfirmationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/auth/confirm", { token });
|
||||
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Confirm your login",
|
||||
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
|
||||
textBody: buildLoginConfirmationEmailText(url, expiresIn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/auth/reset-password", { token });
|
||||
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
|
||||
textBody: buildPasswordResetEmailText(url, expiresIn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send org invite email
|
||||
*/
|
||||
export async function sendOrgInviteEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
orgName: string,
|
||||
inviterName: string,
|
||||
role: OrgRole,
|
||||
): Promise<EmailResult> {
|
||||
const url = buildUrl("/invite/accept", { token });
|
||||
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: `You've been invited to join ${orgName}`,
|
||||
htmlBody: buildOrgInviteEmailHtml(
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
url,
|
||||
expiresIn,
|
||||
),
|
||||
textBody: buildOrgInviteEmailText(
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
url,
|
||||
expiresIn,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import {
|
||||
_resetForTesting,
|
||||
_setReaderForTesting,
|
||||
extractClientIP,
|
||||
getGeoInfo,
|
||||
getUserAgent,
|
||||
initGeoReader,
|
||||
lookupGeoFromIP,
|
||||
} from "./geo.js";
|
||||
|
||||
@@ -220,3 +228,110 @@ describe("getUserAgent", () => {
|
||||
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initGeoReader", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
test("calling initGeoReader twice does not reinitialize", async () => {
|
||||
// First call initializes
|
||||
await initGeoReader();
|
||||
|
||||
// Second call should return early (covers the early return branch)
|
||||
await initGeoReader();
|
||||
|
||||
// If we get here without error, the early return worked
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("handles missing database file gracefully", async () => {
|
||||
// Save original env
|
||||
const originalPath = Bun.env.GEOIP_DATABASE_PATH;
|
||||
|
||||
// Point to non-existent file
|
||||
Bun.env.GEOIP_DATABASE_PATH = "/nonexistent/path/to/db.mmdb";
|
||||
|
||||
// Should not throw, just log a warning
|
||||
await initGeoReader();
|
||||
|
||||
// Lookups should return nulls since reader failed to initialize
|
||||
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
||||
city: null,
|
||||
region: null,
|
||||
country: null,
|
||||
});
|
||||
|
||||
// Restore original env
|
||||
if (originalPath) {
|
||||
Bun.env.GEOIP_DATABASE_PATH = originalPath;
|
||||
} else {
|
||||
delete Bun.env.GEOIP_DATABASE_PATH;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only run real database tests if GEOIP_DATABASE_PATH is set
|
||||
const hasGeoDatabase = !!Bun.env.GEOIP_DATABASE_PATH;
|
||||
|
||||
describe.skipIf(!hasGeoDatabase)("real GeoIP database", () => {
|
||||
beforeAll(async () => {
|
||||
_resetForTesting();
|
||||
await initGeoReader();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
test("looks up Google DNS (8.8.8.8) - US", () => {
|
||||
const result = lookupGeoFromIP("8.8.8.8");
|
||||
expect(result.country).toBe("US");
|
||||
});
|
||||
|
||||
test("looks up Cloudflare DNS (1.1.1.1) - AU", () => {
|
||||
const result = lookupGeoFromIP("1.1.1.1");
|
||||
// Cloudflare's 1.1.1.1 is geolocated to Sydney, Australia
|
||||
expect(result.country).toBe("AU");
|
||||
});
|
||||
|
||||
test("looks up known German IP", () => {
|
||||
// Deutsche Telekom IP range
|
||||
const result = lookupGeoFromIP("80.150.6.143");
|
||||
expect(result.country).toBe("DE");
|
||||
});
|
||||
|
||||
test("looks up known UK IP", () => {
|
||||
// BBC IP range
|
||||
const result = lookupGeoFromIP("212.58.244.71");
|
||||
expect(result.country).toBe("GB");
|
||||
});
|
||||
|
||||
test("returns city data for major IPs", () => {
|
||||
const result = lookupGeoFromIP("8.8.8.8");
|
||||
// DBIP returns "Mountain View" for Google DNS
|
||||
expect(result.city).toBe("Mountain View");
|
||||
expect(result.region).toBe("California");
|
||||
});
|
||||
|
||||
test("getGeoInfo uses real database when no CF headers", () => {
|
||||
const headers = createHeaders({ "X-Real-IP": "8.8.8.8" });
|
||||
const result = getGeoInfo(headers);
|
||||
|
||||
expect(result.ip).toBe("8.8.8.8");
|
||||
expect(result.country).toBe("US");
|
||||
expect(result.city).toBe("Mountain View");
|
||||
});
|
||||
|
||||
test("returns nulls for private/reserved IPs", () => {
|
||||
const result = lookupGeoFromIP("192.168.1.1");
|
||||
expect(result.city).toBeNull();
|
||||
expect(result.country).toBeNull();
|
||||
});
|
||||
|
||||
test("returns nulls for localhost", () => {
|
||||
const result = lookupGeoFromIP("127.0.0.1");
|
||||
expect(result.city).toBeNull();
|
||||
expect(result.country).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,9 +126,16 @@ export const lookupGeoFromIP = (
|
||||
/**
|
||||
* Extract geolocation info from request headers.
|
||||
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
|
||||
*
|
||||
* @param headers - Request headers to extract proxy IP headers from
|
||||
* @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP)
|
||||
*/
|
||||
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
||||
const ip = extractClientIP(headers);
|
||||
export const getGeoInfo = (
|
||||
headers: Headers,
|
||||
fallbackIP?: string | null,
|
||||
): GeoInfo => {
|
||||
// Try proxy headers first, then fall back to direct connection IP
|
||||
const ip = extractClientIP(headers) ?? fallbackIP ?? null;
|
||||
|
||||
// Try Cloudflare geo headers first
|
||||
const cfCountry = headers.get("CF-IPCountry");
|
||||
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
|
||||
86
apps/cli/README.md
Normal file
86
apps/cli/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# RevIQ CLI
|
||||
|
||||
Command-line interface for RevIQ database and user management.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Build the CLI
|
||||
bun run build
|
||||
|
||||
# The compiled binary will be at dist/reviq
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run directly with bun
|
||||
bun run cli <command>
|
||||
|
||||
# Or use the compiled binary
|
||||
./dist/reviq <command>
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Login with an API token
|
||||
reviq auth login --token <your-token>
|
||||
|
||||
# Check authentication status
|
||||
reviq auth status
|
||||
|
||||
# Logout
|
||||
reviq auth logout
|
||||
```
|
||||
|
||||
To get an API token:
|
||||
1. Log in to the web dashboard
|
||||
2. Go to Account Settings > API Tokens
|
||||
3. Create a new token and copy it
|
||||
|
||||
### User Management
|
||||
|
||||
```bash
|
||||
# Create a new user
|
||||
reviq user create --email <email>
|
||||
|
||||
# Confirm email
|
||||
reviq user confirm-email --code <code>
|
||||
```
|
||||
|
||||
### Organization Management
|
||||
|
||||
```bash
|
||||
# List organizations
|
||||
reviq org list
|
||||
|
||||
# Create an organization
|
||||
reviq org create --name <name> --slug <slug>
|
||||
|
||||
# Add a site to an organization
|
||||
reviq org add-site --org <slug> --domain <domain>
|
||||
```
|
||||
|
||||
### Admin Commands
|
||||
|
||||
```bash
|
||||
# Complete login (admin)
|
||||
reviq admin complete-login
|
||||
```
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
# Bootstrap the database
|
||||
reviq bootstrap
|
||||
|
||||
# Generate shell completions
|
||||
reviq completions
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Credentials are stored at `~/.config/reviq/credentials.json`.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { readConfig } from "../../utils/config.js";
|
||||
import { generateToken, hashToken } from "../../utils/token.js";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { readConfig, writeConfig } from "../../utils/config.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
interface LoginFlags {
|
||||
email: string;
|
||||
token: string;
|
||||
"api-url"?: string;
|
||||
}
|
||||
|
||||
interface LoginStatusOutput {
|
||||
status: "pending" | "completed" | "expired";
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to RevIQ with an API token
|
||||
*
|
||||
* To get an API token:
|
||||
* 1. Log in to the web dashboard
|
||||
* 2. Go to Account Settings > API Tokens
|
||||
* 3. Create a new token and copy it
|
||||
* 4. Run: reviq auth login --token <your-token>
|
||||
*/
|
||||
async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
||||
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
|
||||
|
||||
@@ -23,117 +29,28 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting login flow...\n");
|
||||
|
||||
// Generate a unique callback token for this login request
|
||||
const callbackToken = generateToken();
|
||||
const callbackTokenHash = hashToken(callbackToken);
|
||||
console.log("Validating API token...\n");
|
||||
|
||||
try {
|
||||
// Create login request
|
||||
const createResponse = await fetch(
|
||||
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: flags.email }),
|
||||
},
|
||||
);
|
||||
// Create a temporary API client with the provided token
|
||||
const api = createApiClient(apiUrl, flags.token);
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const text = await createResponse.text();
|
||||
console.error(`Error creating login request: ${text}`);
|
||||
this.process.exit(1);
|
||||
}
|
||||
// Validate the token by fetching the user's auth status
|
||||
const authStatus = await api.me.authStatus();
|
||||
|
||||
// Construct the login URL
|
||||
const loginUrl = new URL(`${apiUrl}/login`);
|
||||
loginUrl.searchParams.set("email", flags.email);
|
||||
loginUrl.searchParams.set("cli_callback", callbackTokenHash);
|
||||
|
||||
console.log("Opening browser for authentication...");
|
||||
console.log(`\nIf the browser doesn't open, visit:`);
|
||||
console.log(` ${loginUrl.toString()}\n`);
|
||||
|
||||
// Try to open the browser
|
||||
const openCommand =
|
||||
process.platform === "darwin"
|
||||
? "open"
|
||||
: process.platform === "win32"
|
||||
? "start"
|
||||
: "xdg-open";
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
// Save credentials
|
||||
await writeConfig({
|
||||
apiUrl,
|
||||
token: flags.token,
|
||||
email: authStatus.user.email,
|
||||
});
|
||||
await proc.exited;
|
||||
} catch {
|
||||
// Ignore errors opening browser - user can use the URL
|
||||
}
|
||||
|
||||
console.log("Waiting for login to complete...");
|
||||
console.log("(Press Ctrl+C to cancel)\n");
|
||||
|
||||
// Poll for completion
|
||||
const maxAttempts = 120; // 2 minutes at 1 second intervals
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
const statusResponse = await fetch(
|
||||
`${apiUrl}/api/v1/rpc/auth.loginIfRequestIsCompleted`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CLI-Callback-Token": callbackToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (statusResponse.ok) {
|
||||
const status = (await statusResponse.json()) as LoginStatusOutput;
|
||||
|
||||
if (status.status === "completed") {
|
||||
// Login completed - we should have received a token
|
||||
// For now, we'll need the API to return the token
|
||||
console.log("Login completed successfully!");
|
||||
|
||||
// TODO: The API needs to return the session token when login completes
|
||||
// For now, this is a placeholder
|
||||
console.log(
|
||||
"\nNote: Browser-based login flow requires API integration.",
|
||||
);
|
||||
console.log("Use 'reviq bootstrap' to create initial credentials.");
|
||||
return;
|
||||
}
|
||||
if (status.status === "expired") {
|
||||
console.error("Login request expired. Please try again.");
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors and continue
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
process.stdout.write(".");
|
||||
}
|
||||
|
||||
console.log("\n\nLogin timed out. Please try again.");
|
||||
this.process.exit(1);
|
||||
console.log(`Logged in as ${authStatus.user.email}`);
|
||||
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -142,10 +59,10 @@ export const loginCommand = buildCommand({
|
||||
func: login,
|
||||
parameters: {
|
||||
flags: {
|
||||
email: {
|
||||
token: {
|
||||
kind: "parsed",
|
||||
parse: String,
|
||||
brief: "Email address to login with",
|
||||
brief: "API token from the web dashboard",
|
||||
},
|
||||
"api-url": {
|
||||
kind: "parsed",
|
||||
@@ -156,8 +73,13 @@ export const loginCommand = buildCommand({
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
brief: "Login to RevIQ",
|
||||
fullDescription:
|
||||
"Opens a browser to complete authentication and stores the credentials locally.",
|
||||
brief: "Login to RevIQ with an API token",
|
||||
fullDescription: `Authenticates with RevIQ using an API token.
|
||||
|
||||
To get an API token:
|
||||
1. Log in to the web dashboard at http://localhost:9861
|
||||
2. Go to Account Settings > API Tokens
|
||||
3. Create a new token and copy it
|
||||
4. Run: reviq auth login --token <your-token>`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,23 +1,26 @@
|
||||
import type { LocalContext } from "../../context.js";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { createApiClient } from "../../utils/api-client.js";
|
||||
import { formatError } from "../../utils/format-error.js";
|
||||
|
||||
type OrgRole = "owner" | "admin" | "member";
|
||||
|
||||
const validRoles: OrgRole[] = ["owner", "admin", "member"];
|
||||
const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
|
||||
|
||||
function parseRole(role: string | undefined): OrgRole | undefined {
|
||||
if (!role) {
|
||||
return undefined;
|
||||
}
|
||||
if (validRoles.includes(role as OrgRole)) {
|
||||
return role as OrgRole;
|
||||
}
|
||||
|
||||
if (!VALID_ROLES.includes(role as OrgRole)) {
|
||||
throw new Error(
|
||||
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
|
||||
`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return role as OrgRole;
|
||||
}
|
||||
|
||||
interface CreateUserFlags {
|
||||
email: string;
|
||||
name?: string;
|
||||
@@ -45,10 +48,7 @@ async function create(
|
||||
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Error:", formatError(error));
|
||||
this.process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,40 @@ 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
|
||||
*/
|
||||
export function createApiClient(apiUrl: string, token: string): ApiClient;
|
||||
|
||||
/**
|
||||
* Create an oRPC API client with the stored credentials
|
||||
* Throws an error if not logged in
|
||||
*/
|
||||
export const createApiClient = async (): Promise<ApiClient> => {
|
||||
export function createApiClient(): Promise<ApiClient>;
|
||||
|
||||
export function createApiClient(
|
||||
apiUrl?: string,
|
||||
token?: string,
|
||||
): ApiClient | Promise<ApiClient> {
|
||||
if (apiUrl !== undefined && token !== undefined) {
|
||||
return buildClient(apiUrl, token);
|
||||
}
|
||||
|
||||
return (async (): Promise<ApiClient> => {
|
||||
const config = await readConfig();
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
"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,
|
||||
},
|
||||
});
|
||||
|
||||
// Cast to ApiClient for type-safe API calls
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ClockIcon from "@lucide/svelte/icons/clock";
|
||||
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
|
||||
import MonitorIcon from "@lucide/svelte/icons/monitor";
|
||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||
import UserIcon from "@lucide/svelte/icons/user";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { page } from "$app/stores";
|
||||
import { api } from "$lib/api/client";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
@@ -12,13 +15,33 @@ interface Props {
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
// Fetch current user to check superuser status
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
}));
|
||||
|
||||
const baseNavItems = [
|
||||
{ href: "/account", label: "Profile", icon: UserIcon },
|
||||
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
|
||||
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
|
||||
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
|
||||
];
|
||||
|
||||
// Add API Tokens link for superusers only
|
||||
const navItems = $derived(
|
||||
userQuery.data?.isSuperuser
|
||||
? [
|
||||
...baseNavItems,
|
||||
{
|
||||
href: "/account/api-tokens",
|
||||
label: "API Tokens",
|
||||
icon: KeyRoundIcon,
|
||||
},
|
||||
]
|
||||
: baseNavItems,
|
||||
);
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
if (href === "/account") {
|
||||
return pathname === "/account";
|
||||
@@ -33,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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user