Compare commits

..

23 Commits

Author SHA1 Message Date
igm
16f827e8f0 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add test utilities and ast-grep rules for code quality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:05:07 +08:00
igm
947c73dbdc Remove unnecessary exclude from tsconfig files
TypeScript excludes node_modules by default, and dist is handled
by outDir or include patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:55 +08:00
igm
2baf10b0cd Replace String() calls with .toString()/.toLocaleString() per ast-grep rule
- Add formatError() helper in CLI to safely handle unknown error types
- Add uniqueTestId() helper for generating unique test identifiers
- Replace String(id) with id.toString() for database ID conversions
- Replace String(n) with n.toLocaleString() for user-facing number formatting
- Fix TypeScript errors in test files (undefined checks, unused variables)
- Update lint commands to include ast-grep scanning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:46 +08:00
igm
8b081d5ba8 Remove unnecessary testing/README.md
Sub-packages test-helpers and virtual-authenticator have their own READMEs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:57:28 +08:00
igm
01f1e1c9e3 Add READMEs for remaining packages
- db-schema: Database schema types from kysely-codegen
- db: Database client and helper functions
- testing: Overview of testing packages
- test-helpers: Database testing utilities
- virtual-authenticator: WebAuthn virtual authenticator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:59:34 +08:00
igm
26d10d452f Rename @reviq/utils to @reviq/server-utils and add package READMEs
- Rename packages/utils/ to packages/server-utils/
- Update all imports and package.json references
- Add READMEs for frontend-utils, server-utils, and common packages
- Update main README with new package structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:57:28 +08:00
igm
8b63eb3538 Add ast-grep rule to prevent String() function usage
Prefer .toString() or .toLocaleString() over String() for
more predictable behavior and consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:48:13 +08:00
igm
587e151fbd Fix ast-grep tests and add no-countall-number test
- Update zod-namespace-import snapshot (semicolon fix)
- Add test cases for no-countall-number rule
- Update rule pattern to match method calls on objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:45:14 +08:00
igm
94b6de5970 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add @reviq/test-helpers package with e2e tests for admin, auth, orgs, and webauthn.
Move test utilities to shared package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:43:28 +08:00
igm
6fa4da1abb Fix lint errors and add ast-grep rule for countAll
- Fix template literal expressions: wrap Date.now() in String()
- Add missing afterAll import in admin.test.ts
- Fix countOwners to use countAll() without misleading <number> type
- Add ast-grep rule to prevent countAll<number>() usage
- Fix formatting issues from merge conflict resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:40:06 +08:00
igm
92f7e1df09 Merge origin/master and migrate tests to describeE2E
- Resolve merge conflicts in auth.test.ts, me.test.ts, db/schema.sql
- Merge new loginRequestMiddleware tests into auth.test.ts describeE2E wrapper
- Merge new authMiddleware tests into me.test.ts describeE2E wrapper
- Add me.apiTokens and me.invites tests in separate describeE2E block
- Migrate admin.test.ts to use describeE2E and @reviq/test-helpers
- Migrate orgs.test.ts to use describeE2E and @reviq/test-helpers

All e2e tests now properly use the describeE2E helper which enables
SKIP_DB_TESTS environment variable support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:19:29 +08:00
igm
b2fba6e150 Add test infrastructure with coverage and DB test skipping
- Create @reviq/test-helpers package with shared test utilities
- Add describeE2E helper that auto-prefixes test names with [e2e]
- Support SKIP_DB_TESTS=1 to skip database-dependent tests
- Add unix socket support for TEST_DATABASE_URL
- Add root commands: test:unit, test:all, test:cov, test:unit:cov
- Configure bunfig.toml to exclude dist/ from coverage reports
- Clean up tsconfig.json files to remove redundant settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:03:41 +08:00
igm
ebc85af62c Add comprehensive e2e tests for API procedures with 100% coverage
- Add admin.test.ts: Tests for superuser operations (users, orgs, sites)
- Add orgs.test.ts: Tests for org management, members, invites, sites
- Expand me.test.ts: Add API tokens, invites, authMiddleware error paths
- Expand auth.test.ts: Add loginRequestMiddleware tests, weak password test fix

Bug fixes:
- Fix countOwners() in orgs/helpers.ts to convert PostgreSQL bigint to number
- Fix signup race condition by handling unique constraint violations gracefully

All 283 tests pass with 100% function coverage on procedures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:53:19 +08:00
igm
6b8dd27898 Merge branch 'schema-sql-fix' 2026-01-12 12:41:15 +08:00
igm
61fdd3329f Add OrgAvatar component and frontend-utils package
Some checks failed
CI / ci (push) Has been cancelled
- Create @reviq/frontend-utils package for frontend-specific utilities
- Add OrgAvatar component with size variants (xs, sm, md, lg, xl)
- Display org initials with deterministic colors when no logo available
- Add getOrgInitials and getOrgColor utility functions
- Update org-switcher and all org display pages to use OrgAvatar
- Add noNonNullAssertion lint rule as error in biome.jsonc

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:34:23 +08:00
igm
848d9e9af1 Add db-dump and db-migrate scripts to strip \restrict lines
PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump
output (CVE-2025-8714 security fix), causing schema.sql to appear
changed on every dump even when the schema hasn't changed.

These wrapper scripts run dbmate and strip the \restrict lines from
the output to keep schema.sql stable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:33:20 +08:00
igm
44a480179b Merge remote-tracking branch 'origin/master'
Some checks failed
CI / ci (push) Has been cancelled
2026-01-11 14:19:58 +08:00
igm
628b01f4d8 Add type-safe navigation helpers and public pages
- Create gotoLogin() helper for login redirects with search params
- Add /terms and /privacy public routes with Tailwind typography
- Update auth-guard to allow unauthenticated access to public pages
- Fix resolve() usage across navigation components using as const pattern
- Fix eslint-disable-next-line placement for svelte/no-navigation-without-resolve
- Document SvelteKit resolve() patterns in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:19:33 +08:00
igm
8939deefbe Merge pull request 'Update db and db-schema packages to export from dist/' (#1) from fix-exports into master
Reviewed-on: https://git.rev.iq/igm/publisher-dashboard/pulls/1
2026-01-11 05:19:11 +00:00
igm
76a5e40900 Merge branch 'gitea-action' 2026-01-11 12:34:17 +08:00
igm
b1d07626f3 Add packages/common for shared utilities
Create new @reviq/common package with environment-agnostic utilities:
- Date formatting: formatDate, formatDateTime, formatLongDate,
  formatRelativeDate, formatRelativeTime
- User utilities: getUserInitials, formatRole

Consolidate date formatting from publisher-dashboard into shared package.
All utilities include comprehensive test coverage with bun:test.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:34:10 +08:00
igm
99539bbdcb Update Bun version to 1.3.5
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:27:52 +08:00
igm
eedd664db8 Add Gitea Action CI workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:22:51 +08:00
131 changed files with 10475 additions and 4391 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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")

View 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())

View 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()

View 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
View 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

View File

@@ -1,5 +1,13 @@
# Claude Code Notes # Claude Code Notes
## 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:
@@ -21,3 +29,50 @@ macOS uses BSD sed which differs from GNU sed:
- GNU sed (Linux): `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`
## 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);
```

View File

@@ -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
``` ```
@@ -111,6 +115,8 @@ bun run dev
| `bun run lint:fix` | Fix linting issues | | `bun run lint:fix` | Fix linting issues |
| `bun run test` | Run tests | | `bun run test` | Run tests |
| `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

View File

@@ -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:"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -41,14 +41,20 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
uniqueTestId,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js"; import { hashPassword } from "../../utils/password.js";
import { TEST_RP } from "../helpers/test-constants.js";
import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -141,7 +147,7 @@ async function createSession(
userId: number, userId: number,
options?: { deviceId?: bigint }, options?: { deviceId?: bigint },
): Promise<{ token: string; sessionId: number }> { ): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -149,7 +155,7 @@ async function createSession(
.insertInto("sessions") .insertInto("sessions")
.values({ .values({
user_id: userId, user_id: userId,
device_id: options?.deviceId ? String(options.deviceId) : null, device_id: options?.deviceId ? options.deviceId.toString() : null,
token_hash: tokenHashValue, token_hash: tokenHashValue,
trusted_mode: false, trusted_mode: false,
expires_at: expiresAt, expires_at: expiresAt,
@@ -173,7 +179,7 @@ async function createLoginRequest(
expiresAt?: Date; expiresAt?: Date;
}, },
): Promise<{ token: string; id: number }> { ): Promise<{ token: string; id: number }> {
const token = `login_test-${String(Date.now())}${String(Math.random())}`; const token = `login_test-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
@@ -223,7 +229,7 @@ async function createEmailVerification(
userId: number, userId: number,
options?: { expiresAt?: Date }, options?: { expiresAt?: Date },
): Promise<string> { ): Promise<string> {
const token = `verify-${String(Date.now())}${String(Math.random())}`; const token = `verify-${uniqueTestId()}`;
const expiresAt = const expiresAt =
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000); options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
@@ -247,7 +253,7 @@ async function createPasswordReset(
userId: number, userId: number,
options?: { expiresAt?: Date; usedAt?: Date | null }, options?: { expiresAt?: Date; usedAt?: Date | null },
): Promise<string> { ): Promise<string> {
const token = `reset-${String(Date.now())}${String(Math.random())}`; const token = `reset-${uniqueTestId()}`;
const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000); const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000);
await db await db
@@ -263,16 +269,17 @@ async function createPasswordReset(
return token; return token;
} }
// Test setup describeE2E("auth", () => {
beforeAll(async () => { // Test setup
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
// ============================================================================= // =============================================================================
// auth.signup tests // auth.signup tests
// ============================================================================= // =============================================================================
describe("auth.signup", () => { describe("auth.signup", () => {
test("creates user with valid password", async () => { test("creates user with valid password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
@@ -391,7 +398,9 @@ describe("auth.signup", () => {
// nested transactions. // nested transactions.
test("creates user with passkey", async () => { test("creates user with passkey", async () => {
const db = getSharedDb(); const db = getSharedDb();
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
// Step 1: Create registration options // Step 1: Create registration options
@@ -449,7 +458,7 @@ describe("auth.signup", () => {
const challenges = await db const challenges = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.selectAll() .selectAll()
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
@@ -475,7 +484,7 @@ describe("auth.signup", () => {
await db await db
.updateTable("webauthn_challenges") .updateTable("webauthn_challenges")
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
// Step 4: Try to signup with expired challenge // Step 4: Try to signup with expired challenge
@@ -532,18 +541,18 @@ describe("auth.signup", () => {
const challenges = await db const challenges = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.selectAll() .selectAll()
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.createLoginRequest tests // auth.createLoginRequest tests
// ============================================================================= // =============================================================================
describe("auth.createLoginRequest", () => { describe("auth.createLoginRequest", () => {
test("returns auth methods for existing user with password", async () => { test("returns auth methods for existing user with password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, { await createTestUser(db, {
@@ -671,13 +680,13 @@ describe("auth.createLoginRequest", () => {
expect(fingerprint).not.toBeNull(); expect(fingerprint).not.toBeNull();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginPassword tests // auth.loginPassword tests
// ============================================================================= // =============================================================================
describe("auth.loginPassword", () => { describe("auth.loginPassword", () => {
test("completes login immediately for trusted device", async () => { test("completes login immediately for trusted device", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -863,13 +872,13 @@ describe("auth.loginPassword", () => {
).rejects.toThrow("Invalid email or password"); ).rejects.toThrow("Invalid email or password");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginPasswordConfirm tests // auth.loginPasswordConfirm tests
// ============================================================================= // =============================================================================
describe("auth.loginPasswordConfirm", () => { describe("auth.loginPasswordConfirm", () => {
test("marks login request as completed with valid token", async () => { test("marks login request as completed with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -964,13 +973,13 @@ describe("auth.loginPasswordConfirm", () => {
).rejects.toThrow("Invalid or expired confirmation link"); ).rejects.toThrow("Invalid or expired confirmation link");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginIfRequestIsCompleted tests // auth.loginIfRequestIsCompleted tests
// ============================================================================= // =============================================================================
describe("auth.loginIfRequestIsCompleted", () => { describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for incomplete login request", async () => { test("returns pending for incomplete login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1064,7 +1073,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
const loginRequest = await db const loginRequest = await db
.selectFrom("login_requests") .selectFrom("login_requests")
.selectAll() .selectAll()
.where("id", "=", String(loginRequestId)) .where("id", "=", loginRequestId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(loginRequest).toBeUndefined(); expect(loginRequest).toBeUndefined();
@@ -1111,7 +1120,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for fake/non-existent token", async () => { test("returns pending for fake/non-existent token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" }); const ctx = createAPIContext(db, {
loginRequestToken: "fake-token-xyz",
});
const result = await call( const result = await call(
router.auth.loginIfRequestIsCompleted, router.auth.loginIfRequestIsCompleted,
undefined, undefined,
@@ -1142,7 +1153,7 @@ describe("auth.loginIfRequestIsCompleted", () => {
}); });
// Create login request without device fingerprint // Create login request without device fingerprint
const token = `login_test-${String(Date.now())}`; const token = `login_test-${uniqueTestId()}`;
await db await db
.insertInto("login_requests") .insertInto("login_requests")
.values({ .values({
@@ -1165,13 +1176,13 @@ describe("auth.loginIfRequestIsCompleted", () => {
expect(result.status).toBe("pending"); expect(result.status).toBe("pending");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.verifyEmail tests // auth.verifyEmail tests
// ============================================================================= // =============================================================================
describe("auth.verifyEmail", () => { describe("auth.verifyEmail", () => {
test("verifies email with valid token", async () => { test("verifies email with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1247,13 +1258,13 @@ describe("auth.verifyEmail", () => {
expect(verifications.length).toBe(0); expect(verifications.length).toBe(0);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.resendVerificationEmail tests // auth.resendVerificationEmail tests
// ============================================================================= // =============================================================================
describe("auth.resendVerificationEmail", () => { describe("auth.resendVerificationEmail", () => {
test("creates new verification token for unverified user", async () => { test("creates new verification token for unverified user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1344,17 +1355,19 @@ describe("auth.resendVerificationEmail", () => {
const ctx = createAPIContext(db); // No session const ctx = createAPIContext(db); // No session
await expect( await expect(
call(router.auth.resendVerificationEmail, undefined, { context: ctx }), call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
}),
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.forgotPassword tests // auth.forgotPassword tests
// ============================================================================= // =============================================================================
describe("auth.forgotPassword", () => { describe("auth.forgotPassword", () => {
test("creates password reset token for existing user", async () => { test("creates password reset token for existing user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1449,13 +1462,13 @@ describe("auth.forgotPassword", () => {
expect(resets.length).toBe(1); expect(resets.length).toBe(1);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.resetPassword tests // auth.resetPassword tests
// ============================================================================= // =============================================================================
describe("auth.resetPassword", () => { describe("auth.resetPassword", () => {
test("resets password with valid token", async () => { test("resets password with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1503,7 +1516,6 @@ describe("auth.resetPassword", () => {
// Create some sessions // Create some sessions
await createSession(db, user.id); await createSession(db, user.id);
await createSession(db, user.id);
const token = await createPasswordReset(db, user.id); const token = await createPasswordReset(db, user.id);
@@ -1604,13 +1616,13 @@ describe("auth.resetPassword", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.logout tests // auth.logout tests
// ============================================================================= // =============================================================================
describe("auth.logout", () => { describe("auth.logout", () => {
test("revokes current session", async () => { test("revokes current session", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1633,7 +1645,7 @@ describe("auth.logout", () => {
const session = await db const session = await db
.selectFrom("sessions") .selectFrom("sessions")
.select(["revoked_at"]) .select(["revoked_at"])
.where("id", "=", String(sessionId)) .where("id", "=", sessionId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(session?.revoked_at).not.toBeNull(); expect(session?.revoked_at).not.toBeNull();
@@ -1656,13 +1668,13 @@ describe("auth.logout", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// End-to-end login scenarios from docs/initial-app.md // End-to-end login scenarios from docs/initial-app.md
// ============================================================================= // =============================================================================
describe("End-to-end login scenarios", () => { describe("End-to-end login scenarios", () => {
test("Scenario: Password login with trusted device (immediate completion)", async () => { test("Scenario: Password login with trusted device (immediate completion)", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with password and trusted device // Setup: User with password and trusted device
@@ -1875,7 +1887,10 @@ describe("End-to-end login scenarios", () => {
const ctx2 = createAPIContext(db); const ctx2 = createAPIContext(db);
await call( await call(
router.auth.resetPassword, router.auth.resetPassword,
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, {
token: assertDefined(reset).token,
newPassword: "NewSecureP@ss123!",
},
{ context: ctx2 }, { context: ctx2 },
); );
@@ -1967,7 +1982,7 @@ describe("End-to-end login scenarios", () => {
// Clean up registration session // Clean up registration session
await db await db
.deleteFrom("sessions") .deleteFrom("sessions")
.where("id", "=", String(regSessionId)) .where("id", "=", regSessionId.toString())
.execute(); .execute();
// Step 1: Create login request // Step 1: Create login request
@@ -1991,7 +2006,8 @@ describe("End-to-end login scenarios", () => {
loginRequestToken: assertDefined(loginToken), loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint, deviceFingerprint: fingerprint,
}); });
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: ctx2 }, { context: ctx2 },
@@ -2104,4 +2120,63 @@ describe("End-to-end login scenarios", () => {
expect(loginRequest?.completed_at).toBeNull(); expect(loginRequest?.completed_at).toBeNull();
}); });
}); });
}); });
// =============================================================================
// loginRequestMiddleware tests (base.ts)
// =============================================================================
describe("loginRequestMiddleware", () => {
test("rejects request with no login request cookie", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// No login request token in context
const ctx = createAPIContext(db);
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("No login request found");
});
});
test("rejects request with invalid login request token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Invalid token that doesn't exist in DB
const ctx = createAPIContext(db, {
loginRequestToken: "invalid-login-request-token",
});
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("Login request expired or not found");
});
});
test("rejects request with expired login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expiredloginreq@example.com",
});
// Create an expired login request
const { token: loginToken } = await createLoginRequest(
db,
user.id,
user.email,
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("Login request expired or not found");
});
});
});
}); // Close outer describeE2E

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,22 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
destroySharedDb,
getSharedDb,
initTestDb,
KNOWN_AAGUIDS,
TEST_RP,
uniqueTestId,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { getUserPasskeys } from "../../utils/webauthn.js"; import { getUserPasskeys } from "../../utils/webauthn.js";
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
import {
createTestUser,
destroySharedDb,
getSharedDb,
initTestDb,
} from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -58,7 +61,7 @@ async function createSession(
db: Kysely<Database>, db: Kysely<Database>,
userId: number, userId: number,
): Promise<string> { ): Promise<string> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -85,7 +88,7 @@ async function createLoginRequest(
userId: number, userId: number,
email: string, email: string,
): Promise<{ id: number; token: string }> { ): Promise<{ id: number; token: string }> {
const token = `test-login-${String(Date.now())}${String(Math.random())}`; const token = `test-login-${uniqueTestId()}`;
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
const result = await db const result = await db
@@ -198,15 +201,16 @@ async function authenticate(
); );
} }
beforeAll(async () => { describeE2E("webauthn", () => {
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
afterAll(async () => { afterAll(async () => {
await destroySharedDb(); await destroySharedDb();
}); });
describe("registration flow", () => { describe("registration flow", () => {
test("creates registration options with challenge stored in DB via router", async () => { test("creates registration options with challenge stored in DB via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -233,7 +237,7 @@ describe("registration flow", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeDefined(); expect(challengeRow).toBeDefined();
@@ -379,7 +383,7 @@ describe("registration flow", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
@@ -419,9 +423,9 @@ describe("registration flow", () => {
} }
}); });
}); });
}); });
describe("authentication flow", () => { describe("authentication flow", () => {
test("creates authentication options with user's passkeys via router", async () => { test("creates authentication options with user's passkeys via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -483,7 +487,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -525,7 +530,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -563,7 +569,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -579,7 +586,7 @@ describe("authentication flow", () => {
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(authChallengeId)) .where("id", "=", authChallengeId.toString())
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
@@ -636,9 +643,9 @@ describe("authentication flow", () => {
} }
}); });
}); });
}); });
describe("security tests", () => { describe("security tests", () => {
test("rejects replayed credentials (counter check) via router", async () => { test("rejects replayed credentials (counter check) via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -772,9 +779,9 @@ describe("security tests", () => {
} }
}); });
}); });
}); });
describe("full passkey lifecycle", () => { describe("full passkey lifecycle", () => {
test("register → authenticate → add second passkey → authenticate with either via router", async () => { test("register → authenticate → add second passkey → authenticate with either via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "lifecycle@test.com" }); const user = await createTestUser(db, { email: "lifecycle@test.com" });
@@ -834,9 +841,9 @@ describe("full passkey lifecycle", () => {
} }
}); });
}); });
}); });
describe("passkey management", () => { describe("passkey management", () => {
test("lists passkeys with correct data via router", async () => { test("lists passkeys with correct data via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -864,7 +871,9 @@ describe("passkey management", () => {
expect(passkeys).toHaveLength(2); expect(passkeys).toHaveLength(2);
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt) // Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain"); const icloudPasskey = passkeys.find(
(p) => p.name === "iCloud Keychain",
);
if (!icloudPasskey) { if (!icloudPasskey) {
throw new Error("Expected iCloud Keychain passkey to exist"); throw new Error("Expected iCloud Keychain passkey to exist");
} }
@@ -1003,7 +1012,9 @@ describe("passkey management", () => {
email: "delete-with-password@test.com", email: "delete-with-password@test.com",
passwordHash: "fake-password-hash", passwordHash: "fake-password-hash",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(db, user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
@@ -1019,7 +1030,9 @@ describe("passkey management", () => {
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
// Verify passkey is deleted // Verify passkey is deleted
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(0); expect(passkeys).toHaveLength(0);
}); });
@@ -1052,7 +1065,9 @@ describe("passkey management", () => {
); );
// Verify only one passkey remains // Verify only one passkey remains
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.id).not.toBe(firstPasskeyId); expect(firstPasskey.id).not.toBe(firstPasskeyId);
@@ -1066,7 +1081,9 @@ describe("passkey management", () => {
email: "delete-last@test.com", email: "delete-last@test.com",
// No password set // No password set
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(db, user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
@@ -1139,9 +1156,13 @@ describe("passkey management", () => {
} }
// User2's passkey should still exist // User2's passkey should still exist
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { const user2PasskeysAfter = await call(
router.me.passkeys.list,
undefined,
{
context: ctx2, context: ctx2,
}); },
);
expect(user2PasskeysAfter).toHaveLength(1); expect(user2PasskeysAfter).toHaveLength(1);
}); });
@@ -1193,4 +1214,5 @@ describe("passkey management", () => {
expect(firstPasskey.transports).toContain("hybrid"); expect(firstPasskey.transports).toContain("hybrid");
}); });
}); });
}); });
}); // Close outer describe.skipIf

View File

@@ -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",
]; ];

View File

@@ -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();

View File

@@ -52,7 +52,8 @@ export async function signupWithPassword(
// Hash password // Hash password
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
// Create user // Create user (handle race condition if concurrent signup with same email)
try {
const user = await db const user = await db
.insertInto("users") .insertInto("users")
.values({ .values({
@@ -63,6 +64,16 @@ export async function signupWithPassword(
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return user.id; return user.id;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -97,7 +108,7 @@ export async function signupWithPasskey(
const challengeRow = await db const challengeRow = await db
.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("options") .select("options")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.where("created_at", ">", fifteenMinutesAgo) .where("created_at", ">", fifteenMinutesAgo)
.executeTakeFirst(); .executeTakeFirst();
@@ -123,7 +134,7 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
// Log error for debugging but don't expose to client // Log error for debugging but don't expose to client
@@ -138,7 +149,7 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await db await db
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
@@ -146,7 +157,8 @@ export async function signupWithPasskey(
}); });
} }
// Create user and passkey in a transaction // Create user and passkey in a transaction (handle race condition if concurrent signup)
try {
const result = await db.transaction().execute(async (trx) => { const result = await db.transaction().execute(async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
@@ -188,13 +200,23 @@ export async function signupWithPasskey(
// Delete the challenge // Delete the challenge
await trx await trx
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", challengeId.toString())
.execute(); .execute();
return { userId: newUserId }; return { userId: newUserId };
}); });
return result.userId; return result.userId;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -241,7 +263,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
); );
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else { } else {
// Should never reach here due to schema validation // Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required", message: "Either password or passkeyInfo is required",
}); });

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
} }

View File

@@ -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 };

View File

@@ -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

View File

@@ -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 {

View File

@@ -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();
} }
}; };

View File

@@ -19,6 +19,5 @@
"isolatedDeclarations": false, "isolatedDeclarations": false,
"composite": false "composite": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["node_modules", "dist"]
} }

View File

@@ -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",

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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.",
); );

View File

@@ -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);
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View 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}`;
}

View File

@@ -19,6 +19,5 @@
"isolatedDeclarations": false, "isolatedDeclarations": false,
"composite": false "composite": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["node_modules", "dist"]
} }

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;

View File

@@ -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}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Check } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
})); }));
const orgs = $derived(orgsQuery.data ?? []); const orgs = $derived(orgsQuery.data ?? []);
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
function handleOrgSelect(slug: string) { function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}` as any)); goto(resolve("/dashboard/[slug]", { slug }));
} }
</script> </script>
@@ -30,8 +33,13 @@ function handleOrgSelect(slug: string) {
<button <button
{...props} {...props}
aria-label="Switch organization" aria-label="Switch organization"
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm transition-transform duration-200 hover:scale-105" class="group flex h-8 w-8 items-center justify-center transition-transform duration-200 hover:scale-105"
> >
{#if currentOrg}
<OrgAvatar org={currentOrg} size="md" />
{:else}
<!-- Default icon when no org is selected -->
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
<svg <svg
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110" class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -41,6 +49,8 @@ function handleOrgSelect(slug: string) {
> >
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" /> <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div>
{/if}
</button> </button>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
class={cn(isActive && "bg-accent")} class={cn(isActive && "bg-accent")}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if org.logoUrl} <OrgAvatar {org} size="xs" />
<img src={org.logoUrl} alt="" class="h-5 w-5 rounded" />
{:else}
<div class="flex h-5 w-5 items-center justify-center rounded bg-muted text-[10px] font-medium">
{org.displayName.charAt(0).toUpperCase()}
</div>
{/if}
<span class="flex-1 truncate">{org.displayName}</span> <span class="flex-1 truncate">{org.displayName}</span>
{#if isActive} {#if isActive}
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Check class="h-4 w-4 text-primary" />
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if} {/if}
</div> </div>
</DropdownMenu.Item> </DropdownMenu.Item>

View File

@@ -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) {

View File

@@ -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

View File

@@ -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";

View File

@@ -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}

View File

@@ -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}

View File

@@ -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",
});
}

View 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);
}

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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;

View File

@@ -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,
),
);
} }
}); });

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 || "/");
} }
}); });

View File

@@ -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} &middot; {formatDate(new Date(invite.createdAt))} From {invite.invitedBy} &middot; {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>

View File

@@ -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>

View File

@@ -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;
} }

View 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>

View 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>

View File

@@ -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,

View File

@@ -22,7 +22,7 @@
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*", "@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*", "@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0", "@simplewebauthn/types": "^12.0.0",
@@ -35,12 +35,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:", "typescript": "catalog:",
}, },
@@ -77,6 +76,8 @@
"@orpc/client": "^1.13.2", "@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",
@@ -98,6 +99,7 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4", "@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3", "@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
@@ -129,13 +131,24 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/common": {
"name": "@reviq/common",
"version": "0.0.1",
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/db": { "packages/db": {
"name": "@reviq/db", "name": "@reviq/db",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*", "@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"kysely": "^0.28.9", "kysely": "^0.28.9",
"pg": "^8.13.1", "pg": "^8.13.1",
@@ -167,6 +180,47 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/frontend-utils": {
"name": "@reviq/frontend-utils",
"version": "0.0.1",
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/server-utils": {
"name": "@reviq/server-utils",
"version": "0.0.1",
"devDependencies": {
"@cloudflare/workers-types": "^4.20250529.0",
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/testing/test-helpers": {
"name": "@reviq/test-helpers",
"version": "0.0.1",
"dependencies": {
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"kysely": "^0.28.2",
"pg": "^8.16.3",
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/testing/virtual-authenticator": { "packages/testing/virtual-authenticator": {
"name": "@reviq/virtual-authenticator", "name": "@reviq/virtual-authenticator",
"version": "0.0.1", "version": "0.0.1",
@@ -174,22 +228,10 @@
"@simplewebauthn/types": "^12.0.0", "@simplewebauthn/types": "^12.0.0",
}, },
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "latest",
"@types/node": "^25.0.3",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/utils": {
"name": "@reviq/utils",
"version": "0.0.1",
"devDependencies": {
"@cloudflare/workers-types": "^4.20250529.0",
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "^25.0.3",
"eslint": "catalog:", "eslint": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
}, },
@@ -406,11 +448,17 @@
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"], "@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
"@reviq/common": ["@reviq/common@workspace:packages/common"],
"@reviq/db": ["@reviq/db@workspace:packages/db"], "@reviq/db": ["@reviq/db@workspace:packages/db"],
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"], "@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
"@reviq/server-utils": ["@reviq/server-utils@workspace:packages/server-utils"],
"@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"],
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"], "@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
@@ -518,6 +566,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
@@ -946,7 +996,7 @@
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -1154,6 +1204,8 @@
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], "pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],

5
bunfig.toml Normal file
View File

@@ -0,0 +1,5 @@
[test]
coveragePathIgnorePatterns = [
"**/dist/**",
"**/node_modules/**",
]

View File

@@ -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
-- --

View File

@@ -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"
} }

View File

@@ -12,7 +12,7 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "bun test", "test": "bun test src/",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache" "lint": "eslint . --cache"
}, },

View File

@@ -2,6 +2,5 @@
"extends": "@macalinao/tsconfig/tsconfig.base.json", "extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"isolatedDeclarations": false "isolatedDeclarations": false
}, }
"exclude": ["**/*.test.ts"]
} }

136
packages/common/README.md Normal file
View 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

View File

@@ -0,0 +1,26 @@
{
"name": "@reviq/common",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"test": "bun test src/"
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View 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");
});
});

View 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);
}

View File

@@ -0,0 +1,9 @@
export {
type FormatRelativeDateOptions,
formatDate,
formatDateTime,
formatLongDate,
formatRelativeDate,
formatRelativeTime,
} from "./format-date.js";
export { formatRole, getUserInitials } from "./user.js";

View 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("");
});
});

View 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);
}

View 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`)

View File

@@ -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
View 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

View File

@@ -19,7 +19,7 @@
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@reviq/db-schema": "workspace:*", "@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*", "@reviq/server-utils": "workspace:*",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"kysely": "^0.28.9", "kysely": "^0.28.9",
"pg": "^8.13.1" "pg": "^8.13.1"

View File

@@ -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";
/** /**

View File

@@ -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"]
} }

View File

@@ -0,0 +1,15 @@
# @reviq/frontend-utils
Frontend-specific utilities for the RevIQ publisher dashboard.
## Usage
```typescript
import { getOrgColor, getOrgInitials } from "@reviq/frontend-utils";
```
## Exports
- `getOrgInitials(org)` - Get display initials from an organization's slug or display name
- `getOrgColor(org)` - Get a consistent HSL color based on the organization slug
- `OrgLike` - Type interface for organization objects

View File

@@ -0,0 +1,15 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

Some files were not shown because too many files have changed in this diff Show More