Compare commits
26 Commits
fix-export
...
f9f1dc7403
| Author | SHA1 | Date | |
|---|---|---|---|
|
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,9 +9,7 @@
|
|||||||
"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",
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
@@ -21,7 +19,7 @@
|
|||||||
"@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/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:"
|
||||||
}
|
}
|
||||||
|
|||||||
1946
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
1946
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,14 +41,20 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
describeE2E,
|
||||||
|
getSharedDb,
|
||||||
|
initTestDb,
|
||||||
|
TEST_RP,
|
||||||
|
uniqueTestId,
|
||||||
|
withTestTransaction,
|
||||||
|
} from "@reviq/test-helpers";
|
||||||
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
||||||
import { router } from "../../router.js";
|
import { router } from "../../router.js";
|
||||||
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
||||||
import { hashToken } from "../../utils/crypto.js";
|
import { hashToken } from "../../utils/crypto.js";
|
||||||
import { hashPassword } from "../../utils/password.js";
|
import { hashPassword } from "../../utils/password.js";
|
||||||
import { TEST_RP } from "../helpers/test-constants.js";
|
|
||||||
import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js";
|
|
||||||
import { withTestTransaction } from "../helpers/test-transaction.js";
|
|
||||||
|
|
||||||
/** Session expiry duration: 24 hours in milliseconds */
|
/** Session expiry duration: 24 hours in milliseconds */
|
||||||
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -141,7 +147,7 @@ async function createSession(
|
|||||||
userId: number,
|
userId: number,
|
||||||
options?: { deviceId?: bigint },
|
options?: { deviceId?: bigint },
|
||||||
): Promise<{ token: string; sessionId: number }> {
|
): Promise<{ token: string; sessionId: number }> {
|
||||||
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-session-${uniqueTestId()}`;
|
||||||
const tokenHashValue = await hashToken(token);
|
const tokenHashValue = await hashToken(token);
|
||||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||||
|
|
||||||
@@ -149,7 +155,7 @@ async function createSession(
|
|||||||
.insertInto("sessions")
|
.insertInto("sessions")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
device_id: options?.deviceId ? String(options.deviceId) : null,
|
device_id: options?.deviceId ? options.deviceId.toString() : null,
|
||||||
token_hash: tokenHashValue,
|
token_hash: tokenHashValue,
|
||||||
trusted_mode: false,
|
trusted_mode: false,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
@@ -173,7 +179,7 @@ async function createLoginRequest(
|
|||||||
expiresAt?: Date;
|
expiresAt?: Date;
|
||||||
},
|
},
|
||||||
): Promise<{ token: string; id: number }> {
|
): Promise<{ token: string; id: number }> {
|
||||||
const token = `login_test-${String(Date.now())}${String(Math.random())}`;
|
const token = `login_test-${uniqueTestId()}`;
|
||||||
const expiresAt =
|
const expiresAt =
|
||||||
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
|
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
|
||||||
|
|
||||||
@@ -223,7 +229,7 @@ async function createEmailVerification(
|
|||||||
userId: number,
|
userId: number,
|
||||||
options?: { expiresAt?: Date },
|
options?: { expiresAt?: Date },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const token = `verify-${String(Date.now())}${String(Math.random())}`;
|
const token = `verify-${uniqueTestId()}`;
|
||||||
const expiresAt =
|
const expiresAt =
|
||||||
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
|
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -247,7 +253,7 @@ async function createPasswordReset(
|
|||||||
userId: number,
|
userId: number,
|
||||||
options?: { expiresAt?: Date; usedAt?: Date | null },
|
options?: { expiresAt?: Date; usedAt?: Date | null },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const token = `reset-${String(Date.now())}${String(Math.random())}`;
|
const token = `reset-${uniqueTestId()}`;
|
||||||
const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000);
|
const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -263,6 +269,7 @@ async function createPasswordReset(
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describeE2E("auth", () => {
|
||||||
// Test setup
|
// Test setup
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initTestDb();
|
await initTestDb();
|
||||||
@@ -391,7 +398,9 @@ describe("auth.signup", () => {
|
|||||||
// nested transactions.
|
// nested transactions.
|
||||||
test("creates user with passkey", async () => {
|
test("creates user with passkey", async () => {
|
||||||
const db = getSharedDb();
|
const db = getSharedDb();
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
const ctx = createAPIContext(db);
|
const ctx = createAPIContext(db);
|
||||||
|
|
||||||
// Step 1: Create registration options
|
// Step 1: Create registration options
|
||||||
@@ -449,7 +458,7 @@ describe("auth.signup", () => {
|
|||||||
const challenges = await db
|
const challenges = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
expect(challenges.length).toBe(0);
|
expect(challenges.length).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -475,7 +484,7 @@ describe("auth.signup", () => {
|
|||||||
await db
|
await db
|
||||||
.updateTable("webauthn_challenges")
|
.updateTable("webauthn_challenges")
|
||||||
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
|
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Step 4: Try to signup with expired challenge
|
// Step 4: Try to signup with expired challenge
|
||||||
@@ -532,7 +541,7 @@ describe("auth.signup", () => {
|
|||||||
const challenges = await db
|
const challenges = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
expect(challenges.length).toBe(0);
|
expect(challenges.length).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -1064,7 +1073,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
|
|||||||
const loginRequest = await db
|
const loginRequest = await db
|
||||||
.selectFrom("login_requests")
|
.selectFrom("login_requests")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("id", "=", String(loginRequestId))
|
.where("id", "=", loginRequestId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
expect(loginRequest).toBeUndefined();
|
expect(loginRequest).toBeUndefined();
|
||||||
|
|
||||||
@@ -1111,7 +1120,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
|
|||||||
|
|
||||||
test("returns pending for fake/non-existent token", async () => {
|
test("returns pending for fake/non-existent token", async () => {
|
||||||
await withTestTransaction(getSharedDb(), async (db) => {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" });
|
const ctx = createAPIContext(db, {
|
||||||
|
loginRequestToken: "fake-token-xyz",
|
||||||
|
});
|
||||||
const result = await call(
|
const result = await call(
|
||||||
router.auth.loginIfRequestIsCompleted,
|
router.auth.loginIfRequestIsCompleted,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1142,7 +1153,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create login request without device fingerprint
|
// Create login request without device fingerprint
|
||||||
const token = `login_test-${String(Date.now())}`;
|
const token = `login_test-${uniqueTestId()}`;
|
||||||
await db
|
await db
|
||||||
.insertInto("login_requests")
|
.insertInto("login_requests")
|
||||||
.values({
|
.values({
|
||||||
@@ -1344,7 +1355,9 @@ describe("auth.resendVerificationEmail", () => {
|
|||||||
const ctx = createAPIContext(db); // No session
|
const ctx = createAPIContext(db); // No session
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
call(router.auth.resendVerificationEmail, undefined, { context: ctx }),
|
call(router.auth.resendVerificationEmail, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1503,7 +1516,6 @@ describe("auth.resetPassword", () => {
|
|||||||
|
|
||||||
// Create some sessions
|
// Create some sessions
|
||||||
await createSession(db, user.id);
|
await createSession(db, user.id);
|
||||||
await createSession(db, user.id);
|
|
||||||
|
|
||||||
const token = await createPasswordReset(db, user.id);
|
const token = await createPasswordReset(db, user.id);
|
||||||
|
|
||||||
@@ -1633,7 +1645,7 @@ describe("auth.logout", () => {
|
|||||||
const session = await db
|
const session = await db
|
||||||
.selectFrom("sessions")
|
.selectFrom("sessions")
|
||||||
.select(["revoked_at"])
|
.select(["revoked_at"])
|
||||||
.where("id", "=", String(sessionId))
|
.where("id", "=", sessionId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(session?.revoked_at).not.toBeNull();
|
expect(session?.revoked_at).not.toBeNull();
|
||||||
@@ -1875,7 +1887,10 @@ describe("End-to-end login scenarios", () => {
|
|||||||
const ctx2 = createAPIContext(db);
|
const ctx2 = createAPIContext(db);
|
||||||
await call(
|
await call(
|
||||||
router.auth.resetPassword,
|
router.auth.resetPassword,
|
||||||
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" },
|
{
|
||||||
|
token: assertDefined(reset).token,
|
||||||
|
newPassword: "NewSecureP@ss123!",
|
||||||
|
},
|
||||||
{ context: ctx2 },
|
{ context: ctx2 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1967,7 +1982,7 @@ describe("End-to-end login scenarios", () => {
|
|||||||
// Clean up registration session
|
// Clean up registration session
|
||||||
await db
|
await db
|
||||||
.deleteFrom("sessions")
|
.deleteFrom("sessions")
|
||||||
.where("id", "=", String(regSessionId))
|
.where("id", "=", regSessionId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Step 1: Create login request
|
// Step 1: Create login request
|
||||||
@@ -1991,7 +2006,8 @@ describe("End-to-end login scenarios", () => {
|
|||||||
loginRequestToken: assertDefined(loginToken),
|
loginRequestToken: assertDefined(loginToken),
|
||||||
deviceFingerprint: fingerprint,
|
deviceFingerprint: fingerprint,
|
||||||
});
|
});
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: ctx2 },
|
{ context: ctx2 },
|
||||||
@@ -2105,3 +2121,62 @@ describe("End-to-end login scenarios", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// loginRequestMiddleware tests (base.ts)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("loginRequestMiddleware", () => {
|
||||||
|
test("rejects request with no login request cookie", async () => {
|
||||||
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
// No login request token in context
|
||||||
|
const ctx = createAPIContext(db);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("No login request found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects request with invalid login request token", async () => {
|
||||||
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
// Invalid token that doesn't exist in DB
|
||||||
|
const ctx = createAPIContext(db, {
|
||||||
|
loginRequestToken: "invalid-login-request-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Login request expired or not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects request with expired login request", async () => {
|
||||||
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
|
email: "expiredloginreq@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an expired login request
|
||||||
|
const { token: loginToken } = await createLoginRequest(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Login request expired or not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}); // Close outer describeE2E
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1841
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
1841
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,19 +12,22 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
describeE2E,
|
||||||
|
destroySharedDb,
|
||||||
|
getSharedDb,
|
||||||
|
initTestDb,
|
||||||
|
KNOWN_AAGUIDS,
|
||||||
|
TEST_RP,
|
||||||
|
uniqueTestId,
|
||||||
|
withTestTransaction,
|
||||||
|
} from "@reviq/test-helpers";
|
||||||
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
||||||
import { router } from "../../router.js";
|
import { router } from "../../router.js";
|
||||||
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
||||||
import { hashToken } from "../../utils/crypto.js";
|
import { hashToken } from "../../utils/crypto.js";
|
||||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
|
|
||||||
import {
|
|
||||||
createTestUser,
|
|
||||||
destroySharedDb,
|
|
||||||
getSharedDb,
|
|
||||||
initTestDb,
|
|
||||||
} from "../helpers/test-db.js";
|
|
||||||
import { withTestTransaction } from "../helpers/test-transaction.js";
|
|
||||||
|
|
||||||
/** Session expiry duration: 24 hours in milliseconds */
|
/** Session expiry duration: 24 hours in milliseconds */
|
||||||
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -58,7 +61,7 @@ async function createSession(
|
|||||||
db: Kysely<Database>,
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-session-${uniqueTestId()}`;
|
||||||
const tokenHashValue = await hashToken(token);
|
const tokenHashValue = await hashToken(token);
|
||||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||||
|
|
||||||
@@ -85,7 +88,7 @@ async function createLoginRequest(
|
|||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<{ id: number; token: string }> {
|
): Promise<{ id: number; token: string }> {
|
||||||
const token = `test-login-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-login-${uniqueTestId()}`;
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
@@ -198,6 +201,7 @@ async function authenticate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describeE2E("webauthn", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initTestDb();
|
await initTestDb();
|
||||||
});
|
});
|
||||||
@@ -233,7 +237,7 @@ describe("registration flow", () => {
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeDefined();
|
expect(challengeRow).toBeDefined();
|
||||||
@@ -379,7 +383,7 @@ describe("registration flow", () => {
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeUndefined();
|
expect(challengeRow).toBeUndefined();
|
||||||
@@ -483,7 +487,8 @@ describe("authentication flow", () => {
|
|||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
const loginCtx = createLoginRequestContext(db, loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: loginCtx },
|
{ context: loginCtx },
|
||||||
@@ -525,7 +530,8 @@ describe("authentication flow", () => {
|
|||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
const loginCtx = createLoginRequestContext(db, loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: loginCtx },
|
{ context: loginCtx },
|
||||||
@@ -563,7 +569,8 @@ describe("authentication flow", () => {
|
|||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
const loginCtx = createLoginRequestContext(db, loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: loginCtx },
|
{ context: loginCtx },
|
||||||
@@ -579,7 +586,7 @@ describe("authentication flow", () => {
|
|||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(authChallengeId))
|
.where("id", "=", authChallengeId.toString())
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeUndefined();
|
expect(challengeRow).toBeUndefined();
|
||||||
@@ -864,7 +871,9 @@ describe("passkey management", () => {
|
|||||||
expect(passkeys).toHaveLength(2);
|
expect(passkeys).toHaveLength(2);
|
||||||
|
|
||||||
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
|
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
|
||||||
const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain");
|
const icloudPasskey = passkeys.find(
|
||||||
|
(p) => p.name === "iCloud Keychain",
|
||||||
|
);
|
||||||
if (!icloudPasskey) {
|
if (!icloudPasskey) {
|
||||||
throw new Error("Expected iCloud Keychain passkey to exist");
|
throw new Error("Expected iCloud Keychain passkey to exist");
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1012,9 @@ describe("passkey management", () => {
|
|||||||
email: "delete-with-password@test.com",
|
email: "delete-with-password@test.com",
|
||||||
passwordHash: "fake-password-hash",
|
passwordHash: "fake-password-hash",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(db, user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
@@ -1019,7 +1030,9 @@ describe("passkey management", () => {
|
|||||||
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
|
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
|
||||||
|
|
||||||
// Verify passkey is deleted
|
// Verify passkey is deleted
|
||||||
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx });
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
expect(passkeys).toHaveLength(0);
|
expect(passkeys).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1052,7 +1065,9 @@ describe("passkey management", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify only one passkey remains
|
// Verify only one passkey remains
|
||||||
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx });
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
||||||
@@ -1066,7 +1081,9 @@ describe("passkey management", () => {
|
|||||||
email: "delete-last@test.com",
|
email: "delete-last@test.com",
|
||||||
// No password set
|
// No password set
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(db, user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
@@ -1139,9 +1156,13 @@ describe("passkey management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User2's passkey should still exist
|
// User2's passkey should still exist
|
||||||
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, {
|
const user2PasskeysAfter = await call(
|
||||||
|
router.me.passkeys.list,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
expect(user2PasskeysAfter).toHaveLength(1);
|
expect(user2PasskeysAfter).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1194,3 +1215,4 @@ describe("passkey management", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}); // Close outer describe.skipIf
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -45,7 +45,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();
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ 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)
|
||||||
|
try {
|
||||||
const user = await db
|
const user = await db
|
||||||
.insertInto("users")
|
.insertInto("users")
|
||||||
.values({
|
.values({
|
||||||
@@ -63,6 +64,16 @@ export async function signupWithPassword(
|
|||||||
.executeTakeFirstOrThrow();
|
.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 +108,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 +134,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 +149,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,7 +157,8 @@ export async function signupWithPasskey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user and passkey in a transaction
|
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||||
|
try {
|
||||||
const result = await db.transaction().execute(async (trx) => {
|
const result = await db.transaction().execute(async (trx) => {
|
||||||
// Create user
|
// Create user
|
||||||
const user = await trx
|
const user = await trx
|
||||||
@@ -188,13 +200,23 @@ export async function signupWithPasskey(
|
|||||||
// 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,7 +263,7 @@ 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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LocalContext } from "../../context.js";
|
|||||||
import { ORPCError } from "@orpc/client";
|
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;
|
||||||
@@ -21,12 +22,10 @@ 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) {
|
if (error instanceof ORPCError) {
|
||||||
console.error(`Error [${String(error.code)}]:`, error.message);
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- ORPCError.code is typed as any
|
||||||
|
console.error(`Error [${error.code}]:`, error.message);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error("Error:", formatError(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) {
|
if (diffHours <= 0) {
|
||||||
return "expired";
|
return "expired";
|
||||||
}
|
}
|
||||||
return `in ${String(diffHours)} hours`;
|
return `in ${diffHours.toLocaleString()} 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,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,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";
|
||||||
|
|
||||||
type OrgRole = "owner" | "admin" | "member";
|
type OrgRole = "owner" | "admin" | "member";
|
||||||
|
|
||||||
@@ -45,10 +46,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/cli/src/utils/format-error.ts
Normal file
14
apps/cli/src/utils/format-error.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Format an unknown error value into a string message.
|
||||||
|
* Handles Error instances, strings, and other types safely.
|
||||||
|
*/
|
||||||
|
export function formatError(error: unknown): string {
|
||||||
|
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,5 +1,6 @@
|
|||||||
<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";
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +33,13 @@ 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"
|
||||||
>
|
>
|
||||||
|
{#if currentOrg}
|
||||||
|
<OrgAvatar org={currentOrg} size="md" />
|
||||||
|
{:else}
|
||||||
|
<!-- Default icon when no org is selected -->
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -41,6 +49,8 @@ function handleOrgSelect(slug: string) {
|
|||||||
>
|
>
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</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,2 +1,3 @@
|
|||||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
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,7 +48,6 @@ 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",
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,6 +8,7 @@ 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";
|
||||||
@@ -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,9 +8,9 @@ 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/account";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
@@ -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,6 +10,7 @@ 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";
|
||||||
@@ -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,5 +1,6 @@
|
|||||||
<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";
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 @@ 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 { ConfirmDialog, 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 {
|
||||||
@@ -37,7 +37,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
|
||||||
@@ -259,19 +258,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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
52
apps/publisher-dashboard/src/routes/terms/+page.svelte
Normal file
52
apps/publisher-dashboard/src/routes/terms/+page.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Terms of Service | 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>Terms of Service</h1>
|
||||||
|
<p class="lead">Last updated: January 2025</p>
|
||||||
|
|
||||||
|
<h2>1. Acceptance of Terms</h2>
|
||||||
|
<p>
|
||||||
|
By accessing and using the Publisher Dashboard, you agree to be bound by these Terms of Service
|
||||||
|
and all applicable laws and regulations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Use of Service</h2>
|
||||||
|
<p>
|
||||||
|
You agree to use the service only for lawful purposes and in accordance with these Terms.
|
||||||
|
You are responsible for maintaining the confidentiality of your account credentials.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Privacy</h2>
|
||||||
|
<p>
|
||||||
|
Your use of the service is also governed by our
|
||||||
|
<a href={resolve("/privacy")}>Privacy Policy</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Modifications</h2>
|
||||||
|
<p>
|
||||||
|
We reserve the right to modify these terms at any time. Continued use of the service
|
||||||
|
constitutes acceptance of any modifications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Contact</h2>
|
||||||
|
<p>
|
||||||
|
If you have any questions about these Terms, 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>
|
||||||
@@ -40,6 +40,13 @@
|
|||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2
|
"indentWidth": 2
|
||||||
},
|
},
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
// Svelte 5 runes require `let` for $props(), template variables/imports appear unused to Biome,
|
// Svelte 5 runes require `let` for $props(), template variables/imports appear unused to Biome,
|
||||||
|
|||||||
90
bun.lock
90
bun.lock
@@ -22,7 +22,7 @@
|
|||||||
"@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/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",
|
||||||
@@ -35,12 +35,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:",
|
||||||
},
|
},
|
||||||
@@ -77,6 +76,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",
|
||||||
@@ -98,6 +99,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",
|
||||||
@@ -129,13 +131,24 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/common": {
|
||||||
|
"name": "@reviq/common",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@reviq/db",
|
"name": "@reviq/db",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@reviq/db-schema": "workspace:*",
|
"@reviq/db-schema": "workspace:*",
|
||||||
"@reviq/utils": "workspace:*",
|
"@reviq/server-utils": "workspace:*",
|
||||||
"@scure/base": "^2.0.0",
|
"@scure/base": "^2.0.0",
|
||||||
"kysely": "^0.28.9",
|
"kysely": "^0.28.9",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
@@ -167,6 +180,47 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/frontend-utils": {
|
||||||
|
"name": "@reviq/frontend-utils",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/server-utils": {
|
||||||
|
"name": "@reviq/server-utils",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250529.0",
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/testing/test-helpers": {
|
||||||
|
"name": "@reviq/test-helpers",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@reviq/db": "workspace:*",
|
||||||
|
"@reviq/db-schema": "workspace:*",
|
||||||
|
"kysely": "^0.28.2",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/testing/virtual-authenticator": {
|
"packages/testing/virtual-authenticator": {
|
||||||
"name": "@reviq/virtual-authenticator",
|
"name": "@reviq/virtual-authenticator",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -174,22 +228,10 @@
|
|||||||
"@simplewebauthn/types": "^12.0.0",
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
|
||||||
"@macalinao/tsconfig": "catalog:",
|
|
||||||
"@types/bun": "latest",
|
|
||||||
"@types/node": "^25.0.3",
|
|
||||||
"eslint": "catalog:",
|
|
||||||
"typescript": "catalog:",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/utils": {
|
|
||||||
"name": "@reviq/utils",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^4.20250529.0",
|
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -406,11 +448,17 @@
|
|||||||
|
|
||||||
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
|
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
|
||||||
|
|
||||||
|
"@reviq/common": ["@reviq/common@workspace:packages/common"],
|
||||||
|
|
||||||
"@reviq/db": ["@reviq/db@workspace:packages/db"],
|
"@reviq/db": ["@reviq/db@workspace:packages/db"],
|
||||||
|
|
||||||
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
|
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
|
||||||
|
|
||||||
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
|
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
|
||||||
|
|
||||||
|
"@reviq/server-utils": ["@reviq/server-utils@workspace:packages/server-utils"],
|
||||||
|
|
||||||
|
"@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"],
|
||||||
|
|
||||||
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
|
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
|
||||||
|
|
||||||
@@ -518,6 +566,8 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
|
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
|
||||||
@@ -946,7 +996,7 @@
|
|||||||
|
|
||||||
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
||||||
|
|
||||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||||
|
|
||||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
@@ -1154,6 +1204,8 @@
|
|||||||
|
|
||||||
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||||
|
|
||||||
|
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||||
|
|
||||||
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
||||||
|
|
||||||
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||||
|
|||||||
5
bunfig.toml
Normal file
5
bunfig.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[test]
|
||||||
|
coveragePathIgnorePatterns = [
|
||||||
|
"**/dist/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
]
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
|
||||||
|
|
||||||
-- Dumped from database version 17.7
|
-- Dumped from database version 17.7
|
||||||
-- Dumped by pg_dump version 17.7
|
-- Dumped by pg_dump version 17.7
|
||||||
@@ -1084,7 +1083,6 @@ ALTER TABLE ONLY public.user_devices
|
|||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
||||||
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ in
|
|||||||
dbmate
|
dbmate
|
||||||
ast-grep
|
ast-grep
|
||||||
dbip-city-lite
|
dbip-city-lite
|
||||||
|
gnused # GNU sed for consistent cross-platform behavior
|
||||||
];
|
];
|
||||||
|
|
||||||
dotenv.enable = true;
|
dotenv.enable = true;
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -11,11 +11,15 @@
|
|||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"build:watch:packages": "turbo watch build --filter=./packages/*",
|
"build:watch:packages": "turbo watch build --filter=./packages/*",
|
||||||
"build:packages": "turbo build --filter=./packages/*",
|
"build:packages": "turbo build --filter=./packages/*",
|
||||||
"lint": "biome check && turbo run lint",
|
"lint": "biome check && ast-grep scan && turbo run lint",
|
||||||
"lint:fix": "biome check --write --unsafe && turbo run lint -- --fix",
|
"lint:fix": "biome check --write --unsafe && ast-grep scan --update-all && turbo run lint -- --fix",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"clean": "turbo clean",
|
"clean": "turbo clean",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
|
"test:unit": "SKIP_DB_TESTS=1 turbo test",
|
||||||
|
"test:all": "turbo test",
|
||||||
|
"test:cov": "bun test --coverage",
|
||||||
|
"test:unit:cov": "SKIP_DB_TESTS=1 bun test --coverage",
|
||||||
"db:codegen": "bun run --cwd packages/db-schema generate"
|
"db:codegen": "bun run --cwd packages/db-schema generate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,5 +36,5 @@
|
|||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.1.42"
|
"packageManager": "bun@1.3.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "bun test",
|
"test": "bun test src/",
|
||||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
"lint": "eslint . --cache"
|
"lint": "eslint . --cache"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,5 @@
|
|||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"isolatedDeclarations": false
|
"isolatedDeclarations": false
|
||||||
},
|
}
|
||||||
"exclude": ["**/*.test.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
136
packages/common/README.md
Normal file
136
packages/common/README.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# @reviq/common
|
||||||
|
|
||||||
|
Shared utilities for frontend and backend. This package contains environment-agnostic code that works in browsers, Node.js, Bun, and Cloudflare Workers.
|
||||||
|
|
||||||
|
Use this package for utilities that need to work in both the publisher dashboard and the API server.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
This package is used internally within the monorepo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to your app's package.json
|
||||||
|
"dependencies": {
|
||||||
|
"@reviq/common": "workspace:*"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Date Formatting
|
||||||
|
|
||||||
|
Consistent date formatting utilities for displaying dates across the application.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `formatDate(date)`
|
||||||
|
|
||||||
|
Format a date for display in tables and lists.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatDate } from "@reviq/common";
|
||||||
|
|
||||||
|
formatDate("2024-01-15"); // "Jan 15, 2024"
|
||||||
|
formatDate(new Date()); // "Jan 15, 2024"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `formatDateTime(date)`
|
||||||
|
|
||||||
|
Format a date with time for detailed views.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatDateTime } from "@reviq/common";
|
||||||
|
|
||||||
|
formatDateTime("2024-01-15T15:30:00"); // "Jan 15, 2024, 3:30 PM"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `formatLongDate(date)`
|
||||||
|
|
||||||
|
Format a date in long form.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatLongDate } from "@reviq/common";
|
||||||
|
|
||||||
|
formatLongDate("2024-01-15"); // "January 15, 2024"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `formatRelativeDate(date, options?)`
|
||||||
|
|
||||||
|
Format a date as a relative time string.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatRelativeDate } from "@reviq/common";
|
||||||
|
|
||||||
|
formatRelativeDate("2024-01-15"); // "Today" (if today is Jan 15)
|
||||||
|
formatRelativeDate("2024-01-14"); // "Yesterday"
|
||||||
|
formatRelativeDate("2024-01-10"); // "5 days ago"
|
||||||
|
formatRelativeDate("2024-01-01"); // "2 weeks ago"
|
||||||
|
formatRelativeDate("2023-06-15"); // "Jun 15, 2023"
|
||||||
|
|
||||||
|
// With custom reference date
|
||||||
|
formatRelativeDate("2024-01-10", { now: new Date("2024-01-15") });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `formatRelativeTime(date, options?)`
|
||||||
|
|
||||||
|
Same as `formatRelativeDate`, but returns "Never" for null/undefined values. Useful for "last used" timestamps.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatRelativeTime } from "@reviq/common";
|
||||||
|
|
||||||
|
formatRelativeTime("2024-01-15"); // "Today"
|
||||||
|
formatRelativeTime(null); // "Never"
|
||||||
|
formatRelativeTime(undefined); // "Never"
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Utilities
|
||||||
|
|
||||||
|
Helper functions for working with user data.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `getUserInitials(user)`
|
||||||
|
|
||||||
|
Generate initials from a user's display name or email.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getUserInitials } from "@reviq/common";
|
||||||
|
|
||||||
|
getUserInitials({ displayName: "John Doe", email: "john@example.com" }); // "JD"
|
||||||
|
getUserInitials({ displayName: "John", email: "john@example.com" }); // "JO"
|
||||||
|
getUserInitials({ email: "john@example.com" }); // "JO"
|
||||||
|
getUserInitials(null); // "??"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `formatRole(role)`
|
||||||
|
|
||||||
|
Format a role string for display (capitalizes first letter).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatRole } from "@reviq/common";
|
||||||
|
|
||||||
|
formatRole("admin"); // "Admin"
|
||||||
|
formatRole("member"); // "Member"
|
||||||
|
formatRole("owner"); // "Owner"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Build
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Utilities
|
||||||
|
|
||||||
|
When adding new utilities to this package:
|
||||||
|
|
||||||
|
1. Create a new file in `src/` (e.g., `src/my-utility.ts`)
|
||||||
|
2. Add comprehensive tests in `src/my-utility.test.ts`
|
||||||
|
3. Export from `src/index.ts`
|
||||||
|
4. Run `bun test` to verify tests pass
|
||||||
|
5. Run `bun run build` to compile
|
||||||
26
packages/common/package.json
Normal file
26
packages/common/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@reviq/common",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
|
"lint": "eslint . --cache",
|
||||||
|
"test": "bun test src/"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
141
packages/common/src/format-date.test.ts
Normal file
141
packages/common/src/format-date.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
formatLongDate,
|
||||||
|
formatRelativeDate,
|
||||||
|
formatRelativeTime,
|
||||||
|
} from "./format-date.js";
|
||||||
|
|
||||||
|
describe("formatDate", () => {
|
||||||
|
test("formats a Date object", () => {
|
||||||
|
const date = new Date("2024-01-15T12:00:00Z");
|
||||||
|
expect(formatDate(date)).toBe("Jan 15, 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a date string", () => {
|
||||||
|
expect(formatDate("2024-01-15T12:00:00Z")).toBe("Jan 15, 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats different months correctly", () => {
|
||||||
|
expect(formatDate("2024-06-01T12:00:00Z")).toBe("Jun 1, 2024");
|
||||||
|
expect(formatDate("2024-12-25T12:00:00Z")).toBe("Dec 25, 2024");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDateTime", () => {
|
||||||
|
test("formats date with time", () => {
|
||||||
|
const date = new Date("2024-01-15T15:30:00Z");
|
||||||
|
const result = formatDateTime(date);
|
||||||
|
// Contains date parts
|
||||||
|
expect(result).toContain("Jan");
|
||||||
|
expect(result).toContain("15");
|
||||||
|
expect(result).toContain("2024");
|
||||||
|
// Contains time (format may vary by locale)
|
||||||
|
expect(result).toMatch(/\d{1,2}:\d{2}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a date string with time", () => {
|
||||||
|
const result = formatDateTime("2024-01-15T08:00:00Z");
|
||||||
|
expect(result).toContain("Jan");
|
||||||
|
expect(result).toContain("15");
|
||||||
|
expect(result).toContain("2024");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatLongDate", () => {
|
||||||
|
test("formats date in long form", () => {
|
||||||
|
const date = new Date("2024-01-15T12:00:00Z");
|
||||||
|
expect(formatLongDate(date)).toBe("January 15, 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a date string in long form", () => {
|
||||||
|
expect(formatLongDate("2024-06-01T12:00:00Z")).toBe("June 1, 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats December correctly", () => {
|
||||||
|
expect(formatLongDate("2024-12-25T12:00:00Z")).toBe("December 25, 2024");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelativeDate", () => {
|
||||||
|
const now = new Date("2024-01-15T12:00:00Z");
|
||||||
|
|
||||||
|
test("returns 'Today' for same day", () => {
|
||||||
|
const today = new Date("2024-01-15T08:00:00Z");
|
||||||
|
expect(formatRelativeDate(today, { now })).toBe("Today");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 'Yesterday' for previous day", () => {
|
||||||
|
const yesterday = new Date("2024-01-14T12:00:00Z");
|
||||||
|
expect(formatRelativeDate(yesterday, { now })).toBe("Yesterday");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 'X days ago' for 2-6 days", () => {
|
||||||
|
expect(formatRelativeDate("2024-01-13T12:00:00Z", { now })).toBe(
|
||||||
|
"2 days ago",
|
||||||
|
);
|
||||||
|
expect(formatRelativeDate("2024-01-12T12:00:00Z", { now })).toBe(
|
||||||
|
"3 days ago",
|
||||||
|
);
|
||||||
|
expect(formatRelativeDate("2024-01-09T12:00:00Z", { now })).toBe(
|
||||||
|
"6 days ago",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns '1 week ago' for exactly 7 days", () => {
|
||||||
|
const oneWeekAgo = new Date("2024-01-08T12:00:00Z");
|
||||||
|
expect(formatRelativeDate(oneWeekAgo, { now })).toBe("1 week ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 'X weeks ago' for 2-4 weeks", () => {
|
||||||
|
expect(formatRelativeDate("2024-01-01T12:00:00Z", { now })).toBe(
|
||||||
|
"2 weeks ago",
|
||||||
|
);
|
||||||
|
expect(formatRelativeDate("2023-12-25T12:00:00Z", { now })).toBe(
|
||||||
|
"3 weeks ago",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns formatted date for older dates in same year", () => {
|
||||||
|
// Use a "now" later in the year to test same-year formatting
|
||||||
|
const laterNow = new Date("2024-06-15T12:00:00Z");
|
||||||
|
const result = formatRelativeDate("2024-01-15T12:00:00Z", {
|
||||||
|
now: laterNow,
|
||||||
|
});
|
||||||
|
expect(result).toBe("Jan 15");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns formatted date with year for different year", () => {
|
||||||
|
const result = formatRelativeDate("2023-06-15T12:00:00Z", { now });
|
||||||
|
expect(result).toBe("Jun 15, 2023");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts string input", () => {
|
||||||
|
expect(formatRelativeDate("2024-01-15T08:00:00Z", { now })).toBe("Today");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelativeTime", () => {
|
||||||
|
const now = new Date("2024-01-15T12:00:00Z");
|
||||||
|
|
||||||
|
test("returns 'Never' for null", () => {
|
||||||
|
expect(formatRelativeTime(null)).toBe("Never");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 'Never' for undefined", () => {
|
||||||
|
expect(formatRelativeTime(undefined)).toBe("Never");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns relative date for valid input", () => {
|
||||||
|
expect(formatRelativeTime("2024-01-15T08:00:00Z", { now })).toBe("Today");
|
||||||
|
expect(formatRelativeTime("2024-01-14T12:00:00Z", { now })).toBe(
|
||||||
|
"Yesterday",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles Date objects", () => {
|
||||||
|
const date = new Date("2024-01-13T12:00:00Z");
|
||||||
|
expect(formatRelativeTime(date, { now })).toBe("2 days ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
128
packages/common/src/format-date.ts
Normal file
128
packages/common/src/format-date.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Date formatting utilities for consistent display across the app.
|
||||||
|
* Works in all JavaScript environments (browser, Node.js, Bun, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type DateInput = string | Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely convert a date input to a Date object.
|
||||||
|
*/
|
||||||
|
function toDate(date: DateInput): Date {
|
||||||
|
return typeof date === "string" ? new Date(date) : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the difference in days between two dates.
|
||||||
|
*/
|
||||||
|
function daysDiff(from: Date, to: Date): number {
|
||||||
|
const diffMs = to.getTime() - from.getTime();
|
||||||
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display in tables and lists.
|
||||||
|
* @example formatDate("2024-01-15") // "Jan 15, 2024"
|
||||||
|
*/
|
||||||
|
export function formatDate(date: DateInput): string {
|
||||||
|
const d = toDate(date);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date with time for detailed views.
|
||||||
|
* @example formatDateTime("2024-01-15T15:30:00") // "Jan 15, 2024, 3:30 PM"
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: DateInput): string {
|
||||||
|
const d = toDate(date);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date in long form.
|
||||||
|
* @example formatLongDate("2024-01-15") // "January 15, 2024"
|
||||||
|
*/
|
||||||
|
export function formatLongDate(date: DateInput): string {
|
||||||
|
const d = toDate(date);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for relative date formatting.
|
||||||
|
*/
|
||||||
|
export interface FormatRelativeDateOptions {
|
||||||
|
/**
|
||||||
|
* Reference date to compare against. Defaults to current date.
|
||||||
|
*/
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as a relative time string.
|
||||||
|
* @example
|
||||||
|
* formatRelativeDate("2024-01-15") // "Today" (if today is Jan 15)
|
||||||
|
* formatRelativeDate("2024-01-14") // "Yesterday" (if today is Jan 15)
|
||||||
|
* formatRelativeDate("2024-01-10") // "5 days ago" (if today is Jan 15)
|
||||||
|
* formatRelativeDate("2024-01-01") // "2 weeks ago" (if today is Jan 15)
|
||||||
|
* formatRelativeDate("2023-06-15") // "Jun 15, 2023" (older dates)
|
||||||
|
*/
|
||||||
|
export function formatRelativeDate(
|
||||||
|
date: DateInput,
|
||||||
|
options?: FormatRelativeDateOptions,
|
||||||
|
): string {
|
||||||
|
const d = toDate(date);
|
||||||
|
const now = options?.now ?? new Date();
|
||||||
|
const diffDays = daysDiff(d, now);
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return "Today";
|
||||||
|
}
|
||||||
|
if (diffDays === 1) {
|
||||||
|
return "Yesterday";
|
||||||
|
}
|
||||||
|
if (diffDays < 7) {
|
||||||
|
return `${diffDays.toLocaleString()} days ago`;
|
||||||
|
}
|
||||||
|
if (diffDays < 30) {
|
||||||
|
const weeks = Math.floor(diffDays / 7);
|
||||||
|
return weeks === 1 ? "1 week ago" : `${weeks.toLocaleString()} weeks ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For older dates, show the actual date
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as a relative time string, with "Never" for null values.
|
||||||
|
* Useful for displaying "last used" timestamps.
|
||||||
|
* @example
|
||||||
|
* formatRelativeTime("2024-01-15") // "Today"
|
||||||
|
* formatRelativeTime(null) // "Never"
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(
|
||||||
|
date: DateInput | null | undefined,
|
||||||
|
options?: FormatRelativeDateOptions,
|
||||||
|
): string {
|
||||||
|
if (date === null || date === undefined) {
|
||||||
|
return "Never";
|
||||||
|
}
|
||||||
|
return formatRelativeDate(date, options);
|
||||||
|
}
|
||||||
9
packages/common/src/index.ts
Normal file
9
packages/common/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
type FormatRelativeDateOptions,
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
formatLongDate,
|
||||||
|
formatRelativeDate,
|
||||||
|
formatRelativeTime,
|
||||||
|
} from "./format-date.js";
|
||||||
|
export { formatRole, getUserInitials } from "./user.js";
|
||||||
84
packages/common/src/user.test.ts
Normal file
84
packages/common/src/user.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { formatRole, getUserInitials } from "./user.js";
|
||||||
|
|
||||||
|
describe("getUserInitials", () => {
|
||||||
|
test("returns '??' for null", () => {
|
||||||
|
expect(getUserInitials(null)).toBe("??");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns '??' for undefined", () => {
|
||||||
|
expect(getUserInitials(undefined)).toBe("??");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns initials from display name with two words", () => {
|
||||||
|
expect(
|
||||||
|
getUserInitials({ displayName: "John Doe", email: "john@example.com" }),
|
||||||
|
).toBe("JD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns initials from display name with multiple words", () => {
|
||||||
|
expect(
|
||||||
|
getUserInitials({
|
||||||
|
displayName: "John Michael Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
}),
|
||||||
|
).toBe("JD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns first two characters for single word display name", () => {
|
||||||
|
expect(
|
||||||
|
getUserInitials({ displayName: "John", email: "john@example.com" }),
|
||||||
|
).toBe("JO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns uppercase initials", () => {
|
||||||
|
expect(
|
||||||
|
getUserInitials({
|
||||||
|
displayName: "john doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
}),
|
||||||
|
).toBe("JD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to email when no display name", () => {
|
||||||
|
expect(getUserInitials({ email: "john@example.com" })).toBe("JO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null display name", () => {
|
||||||
|
expect(
|
||||||
|
getUserInitials({ displayName: null, email: "alice@example.com" }),
|
||||||
|
).toBe("AL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty display name", () => {
|
||||||
|
expect(getUserInitials({ displayName: "", email: "bob@example.com" })).toBe(
|
||||||
|
"BO",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRole", () => {
|
||||||
|
test("capitalizes 'admin'", () => {
|
||||||
|
expect(formatRole("admin")).toBe("Admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capitalizes 'member'", () => {
|
||||||
|
expect(formatRole("member")).toBe("Member");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capitalizes 'owner'", () => {
|
||||||
|
expect(formatRole("owner")).toBe("Owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles already capitalized roles", () => {
|
||||||
|
expect(formatRole("Admin")).toBe("Admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single character", () => {
|
||||||
|
expect(formatRole("a")).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty string", () => {
|
||||||
|
expect(formatRole("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
51
packages/common/src/user.ts
Normal file
51
packages/common/src/user.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* User-related utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface UserLike {
|
||||||
|
displayName?: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate initials from a user's display name or email.
|
||||||
|
* - For display names with 2+ words: first and last initials (e.g., "John Doe" -> "JD")
|
||||||
|
* - For single word names: first 2 characters (e.g., "John" -> "JO")
|
||||||
|
* - Falls back to first 2 characters of email if no display name
|
||||||
|
* - Returns "??" if user is null/undefined
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getUserInitials({ displayName: "John Doe", email: "john@example.com" }) // "JD"
|
||||||
|
* getUserInitials({ displayName: "John", email: "john@example.com" }) // "JO"
|
||||||
|
* getUserInitials({ email: "john@example.com" }) // "JO"
|
||||||
|
* getUserInitials(null) // "??"
|
||||||
|
*/
|
||||||
|
export function getUserInitials(user: UserLike | null | undefined): string {
|
||||||
|
if (!user) {
|
||||||
|
return "??";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.displayName) {
|
||||||
|
const parts = user.displayName.split(" ");
|
||||||
|
const firstPart = parts[0];
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
if (parts.length >= 2 && firstPart && lastPart) {
|
||||||
|
return (firstPart.charAt(0) + lastPart.charAt(0)).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.displayName.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.email.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a role string for display (capitalizes first letter).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatRole("admin") // "Admin"
|
||||||
|
* formatRole("member") // "Member"
|
||||||
|
* formatRole("owner") // "Owner"
|
||||||
|
*/
|
||||||
|
export function formatRole(role: string): string {
|
||||||
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||||
|
}
|
||||||
24
packages/db-schema/README.md
Normal file
24
packages/db-schema/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# @reviq/db-schema
|
||||||
|
|
||||||
|
Database schema types generated from PostgreSQL using kysely-codegen.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Database } from "@reviq/db-schema";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regenerating Types
|
||||||
|
|
||||||
|
When the database schema changes, regenerate the types:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd packages/db-schema generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires `DATABASE_URL` to be set and pointing to a database with the current schema.
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
- `Database` - The full database type for use with Kysely
|
||||||
|
- Table types for all database tables (e.g., `Users`, `Orgs`, `Sessions`)
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"composite": true,
|
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
}
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
33
packages/db/README.md
Normal file
33
packages/db/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# @reviq/db
|
||||||
|
|
||||||
|
Database client and helper functions for the RevIQ platform.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createDb } from "@reviq/db";
|
||||||
|
|
||||||
|
const db = createDb(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
// Use db with Kysely queries
|
||||||
|
const users = await db.selectFrom("users").selectAll().execute();
|
||||||
|
|
||||||
|
// Clean up when done
|
||||||
|
await db.destroy();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- `createDb(url)` - Create a Kysely database instance
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
- `executeBootstrap(trx, input)` - Bootstrap a new database with superuser and org
|
||||||
|
- `generateToken()` - Generate an API token
|
||||||
|
- `hashToken(token)` - Hash a token for storage
|
||||||
|
- `parseToken(token)` - Parse and validate a token
|
||||||
|
- `TOKEN_PREFIX` - The `reviq_` prefix for API tokens
|
||||||
|
|
||||||
|
### Types
|
||||||
|
- `Database` - Re-exported from `@reviq/db-schema`
|
||||||
|
- `BootstrapInput` / `BootstrapResult` - Types for bootstrap operation
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@reviq/db-schema": "workspace:*",
|
"@reviq/db-schema": "workspace:*",
|
||||||
"@reviq/utils": "workspace:*",
|
"@reviq/server-utils": "workspace:*",
|
||||||
"@scure/base": "^2.0.0",
|
"@scure/base": "^2.0.0",
|
||||||
"kysely": "^0.28.9",
|
"kysely": "^0.28.9",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import type { Database } from "@reviq/db-schema";
|
import type { Database } from "@reviq/db-schema";
|
||||||
import type { Kysely, Transaction } from "kysely";
|
import type { Kysely, Transaction } from "kysely";
|
||||||
import { hashPassword } from "@reviq/utils";
|
import { hashPassword } from "@reviq/server-utils";
|
||||||
import { generateToken, hashToken } from "./token.js";
|
import { generateToken, hashToken } from "./token.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
}
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
packages/frontend-utils/README.md
Normal file
15
packages/frontend-utils/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# @reviq/frontend-utils
|
||||||
|
|
||||||
|
Frontend-specific utilities for the RevIQ publisher dashboard.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getOrgColor, getOrgInitials } from "@reviq/frontend-utils";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
- `getOrgInitials(org)` - Get display initials from an organization's slug or display name
|
||||||
|
- `getOrgColor(org)` - Get a consistent HSL color based on the organization slug
|
||||||
|
- `OrgLike` - Type interface for organization objects
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user