Compare commits
40 Commits
fix-export
...
c60041a1bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
c60041a1bb
|
|||
|
e43c006bb1
|
|||
|
8e65c2e698
|
|||
|
b085a315be
|
|||
|
1ed41e5c4c
|
|||
|
84644c8bfb
|
|||
|
5ecf12a1a1
|
|||
|
c2b815dd6a
|
|||
|
67930d90d5
|
|||
|
58ffa68f4c
|
|||
|
5a2e0297e5
|
|||
|
c9de0b1ac5
|
|||
|
0f50291490
|
|||
|
9c6694cad4
|
|||
|
f9f1dc7403
|
|||
|
b27a977809
|
|||
|
7edc4ba8a9
|
|||
|
16f827e8f0
|
|||
|
947c73dbdc
|
|||
|
2baf10b0cd
|
|||
|
8b081d5ba8
|
|||
|
01f1e1c9e3
|
|||
|
26d10d452f
|
|||
|
8b63eb3538
|
|||
|
587e151fbd
|
|||
|
94b6de5970
|
|||
|
6fa4da1abb
|
|||
|
92f7e1df09
|
|||
|
b2fba6e150
|
|||
|
ebc85af62c
|
|||
|
6b8dd27898
|
|||
|
61fdd3329f
|
|||
|
848d9e9af1
|
|||
|
44a480179b
|
|||
|
628b01f4d8
|
|||
| 8939deefbe | |||
|
76a5e40900
|
|||
|
b1d07626f3
|
|||
|
99539bbdcb
|
|||
|
eedd664db8
|
@@ -0,0 +1,16 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
snapshots:
|
||||||
|
countAll<number>():
|
||||||
|
fixed: countAll()
|
||||||
|
labels:
|
||||||
|
- source: countAll<number>()
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 18
|
||||||
|
eb.fn.countAll<number>().as("count"):
|
||||||
|
fixed: eb.fn.countAll().as("count")
|
||||||
|
labels:
|
||||||
|
- source: eb.fn.countAll<number>()
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 24
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
id: no-string-function
|
||||||
|
snapshots:
|
||||||
|
String(123):
|
||||||
|
labels:
|
||||||
|
- source: String(123)
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 11
|
||||||
|
String(Date.now()):
|
||||||
|
labels:
|
||||||
|
- source: String(Date.now())
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 18
|
||||||
|
String(value):
|
||||||
|
labels:
|
||||||
|
- source: String(value)
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 13
|
||||||
@@ -3,7 +3,7 @@ snapshots:
|
|||||||
? |
|
? |
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
: fixed: |
|
: fixed: |
|
||||||
import * as z from "zod"
|
import * as z from "zod";
|
||||||
labels:
|
labels:
|
||||||
- source: import { z } from "zod";
|
- source: import { z } from "zod";
|
||||||
style: primary
|
style: primary
|
||||||
@@ -12,7 +12,7 @@ snapshots:
|
|||||||
? |
|
? |
|
||||||
import { z, ZodError } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
: fixed: |
|
: fixed: |
|
||||||
import * as z from "zod"
|
import * as z from "zod";
|
||||||
labels:
|
labels:
|
||||||
- source: import { z, ZodError } from "zod";
|
- source: import { z, ZodError } from "zod";
|
||||||
style: primary
|
style: primary
|
||||||
|
|||||||
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
valid:
|
||||||
|
# Plain countAll() is fine
|
||||||
|
- eb.fn.countAll().as("count")
|
||||||
|
# Other type arguments are fine
|
||||||
|
- eb.fn.countAll<string>().as("count")
|
||||||
|
invalid:
|
||||||
|
# countAll<number>() should be flagged
|
||||||
|
- eb.fn.countAll<number>().as("count")
|
||||||
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
id: no-string-function
|
||||||
|
valid:
|
||||||
|
# toString() is fine
|
||||||
|
- value.toString()
|
||||||
|
- (123).toString()
|
||||||
|
- date.toLocaleString()
|
||||||
|
# Other functions named String are fine
|
||||||
|
- myString(value)
|
||||||
|
invalid:
|
||||||
|
# String() function should be flagged
|
||||||
|
- String(value)
|
||||||
|
- String(123)
|
||||||
|
- String(Date.now())
|
||||||
8
.ast-grep/rules/no-countall-number.yml
Normal file
8
.ast-grep/rules/no-countall-number.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
language: typescript
|
||||||
|
severity: error
|
||||||
|
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
|
||||||
|
note: "Use Number() to convert the result if you need a number type."
|
||||||
|
rule:
|
||||||
|
pattern: $OBJ.countAll<number>()
|
||||||
|
fix: $OBJ.countAll()
|
||||||
7
.ast-grep/rules/no-string-function.yml
Normal file
7
.ast-grep/rules/no-string-function.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: no-string-function
|
||||||
|
language: typescript
|
||||||
|
severity: error
|
||||||
|
message: "Don't use String() - use .toString() or .toLocaleString() instead."
|
||||||
|
note: "String() can have unexpected behavior. Use .toString() for general conversion or .toLocaleString() for locale-aware formatting."
|
||||||
|
rule:
|
||||||
|
pattern: String($VAL)
|
||||||
34
.gitea/workflows/ci.yaml
Normal file
34
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.5"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
74
CLAUDE.md
74
CLAUDE.md
@@ -1,5 +1,20 @@
|
|||||||
# Claude Code Notes
|
# Claude Code Notes
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Use `bun run test:cov` to run all tests with coverage. This runs both unit tests and e2e tests that require a database connection.
|
||||||
|
|
||||||
|
- `bun run test:cov` - Run all tests with coverage (preferred)
|
||||||
|
- `bun run test:unit:cov` - Run only unit tests with coverage (no database required)
|
||||||
|
|
||||||
|
## Database Scripts
|
||||||
|
|
||||||
|
Use the wrapper scripts instead of running dbmate directly:
|
||||||
|
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
|
||||||
|
- `./scripts/db-migrate` - Run migrations and dump clean schema
|
||||||
|
|
||||||
|
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
|
||||||
|
|
||||||
## Development Server
|
## Development Server
|
||||||
|
|
||||||
Before starting the dev server, check if it's already running:
|
Before starting the dev server, check if it's already running:
|
||||||
@@ -14,10 +29,57 @@ This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
|
|||||||
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
||||||
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
||||||
|
|
||||||
## macOS sed Syntax
|
## sed Syntax (GNU coreutils)
|
||||||
|
|
||||||
macOS uses BSD sed which differs from GNU sed:
|
This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
|
||||||
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
|
- In-place edit: `sed -i 's/old/new/g' file`
|
||||||
- GNU sed (Linux): `sed -i 's/old/new/g' file`
|
- Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|g' file`
|
||||||
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
|
- For multiple files: `for f in *.txt; do sed -i 's/old/new/g' "$f"; done`
|
||||||
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
|
- Do NOT use BSD sed syntax (`sed -i ''`) - we have GNU sed available
|
||||||
|
|
||||||
|
## SvelteKit resolve() Usage
|
||||||
|
|
||||||
|
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
|
||||||
|
|
||||||
|
### Static routes - use resolve() directly
|
||||||
|
```svelte
|
||||||
|
href={resolve("/auth/login")}
|
||||||
|
href={resolve("/dashboard")}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic routes - use two-argument form
|
||||||
|
```svelte
|
||||||
|
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
|
||||||
|
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login redirects - use gotoLogin helper
|
||||||
|
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
|
||||||
|
```typescript
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
|
gotoLogin(page.url.pathname);
|
||||||
|
```
|
||||||
|
This helper uses resolve() internally and handles the query string correctly.
|
||||||
|
|
||||||
|
### Navigation arrays - use `as const` with route patterns
|
||||||
|
For type-safe navigation arrays, define routes as literal strings with `as const`:
|
||||||
|
```typescript
|
||||||
|
const navItems = [
|
||||||
|
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
|
||||||
|
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
|
||||||
|
] as const;
|
||||||
|
```
|
||||||
|
Then use resolve with params:
|
||||||
|
```svelte
|
||||||
|
{#each navItems as item (item.route)}
|
||||||
|
<a href={resolve(item.route, { slug })}>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime strings - skip resolve, use eslint-disable
|
||||||
|
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
|
||||||
|
```typescript
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto(redirectUrl);
|
||||||
|
```
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -26,9 +26,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
|
|||||||
|
|
||||||
### Shared Packages
|
### Shared Packages
|
||||||
- `@reviq/api-contract` - Shared API contract (oRPC)
|
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||||
|
- `@reviq/common` - Shared utilities for frontend and backend
|
||||||
- `@reviq/db` - Database client and queries
|
- `@reviq/db` - Database client and queries
|
||||||
- `@reviq/db-schema` - Database schema and codegen
|
- `@reviq/db-schema` - Database schema and codegen
|
||||||
- `@reviq/utils` - Shared utilities
|
- `@reviq/frontend-utils` - Frontend-specific utilities
|
||||||
|
- `@reviq/server-utils` - Server/CLI utilities
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -40,10 +42,12 @@ publisher-dashboard/
|
|||||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── api-contract/ # Shared oRPC contract
|
│ ├── api-contract/ # Shared oRPC contract
|
||||||
|
│ ├── common/ # Shared utilities (frontend + backend)
|
||||||
│ ├── db/ # Database client
|
│ ├── db/ # Database client
|
||||||
│ ├── db-schema/ # DB schema & codegen
|
│ ├── db-schema/ # DB schema & codegen
|
||||||
│ ├── testing/ # Test utilities
|
│ ├── frontend-utils/ # Frontend utilities
|
||||||
│ └── utils/ # Shared utilities
|
│ ├── server-utils/ # Server/CLI utilities
|
||||||
|
│ └── testing/ # Test utilities
|
||||||
└── db/ # Database migrations
|
└── db/ # Database migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -109,8 +113,13 @@ bun run dev
|
|||||||
| `bun run typecheck` | Run TypeScript type checking |
|
| `bun run typecheck` | Run TypeScript type checking |
|
||||||
| `bun run lint` | Run Biome and ESLint |
|
| `bun run lint` | Run Biome and ESLint |
|
||||||
| `bun run lint:fix` | Fix linting issues |
|
| `bun run lint:fix` | Fix linting issues |
|
||||||
| `bun run test` | Run tests |
|
| `bun run test` | Run all tests (requires database) |
|
||||||
|
| `bun run test:unit` | Run unit tests only (no database required) |
|
||||||
|
| `bun run test:cov` | Run all tests with coverage report |
|
||||||
|
| `bun run test:unit:cov` | Run unit tests with coverage (no database) |
|
||||||
| `bun run db:codegen` | Generate database types |
|
| `bun run db:codegen` | Generate database types |
|
||||||
|
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
|
||||||
|
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,17 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
|
"test": "bun test src/ --no-parallel"
|
||||||
"test:unit": "bun test src/__tests__/unit",
|
|
||||||
"test": "bun test --coverage src/utils"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-durationformat": "^0.9.2",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@orpc/experimental-pino": "^1.13.2",
|
"@orpc/experimental-pino": "^1.13.2",
|
||||||
"@orpc/server": "^1.13.2",
|
"@orpc/server": "^1.13.2",
|
||||||
"@reviq/api-contract": "workspace:*",
|
"@reviq/api-contract": "workspace:*",
|
||||||
"@reviq/db": "workspace:*",
|
"@reviq/db": "workspace:*",
|
||||||
"@reviq/db-schema": "workspace:*",
|
"@reviq/db-schema": "workspace:*",
|
||||||
"@reviq/utils": "workspace:*",
|
"@reviq/emails": "workspace:*",
|
||||||
|
"@reviq/server-utils": "workspace:*",
|
||||||
"@scure/base": "^2.0.0",
|
"@scure/base": "^2.0.0",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@simplewebauthn/types": "^12.0.0",
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
@@ -34,12 +32,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@reviq/test-helpers": "workspace:*",
|
||||||
"@reviq/virtual-authenticator": "workspace:*",
|
"@reviq/virtual-authenticator": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
1954
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
1954
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1847
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
1847
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ export const getAllowedOrigins = (): string[] => {
|
|||||||
|
|
||||||
// Default to localhost origins for development
|
// Default to localhost origins for development
|
||||||
return [
|
return [
|
||||||
`http://localhost:${String(DEFAULT_PORT)}`,
|
`http://localhost:${DEFAULT_PORT.toString()}`,
|
||||||
"http://localhost:6827",
|
"http://localhost:6827",
|
||||||
"http://localhost:6828",
|
"http://localhost:6828",
|
||||||
];
|
];
|
||||||
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
|
|||||||
/** Base URL for generating email links */
|
/** Base URL for generating email links */
|
||||||
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
|
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
|
||||||
|
|
||||||
/** Dev mode: log emails instead of sending (default: true) */
|
/** Postmark API key (optional - uses logging client if not set) */
|
||||||
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
|
|
||||||
|
|
||||||
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
|
|
||||||
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
|
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
|
||||||
|
|
||||||
// ===== Token Expiration Times =====
|
// ===== Token Expiration Times =====
|
||||||
|
|||||||
@@ -3,8 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Database } from "@reviq/db-schema";
|
import type { Database } from "@reviq/db-schema";
|
||||||
|
import type { EmailClient } from "@reviq/emails";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email configuration for the API
|
||||||
|
*/
|
||||||
|
export interface EmailConfig {
|
||||||
|
client: EmailClient;
|
||||||
|
fromAddress: string;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base API context available to all handlers
|
* Base API context available to all handlers
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +33,8 @@ export interface APIContext {
|
|||||||
resHeaders: Headers;
|
resHeaders: Headers;
|
||||||
/** Client IP address from direct connection (fallback when no proxy headers) */
|
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||||
clientIP?: string | null;
|
clientIP?: string | null;
|
||||||
|
/** Email client and configuration */
|
||||||
|
email: EmailConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import type { APIContext } from "./context.js";
|
|||||||
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
|
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
|
||||||
import { RPCHandler } from "@orpc/server/fetch";
|
import { RPCHandler } from "@orpc/server/fetch";
|
||||||
import { createDb } from "@reviq/db";
|
import { createDb } from "@reviq/db";
|
||||||
|
import {
|
||||||
|
createLoggingEmailClient,
|
||||||
|
createPostmarkClient,
|
||||||
|
} from "@reviq/emails";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import {
|
import {
|
||||||
|
BASE_URL,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
DEFAULT_RP_NAME,
|
DEFAULT_RP_NAME,
|
||||||
|
EMAIL_FROM,
|
||||||
|
POSTMARK_API_KEY,
|
||||||
getAllowedOrigins,
|
getAllowedOrigins,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
@@ -24,6 +31,16 @@ if (!databaseUrl) {
|
|||||||
throw new Error("DATABASE_URL environment variable is required");
|
throw new Error("DATABASE_URL environment variable is required");
|
||||||
}
|
}
|
||||||
const db = createDb(databaseUrl);
|
const db = createDb(databaseUrl);
|
||||||
|
|
||||||
|
// Create email client - use Postmark if API key is set, otherwise log to console
|
||||||
|
const emailClient = POSTMARK_API_KEY
|
||||||
|
? createPostmarkClient(POSTMARK_API_KEY)
|
||||||
|
: createLoggingEmailClient();
|
||||||
|
|
||||||
|
if (!POSTMARK_API_KEY) {
|
||||||
|
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
|
||||||
|
}
|
||||||
|
|
||||||
const handler = new RPCHandler(router, {
|
const handler = new RPCHandler(router, {
|
||||||
plugins: [
|
plugins: [
|
||||||
new LoggingHandlerPlugin({
|
new LoggingHandlerPlugin({
|
||||||
@@ -45,7 +62,7 @@ Bun.serve({
|
|||||||
if (url.pathname.startsWith("/api/v1/rpc")) {
|
if (url.pathname.startsWith("/api/v1/rpc")) {
|
||||||
// Build context for the request
|
// Build context for the request
|
||||||
const origin =
|
const origin =
|
||||||
request.headers.get("origin") ?? `http://localhost:${String(port)}`;
|
request.headers.get("origin") ?? `http://localhost:${port.toString()}`;
|
||||||
|
|
||||||
// Create response headers for setting cookies
|
// Create response headers for setting cookies
|
||||||
const resHeaders = new Headers();
|
const resHeaders = new Headers();
|
||||||
@@ -62,6 +79,11 @@ Bun.serve({
|
|||||||
reqHeaders: request.headers,
|
reqHeaders: request.headers,
|
||||||
resHeaders,
|
resHeaders,
|
||||||
clientIP,
|
clientIP,
|
||||||
|
email: {
|
||||||
|
client: emailClient,
|
||||||
|
fromAddress: EMAIL_FROM,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { response } = await handler.handle(request, {
|
const { response } = await handler.handle(request, {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
* This prevents attackers from determining which emails are registered
|
* This prevents attackers from determining which emails are registered
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { withTransaction } from "@reviq/db";
|
||||||
|
import { sendPasswordResetEmail } from "@reviq/emails";
|
||||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||||
import {
|
import {
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
|
||||||
import { os } from "../base.js";
|
import { os } from "../base.js";
|
||||||
|
|
||||||
export const forgotPassword = os.auth.forgotPassword.handler(
|
export const forgotPassword = os.auth.forgotPassword.handler(
|
||||||
@@ -30,29 +31,39 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
|||||||
|
|
||||||
// If user exists, create password reset token and send email
|
// If user exists, create password reset token and send email
|
||||||
if (user) {
|
if (user) {
|
||||||
// Delete any existing password reset tokens for this user (security measure)
|
|
||||||
await context.db
|
|
||||||
.deleteFrom("password_resets")
|
|
||||||
.where("user_id", "=", user.id)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Generate secure base58 token
|
// Generate secure base58 token
|
||||||
const token = generateSecureBase58Token();
|
const token = generateSecureBase58Token();
|
||||||
|
|
||||||
// Create password reset record with 1 hour expiry
|
// Create password reset record with 1 hour expiry
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
||||||
|
|
||||||
await context.db
|
// Delete old tokens and insert new one in transaction
|
||||||
.insertInto("password_resets")
|
await withTransaction(context.db, async (trx) => {
|
||||||
.values({
|
// Delete any existing password reset tokens for this user (security measure)
|
||||||
user_id: user.id,
|
await trx
|
||||||
token,
|
.deleteFrom("password_resets")
|
||||||
expires_at: expiresAt,
|
.where("user_id", "=", user.id)
|
||||||
})
|
.execute();
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send password reset email (stubbed)
|
await trx
|
||||||
await sendPasswordResetEmail(user.email, token);
|
.insertInto("password_resets")
|
||||||
|
.values({
|
||||||
|
user_id: user.id,
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send password reset email
|
||||||
|
await sendPasswordResetEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email: user.email,
|
||||||
|
token,
|
||||||
|
expiryHours: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always return success (anti-enumeration)
|
// Always return success (anti-enumeration)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { withTransaction } from "@reviq/db";
|
||||||
import {
|
import {
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
COOKIE_OPTIONS,
|
COOKIE_OPTIONS,
|
||||||
@@ -89,36 +90,39 @@ export const loginIfRequestIsCompleted =
|
|||||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||||
const userAgent = getUserAgent(context.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
// Upsert user device
|
// Create session in transaction (atomic: device upsert + session + login_request delete)
|
||||||
const deviceId = await upsertUserDevice(
|
const { session, deviceTrusted } = await withTransaction(
|
||||||
context.db,
|
context.db,
|
||||||
userId,
|
async (trx) => {
|
||||||
deviceFingerprint,
|
// Upsert user device
|
||||||
geo,
|
const deviceId = await upsertUserDevice(
|
||||||
userAgent,
|
trx,
|
||||||
);
|
userId,
|
||||||
|
deviceFingerprint,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if device is already trusted
|
// Check if device is already trusted
|
||||||
const deviceTrusted = await isDeviceTrusted(
|
const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
|
||||||
context.db,
|
|
||||||
userId,
|
|
||||||
deviceFingerprint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create session with trusted mode = true (email-confirmed login)
|
// Create session with trusted mode = true (email-confirmed login)
|
||||||
const session = await createSession(context.db, {
|
const newSession = await createSession(trx, {
|
||||||
userId,
|
userId,
|
||||||
deviceId,
|
deviceId,
|
||||||
trustedMode: true,
|
trustedMode: true,
|
||||||
geo,
|
geo,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete the login request (it's been consumed)
|
// Delete the login request (it's been consumed)
|
||||||
await context.db
|
await trx
|
||||||
.deleteFrom("login_requests")
|
.deleteFrom("login_requests")
|
||||||
.where("id", "=", loginRequest.id)
|
.where("id", "=", loginRequest.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
return { session: newSession, deviceTrusted: trusted };
|
||||||
|
});
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { sendLoginConfirmationEmail } from "@reviq/emails";
|
||||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||||
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
|
||||||
import { verifyPassword } from "../../utils/password.js";
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
import { isDeviceTrusted } from "../../utils/session.js";
|
import { isDeviceTrusted } from "../../utils/session.js";
|
||||||
import { os } from "../base.js";
|
import { os } from "../base.js";
|
||||||
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
|
|||||||
} else {
|
} else {
|
||||||
// Device is untrusted - send confirmation email with existing token
|
// Device is untrusted - send confirmation email with existing token
|
||||||
// The same base58 token is used for both cookie lookup and email confirmation
|
// The same base58 token is used for both cookie lookup and email confirmation
|
||||||
await sendLoginConfirmationEmail(result.email, result.token);
|
await sendLoginConfirmationEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email: result.email,
|
||||||
|
token: result.token,
|
||||||
|
expiryMinutes: 15,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
* 5. Send verification email (stubbed)
|
* 5. Send verification email (stubbed)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { sendVerificationEmail } from "@reviq/emails";
|
||||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||||
import {
|
import {
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendVerificationEmail } from "../../utils/email.js";
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||||
@@ -47,8 +47,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
|||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Send verification email (stubbed)
|
// Send verification email
|
||||||
await sendVerificationEmail(context.user.email, token);
|
await sendVerificationEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email: context.user.email,
|
||||||
|
token,
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import type { RPInfo } from "../../utils/webauthn.js";
|
import type { RPInfo } from "../../utils/webauthn.js";
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { withTransaction } from "@reviq/db";
|
||||||
|
import { sendVerificationEmail } from "@reviq/emails";
|
||||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||||
import {
|
import {
|
||||||
COOKIE_NAMES,
|
COOKIE_NAMES,
|
||||||
@@ -21,7 +23,6 @@ import {
|
|||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendVerificationEmail } from "../../utils/email.js";
|
|
||||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||||
import { createSession } from "../../utils/session.js";
|
import { createSession } from "../../utils/session.js";
|
||||||
@@ -52,17 +53,28 @@ export async function signupWithPassword(
|
|||||||
// Hash password
|
// Hash password
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
// Create user
|
// Create user (handle race condition if concurrent signup with same email)
|
||||||
const user = await db
|
try {
|
||||||
.insertInto("users")
|
const user = await db
|
||||||
.values({
|
.insertInto("users")
|
||||||
email,
|
.values({
|
||||||
password_hash: passwordHash,
|
email,
|
||||||
})
|
password_hash: passwordHash,
|
||||||
.returning(["id"])
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return user.id;
|
return user.id;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,7 +109,7 @@ export async function signupWithPasskey(
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("options")
|
.select("options")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.where("created_at", ">", fifteenMinutesAgo)
|
.where("created_at", ">", fifteenMinutesAgo)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -123,7 +135,7 @@ export async function signupWithPasskey(
|
|||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Log error for debugging but don't expose to client
|
// Log error for debugging but don't expose to client
|
||||||
@@ -138,7 +150,7 @@ export async function signupWithPasskey(
|
|||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
@@ -146,55 +158,66 @@ export async function signupWithPasskey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user and passkey in a transaction
|
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||||
const result = await db.transaction().execute(async (trx) => {
|
try {
|
||||||
// Create user
|
const result = await withTransaction(db, async (trx) => {
|
||||||
const user = await trx
|
// Create user
|
||||||
.insertInto("users")
|
const user = await trx
|
||||||
.values({
|
.insertInto("users")
|
||||||
email,
|
.values({
|
||||||
password_hash: null,
|
email,
|
||||||
})
|
password_hash: null,
|
||||||
.returning(["id"])
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const newUserId = user.id;
|
const newUserId = user.id;
|
||||||
|
|
||||||
// Get friendly name from AAGUID
|
// Get friendly name from AAGUID
|
||||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||||
const passkeyName = guidName ?? "Default";
|
const passkeyName = guidName ?? "Default";
|
||||||
|
|
||||||
// Store the passkey
|
// Store the passkey
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||||
registrationInfo;
|
registrationInfo;
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insertInto("passkeys")
|
.insertInto("passkeys")
|
||||||
.values({
|
.values({
|
||||||
user_id: newUserId,
|
user_id: newUserId,
|
||||||
credential_id: Buffer.from(credential.id, "base64url"),
|
credential_id: Buffer.from(credential.id, "base64url"),
|
||||||
public_key: Buffer.from(credential.publicKey),
|
public_key: Buffer.from(credential.publicKey),
|
||||||
webauthn_user_id: options.user.id,
|
webauthn_user_id: options.user.id,
|
||||||
counter: BigInt(credential.counter),
|
counter: BigInt(credential.counter),
|
||||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||||
backup_eligible: registrationInfo.credentialBackedUp,
|
backup_eligible: registrationInfo.credentialBackedUp,
|
||||||
backup_status: credentialBackedUp,
|
backup_status: credentialBackedUp,
|
||||||
transports: JSON.stringify(response.response.transports ?? []),
|
transports: JSON.stringify(response.response.transports ?? []),
|
||||||
rpid: rpInfo.rpID,
|
rpid: rpInfo.rpID,
|
||||||
name: passkeyName,
|
name: passkeyName,
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await trx
|
await trx
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { userId: newUserId };
|
return { userId: newUserId };
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.userId;
|
return result.userId;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,19 +264,40 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
);
|
);
|
||||||
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
||||||
} else {
|
} else {
|
||||||
// Should never reach here due to schema validation
|
// Unreachable - schema validation requires password or passkeyInfo
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Either password or passkeyInfo is required",
|
message: "Either password or passkeyInfo is required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session (7 days, trusted mode false initially, no device)
|
// Generate verification token
|
||||||
const session = await createSession(context.db, {
|
const verificationToken = generateSecureBase58Token();
|
||||||
userId,
|
const verificationExpiresAt = generateExpiry(
|
||||||
deviceId: null,
|
TOKEN_DURATIONS.EMAIL_VERIFICATION,
|
||||||
trustedMode: false,
|
);
|
||||||
geo,
|
|
||||||
userAgent,
|
// Create session and email verification in transaction
|
||||||
|
const session = await withTransaction(context.db, async (trx) => {
|
||||||
|
// Create session (7 days, trusted mode false initially, no device)
|
||||||
|
const newSession = await createSession(trx, {
|
||||||
|
userId,
|
||||||
|
deviceId: null,
|
||||||
|
trustedMode: false,
|
||||||
|
geo,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||||
|
await trx
|
||||||
|
.insertInto("email_verifications")
|
||||||
|
.values({
|
||||||
|
user_id: userId,
|
||||||
|
token: verificationToken,
|
||||||
|
expires_at: verificationExpiresAt,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return newSession;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
@@ -264,22 +308,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
COOKIE_OPTIONS.session,
|
COOKIE_OPTIONS.session,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate verification token
|
// Send verification email
|
||||||
const verificationToken = generateSecureBase58Token();
|
await sendVerificationEmail({
|
||||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
baseUrl: context.email.baseUrl,
|
||||||
await context.db
|
email,
|
||||||
.insertInto("email_verifications")
|
token: verificationToken,
|
||||||
.values({
|
expiryHours: 24,
|
||||||
user_id: userId,
|
});
|
||||||
token: verificationToken,
|
|
||||||
expires_at: expiresAt,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Send verification email (stubbed)
|
|
||||||
await sendVerificationEmail(email, verificationToken);
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const deleteApiToken = os.me.apiTokens.delete
|
|||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const result = await context.db
|
const result = await context.db
|
||||||
.deleteFrom("api_tokens")
|
.deleteFrom("api_tokens")
|
||||||
.where("id", "=", String(input.tokenId))
|
.where("id", "=", input.tokenId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const untrustDevice = os.me.devices.untrust
|
|||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("user_devices")
|
.updateTable("user_devices")
|
||||||
.set({ is_trusted: false })
|
.set({ is_trusted: false })
|
||||||
.where("id", "=", String(input.deviceId))
|
.where("id", "=", input.deviceId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const renamePasskey = os.me.passkeys.rename
|
|||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("passkeys")
|
.updateTable("passkeys")
|
||||||
.set({ name })
|
.set({ name })
|
||||||
.where("id", "=", String(passkeyId))
|
.where("id", "=", passkeyId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export const deletePasskey = os.me.passkeys.delete
|
|||||||
|
|
||||||
const result = await trx
|
const result = await trx
|
||||||
.deleteFrom("passkeys")
|
.deleteFrom("passkeys")
|
||||||
.where("id", "=", String(passkeyId))
|
.where("id", "=", passkeyId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const revokeSession = os.me.sessions.revoke
|
|||||||
const { sessionId } = input;
|
const { sessionId } = input;
|
||||||
|
|
||||||
// Prevent revoking current session (use logout instead)
|
// Prevent revoking current session (use logout instead)
|
||||||
if (String(sessionId) === context.session.id) {
|
if (sessionId.toString() === context.session.id) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Cannot revoke current session. Use logout instead.",
|
message: "Cannot revoke current session. Use logout instead.",
|
||||||
});
|
});
|
||||||
@@ -57,7 +57,7 @@ export const revokeSession = os.me.sessions.revoke
|
|||||||
const result = await context.db
|
const result = await context.db
|
||||||
.updateTable("sessions")
|
.updateTable("sessions")
|
||||||
.set({ revoked_at: new Date() })
|
.set({ revoked_at: new Date() })
|
||||||
.where("id", "=", String(sessionId))
|
.where("id", "=", sessionId.toString())
|
||||||
.where("user_id", "=", context.user.id)
|
.where("user_id", "=", context.user.id)
|
||||||
.where("revoked_at", "is", null)
|
.where("revoked_at", "is", null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|||||||
@@ -115,10 +115,11 @@ export async function countOwners(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
.select((eb) => eb.fn.countAll().as("count"))
|
||||||
.where("org_id", "=", orgId)
|
.where("org_id", "=", orgId)
|
||||||
.where("role", "=", "owner")
|
.where("role", "=", "owner")
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return result.count;
|
// PostgreSQL COUNT returns bigint (string), convert to number
|
||||||
|
return Number(result.count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { sendOrgInviteEmail } from "@reviq/emails";
|
||||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
generateExpiry,
|
generateExpiry,
|
||||||
generateSecureBase58Token,
|
generateSecureBase58Token,
|
||||||
} from "../../utils/crypto.js";
|
} from "../../utils/crypto.js";
|
||||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
|
||||||
import { authMiddleware, os } from "../base.js";
|
import { authMiddleware, os } from "../base.js";
|
||||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||||
|
|
||||||
@@ -122,7 +122,17 @@ export const invitesCreate = os.orgs.invites.create
|
|||||||
|
|
||||||
// Send invitation email
|
// Send invitation email
|
||||||
const inviterName = context.user.displayName ?? context.user.email;
|
const inviterName = context.user.displayName ?? context.user.email;
|
||||||
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
|
await sendOrgInviteEmail({
|
||||||
|
client: context.email.client,
|
||||||
|
fromAddress: context.email.fromAddress,
|
||||||
|
baseUrl: context.email.baseUrl,
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
orgName: org.displayName,
|
||||||
|
inviterName,
|
||||||
|
role,
|
||||||
|
expiryDays: ORG_INVITE_EXPIRY_DAYS,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
|
|||||||
await context.db
|
await context.db
|
||||||
.updateTable("login_requests")
|
.updateTable("login_requests")
|
||||||
.set({ completed_at: new Date() })
|
.set({ completed_at: new Date() })
|
||||||
.where("id", "=", String(context.loginRequestId))
|
.where("id", "=", context.loginRequestId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { generateSecureBase58Token } from "@reviq/utils";
|
import { generateSecureBase58Token } from "@reviq/server-utils";
|
||||||
import { base58 } from "@scure/base";
|
import { base58 } from "@scure/base";
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
|
|||||||
@@ -1,419 +0,0 @@
|
|||||||
/**
|
|
||||||
* Email sending utilities using Postmark
|
|
||||||
* Implements Workstream G: Email Service (Backend)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { OrgRole } from "@reviq/db-schema";
|
|
||||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
|
||||||
import { ServerClient } from "postmark";
|
|
||||||
import {
|
|
||||||
BASE_URL,
|
|
||||||
EMAIL_DEV_MODE,
|
|
||||||
EMAIL_FROM,
|
|
||||||
EMAIL_VERIFICATION_EXPIRY_HOURS,
|
|
||||||
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
|
|
||||||
ORG_INVITE_EXPIRY_DAYS,
|
|
||||||
PASSWORD_RESET_EXPIRY_HOURS,
|
|
||||||
POSTMARK_API_KEY,
|
|
||||||
} from "../constants.js";
|
|
||||||
|
|
||||||
// ===== Types =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email send result
|
|
||||||
*/
|
|
||||||
export interface EmailResult {
|
|
||||||
success: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Postmark Client =====
|
|
||||||
|
|
||||||
let postmarkClient: ServerClient | null = null;
|
|
||||||
|
|
||||||
const getPostmarkClient = (): ServerClient => {
|
|
||||||
if (!postmarkClient) {
|
|
||||||
if (!POSTMARK_API_KEY) {
|
|
||||||
throw new Error(
|
|
||||||
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
postmarkClient = new ServerClient(POSTMARK_API_KEY);
|
|
||||||
}
|
|
||||||
return postmarkClient;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== URL Helpers =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a URL with query parameters using the URL constructor
|
|
||||||
*/
|
|
||||||
const buildUrl = (path: string, params: Record<string, string>): string => {
|
|
||||||
const url = new URL(path, BASE_URL);
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== HTML Escaping =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML special characters to prevent XSS
|
|
||||||
*/
|
|
||||||
const escapeHtml = (unsafe: string): string =>
|
|
||||||
unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
// ===== Core Email Function =====
|
|
||||||
|
|
||||||
interface SendEmailParams {
|
|
||||||
to: string;
|
|
||||||
subject: string;
|
|
||||||
htmlBody: string;
|
|
||||||
textBody: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email via Postmark (or log in dev mode)
|
|
||||||
*/
|
|
||||||
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
|
|
||||||
const { to, subject, htmlBody, textBody } = params;
|
|
||||||
|
|
||||||
// Dev mode: log instead of sending
|
|
||||||
if (EMAIL_DEV_MODE) {
|
|
||||||
console.log("=== DEV MODE EMAIL ===");
|
|
||||||
console.log(`To: ${to}`);
|
|
||||||
console.log(`Subject: ${subject}`);
|
|
||||||
console.log(`Body:\n${textBody}`);
|
|
||||||
console.log("======================");
|
|
||||||
return { success: true, messageId: "dev-mode" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = getPostmarkClient();
|
|
||||||
const result = await client.sendEmail({
|
|
||||||
From: EMAIL_FROM,
|
|
||||||
To: to,
|
|
||||||
Subject: subject,
|
|
||||||
HtmlBody: htmlBody,
|
|
||||||
TextBody: textBody,
|
|
||||||
});
|
|
||||||
return { success: true, messageId: result.MessageID };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
console.error(`Failed to send email to ${to}:`, message);
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Template Helpers =====
|
|
||||||
|
|
||||||
const durationFormatter = new DurationFormat("en", { style: "long" });
|
|
||||||
|
|
||||||
const formatExpiryHours = (hours: number): string =>
|
|
||||||
durationFormatter.format({ hours });
|
|
||||||
|
|
||||||
const formatExpiryMinutes = (minutes: number): string =>
|
|
||||||
durationFormatter.format({ minutes });
|
|
||||||
|
|
||||||
const formatExpiryDays = (days: number): string =>
|
|
||||||
durationFormatter.format({ days });
|
|
||||||
|
|
||||||
const roleLabels: Record<OrgRole, string> = {
|
|
||||||
owner: "Owner",
|
|
||||||
admin: "Admin",
|
|
||||||
member: "Member",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the correct article (a/an) for a role
|
|
||||||
*/
|
|
||||||
const getArticleForRole = (role: OrgRole): string => {
|
|
||||||
return role === "owner" || role === "admin" ? "an" : "a";
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Email Templates =====
|
|
||||||
|
|
||||||
// Common styles
|
|
||||||
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
|
|
||||||
const containerStyles =
|
|
||||||
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
|
|
||||||
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
|
|
||||||
const paragraphStyles =
|
|
||||||
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
|
|
||||||
const buttonStyles =
|
|
||||||
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
|
|
||||||
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
|
|
||||||
|
|
||||||
// Verification Email
|
|
||||||
const buildVerificationEmailHtml = (
|
|
||||||
verifyUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string => `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
</head>
|
|
||||||
<body style="${emailStyles}">
|
|
||||||
<div style="${containerStyles}">
|
|
||||||
<h1 style="${headingStyles}">Verify your email</h1>
|
|
||||||
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
|
|
||||||
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
|
|
||||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
|
||||||
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const buildVerificationEmailText = (
|
|
||||||
verifyUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string =>
|
|
||||||
`Verify your email
|
|
||||||
|
|
||||||
Please verify your email address by clicking the link below:
|
|
||||||
|
|
||||||
${verifyUrl}
|
|
||||||
|
|
||||||
This link expires in ${expiresIn}.
|
|
||||||
|
|
||||||
If you didn't create an account, you can safely ignore this email.
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Password Reset Email
|
|
||||||
const buildPasswordResetEmailHtml = (
|
|
||||||
resetUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string => `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
</head>
|
|
||||||
<body style="${emailStyles}">
|
|
||||||
<div style="${containerStyles}">
|
|
||||||
<h1 style="${headingStyles}">Reset your password</h1>
|
|
||||||
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
|
|
||||||
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
|
|
||||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
|
||||||
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const buildPasswordResetEmailText = (
|
|
||||||
resetUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string =>
|
|
||||||
`Reset your password
|
|
||||||
|
|
||||||
We received a request to reset your password. Click the link below to choose a new password:
|
|
||||||
|
|
||||||
${resetUrl}
|
|
||||||
|
|
||||||
This link expires in ${expiresIn}.
|
|
||||||
|
|
||||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Login Confirmation Email
|
|
||||||
const buildLoginConfirmationEmailHtml = (
|
|
||||||
confirmUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string => `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
</head>
|
|
||||||
<body style="${emailStyles}">
|
|
||||||
<div style="${containerStyles}">
|
|
||||||
<h1 style="${headingStyles}">Confirm your login</h1>
|
|
||||||
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
|
|
||||||
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
|
|
||||||
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
|
||||||
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const buildLoginConfirmationEmailText = (
|
|
||||||
confirmUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string =>
|
|
||||||
`Confirm your login
|
|
||||||
|
|
||||||
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
|
|
||||||
|
|
||||||
${confirmUrl}
|
|
||||||
|
|
||||||
This link expires in ${expiresIn}.
|
|
||||||
|
|
||||||
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Org Invite Email
|
|
||||||
const buildOrgInviteEmailHtml = (
|
|
||||||
email: string,
|
|
||||||
orgName: string,
|
|
||||||
inviterName: string,
|
|
||||||
role: OrgRole,
|
|
||||||
inviteUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string => {
|
|
||||||
const safeOrgName = escapeHtml(orgName);
|
|
||||||
const safeInviterName = escapeHtml(inviterName);
|
|
||||||
const safeEmail = escapeHtml(email);
|
|
||||||
const roleDisplay = formatRoleDisplay(role);
|
|
||||||
const article = getArticleForRole(role);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
</head>
|
|
||||||
<body style="${emailStyles}">
|
|
||||||
<div style="${containerStyles}">
|
|
||||||
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
|
|
||||||
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
|
|
||||||
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
|
|
||||||
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
|
|
||||||
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildOrgInviteEmailText = (
|
|
||||||
email: string,
|
|
||||||
orgName: string,
|
|
||||||
inviterName: string,
|
|
||||||
role: OrgRole,
|
|
||||||
inviteUrl: string,
|
|
||||||
expiresIn: string,
|
|
||||||
): string => {
|
|
||||||
const roleDisplay = formatRoleDisplay(role);
|
|
||||||
const article = getArticleForRole(role);
|
|
||||||
|
|
||||||
return `You've been invited to join ${orgName}
|
|
||||||
|
|
||||||
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
|
|
||||||
|
|
||||||
Click the link below to accept the invitation:
|
|
||||||
|
|
||||||
${inviteUrl}
|
|
||||||
|
|
||||||
This invitation expires in ${expiresIn}.
|
|
||||||
|
|
||||||
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Email Helpers =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send verification email to user
|
|
||||||
*/
|
|
||||||
export async function sendVerificationEmail(
|
|
||||||
email: string,
|
|
||||||
token: string,
|
|
||||||
): Promise<EmailResult> {
|
|
||||||
const url = buildUrl("/auth/verify", { token });
|
|
||||||
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
|
|
||||||
|
|
||||||
return sendEmail({
|
|
||||||
to: email,
|
|
||||||
subject: "Verify your email address",
|
|
||||||
htmlBody: buildVerificationEmailHtml(url, expiresIn),
|
|
||||||
textBody: buildVerificationEmailText(url, expiresIn),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send login confirmation email (for untrusted device flow)
|
|
||||||
*/
|
|
||||||
export async function sendLoginConfirmationEmail(
|
|
||||||
email: string,
|
|
||||||
token: string,
|
|
||||||
): Promise<EmailResult> {
|
|
||||||
const url = buildUrl("/auth/confirm", { token });
|
|
||||||
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
|
|
||||||
|
|
||||||
return sendEmail({
|
|
||||||
to: email,
|
|
||||||
subject: "Confirm your login",
|
|
||||||
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
|
|
||||||
textBody: buildLoginConfirmationEmailText(url, expiresIn),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send password reset email
|
|
||||||
*/
|
|
||||||
export async function sendPasswordResetEmail(
|
|
||||||
email: string,
|
|
||||||
token: string,
|
|
||||||
): Promise<EmailResult> {
|
|
||||||
const url = buildUrl("/auth/reset-password", { token });
|
|
||||||
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
|
|
||||||
|
|
||||||
return sendEmail({
|
|
||||||
to: email,
|
|
||||||
subject: "Reset your password",
|
|
||||||
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
|
|
||||||
textBody: buildPasswordResetEmailText(url, expiresIn),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send org invite email
|
|
||||||
*/
|
|
||||||
export async function sendOrgInviteEmail(
|
|
||||||
email: string,
|
|
||||||
token: string,
|
|
||||||
orgName: string,
|
|
||||||
inviterName: string,
|
|
||||||
role: OrgRole,
|
|
||||||
): Promise<EmailResult> {
|
|
||||||
const url = buildUrl("/invite/accept", { token });
|
|
||||||
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
|
|
||||||
|
|
||||||
return sendEmail({
|
|
||||||
to: email,
|
|
||||||
subject: `You've been invited to join ${orgName}`,
|
|
||||||
htmlBody: buildOrgInviteEmailHtml(
|
|
||||||
email,
|
|
||||||
orgName,
|
|
||||||
inviterName,
|
|
||||||
role,
|
|
||||||
url,
|
|
||||||
expiresIn,
|
|
||||||
),
|
|
||||||
textBody: buildOrgInviteEmailText(
|
|
||||||
email,
|
|
||||||
orgName,
|
|
||||||
inviterName,
|
|
||||||
role,
|
|
||||||
url,
|
|
||||||
expiresIn,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
hashPassword as hashPasswordUtil,
|
hashPassword as hashPasswordUtil,
|
||||||
verifyPassword as verifyPasswordUtil,
|
verifyPassword as verifyPasswordUtil,
|
||||||
} from "@reviq/utils";
|
} from "@reviq/server-utils";
|
||||||
import zxcvbn from "zxcvbn";
|
import zxcvbn from "zxcvbn";
|
||||||
|
|
||||||
export interface PasswordValidationResult {
|
export interface PasswordValidationResult {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Database } from "@reviq/db-schema";
|
import type { Database } from "@reviq/db-schema";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely, Transaction } from "kysely";
|
||||||
import type { GeoInfo } from "./geo.js";
|
import type { GeoInfo } from "./geo.js";
|
||||||
|
import {
|
||||||
|
isDeviceTrusted as dbIsDeviceTrusted,
|
||||||
|
upsertUserDevice as dbUpsertUserDevice,
|
||||||
|
insertSession,
|
||||||
|
} from "@reviq/db";
|
||||||
import { COOKIE_DURATIONS } from "./cookies.js";
|
import { COOKIE_DURATIONS } from "./cookies.js";
|
||||||
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
|
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
|
||||||
|
|
||||||
@@ -23,33 +28,26 @@ export interface SessionResult {
|
|||||||
* Returns the raw token (to be sent in cookie) and session details
|
* Returns the raw token (to be sent in cookie) and session details
|
||||||
*/
|
*/
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
db: Kysely<Database>,
|
db: Kysely<Database> | Transaction<Database>,
|
||||||
options: CreateSessionOptions,
|
options: CreateSessionOptions,
|
||||||
): Promise<SessionResult> {
|
): Promise<SessionResult> {
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const tokenHash = await hashToken(token);
|
const tokenHash = await hashToken(token);
|
||||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
||||||
|
|
||||||
const result = await db
|
const result = await insertSession(db, {
|
||||||
.insertInto("sessions")
|
userId: options.userId,
|
||||||
.values({
|
deviceId: options.deviceId,
|
||||||
user_id: options.userId,
|
tokenHash,
|
||||||
device_id: options.deviceId,
|
trustedMode: options.trustedMode,
|
||||||
token_hash: tokenHash,
|
geo: options.geo,
|
||||||
trusted_mode: options.trustedMode,
|
userAgent: options.userAgent,
|
||||||
ip_address: options.geo.ip,
|
expiresAt,
|
||||||
city: options.geo.city,
|
});
|
||||||
region: options.geo.region,
|
|
||||||
country: options.geo.country,
|
|
||||||
user_agent: options.userAgent,
|
|
||||||
expires_at: expiresAt,
|
|
||||||
})
|
|
||||||
.returning(["id"])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
sessionId: Number(result.id),
|
sessionId: result.sessionId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,53 +58,22 @@ export async function createSession(
|
|||||||
* Returns the device ID
|
* Returns the device ID
|
||||||
*/
|
*/
|
||||||
export async function upsertUserDevice(
|
export async function upsertUserDevice(
|
||||||
db: Kysely<Database>,
|
db: Kysely<Database> | Transaction<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
deviceFingerprint: string,
|
deviceFingerprint: string,
|
||||||
geo: GeoInfo,
|
geo: GeoInfo,
|
||||||
userAgent: string,
|
userAgent: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const result = await db
|
return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
|
||||||
.insertInto("user_devices")
|
|
||||||
.values({
|
|
||||||
user_id: userId,
|
|
||||||
device_fingerprint: deviceFingerprint,
|
|
||||||
user_agent: userAgent,
|
|
||||||
ip_address: geo.ip,
|
|
||||||
city: geo.city,
|
|
||||||
region: geo.region,
|
|
||||||
country: geo.country,
|
|
||||||
})
|
|
||||||
.onConflict((oc) =>
|
|
||||||
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
|
|
||||||
ip_address: geo.ip,
|
|
||||||
city: geo.city,
|
|
||||||
region: geo.region,
|
|
||||||
country: geo.country,
|
|
||||||
user_agent: userAgent,
|
|
||||||
last_used_at: new Date(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.returning(["id"])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return Number(result.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a device is trusted for a user
|
* Check if a device is trusted for a user
|
||||||
*/
|
*/
|
||||||
export async function isDeviceTrusted(
|
export async function isDeviceTrusted(
|
||||||
db: Kysely<Database>,
|
db: Kysely<Database> | Transaction<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
deviceFingerprint: string,
|
deviceFingerprint: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const device = await db
|
return dbIsDeviceTrusted(db, userId, deviceFingerprint);
|
||||||
.selectFrom("user_devices")
|
|
||||||
.select(["is_trusted"])
|
|
||||||
.where("user_id", "=", userId)
|
|
||||||
.where("device_fingerprint", "=", deviceFingerprint)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return device?.is_trusted ?? false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const verifyRegistration = async (
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("options")
|
.select("options")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!challengeRow) {
|
if (!challengeRow) {
|
||||||
@@ -189,7 +189,7 @@ export const verifyRegistration = async (
|
|||||||
// Always delete the challenge
|
// Always delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ export const verifyAuthentication = async (
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("options")
|
.select("options")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!challengeRow) {
|
if (!challengeRow) {
|
||||||
@@ -321,7 +321,7 @@ export const verifyAuthentication = async (
|
|||||||
counter: verification.authenticationInfo.newCounter.toString(),
|
counter: verification.authenticationInfo.newCounter.toString(),
|
||||||
last_used_at: new Date(),
|
last_used_at: new Date(),
|
||||||
})
|
})
|
||||||
.where("id", "=", String(passkey.id))
|
.where("id", "=", passkey.id.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -329,7 +329,7 @@ export const verifyAuthentication = async (
|
|||||||
// Always delete the challenge
|
// Always delete the challenge
|
||||||
await db
|
await db
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,5 @@
|
|||||||
"isolatedDeclarations": false,
|
"isolatedDeclarations": false,
|
||||||
"composite": false
|
"composite": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"]
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test": "bun test"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { ORPCError } from "@orpc/client";
|
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface CompleteLoginFlags {
|
interface CompleteLoginFlags {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -20,14 +20,7 @@ async function completeLogin(
|
|||||||
|
|
||||||
console.log(`Completed login request for: ${flags.email}`);
|
console.log(`Completed login request for: ${flags.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ORPCError) {
|
console.error("Error:", formatError(error));
|
||||||
console.error(`Error [${String(error.code)}]:`, error.message);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
import { readConfig, writeConfig } from "../../utils/config.js";
|
import { readConfig, writeConfig } from "../../utils/config.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface LoginFlags {
|
interface LoginFlags {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -47,10 +48,7 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
|||||||
console.log(`Logged in as ${authStatus.user.email}`);
|
console.log(`Logged in as ${authStatus.user.email}`);
|
||||||
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Login failed:", formatError(error));
|
||||||
"Login failed:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
console.log("\nMake sure your API token is valid.");
|
console.log("\nMake sure your API token is valid.");
|
||||||
console.log("You can create a new token at: /account/api-tokens");
|
console.log("You can create a new token at: /account/api-tokens");
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
import { getConfigPath, readConfig } from "../../utils/config.js";
|
import { getConfigPath, readConfig } from "../../utils/config.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
import { TOKEN_PREFIX } from "../../utils/token.js";
|
import { TOKEN_PREFIX } from "../../utils/token.js";
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
function formatDate(date: Date): string {
|
||||||
@@ -14,19 +15,19 @@ function formatRelativeTime(date: Date): string {
|
|||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays < 0) {
|
if (diffDays < 0) {
|
||||||
return `${String(Math.abs(diffDays))} days ago`;
|
return `${Math.abs(diffDays).toLocaleString()} days ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffDays === 0) {
|
if (diffDays === 0) {
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
if (diffHours <= 0) {
|
return diffHours <= 0 ? "expired" : `in ${diffHours.toLocaleString()} hours`;
|
||||||
return "expired";
|
|
||||||
}
|
|
||||||
return `in ${String(diffHours)} hours`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffDays === 1) {
|
if (diffDays === 1) {
|
||||||
return "tomorrow";
|
return "tomorrow";
|
||||||
}
|
}
|
||||||
return `in ${String(diffDays)} days`;
|
|
||||||
|
return `in ${diffDays.toLocaleString()} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function status(this: LocalContext): Promise<void> {
|
async function status(this: LocalContext): Promise<void> {
|
||||||
@@ -96,9 +97,7 @@ async function status(this: LocalContext): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(
|
console.log(` Error: ${formatError(error)}`);
|
||||||
` Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
console.log(
|
console.log(
|
||||||
"\n Unable to connect to API. Local credentials may be invalid.",
|
"\n Unable to connect to API. Local credentials may be invalid.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../context.js";
|
|||||||
import { createDb, executeBootstrap } from "@reviq/db";
|
import { createDb, executeBootstrap } from "@reviq/db";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { writeConfig } from "../utils/config.js";
|
import { writeConfig } from "../utils/config.js";
|
||||||
|
import { formatError } from "../utils/format-error.js";
|
||||||
|
|
||||||
interface BootstrapFlags {
|
interface BootstrapFlags {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -47,10 +48,7 @@ async function bootstrap(
|
|||||||
|
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { LocalContext } from "../context.js";
|
import type { LocalContext } from "../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
|
|
||||||
type Shell = "bash" | "zsh" | "fish";
|
const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
|
||||||
|
type Shell = (typeof SUPPORTED_SHELLS)[number];
|
||||||
const SUPPORTED_SHELLS: readonly Shell[] = ["bash", "zsh", "fish"] as const;
|
|
||||||
|
|
||||||
function parseShell(value: string): Shell {
|
function parseShell(value: string): Shell {
|
||||||
const shell = value.toLowerCase();
|
const shell = value.toLowerCase();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface AddSiteFlags {
|
interface AddSiteFlags {
|
||||||
org: string;
|
org: string;
|
||||||
@@ -18,10 +19,7 @@ async function addSite(this: LocalContext, flags: AddSiteFlags): Promise<void> {
|
|||||||
|
|
||||||
console.log(`Added site ${flags.domain} to org ${flags.org}`);
|
console.log(`Added site ${flags.domain} to org ${flags.org}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface CreateOrgFlags {
|
interface CreateOrgFlags {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -24,10 +25,7 @@ async function create(
|
|||||||
console.log(`Created org: ${result.slug}`);
|
console.log(`Created org: ${result.slug}`);
|
||||||
console.log(`Owner: ${flags.owner}`);
|
console.log(`Owner: ${flags.owner}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
async function list(this: LocalContext): Promise<void> {
|
async function list(this: LocalContext): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -23,12 +24,9 @@ async function list(this: LocalContext): Promise<void> {
|
|||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Total: ${String(orgs.length)} organization(s)`);
|
console.log(`Total: ${orgs.length.toLocaleString()} organization(s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
interface ConfirmEmailFlags {
|
interface ConfirmEmailFlags {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -19,10 +20,7 @@ async function confirmEmail(
|
|||||||
|
|
||||||
console.log(`Confirmed email for: ${flags.email}`);
|
console.log(`Confirmed email for: ${flags.email}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import type { LocalContext } from "../../context.js";
|
import type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { createApiClient } from "../../utils/api-client.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
|
import { formatError } from "../../utils/format-error.js";
|
||||||
|
|
||||||
type OrgRole = "owner" | "admin" | "member";
|
type OrgRole = "owner" | "admin" | "member";
|
||||||
|
|
||||||
const validRoles: OrgRole[] = ["owner", "admin", "member"];
|
const VALID_ROLES: readonly OrgRole[] = ["owner", "admin", "member"] as const;
|
||||||
|
|
||||||
function parseRole(role: string | undefined): OrgRole | undefined {
|
function parseRole(role: string | undefined): OrgRole | undefined {
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (validRoles.includes(role as OrgRole)) {
|
|
||||||
return role as OrgRole;
|
if (!VALID_ROLES.includes(role as OrgRole)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(", ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error(
|
|
||||||
`Invalid role: ${role}. Must be one of: ${validRoles.join(", ")}`,
|
return role as OrgRole;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateUserFlags {
|
interface CreateUserFlags {
|
||||||
@@ -45,10 +48,7 @@ async function create(
|
|||||||
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
|
console.log(`Added to org: ${flags.org} as ${flags.role ?? "member"}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error:", formatError(error));
|
||||||
"Error:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { readConfig } from "./config.js";
|
|||||||
|
|
||||||
export type ApiClient = ContractRouterClient<typeof contract>;
|
export type ApiClient = ContractRouterClient<typeof contract>;
|
||||||
|
|
||||||
|
function buildClient(apiUrl: string, token: string): ApiClient {
|
||||||
|
const link = new RPCLink({
|
||||||
|
url: `${apiUrl}/api/v1/rpc`,
|
||||||
|
headers: { "X-API-Key": token },
|
||||||
|
});
|
||||||
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an oRPC API client with provided credentials
|
* Create an oRPC API client with provided credentials
|
||||||
*/
|
*/
|
||||||
@@ -25,18 +33,10 @@ export function createApiClient(
|
|||||||
apiUrl?: string,
|
apiUrl?: string,
|
||||||
token?: string,
|
token?: string,
|
||||||
): ApiClient | Promise<ApiClient> {
|
): ApiClient | Promise<ApiClient> {
|
||||||
// If both arguments are provided, create client directly
|
|
||||||
if (apiUrl !== undefined && token !== undefined) {
|
if (apiUrl !== undefined && token !== undefined) {
|
||||||
const link = new RPCLink({
|
return buildClient(apiUrl, token);
|
||||||
url: `${apiUrl}/api/v1/rpc`,
|
|
||||||
headers: {
|
|
||||||
"X-API-Key": token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return createORPCClient(link) as unknown as ApiClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, read from config asynchronously
|
|
||||||
return (async (): Promise<ApiClient> => {
|
return (async (): Promise<ApiClient> => {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -44,14 +44,6 @@ export function createApiClient(
|
|||||||
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return buildClient(config.apiUrl, config.token);
|
||||||
const link = new RPCLink({
|
|
||||||
url: `${config.apiUrl}/api/v1/rpc`,
|
|
||||||
headers: {
|
|
||||||
"X-API-Key": config.token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return createORPCClient(link) as unknown as ApiClient;
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,40 +19,42 @@ const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
|
|||||||
/**
|
/**
|
||||||
* Get the path to the config file
|
* Get the path to the config file
|
||||||
*/
|
*/
|
||||||
export const getConfigPath = (): string => CONFIG_FILE;
|
export function getConfigPath(): string {
|
||||||
|
return CONFIG_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the config file
|
* Read the config file
|
||||||
* Returns null if the file doesn't exist or is invalid
|
* Returns null if the file doesn't exist or is invalid
|
||||||
*/
|
*/
|
||||||
export const readConfig = async (): Promise<Config | null> => {
|
export async function readConfig(): Promise<Config | null> {
|
||||||
try {
|
try {
|
||||||
const data = await readFile(CONFIG_FILE, "utf-8");
|
const data = await readFile(CONFIG_FILE, "utf-8");
|
||||||
return JSON.parse(data) as Config;
|
return JSON.parse(data) as Config;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the config file
|
* Write the config file
|
||||||
* Creates the config directory if it doesn't exist
|
* Creates the config directory if it doesn't exist
|
||||||
*/
|
*/
|
||||||
export const writeConfig = async (config: Config): Promise<void> => {
|
export async function writeConfig(config: Config): Promise<void> {
|
||||||
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the config file
|
* Delete the config file
|
||||||
* Ignores errors if the file doesn't exist
|
* Ignores errors if the file doesn't exist
|
||||||
*/
|
*/
|
||||||
export const deleteConfig = async (): Promise<void> => {
|
export async function deleteConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await unlink(CONFIG_FILE);
|
await unlink(CONFIG_FILE);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if doesn't exist
|
// Ignore if doesn't exist
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
20
apps/cli/src/utils/format-error.ts
Normal file
20
apps/cli/src/utils/format-error.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ORPCError } from "@orpc/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an unknown error value into a string message.
|
||||||
|
* Handles ORPCError, Error instances, strings, and other types safely.
|
||||||
|
*/
|
||||||
|
export function formatError(error: unknown): string {
|
||||||
|
if (error instanceof ORPCError) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
|
||||||
|
return `[${error.code}] ${error.message}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- intentional unknown coercion
|
||||||
|
return `${error}`;
|
||||||
|
}
|
||||||
@@ -19,6 +19,5 @@
|
|||||||
"isolatedDeclarations": false,
|
"isolatedDeclarations": false,
|
||||||
"composite": false
|
"composite": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"]
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"@orpc/client": "^1.13.2",
|
"@orpc/client": "^1.13.2",
|
||||||
"@orpc/contract": "^1.13.2",
|
"@orpc/contract": "^1.13.2",
|
||||||
"@reviq/api-contract": "workspace:*",
|
"@reviq/api-contract": "workspace:*",
|
||||||
|
"@reviq/common": "workspace:*",
|
||||||
|
"@reviq/frontend-utils": "workspace:*",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@tanstack/svelte-query": "^6.0.14",
|
"@tanstack/svelte-query": "^6.0.14",
|
||||||
"@tanstack/svelte-query-devtools": "^6.0.3",
|
"@tanstack/svelte-query-devtools": "^6.0.3",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.49.4",
|
"@sveltejs/kit": "^2.49.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
/* Geist Sans - Modern, clean typeface */
|
/* Geist Sans - Modern, clean typeface */
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
|
|||||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||||
import UserIcon from "@lucide/svelte/icons/user";
|
import UserIcon from "@lucide/svelte/icons/user";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
|
|||||||
>
|
>
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.href)}
|
||||||
{@const active = isActive(item.href, $page.url.pathname)}
|
{@const active = isActive(item.href, $page.url.pathname)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export { default as AccountNav } from "./account-nav.svelte";
|
export { default as AccountNav } from "./account-nav.svelte";
|
||||||
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
|
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
|
||||||
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
|
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
|
||||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
|
||||||
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
|
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
|
||||||
export { default as PasskeyList } from "./passkey-list.svelte";
|
export { default as PasskeyList } from "./passkey-list.svelte";
|
||||||
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";
|
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Key, Pencil, Trash2 } from "@lucide/svelte";
|
import { Key, Pencil, Trash2 } from "@lucide/svelte";
|
||||||
|
import { formatDate, formatRelativeTime } from "@reviq/common";
|
||||||
import { useQueryClient } from "@tanstack/svelte-query";
|
import { useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import ConfirmDialog from "./confirm-dialog.svelte";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
|
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
|
||||||
|
|
||||||
interface Passkey {
|
interface Passkey {
|
||||||
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
|
|||||||
let selectedPasskey = $state<Passkey | null>(null);
|
let selectedPasskey = $state<Passkey | null>(null);
|
||||||
let isDeleting = $state(false);
|
let isDeleting = $state(false);
|
||||||
|
|
||||||
function formatDate(date: Date | string): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(date: Date | string | null): string {
|
|
||||||
if (!date) {
|
|
||||||
return "Never";
|
|
||||||
}
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
if (diffDays === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (diffDays < 7) {
|
|
||||||
return `${diffDays} days ago`;
|
|
||||||
}
|
|
||||||
if (diffDays < 30) {
|
|
||||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
|
||||||
}
|
|
||||||
return formatDate(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRename(passkey: Passkey) {
|
function openRename(passkey: Passkey) {
|
||||||
selectedPasskey = passkey;
|
selectedPasskey = passkey;
|
||||||
renameDialogOpen = true;
|
renameDialogOpen = true;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -12,29 +11,29 @@ interface Props {
|
|||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
// Check if current path is an auth page (doesn't require login)
|
// Check if current path is a public page (doesn't require login)
|
||||||
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
|
const isPublicPage = $derived(
|
||||||
|
page.url.pathname.startsWith("/auth") ||
|
||||||
|
page.url.pathname === "/terms" ||
|
||||||
|
page.url.pathname === "/privacy",
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch user to check if logged in (only for non-auth pages)
|
// Fetch user to check if logged in (only for protected pages)
|
||||||
const userQuery = createQuery(() => ({
|
const userQuery = createQuery(() => ({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: () => api.me.get(),
|
queryFn: () => api.me.get(),
|
||||||
enabled: !isAuthPage,
|
enabled: !isPublicPage,
|
||||||
retry: false,
|
retry: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Redirect to login if not authenticated on non-auth pages
|
// Redirect to login if not authenticated on protected pages
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isAuthPage && userQuery.error) {
|
if (!isPublicPage && userQuery.error) {
|
||||||
goto(
|
gotoLogin(page.url.pathname);
|
||||||
resolve(
|
|
||||||
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isAuthPage || userQuery.data || userQuery.isPending}
|
{#if isPublicPage || userQuery.data || userQuery.isPending}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,8 +26,8 @@ const filters = [
|
|||||||
|
|
||||||
<div class="divide-y divide-border/50">
|
<div class="divide-y divide-border/50">
|
||||||
{#each filters as filter (filter.label)}
|
{#each filters as filter (filter.label)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(filter.href as any)}
|
<a href={filter.href}
|
||||||
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -33,14 +32,15 @@ const activeTab = $derived(
|
|||||||
($page.url.searchParams.get("tab") as TabId) || defaultTab,
|
($page.url.searchParams.get("tab") as TabId) || defaultTab,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleTabChange(tabId: string) {
|
function handleTabChange(tabId: string): void {
|
||||||
const url = new URL($page.url);
|
const url = new URL($page.url);
|
||||||
if (tabId === defaultTab) {
|
if (tabId === defaultTab) {
|
||||||
url.searchParams.delete("tab");
|
url.searchParams.delete("tab");
|
||||||
} else {
|
} else {
|
||||||
url.searchParams.set("tab", tabId);
|
url.searchParams.set("tab", tabId);
|
||||||
}
|
}
|
||||||
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true });
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto(url.toString(), { replaceState: true, noScroll: true });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface AdUnitRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: AdUnitRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "/header/leaderboard-728x90",
|
name: "/header/leaderboard-728x90",
|
||||||
@@ -51,58 +55,10 @@ const tableData = [
|
|||||||
impPercent: 9.16,
|
impPercent: 9.16,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Ad unit" showSortIcon>
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
<code class="font-mono text-[13px] text-foreground">{(row as AdUnitRow).name}</code>
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head>
|
</MetricsTable>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
% of revenue
|
|
||||||
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</Table.Head>
|
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface CountryRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: CountryRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "United States",
|
name: "United States",
|
||||||
@@ -57,54 +62,14 @@ const tableData = [
|
|||||||
impPercent: 4.68,
|
impPercent: 4.68,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Country">
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
{@const countryRow = row as CountryRow}
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
<div class="flex items-center gap-2">
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{countryRow.code}</span>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
<span class="text-[13px] font-medium text-foreground">{countryRow.name}</span>
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
</div>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
</MetricsTable>
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{row.code}</span>
|
|
||||||
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface DomainRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: DomainRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "example.com",
|
name: "example.com",
|
||||||
@@ -27,51 +31,10 @@ const tableData = [
|
|||||||
impPercent: 18.45,
|
impPercent: 18.45,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Domain">
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
<span class="text-[13px] font-medium text-foreground">{(row as DomainRow).name}</span>
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Domain</Table.Head>
|
</MetricsTable>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export { default as AdUnitTable } from "./ad-unit-table.svelte";
|
|||||||
export { default as CountryTable } from "./country-table.svelte";
|
export { default as CountryTable } from "./country-table.svelte";
|
||||||
export { default as DomainTable } from "./domain-table.svelte";
|
export { default as DomainTable } from "./domain-table.svelte";
|
||||||
export { default as KeyValueTable } from "./key-value-table.svelte";
|
export { default as KeyValueTable } from "./key-value-table.svelte";
|
||||||
|
export { default as MetricsTable, type MetricsRow } from "./metrics-table.svelte";
|
||||||
export { default as SourceTable } from "./source-table.svelte";
|
export { default as SourceTable } from "./source-table.svelte";
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import * as Table from "$lib/components/ui/table";
|
||||||
|
|
||||||
const tableData = [
|
interface KeyValueRow {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
revenue: string;
|
||||||
|
revPercent: number;
|
||||||
|
impressions: string;
|
||||||
|
impPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: KeyValueRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
key: "device",
|
key: "device",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Table from "$lib/components/ui/table";
|
||||||
|
|
||||||
|
export interface MetricsRow {
|
||||||
|
id: number;
|
||||||
|
revenue: string;
|
||||||
|
revPercent: number;
|
||||||
|
impressions: string;
|
||||||
|
impPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: MetricsRow[];
|
||||||
|
labelHeader: string;
|
||||||
|
labelCell: Snippet<[{ row: MetricsRow; index: number }]>;
|
||||||
|
showSortIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, labelHeader, labelCell, showSortIcon = false }: Props = $props();
|
||||||
|
|
||||||
|
function getBarWidth(value: number, max: number): number {
|
||||||
|
return (value / max) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRevPercent = $derived(Math.max(...data.map((d) => d.revPercent)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row class="border-b border-border hover:bg-transparent">
|
||||||
|
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
||||||
|
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">{labelHeader}</Table.Head>
|
||||||
|
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
||||||
|
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
% of revenue
|
||||||
|
{#if showSortIcon}
|
||||||
|
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Table.Head>
|
||||||
|
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
||||||
|
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each data as row, i (row.id)}
|
||||||
|
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||||
|
<Table.Cell class="w-10 py-3 pl-5">
|
||||||
|
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="py-3">
|
||||||
|
{@render labelCell({ row, index: i })}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
||||||
|
<Table.Cell class="w-32 py-3">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
||||||
|
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
||||||
|
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from "$lib/components/ui/table";
|
import MetricsTable, { type MetricsRow } from "./metrics-table.svelte";
|
||||||
|
|
||||||
const tableData = [
|
interface SourceRow extends MetricsRow {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData: SourceRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Google AdX",
|
name: "Google AdX",
|
||||||
@@ -43,51 +47,10 @@ const tableData = [
|
|||||||
impPercent: 7.28,
|
impPercent: 7.28,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getBarWidth(value: number, max: number): number {
|
|
||||||
return (value / max) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<MetricsTable data={tableData} labelHeader="Source">
|
||||||
<Table.Header>
|
{#snippet labelCell({ row })}
|
||||||
<Table.Row class="border-b border-border hover:bg-transparent">
|
<span class="text-[13px] font-medium text-foreground">{(row as SourceRow).name}</span>
|
||||||
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
|
{/snippet}
|
||||||
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Source</Table.Head>
|
</MetricsTable>
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
|
|
||||||
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
|
|
||||||
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each tableData as row, i (row.id)}
|
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3">
|
|
||||||
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
|
|
||||||
<Table.Cell class="w-32 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
|
|
||||||
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
|
|
||||||
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
|
|||||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||||
import UserIcon from "@lucide/svelte/icons/user";
|
import UserIcon from "@lucide/svelte/icons/user";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { DashboardLayout } from "$lib/components/layout";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
@@ -94,8 +93,8 @@ function isActive(href: string): boolean {
|
|||||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.href)}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.href)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||||
active
|
active
|
||||||
@@ -113,8 +112,8 @@ function isActive(href: string): boolean {
|
|||||||
<div class="hidden space-y-1 lg:block">
|
<div class="hidden space-y-1 lg:block">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.href)}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.href)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getUserInitials } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const user = $derived(userQuery.data);
|
const user = $derived(userQuery.data);
|
||||||
|
const initials = $derived(getUserInitials(user));
|
||||||
// Generate initials from display name or email
|
|
||||||
const initials = $derived.by(() => {
|
|
||||||
if (!user) {
|
|
||||||
return "??";
|
|
||||||
}
|
|
||||||
if (user.displayName) {
|
|
||||||
const parts = user.displayName.split(" ");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (
|
|
||||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
|
||||||
).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.displayName.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.email.slice(0, 2).toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
function handleNavClick() {
|
function handleNavClick(): void {
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
@@ -98,8 +83,8 @@ const navItems = [
|
|||||||
item.href === "/admin"
|
item.href === "/admin"
|
||||||
? $page.url.pathname === "/admin"
|
? $page.url.pathname === "/admin"
|
||||||
: $page.url.pathname.startsWith(item.href)}
|
: $page.url.pathname.startsWith(item.href)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
onclick={handleNavClick}
|
onclick={handleNavClick}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getUserInitials } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const user = $derived(userQuery.data);
|
const user = $derived(userQuery.data);
|
||||||
|
const initials = $derived(getUserInitials(user));
|
||||||
// Generate initials from display name or email
|
|
||||||
const initials = $derived.by(() => {
|
|
||||||
if (!user) {
|
|
||||||
return "??";
|
|
||||||
}
|
|
||||||
if (user.displayName) {
|
|
||||||
const parts = user.displayName.split(" ");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (
|
|
||||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
|
||||||
).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.displayName.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.email.slice(0, 2).toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
@@ -90,8 +75,8 @@ const navItems = [
|
|||||||
item.href === "/admin"
|
item.href === "/admin"
|
||||||
? $page.url.pathname === "/admin"
|
? $page.url.pathname === "/admin"
|
||||||
: $page.url.pathname.startsWith(item.href)}
|
: $page.url.pathname.startsWith(item.href)}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ const navItems = $derived.by(() => {
|
|||||||
? $page.url.pathname === item.href
|
? $page.url.pathname === item.href
|
||||||
: $page.url.pathname === item.href ||
|
: $page.url.pathname === item.href ||
|
||||||
$page.url.pathname.startsWith(item.href + "/")}
|
$page.url.pathname.startsWith(item.href + "/")}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
isActive
|
isActive
|
||||||
@@ -163,7 +163,7 @@ const navItems = $derived.by(() => {
|
|||||||
{#if currentSlug}
|
{#if currentSlug}
|
||||||
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
|
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/dashboard/${currentSlug}/settings`)}
|
href={resolve("/dashboard/[slug]/settings", { slug: currentSlug })}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
isSettingsActive
|
isSettingsActive
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getUserInitials } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -32,28 +33,11 @@ const userQuery = createQuery(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const user = $derived(userQuery.data);
|
const user = $derived(userQuery.data);
|
||||||
|
const initials = $derived(getUserInitials(user));
|
||||||
// Generate initials from display name or email
|
|
||||||
const initials = $derived.by(() => {
|
|
||||||
if (!user) {
|
|
||||||
return "??";
|
|
||||||
}
|
|
||||||
if (user.displayName) {
|
|
||||||
const parts = user.displayName.split(" ");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (
|
|
||||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
|
||||||
).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.displayName.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.email.slice(0, 2).toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nav items depend on whether we're in an org context
|
// Nav items depend on whether we're in an org context
|
||||||
const navItems = $derived.by(() => {
|
const navItems = $derived.by(() => {
|
||||||
if (currentSlug) {
|
if (currentSlug) {
|
||||||
// In org context - org-specific navigation
|
|
||||||
return [
|
return [
|
||||||
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
|
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
|
||||||
{
|
{
|
||||||
@@ -68,7 +52,6 @@ const navItems = $derived.by(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// Outside org context - general navigation
|
|
||||||
return [
|
return [
|
||||||
{ icon: "home", href: "/", label: "Home" },
|
{ icon: "home", href: "/", label: "Home" },
|
||||||
{ icon: "building", href: "/dashboard", label: "Organizations" },
|
{ icon: "building", href: "/dashboard", label: "Organizations" },
|
||||||
@@ -77,16 +60,17 @@ const navItems = $derived.by(() => {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
function handleNavClick() {
|
function handleNavClick(): void {
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
open = false;
|
open = false;
|
||||||
goto(resolve("/auth/login"));
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto("/auth/login");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to sign out:", error);
|
console.error("Failed to sign out:", error);
|
||||||
}
|
}
|
||||||
@@ -123,8 +107,8 @@ async function handleSignOut() {
|
|||||||
{@const isActive =
|
{@const isActive =
|
||||||
$page.url.pathname === item.href ||
|
$page.url.pathname === item.href ||
|
||||||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
||||||
<a
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href as any)}
|
<a href={item.href}
|
||||||
onclick={handleNavClick}
|
onclick={handleNavClick}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Check } from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
import { OrgAvatar } from "$lib/components/org";
|
||||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const orgs = $derived(orgsQuery.data ?? []);
|
const orgs = $derived(orgsQuery.data ?? []);
|
||||||
|
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
|
||||||
|
|
||||||
function handleOrgSelect(slug: string) {
|
function handleOrgSelect(slug: string) {
|
||||||
goto(resolve(`/dashboard/${slug}` as any));
|
goto(resolve("/dashboard/[slug]", { slug }));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,17 +33,24 @@ function handleOrgSelect(slug: string) {
|
|||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
aria-label="Switch organization"
|
aria-label="Switch organization"
|
||||||
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm transition-transform duration-200 hover:scale-105"
|
class="group flex h-8 w-8 items-center justify-center transition-transform duration-200 hover:scale-105"
|
||||||
>
|
>
|
||||||
<svg
|
{#if currentOrg}
|
||||||
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
<OrgAvatar org={currentOrg} size="md" />
|
||||||
viewBox="0 0 24 24"
|
{:else}
|
||||||
fill="none"
|
<!-- Default icon when no org is selected -->
|
||||||
stroke="currentColor"
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
|
||||||
stroke-width="2.5"
|
<svg
|
||||||
>
|
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
|
|||||||
class={cn(isActive && "bg-accent")}
|
class={cn(isActive && "bg-accent")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if org.logoUrl}
|
<OrgAvatar {org} size="xs" />
|
||||||
<img src={org.logoUrl} alt="" class="h-5 w-5 rounded" />
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-5 w-5 items-center justify-center rounded bg-muted text-[10px] font-medium">
|
|
||||||
{org.displayName.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span class="flex-1 truncate">{org.displayName}</span>
|
<span class="flex-1 truncate">{org.displayName}</span>
|
||||||
{#if isActive}
|
{#if isActive}
|
||||||
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<Check class="h-4 w-4 text-primary" />
|
||||||
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getUserInitials } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const user = $derived(userQuery.data);
|
const user = $derived(userQuery.data);
|
||||||
|
const initials = $derived(getUserInitials(user));
|
||||||
// Generate initials from display name or email
|
|
||||||
const initials = $derived.by(() => {
|
|
||||||
if (!user) {
|
|
||||||
return "??";
|
|
||||||
}
|
|
||||||
if (user.displayName) {
|
|
||||||
const parts = user.displayName.split(" ");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (
|
|
||||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
|
||||||
).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.displayName.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.email.slice(0, 2).toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
// Clear all cached queries
|
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
goto(resolve("/auth/login"));
|
goto(resolve("/auth/login"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { Building2, Globe, Settings, Users } from "@lucide/svelte";
|
import { Globe, Settings, Users } from "@lucide/svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
@@ -16,36 +16,37 @@ let { title, children }: Props = $props();
|
|||||||
|
|
||||||
// Get org context from parent layout
|
// Get org context from parent layout
|
||||||
const orgContext = getContext<{ slug: string }>("orgContext");
|
const orgContext = getContext<{ slug: string }>("orgContext");
|
||||||
const slug = $derived(orgContext?.slug);
|
const slug = $derived(orgContext?.slug ?? "");
|
||||||
|
|
||||||
// Settings navigation items
|
// Settings navigation items with route patterns for type-safe resolve()
|
||||||
const navItems = $derived.by(() => [
|
const navItems = [
|
||||||
{
|
{
|
||||||
href: `/dashboard/${slug}/settings`,
|
route: "/dashboard/[slug]/settings",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: "General",
|
label: "General",
|
||||||
description: "Organization name, logo, and preferences",
|
description: "Organization name, logo, and preferences",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/dashboard/${slug}/settings/members`,
|
route: "/dashboard/[slug]/settings/members",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
label: "Members",
|
label: "Members",
|
||||||
description: "Manage team members and invitations",
|
description: "Manage team members and invitations",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/dashboard/${slug}/settings/sites`,
|
route: "/dashboard/[slug]/settings/sites",
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
label: "Sites",
|
label: "Sites",
|
||||||
description: "Connected websites and domains",
|
description: "Connected websites and domains",
|
||||||
},
|
},
|
||||||
]);
|
] as const;
|
||||||
|
|
||||||
// Determine active item
|
// Determine active item
|
||||||
const activeHref = $derived($page.url.pathname);
|
const activeHref = $derived($page.url.pathname);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(route: (typeof navItems)[number]["route"]): boolean {
|
||||||
|
const href = resolve(route, { slug });
|
||||||
// Exact match for base settings path
|
// Exact match for base settings path
|
||||||
if (href === `/dashboard/${slug}/settings`) {
|
if (route === "/dashboard/[slug]/settings") {
|
||||||
return activeHref === href;
|
return activeHref === href;
|
||||||
}
|
}
|
||||||
// Prefix match for sub-pages
|
// Prefix match for sub-pages
|
||||||
@@ -59,10 +60,10 @@ function isActive(href: string): boolean {
|
|||||||
<nav class="w-full shrink-0 lg:w-64">
|
<nav class="w-full shrink-0 lg:w-64">
|
||||||
<!-- Mobile: horizontal scroll -->
|
<!-- Mobile: horizontal scroll -->
|
||||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.route)}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.route)}
|
||||||
<a
|
<a
|
||||||
href={resolve(item.href as any)}
|
href={resolve(item.route, { slug })}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||||
active
|
active
|
||||||
@@ -78,10 +79,10 @@ function isActive(href: string): boolean {
|
|||||||
|
|
||||||
<!-- Desktop: vertical list -->
|
<!-- Desktop: vertical list -->
|
||||||
<div class="hidden space-y-1 lg:block">
|
<div class="hidden space-y-1 lg:block">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.route)}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.route)}
|
||||||
<a
|
<a
|
||||||
href={resolve(item.href as any)}
|
href={resolve(item.route, { slug })}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { X } from "@lucide/svelte";
|
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
variant?: "destructive" | "default";
|
|
||||||
loading?: boolean;
|
|
||||||
onconfirm: () => void;
|
|
||||||
oncancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
open = $bindable(false),
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = "Confirm",
|
|
||||||
cancelLabel = "Cancel",
|
|
||||||
variant = "default",
|
|
||||||
loading = false,
|
|
||||||
onconfirm,
|
|
||||||
oncancel,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
open = false;
|
|
||||||
oncancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
onconfirm();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DialogPrimitive.Root bind:open>
|
|
||||||
<DialogPrimitive.Portal>
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
||||||
/>
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
class={cn(
|
|
||||||
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
|
|
||||||
"rounded-lg border bg-background p-6 shadow-lg",
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
||||||
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
|
|
||||||
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
|
||||||
"duration-200"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Close button -->
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
||||||
onclick={handleCancel}
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
<span class="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
|
|
||||||
{title}
|
|
||||||
</DialogPrimitive.Title>
|
|
||||||
<DialogPrimitive.Description class="text-sm text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</DialogPrimitive.Description>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<Button variant="outline" onclick={handleCancel} disabled={loading}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={variant === "destructive" ? "destructive" : "default"}
|
|
||||||
onclick={handleConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
|
||||||
{/if}
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPrimitive.Portal>
|
|
||||||
</DialogPrimitive.Root>
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
export { default as OrgAvatar } from "./org-avatar.svelte";
|
||||||
export { default as RoleBadge } from "./role-badge.svelte";
|
export { default as RoleBadge } from "./role-badge.svelte";
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getOrgColor,
|
||||||
|
getOrgInitials,
|
||||||
|
type OrgLike,
|
||||||
|
} from "@reviq/frontend-utils";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
org: OrgLike | null | undefined;
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { org, size = "md", class: className }: Props = $props();
|
||||||
|
|
||||||
|
const initials = $derived(getOrgInitials(org));
|
||||||
|
const colorClass = $derived(getOrgColor(org));
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: "h-5 w-5 text-[10px] rounded",
|
||||||
|
sm: "h-6 w-6 text-[10px] rounded",
|
||||||
|
md: "h-8 w-8 text-xs rounded-lg",
|
||||||
|
lg: "h-10 w-10 text-sm rounded-lg",
|
||||||
|
xl: "h-16 w-16 text-xl rounded-xl",
|
||||||
|
} as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if org?.logoUrl}
|
||||||
|
<img
|
||||||
|
src={org.logoUrl}
|
||||||
|
alt="{org.displayName} logo"
|
||||||
|
class={cn(sizeClasses[size], "shrink-0 object-cover", className)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"flex shrink-0 items-center justify-center bg-gradient-to-br font-semibold text-white",
|
||||||
|
sizeClasses[size],
|
||||||
|
colorClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -48,8 +48,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */
|
let {
|
||||||
let {
|
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
@@ -67,7 +66,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href/* eslint-disable-line svelte/no-navigation-without-resolve */}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? "link" : undefined}
|
role={disabled ? "link" : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ let {
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm(): Promise<void> {
|
||||||
await onConfirm();
|
await onConfirm();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -54,8 +54,8 @@ async function handleConfirm() {
|
|||||||
<LoadingButton
|
<LoadingButton
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={loading}
|
{loading}
|
||||||
loadingText={loadingText}
|
{loadingText}
|
||||||
onclick={handleConfirm}
|
onclick={handleConfirm}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
@@ -77,7 +77,7 @@ async function handleConfirm() {
|
|||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
loading={loading}
|
{loading}
|
||||||
onclick={handleConfirm}
|
onclick={handleConfirm}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Date formatting utilities for consistent display across the app
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date for display in tables and lists
|
|
||||||
* Example: "Jan 15, 2024"
|
|
||||||
*/
|
|
||||||
export function formatDate(date: string | Date): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date with time for detailed views
|
|
||||||
* Example: "Jan 15, 2024, 3:30 PM"
|
|
||||||
*/
|
|
||||||
export function formatDateTime(date: string | Date): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
26
apps/publisher-dashboard/src/lib/utils/navigation.ts
Normal file
26
apps/publisher-dashboard/src/lib/utils/navigation.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
|
||||||
|
type SearchParams = Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a query string from an object.
|
||||||
|
*/
|
||||||
|
function buildSearchParams(params: SearchParams): string {
|
||||||
|
const searchParams = new URLSearchParams(params);
|
||||||
|
const str = searchParams.toString();
|
||||||
|
return str ? `?${str}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to /auth/login with a redirect parameter.
|
||||||
|
* This is the primary use case for navigation with search params.
|
||||||
|
*
|
||||||
|
* Note: eslint-disable is required because the lint rule doesn't recognize
|
||||||
|
* resolve() inside a template literal, even though it's used correctly.
|
||||||
|
*/
|
||||||
|
export function gotoLogin(redirect: string): ReturnType<typeof goto> {
|
||||||
|
const url = `${resolve("/auth/login")}${buildSearchParams({ redirect })}`;
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used above
|
||||||
|
return goto(url);
|
||||||
|
}
|
||||||
@@ -17,11 +17,12 @@ const orgsQuery = createQuery(() => ({
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (orgsQuery.error) {
|
if (orgsQuery.error) {
|
||||||
// Not authenticated, redirect to login
|
// Not authenticated, redirect to login
|
||||||
goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any));
|
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
|
||||||
|
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent("/")}`);
|
||||||
} else if (orgsQuery.data) {
|
} else if (orgsQuery.data) {
|
||||||
if (orgsQuery.data.length > 0) {
|
if (orgsQuery.data.length > 0) {
|
||||||
// Redirect to first org's dashboard
|
// Redirect to first org's dashboard
|
||||||
goto(resolve(`/dashboard/${orgsQuery.data[0].slug}` as any), {
|
goto(resolve("/dashboard/[slug]", { slug: orgsQuery.data[0].slug }), {
|
||||||
replaceState: true,
|
replaceState: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { formatDate, formatRelativeDate } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ConfirmDialog } from "$lib/components/account";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -59,33 +60,6 @@ let isCreating = $state(false);
|
|||||||
let newlyCreatedToken = $state<string | null>(null);
|
let newlyCreatedToken = $state<string | null>(null);
|
||||||
let tokenCopied = $state(false);
|
let tokenCopied = $state(false);
|
||||||
|
|
||||||
function formatDate(date: Date | string): string {
|
|
||||||
return new Date(date).toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(date: Date | string): string {
|
|
||||||
const diffDays = Math.floor(
|
|
||||||
(Date.now() - new Date(date).getTime()) / 86400000,
|
|
||||||
);
|
|
||||||
if (diffDays === 0) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
if (diffDays === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (diffDays < 7) {
|
|
||||||
return `${diffDays} days ago`;
|
|
||||||
}
|
|
||||||
if (diffDays < 30) {
|
|
||||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
|
||||||
}
|
|
||||||
return formatDate(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateToken(e: Event) {
|
async function handleCreateToken(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTokenName.trim() || isCreating) {
|
if (!newTokenName.trim() || isCreating) {
|
||||||
@@ -261,9 +235,9 @@ async function handleDelete() {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">{token.name}</p>
|
<p class="text-sm font-medium">{token.name}</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Created {formatRelativeTime(token.createdAt)}
|
Created {formatRelativeDate(token.createdAt)}
|
||||||
{#if token.lastUsedAt}
|
{#if token.lastUsedAt}
|
||||||
· Last used {formatRelativeTime(token.lastUsedAt)}
|
· Last used {formatRelativeDate(token.lastUsedAt)}
|
||||||
{:else}
|
{:else}
|
||||||
· Never used
|
· Never used
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Tablet,
|
Tablet,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { formatRelativeTime } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { UAParser } from "ua-parser-js";
|
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ConfirmDialog } from "$lib/components/account";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -54,31 +54,6 @@ function formatLocation(device: {
|
|||||||
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(date: Date | string): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
if (diffDays === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (diffDays < 7) {
|
|
||||||
return `${diffDays} days ago`;
|
|
||||||
}
|
|
||||||
if (diffDays < 30) {
|
|
||||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
|
||||||
}
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeviceIcon(name: string) {
|
function getDeviceIcon(name: string) {
|
||||||
const nameLower = name.toLowerCase();
|
const nameLower = name.toLowerCase();
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Building2,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { formatLongDate, formatRole } from "@reviq/common";
|
||||||
import {
|
import {
|
||||||
createMutation,
|
createMutation,
|
||||||
createQuery,
|
createQuery,
|
||||||
@@ -20,6 +20,7 @@ import { goto } from "$app/navigation";
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
import { OrgAvatar } from "$lib/components/org";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -48,12 +49,10 @@ const acceptMutation = createMutation(() => ({
|
|||||||
mutationFn: () => api.me.invites.accept({ inviteId }),
|
mutationFn: () => api.me.invites.accept({ inviteId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("You've joined the organization!");
|
toast.success("You've joined the organization!");
|
||||||
// Invalidate queries
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
|
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||||
// Redirect to the org dashboard
|
|
||||||
if (inviteQuery.data) {
|
if (inviteQuery.data) {
|
||||||
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
|
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}`));
|
||||||
} else {
|
} else {
|
||||||
goto(resolve("/dashboard"));
|
goto(resolve("/dashboard"));
|
||||||
}
|
}
|
||||||
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
|
|||||||
mutationFn: () => api.me.invites.decline({ inviteId }),
|
mutationFn: () => api.me.invites.decline({ inviteId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Invitation declined");
|
toast.success("Invitation declined");
|
||||||
// Invalidate queries
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
|
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
|
||||||
goto(resolve("/dashboard"));
|
goto(resolve("/dashboard"));
|
||||||
},
|
},
|
||||||
@@ -81,24 +79,6 @@ const declineMutation = createMutation(() => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
|
||||||
* Format role for display
|
|
||||||
*/
|
|
||||||
function formatRole(role: string): string {
|
|
||||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date for display
|
|
||||||
*/
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if invite is expiring soon (within 3 days)
|
* Check if invite is expiring soon (within 3 days)
|
||||||
*/
|
*/
|
||||||
@@ -141,17 +121,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
{#if invite.org.logoUrl}
|
<OrgAvatar org={invite.org} size="xl" />
|
||||||
<img
|
|
||||||
src={invite.org.logoUrl}
|
|
||||||
alt="{invite.org.displayName} logo"
|
|
||||||
class="h-16 w-16 rounded-xl object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
|
|
||||||
<Building2 class="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
|
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
|
||||||
<CardDescription class="mt-1">
|
<CardDescription class="mt-1">
|
||||||
@@ -187,7 +157,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">Sent on</p>
|
<p class="text-sm font-medium">Sent on</p>
|
||||||
<p class="text-sm text-muted-foreground">{formatDate(new Date(invite.createdAt))}</p>
|
<p class="text-sm text-muted-foreground">{formatLongDate(invite.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -197,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">Expires on</p>
|
<p class="text-sm font-medium">Expires on</p>
|
||||||
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
|
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
|
||||||
{formatDate(new Date(invite.expiresAt))}
|
{formatLongDate(invite.expiresAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
|
|||||||
<Alert>
|
<Alert>
|
||||||
<Clock class="h-4 w-4" />
|
<Clock class="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization.
|
This invitation will expire soon. Accept it before {formatLongDate(invite.expiresAt)} to join the organization.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Tablet,
|
Tablet,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { formatDate, formatRelativeTime } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { UAParser } from "ua-parser-js";
|
import { UAParser } from "ua-parser-js";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ConfirmDialog } from "$lib/components/account";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -56,36 +57,6 @@ function formatLocation(session: {
|
|||||||
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date | string): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(date: Date | string): string {
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
if (diffDays === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (diffDays < 7) {
|
|
||||||
return `${diffDays} days ago`;
|
|
||||||
}
|
|
||||||
if (diffDays < 30) {
|
|
||||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
|
||||||
}
|
|
||||||
return formatDate(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUserAgent(userAgent: string): {
|
function parseUserAgent(userAgent: string): {
|
||||||
browser: string;
|
browser: string;
|
||||||
os: string;
|
os: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "svelte-sonner";
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -26,11 +27,7 @@ $effect(() => {
|
|||||||
goto(resolve("/dashboard"));
|
goto(resolve("/dashboard"));
|
||||||
}
|
}
|
||||||
if (userQuery.error) {
|
if (userQuery.error) {
|
||||||
goto(
|
gotoLogin(window.location.pathname);
|
||||||
resolve(
|
|
||||||
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
|
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
|
||||||
|
import { formatDate } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
import { AdminLayout } from "$lib/components/layout";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "$lib/components/ui/table/index.js";
|
} from "$lib/components/ui/table/index.js";
|
||||||
import { formatDate } from "$lib/utils/format-date.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Organizations list page
|
* Admin Organizations list page
|
||||||
@@ -238,8 +238,7 @@ async function executeConfirmAction() {
|
|||||||
title={confirmDialogTitle}
|
title={confirmDialogTitle}
|
||||||
description={confirmDialogDescription}
|
description={confirmDialogDescription}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="Delete"
|
confirmText="Delete"
|
||||||
loading={isConfirmLoading}
|
loading={isConfirmLoading}
|
||||||
onconfirm={executeConfirmAction}
|
onConfirm={executeConfirmAction}
|
||||||
oncancel={() => confirmDialogOpen = false}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Building,
|
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { formatDate } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -16,7 +16,8 @@ import { resolve } from "$app/paths";
|
|||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { AdminLayout } from "$lib/components/layout";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog } from "$lib/components/org";
|
import { OrgAvatar } from "$lib/components/org";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +38,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "$lib/components/ui/table";
|
} from "$lib/components/ui/table";
|
||||||
import { formatDate } from "$lib/utils/format-date.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin organization details page
|
* Admin organization details page
|
||||||
@@ -83,7 +83,7 @@ let confirmDialogOpen = $state(false);
|
|||||||
let confirmDialogTitle = $state("");
|
let confirmDialogTitle = $state("");
|
||||||
let confirmDialogDescription = $state("");
|
let confirmDialogDescription = $state("");
|
||||||
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||||
let confirmDialogConfirmLabel = $state("Confirm");
|
let confirmDialogConfirmText = $state("Confirm");
|
||||||
let isConfirmLoading = $state(false);
|
let isConfirmLoading = $state(false);
|
||||||
let pendingAction: (() => Promise<void>) | null = $state(null);
|
let pendingAction: (() => Promise<void>) | null = $state(null);
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ function handleRemoveSite(domain: string) {
|
|||||||
confirmDialogTitle = "Remove Site";
|
confirmDialogTitle = "Remove Site";
|
||||||
confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`;
|
confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`;
|
||||||
confirmDialogVariant = "destructive";
|
confirmDialogVariant = "destructive";
|
||||||
confirmDialogConfirmLabel = "Remove Site";
|
confirmDialogConfirmText = "Remove Site";
|
||||||
pendingAction = async () => {
|
pendingAction = async () => {
|
||||||
try {
|
try {
|
||||||
await api.admin.orgs.removeSite({ slug: slug ?? "", domain });
|
await api.admin.orgs.removeSite({ slug: slug ?? "", domain });
|
||||||
@@ -181,7 +181,7 @@ function handleDelete() {
|
|||||||
confirmDialogTitle = "Delete Organization";
|
confirmDialogTitle = "Delete Organization";
|
||||||
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
||||||
confirmDialogVariant = "destructive";
|
confirmDialogVariant = "destructive";
|
||||||
confirmDialogConfirmLabel = "Delete Organization";
|
confirmDialogConfirmText = "Delete Organization";
|
||||||
pendingAction = async () => {
|
pendingAction = async () => {
|
||||||
try {
|
try {
|
||||||
await api.admin.orgs.delete({ slug: slug ?? "" });
|
await api.admin.orgs.delete({ slug: slug ?? "" });
|
||||||
@@ -259,19 +259,7 @@ async function executeConfirmAction() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
{#if org.logoUrl}
|
<OrgAvatar {org} size="xl" />
|
||||||
<img
|
|
||||||
src={org.logoUrl}
|
|
||||||
alt="{org.displayName} logo"
|
|
||||||
class="h-16 w-16 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-lg bg-muted"
|
|
||||||
>
|
|
||||||
<Building class="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<CardTitle class="text-2xl">{org.displayName}</CardTitle>
|
<CardTitle class="text-2xl">{org.displayName}</CardTitle>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
@@ -465,11 +453,7 @@ async function executeConfirmAction() {
|
|||||||
title={confirmDialogTitle}
|
title={confirmDialogTitle}
|
||||||
description={confirmDialogDescription}
|
description={confirmDialogDescription}
|
||||||
variant={confirmDialogVariant}
|
variant={confirmDialogVariant}
|
||||||
confirmLabel={confirmDialogConfirmLabel}
|
confirmText={confirmDialogConfirmText}
|
||||||
loading={isConfirmLoading}
|
loading={isConfirmLoading}
|
||||||
onconfirm={executeConfirmAction}
|
onConfirm={executeConfirmAction}
|
||||||
oncancel={() => {
|
|
||||||
confirmDialogOpen = false;
|
|
||||||
pendingAction = null;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ let { children }: Props = $props();
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<p class="text-center text-xs text-muted-foreground">
|
<p class="text-center text-xs text-muted-foreground">
|
||||||
By continuing, you agree to our
|
By continuing, you agree to our
|
||||||
<a href={resolve("/terms" as any)} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
|
<a href={resolve("/terms")} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
|
||||||
and
|
and
|
||||||
<a href={resolve("/privacy" as any)} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
|
<a href={resolve("/privacy")} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ const statusQuery = createQuery(() => ({
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (statusQuery.data?.status === "completed") {
|
if (statusQuery.data?.status === "completed") {
|
||||||
clearLoginFlowState();
|
clearLoginFlowState();
|
||||||
goto(resolve((statusQuery.data.redirectTo || "/") as any));
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto(statusQuery.data.redirectTo || "/");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
import { formatRelativeDate, formatRole } from "@reviq/common";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { DashboardLayout } from "$lib/components/layout";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
|
import { OrgAvatar } from "$lib/components/org";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "$lib/components/ui/card";
|
} from "$lib/components/ui/card";
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard page - lists all organizations the user is a member of
|
* Dashboard page - lists all organizations the user is a member of
|
||||||
@@ -40,48 +42,9 @@ const invitesQuery = createQuery(() => ({
|
|||||||
// Redirect to login on auth error
|
// Redirect to login on auth error
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (orgsQuery.error) {
|
if (orgsQuery.error) {
|
||||||
goto(
|
gotoLogin(window.location.pathname);
|
||||||
resolve(
|
|
||||||
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date to relative or absolute string
|
|
||||||
*/
|
|
||||||
function formatDate(date: Date): string {
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (days === 0) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
if (days === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (days < 7) {
|
|
||||||
return `${days} days ago`;
|
|
||||||
}
|
|
||||||
if (days < 30) {
|
|
||||||
return `${Math.floor(days / 7)} weeks ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format role for display
|
|
||||||
*/
|
|
||||||
function formatRole(role: string): string {
|
|
||||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -101,24 +64,14 @@ function formatRole(role: string): string {
|
|||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each invitesQuery.data as invite (invite.id)}
|
{#each invitesQuery.data as invite (invite.id)}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/account/org-invites/${invite.id}`)}
|
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
|
||||||
class="group block"
|
class="group block"
|
||||||
>
|
>
|
||||||
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
|
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if invite.org.logoUrl}
|
<OrgAvatar org={invite.org} size="lg" />
|
||||||
<img
|
|
||||||
src={invite.org.logoUrl}
|
|
||||||
alt="{invite.org.displayName} logo"
|
|
||||||
class="h-10 w-10 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
|
||||||
<Building2 class="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="truncate text-base">
|
<CardTitle class="truncate text-base">
|
||||||
{invite.org.displayName}
|
{invite.org.displayName}
|
||||||
@@ -133,7 +86,7 @@ function formatRole(role: string): string {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="pt-0">
|
<CardContent class="pt-0">
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
From {invite.invitedBy} · {formatDate(new Date(invite.createdAt))}
|
From {invite.invitedBy} · {formatRelativeDate(invite.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -186,24 +139,13 @@ function formatRole(role: string): string {
|
|||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each orgsQuery.data as org (org.id)}
|
{#each orgsQuery.data as org (org.id)}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/dashboard/${org.slug}`)}
|
href={resolve("/dashboard/[slug]", { slug: org.slug })}
|
||||||
class="group block transition-transform hover:scale-[1.02]"
|
class="group block transition-transform hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
<Card class="h-full transition-colors group-hover:border-primary/50">
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<!-- Logo or placeholder -->
|
<OrgAvatar {org} size="lg" />
|
||||||
{#if org.logoUrl}
|
|
||||||
<img
|
|
||||||
src={org.logoUrl}
|
|
||||||
alt="{org.displayName} logo"
|
|
||||||
class="h-10 w-10 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
|
|
||||||
<Building2 class="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="truncate text-base">
|
<CardTitle class="truncate text-base">
|
||||||
{org.displayName}
|
{org.displayName}
|
||||||
@@ -216,7 +158,7 @@ function formatRole(role: string): string {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="pt-0">
|
<CardContent class="pt-0">
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Created {formatDate(new Date(org.createdAt))}
|
Created {formatRelativeDate(org.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -13,7 +12,7 @@ import { getContext } from "svelte";
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { DashboardLayout } from "$lib/components/layout";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
import { RoleBadge } from "$lib/components/org";
|
import { OrgAvatar, RoleBadge } from "$lib/components/org";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -98,17 +97,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
|
|||||||
<!-- Header with org info -->
|
<!-- Header with org info -->
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
{#if orgQuery.data?.logoUrl}
|
<OrgAvatar org={orgQuery.data} size="xl" />
|
||||||
<img
|
|
||||||
src={orgQuery.data.logoUrl}
|
|
||||||
alt="{orgName} logo"
|
|
||||||
class="h-16 w-16 rounded-xl object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
|
|
||||||
<Building2 class="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold">{orgName}</h1>
|
<h1 class="text-2xl font-semibold">{orgName}</h1>
|
||||||
<p class="text-sm text-muted-foreground">{slug}</p>
|
<p class="text-sm text-muted-foreground">{slug}</p>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { getContext } from "svelte";
|
|||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { DashboardLayout } from "$lib/components/layout";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
import { RoleBadge } from "$lib/components/org";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
|
|||||||
description={confirmDialogDescription}
|
description={confirmDialogDescription}
|
||||||
variant={confirmDialogVariant}
|
variant={confirmDialogVariant}
|
||||||
loading={isConfirmLoading}
|
loading={isConfirmLoading}
|
||||||
onconfirm={executeConfirmAction}
|
onConfirm={executeConfirmAction}
|
||||||
oncancel={() => confirmDialogOpen = false}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { goto } from "$app/navigation";
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { SettingsLayout } from "$lib/components/layout";
|
import { SettingsLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog } from "$lib/components/org";
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -82,7 +82,7 @@ let confirmDialogOpen = $state(false);
|
|||||||
let confirmDialogTitle = $state("");
|
let confirmDialogTitle = $state("");
|
||||||
let confirmDialogDescription = $state("");
|
let confirmDialogDescription = $state("");
|
||||||
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||||
let confirmDialogConfirmLabel = $state("Confirm");
|
let confirmDialogConfirmText = $state("Confirm");
|
||||||
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
||||||
let isConfirmLoading = $state(false);
|
let isConfirmLoading = $state(false);
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ function handleLeave() {
|
|||||||
confirmDialogDescription =
|
confirmDialogDescription =
|
||||||
"Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin.";
|
"Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin.";
|
||||||
confirmDialogVariant = "destructive";
|
confirmDialogVariant = "destructive";
|
||||||
confirmDialogConfirmLabel = "Leave Organization";
|
confirmDialogConfirmText = "Leave Organization";
|
||||||
confirmAction = async () => {
|
confirmAction = async () => {
|
||||||
try {
|
try {
|
||||||
await api.orgs.leave({ slug });
|
await api.orgs.leave({ slug });
|
||||||
@@ -142,7 +142,7 @@ function handleDelete() {
|
|||||||
confirmDialogTitle = "Delete Organization";
|
confirmDialogTitle = "Delete Organization";
|
||||||
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
||||||
confirmDialogVariant = "destructive";
|
confirmDialogVariant = "destructive";
|
||||||
confirmDialogConfirmLabel = "Delete Organization";
|
confirmDialogConfirmText = "Delete Organization";
|
||||||
confirmAction = async () => {
|
confirmAction = async () => {
|
||||||
try {
|
try {
|
||||||
await api.orgs.delete({ slug });
|
await api.orgs.delete({ slug });
|
||||||
@@ -306,8 +306,7 @@ async function executeConfirmAction() {
|
|||||||
title={confirmDialogTitle}
|
title={confirmDialogTitle}
|
||||||
description={confirmDialogDescription}
|
description={confirmDialogDescription}
|
||||||
variant={confirmDialogVariant}
|
variant={confirmDialogVariant}
|
||||||
confirmLabel={confirmDialogConfirmLabel}
|
confirmText={confirmDialogConfirmText}
|
||||||
loading={isConfirmLoading}
|
loading={isConfirmLoading}
|
||||||
onconfirm={executeConfirmAction}
|
onConfirm={executeConfirmAction}
|
||||||
oncancel={() => confirmDialogOpen = false}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { getContext } from "svelte";
|
|||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { SettingsLayout } from "$lib/components/layout";
|
import { SettingsLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
import { RoleBadge } from "$lib/components/org";
|
||||||
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -464,6 +465,5 @@ const availableInviteRoles = $derived.by(() => {
|
|||||||
description={confirmDialogDescription}
|
description={confirmDialogDescription}
|
||||||
variant={confirmDialogVariant}
|
variant={confirmDialogVariant}
|
||||||
loading={isConfirmLoading}
|
loading={isConfirmLoading}
|
||||||
onconfirm={executeConfirmAction}
|
onConfirm={executeConfirmAction}
|
||||||
oncancel={() => confirmDialogOpen = false}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ async function acceptInvite(): Promise<void> {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Redirect to login with return URL
|
// Redirect to login with return URL
|
||||||
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
|
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
|
||||||
goto(
|
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
|
||||||
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any),
|
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent(returnUrl)}`);
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
apps/publisher-dashboard/src/routes/privacy/+page.svelte
Normal file
52
apps/publisher-dashboard/src/routes/privacy/+page.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Privacy Policy | Publisher Dashboard</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||||
|
<article class="prose prose-neutral dark:prose-invert">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="lead">Last updated: January 2025</p>
|
||||||
|
|
||||||
|
<h2>1. Information We Collect</h2>
|
||||||
|
<p>
|
||||||
|
We collect information you provide directly to us, such as your email address,
|
||||||
|
name, and organization details when you create an account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. How We Use Your Information</h2>
|
||||||
|
<p>
|
||||||
|
We use the information we collect to provide, maintain, and improve our services,
|
||||||
|
and to communicate with you about your account and updates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Data Security</h2>
|
||||||
|
<p>
|
||||||
|
We implement appropriate security measures to protect your personal information
|
||||||
|
against unauthorized access, alteration, or destruction.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Data Retention</h2>
|
||||||
|
<p>
|
||||||
|
We retain your information for as long as your account is active or as needed
|
||||||
|
to provide you services and comply with legal obligations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Contact</h2>
|
||||||
|
<p>
|
||||||
|
If you have any questions about this Privacy Policy, please contact us.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="mt-12">
|
||||||
|
<a
|
||||||
|
href={resolve("/auth/login")}
|
||||||
|
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user