Compare commits
30 Commits
fix-export
...
5a2e0297e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
5a2e0297e5
|
|||
|
c9de0b1ac5
|
|||
|
0f50291490
|
|||
|
9c6694cad4
|
|||
|
f9f1dc7403
|
|||
|
b27a977809
|
|||
|
7edc4ba8a9
|
|||
|
16f827e8f0
|
|||
|
947c73dbdc
|
|||
|
2baf10b0cd
|
|||
|
8b081d5ba8
|
|||
|
01f1e1c9e3
|
|||
|
26d10d452f
|
|||
|
8b63eb3538
|
|||
|
587e151fbd
|
|||
|
94b6de5970
|
|||
|
6fa4da1abb
|
|||
|
92f7e1df09
|
|||
|
b2fba6e150
|
|||
|
ebc85af62c
|
|||
|
6b8dd27898
|
|||
|
61fdd3329f
|
|||
|
848d9e9af1
|
|||
|
44a480179b
|
|||
|
628b01f4d8
|
|||
| 8939deefbe | |||
|
76a5e40900
|
|||
|
b1d07626f3
|
|||
|
99539bbdcb
|
|||
|
eedd664db8
|
@@ -0,0 +1,16 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
snapshots:
|
||||||
|
countAll<number>():
|
||||||
|
fixed: countAll()
|
||||||
|
labels:
|
||||||
|
- source: countAll<number>()
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 18
|
||||||
|
eb.fn.countAll<number>().as("count"):
|
||||||
|
fixed: eb.fn.countAll().as("count")
|
||||||
|
labels:
|
||||||
|
- source: eb.fn.countAll<number>()
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 24
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
id: no-string-function
|
||||||
|
snapshots:
|
||||||
|
String(123):
|
||||||
|
labels:
|
||||||
|
- source: String(123)
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 11
|
||||||
|
String(Date.now()):
|
||||||
|
labels:
|
||||||
|
- source: String(Date.now())
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 18
|
||||||
|
String(value):
|
||||||
|
labels:
|
||||||
|
- source: String(value)
|
||||||
|
style: primary
|
||||||
|
start: 0
|
||||||
|
end: 13
|
||||||
@@ -3,7 +3,7 @@ snapshots:
|
|||||||
? |
|
? |
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
: fixed: |
|
: fixed: |
|
||||||
import * as z from "zod"
|
import * as z from "zod";
|
||||||
labels:
|
labels:
|
||||||
- source: import { z } from "zod";
|
- source: import { z } from "zod";
|
||||||
style: primary
|
style: primary
|
||||||
@@ -12,7 +12,7 @@ snapshots:
|
|||||||
? |
|
? |
|
||||||
import { z, ZodError } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
: fixed: |
|
: fixed: |
|
||||||
import * as z from "zod"
|
import * as z from "zod";
|
||||||
labels:
|
labels:
|
||||||
- source: import { z, ZodError } from "zod";
|
- source: import { z, ZodError } from "zod";
|
||||||
style: primary
|
style: primary
|
||||||
|
|||||||
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
9
.ast-grep/rule-tests/no-countall-number-test.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
valid:
|
||||||
|
# Plain countAll() is fine
|
||||||
|
- eb.fn.countAll().as("count")
|
||||||
|
# Other type arguments are fine
|
||||||
|
- eb.fn.countAll<string>().as("count")
|
||||||
|
invalid:
|
||||||
|
# countAll<number>() should be flagged
|
||||||
|
- eb.fn.countAll<number>().as("count")
|
||||||
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
13
.ast-grep/rule-tests/no-string-function-test.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
id: no-string-function
|
||||||
|
valid:
|
||||||
|
# toString() is fine
|
||||||
|
- value.toString()
|
||||||
|
- (123).toString()
|
||||||
|
- date.toLocaleString()
|
||||||
|
# Other functions named String are fine
|
||||||
|
- myString(value)
|
||||||
|
invalid:
|
||||||
|
# String() function should be flagged
|
||||||
|
- String(value)
|
||||||
|
- String(123)
|
||||||
|
- String(Date.now())
|
||||||
8
.ast-grep/rules/no-countall-number.yml
Normal file
8
.ast-grep/rules/no-countall-number.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
language: typescript
|
||||||
|
severity: error
|
||||||
|
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
|
||||||
|
note: "Use Number() to convert the result if you need a number type."
|
||||||
|
rule:
|
||||||
|
pattern: $OBJ.countAll<number>()
|
||||||
|
fix: $OBJ.countAll()
|
||||||
7
.ast-grep/rules/no-string-function.yml
Normal file
7
.ast-grep/rules/no-string-function.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: no-string-function
|
||||||
|
language: typescript
|
||||||
|
severity: error
|
||||||
|
message: "Don't use String() - use .toString() or .toLocaleString() instead."
|
||||||
|
note: "String() can have unexpected behavior. Use .toString() for general conversion or .toLocaleString() for locale-aware formatting."
|
||||||
|
rule:
|
||||||
|
pattern: String($VAL)
|
||||||
34
.gitea/workflows/ci.yaml
Normal file
34
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.5"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
74
CLAUDE.md
74
CLAUDE.md
@@ -1,5 +1,20 @@
|
|||||||
# Claude Code Notes
|
# Claude Code Notes
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Use `bun run test:cov` to run all tests with coverage. This runs both unit tests and e2e tests that require a database connection.
|
||||||
|
|
||||||
|
- `bun run test:cov` - Run all tests with coverage (preferred)
|
||||||
|
- `bun run test:unit:cov` - Run only unit tests with coverage (no database required)
|
||||||
|
|
||||||
|
## Database Scripts
|
||||||
|
|
||||||
|
Use the wrapper scripts instead of running dbmate directly:
|
||||||
|
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
|
||||||
|
- `./scripts/db-migrate` - Run migrations and dump clean schema
|
||||||
|
|
||||||
|
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
|
||||||
|
|
||||||
## Development Server
|
## Development Server
|
||||||
|
|
||||||
Before starting the dev server, check if it's already running:
|
Before starting the dev server, check if it's already running:
|
||||||
@@ -14,10 +29,57 @@ This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
|
|||||||
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
||||||
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
||||||
|
|
||||||
## macOS sed Syntax
|
## sed Syntax (GNU coreutils)
|
||||||
|
|
||||||
macOS uses BSD sed which differs from GNU sed:
|
This project uses GNU coreutils via devenv, so use standard GNU sed syntax:
|
||||||
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
|
- In-place edit: `sed -i 's/old/new/g' file`
|
||||||
- GNU sed (Linux): `sed -i 's/old/new/g' file`
|
- Use `|` as delimiter when patterns contain `/`: `sed -i 's|old/path|new/path|g' file`
|
||||||
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
|
- For multiple files: `for f in *.txt; do sed -i 's/old/new/g' "$f"; done`
|
||||||
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
|
- Do NOT use BSD sed syntax (`sed -i ''`) - we have GNU sed available
|
||||||
|
|
||||||
|
## SvelteKit resolve() Usage
|
||||||
|
|
||||||
|
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
|
||||||
|
|
||||||
|
### Static routes - use resolve() directly
|
||||||
|
```svelte
|
||||||
|
href={resolve("/auth/login")}
|
||||||
|
href={resolve("/dashboard")}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic routes - use two-argument form
|
||||||
|
```svelte
|
||||||
|
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
|
||||||
|
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login redirects - use gotoLogin helper
|
||||||
|
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
|
||||||
|
```typescript
|
||||||
|
import { gotoLogin } from "$lib/utils/navigation";
|
||||||
|
|
||||||
|
gotoLogin(page.url.pathname);
|
||||||
|
```
|
||||||
|
This helper uses resolve() internally and handles the query string correctly.
|
||||||
|
|
||||||
|
### Navigation arrays - use `as const` with route patterns
|
||||||
|
For type-safe navigation arrays, define routes as literal strings with `as const`:
|
||||||
|
```typescript
|
||||||
|
const navItems = [
|
||||||
|
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
|
||||||
|
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
|
||||||
|
] as const;
|
||||||
|
```
|
||||||
|
Then use resolve with params:
|
||||||
|
```svelte
|
||||||
|
{#each navItems as item (item.route)}
|
||||||
|
<a href={resolve(item.route, { slug })}>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime strings - skip resolve, use eslint-disable
|
||||||
|
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
|
||||||
|
```typescript
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
|
goto(redirectUrl);
|
||||||
|
```
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -26,9 +26,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
|
|||||||
|
|
||||||
### Shared Packages
|
### Shared Packages
|
||||||
- `@reviq/api-contract` - Shared API contract (oRPC)
|
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||||
|
- `@reviq/common` - Shared utilities for frontend and backend
|
||||||
- `@reviq/db` - Database client and queries
|
- `@reviq/db` - Database client and queries
|
||||||
- `@reviq/db-schema` - Database schema and codegen
|
- `@reviq/db-schema` - Database schema and codegen
|
||||||
- `@reviq/utils` - Shared utilities
|
- `@reviq/frontend-utils` - Frontend-specific utilities
|
||||||
|
- `@reviq/server-utils` - Server/CLI utilities
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -40,10 +42,12 @@ publisher-dashboard/
|
|||||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── api-contract/ # Shared oRPC contract
|
│ ├── api-contract/ # Shared oRPC contract
|
||||||
|
│ ├── common/ # Shared utilities (frontend + backend)
|
||||||
│ ├── db/ # Database client
|
│ ├── db/ # Database client
|
||||||
│ ├── db-schema/ # DB schema & codegen
|
│ ├── db-schema/ # DB schema & codegen
|
||||||
│ ├── testing/ # Test utilities
|
│ ├── frontend-utils/ # Frontend utilities
|
||||||
│ └── utils/ # Shared utilities
|
│ ├── server-utils/ # Server/CLI utilities
|
||||||
|
│ └── testing/ # Test utilities
|
||||||
└── db/ # Database migrations
|
└── db/ # Database migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -109,8 +113,13 @@ bun run dev
|
|||||||
| `bun run typecheck` | Run TypeScript type checking |
|
| `bun run typecheck` | Run TypeScript type checking |
|
||||||
| `bun run lint` | Run Biome and ESLint |
|
| `bun run lint` | Run Biome and ESLint |
|
||||||
| `bun run lint:fix` | Fix linting issues |
|
| `bun run lint:fix` | Fix linting issues |
|
||||||
| `bun run test` | Run tests |
|
| `bun run test` | Run all tests (requires database) |
|
||||||
|
| `bun run test:unit` | Run unit tests only (no database required) |
|
||||||
|
| `bun run test:cov` | Run all tests with coverage report |
|
||||||
|
| `bun run test:unit:cov` | Run unit tests with coverage (no database) |
|
||||||
| `bun run db:codegen` | Generate database types |
|
| `bun run db:codegen` | Generate database types |
|
||||||
|
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
|
||||||
|
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|||||||
@@ -9,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
File diff suppressed because it is too large
Load Diff
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
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ export const getAllowedOrigins = (): string[] => {
|
|||||||
|
|
||||||
// Default to localhost origins for development
|
// Default to localhost origins for development
|
||||||
return [
|
return [
|
||||||
`http://localhost:${String(DEFAULT_PORT)}`,
|
`http://localhost:${DEFAULT_PORT.toString()}`,
|
||||||
"http://localhost:6827",
|
"http://localhost:6827",
|
||||||
"http://localhost:6828",
|
"http://localhost:6828",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,17 +52,28 @@ export async function signupWithPassword(
|
|||||||
// Hash password
|
// Hash password
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
// Create user
|
// Create user (handle race condition if concurrent signup with same email)
|
||||||
const user = await db
|
try {
|
||||||
.insertInto("users")
|
const user = await db
|
||||||
.values({
|
.insertInto("users")
|
||||||
email,
|
.values({
|
||||||
password_hash: passwordHash,
|
email,
|
||||||
})
|
password_hash: passwordHash,
|
||||||
.returning(["id"])
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return user.id;
|
return user.id;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,7 +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,55 +157,66 @@ export async function signupWithPasskey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user and passkey in a transaction
|
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||||
const result = await db.transaction().execute(async (trx) => {
|
try {
|
||||||
// Create user
|
const result = await db.transaction().execute(async (trx) => {
|
||||||
const user = await trx
|
// Create user
|
||||||
.insertInto("users")
|
const user = await trx
|
||||||
.values({
|
.insertInto("users")
|
||||||
email,
|
.values({
|
||||||
password_hash: null,
|
email,
|
||||||
})
|
password_hash: null,
|
||||||
.returning(["id"])
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.returning(["id"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const newUserId = user.id;
|
const newUserId = user.id;
|
||||||
|
|
||||||
// Get friendly name from AAGUID
|
// Get friendly name from AAGUID
|
||||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||||
const passkeyName = guidName ?? "Default";
|
const passkeyName = guidName ?? "Default";
|
||||||
|
|
||||||
// Store the passkey
|
// Store the passkey
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||||
registrationInfo;
|
registrationInfo;
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insertInto("passkeys")
|
.insertInto("passkeys")
|
||||||
.values({
|
.values({
|
||||||
user_id: newUserId,
|
user_id: newUserId,
|
||||||
credential_id: Buffer.from(credential.id, "base64url"),
|
credential_id: Buffer.from(credential.id, "base64url"),
|
||||||
public_key: Buffer.from(credential.publicKey),
|
public_key: Buffer.from(credential.publicKey),
|
||||||
webauthn_user_id: options.user.id,
|
webauthn_user_id: options.user.id,
|
||||||
counter: BigInt(credential.counter),
|
counter: BigInt(credential.counter),
|
||||||
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
|
||||||
backup_eligible: registrationInfo.credentialBackedUp,
|
backup_eligible: registrationInfo.credentialBackedUp,
|
||||||
backup_status: credentialBackedUp,
|
backup_status: credentialBackedUp,
|
||||||
transports: JSON.stringify(response.response.transports ?? []),
|
transports: JSON.stringify(response.response.transports ?? []),
|
||||||
rpid: rpInfo.rpID,
|
rpid: rpInfo.rpID,
|
||||||
name: passkeyName,
|
name: passkeyName,
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Delete the challenge
|
// Delete the challenge
|
||||||
await trx
|
await trx
|
||||||
.deleteFrom("webauthn_challenges")
|
.deleteFrom("webauthn_challenges")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", challengeId.toString())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { userId: newUserId };
|
return { userId: newUserId };
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.userId;
|
return result.userId;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,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,17 +33,24 @@ function handleOrgSelect(slug: string) {
|
|||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
aria-label="Switch organization"
|
aria-label="Switch organization"
|
||||||
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm transition-transform duration-200 hover:scale-105"
|
class="group flex h-8 w-8 items-center justify-center transition-transform duration-200 hover:scale-105"
|
||||||
>
|
>
|
||||||
<svg
|
{#if currentOrg}
|
||||||
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
<OrgAvatar org={currentOrg} size="md" />
|
||||||
viewBox="0 0 24 24"
|
{:else}
|
||||||
fill="none"
|
<!-- Default icon when no org is selected -->
|
||||||
stroke="currentColor"
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
|
||||||
stroke-width="2.5"
|
<svg
|
||||||
>
|
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
|
|||||||
class={cn(isActive && "bg-accent")}
|
class={cn(isActive && "bg-accent")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if org.logoUrl}
|
<OrgAvatar {org} size="xs" />
|
||||||
<img src={org.logoUrl} alt="" class="h-5 w-5 rounded" />
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-5 w-5 items-center justify-center rounded bg-muted text-[10px] font-medium">
|
|
||||||
{org.displayName.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span class="flex-1 truncate">{org.displayName}</span>
|
<span class="flex-1 truncate">{org.displayName}</span>
|
||||||
{#if isActive}
|
{#if isActive}
|
||||||
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<Check class="h-4 w-4 text-primary" />
|
||||||
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getUserInitials } from "@reviq/common";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const user = $derived(userQuery.data);
|
const user = $derived(userQuery.data);
|
||||||
|
const initials = $derived(getUserInitials(user));
|
||||||
// Generate initials from display name or email
|
|
||||||
const initials = $derived.by(() => {
|
|
||||||
if (!user) {
|
|
||||||
return "??";
|
|
||||||
}
|
|
||||||
if (user.displayName) {
|
|
||||||
const parts = user.displayName.split(" ");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (
|
|
||||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
|
||||||
).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.displayName.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
return user.email.slice(0, 2).toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
// Clear all cached queries
|
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
goto(resolve("/auth/login"));
|
goto(resolve("/auth/login"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { Building2, Globe, Settings, Users } from "@lucide/svelte";
|
import { Globe, Settings, Users } from "@lucide/svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
@@ -16,36 +16,37 @@ let { title, children }: Props = $props();
|
|||||||
|
|
||||||
// Get org context from parent layout
|
// Get org context from parent layout
|
||||||
const orgContext = getContext<{ slug: string }>("orgContext");
|
const orgContext = getContext<{ slug: string }>("orgContext");
|
||||||
const slug = $derived(orgContext?.slug);
|
const slug = $derived(orgContext?.slug ?? "");
|
||||||
|
|
||||||
// Settings navigation items
|
// Settings navigation items with route patterns for type-safe resolve()
|
||||||
const navItems = $derived.by(() => [
|
const navItems = [
|
||||||
{
|
{
|
||||||
href: `/dashboard/${slug}/settings`,
|
route: "/dashboard/[slug]/settings",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: "General",
|
label: "General",
|
||||||
description: "Organization name, logo, and preferences",
|
description: "Organization name, logo, and preferences",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/dashboard/${slug}/settings/members`,
|
route: "/dashboard/[slug]/settings/members",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
label: "Members",
|
label: "Members",
|
||||||
description: "Manage team members and invitations",
|
description: "Manage team members and invitations",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/dashboard/${slug}/settings/sites`,
|
route: "/dashboard/[slug]/settings/sites",
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
label: "Sites",
|
label: "Sites",
|
||||||
description: "Connected websites and domains",
|
description: "Connected websites and domains",
|
||||||
},
|
},
|
||||||
]);
|
] as const;
|
||||||
|
|
||||||
// Determine active item
|
// Determine active item
|
||||||
const activeHref = $derived($page.url.pathname);
|
const activeHref = $derived($page.url.pathname);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(route: (typeof navItems)[number]["route"]): boolean {
|
||||||
|
const href = resolve(route, { slug });
|
||||||
// Exact match for base settings path
|
// Exact match for base settings path
|
||||||
if (href === `/dashboard/${slug}/settings`) {
|
if (route === "/dashboard/[slug]/settings") {
|
||||||
return activeHref === href;
|
return activeHref === href;
|
||||||
}
|
}
|
||||||
// Prefix match for sub-pages
|
// Prefix match for sub-pages
|
||||||
@@ -59,10 +60,10 @@ function isActive(href: string): boolean {
|
|||||||
<nav class="w-full shrink-0 lg:w-64">
|
<nav class="w-full shrink-0 lg:w-64">
|
||||||
<!-- Mobile: horizontal scroll -->
|
<!-- Mobile: horizontal scroll -->
|
||||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.route)}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.route)}
|
||||||
<a
|
<a
|
||||||
href={resolve(item.href as any)}
|
href={resolve(item.route, { slug })}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||||
active
|
active
|
||||||
@@ -78,10 +79,10 @@ function isActive(href: string): boolean {
|
|||||||
|
|
||||||
<!-- Desktop: vertical list -->
|
<!-- Desktop: vertical list -->
|
||||||
<div class="hidden space-y-1 lg:block">
|
<div class="hidden space-y-1 lg:block">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.route)}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.route)}
|
||||||
<a
|
<a
|
||||||
href={resolve(item.href as any)}
|
href={resolve(item.route, { slug })}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -1,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,8 +48,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */
|
let {
|
||||||
let {
|
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
@@ -67,7 +66,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href/* eslint-disable-line svelte/no-navigation-without-resolve */}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? "link" : undefined}
|
role={disabled ? "link" : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
91
bun.lock
91
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",
|
||||||
@@ -125,6 +127,18 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "^1.3.5",
|
||||||
|
"eslint": "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:",
|
"eslint": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -135,7 +149,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",
|
||||||
@@ -167,6 +181,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 +229,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 +449,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 +567,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 +997,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 +1205,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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "@reviq/api-contract",
|
"name": "@reviq/api-contract",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -12,7 +13,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"
|
||||||
},
|
},
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "^1.3.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"isolatedDeclarations": false
|
"isolatedDeclarations": false,
|
||||||
},
|
"types": ["bun"]
|
||||||
"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
|
||||||
27
packages/common/package.json
Normal file
27
packages/common/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@reviq/common",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"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`)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -19,7 +20,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"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user