Compare commits

...

15 Commits

Author SHA1 Message Date
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
86 changed files with 10098 additions and 4278 deletions

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: countAll<number>()
fix: countAll()

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
## Database Scripts
Use the wrapper scripts instead of running dbmate directly:
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
- `./scripts/db-migrate` - Run migrations and dump clean schema
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
## Development Server
Before starting the dev server, check if it's already running:
@@ -21,3 +29,50 @@ macOS uses BSD sed which differs from GNU sed:
- 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`
- 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

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

View File

@@ -9,9 +9,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
"test": "bun test src/ --no-parallel"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
@@ -34,12 +32,11 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3",
"typescript": "catalog:"
}

File diff suppressed because it is too large Load Diff

View File

@@ -41,14 +41,19 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.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 */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -263,16 +268,17 @@ async function createPasswordReset(
return token;
}
// Test setup
beforeAll(async () => {
describeE2E("auth", () => {
// Test setup
beforeAll(async () => {
await initTestDb();
});
});
// =============================================================================
// auth.signup tests
// =============================================================================
// =============================================================================
// auth.signup tests
// =============================================================================
describe("auth.signup", () => {
describe("auth.signup", () => {
test("creates user with valid password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
@@ -391,7 +397,9 @@ describe("auth.signup", () => {
// nested transactions.
test("creates user with passkey", async () => {
const db = getSharedDb();
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db);
// Step 1: Create registration options
@@ -537,13 +545,13 @@ describe("auth.signup", () => {
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 () => {
await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, {
@@ -671,13 +679,13 @@ describe("auth.createLoginRequest", () => {
expect(fingerprint).not.toBeNull();
});
});
});
});
// =============================================================================
// auth.loginPassword tests
// =============================================================================
// =============================================================================
// auth.loginPassword tests
// =============================================================================
describe("auth.loginPassword", () => {
describe("auth.loginPassword", () => {
test("completes login immediately for trusted device", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -863,13 +871,13 @@ describe("auth.loginPassword", () => {
).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 () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -964,13 +972,13 @@ describe("auth.loginPasswordConfirm", () => {
).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 () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -1111,7 +1119,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for fake/non-existent token", async () => {
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(
router.auth.loginIfRequestIsCompleted,
undefined,
@@ -1165,13 +1175,13 @@ describe("auth.loginIfRequestIsCompleted", () => {
expect(result.status).toBe("pending");
});
});
});
});
// =============================================================================
// auth.verifyEmail tests
// =============================================================================
// =============================================================================
// auth.verifyEmail tests
// =============================================================================
describe("auth.verifyEmail", () => {
describe("auth.verifyEmail", () => {
test("verifies email with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -1247,13 +1257,13 @@ describe("auth.verifyEmail", () => {
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 () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -1344,17 +1354,19 @@ describe("auth.resendVerificationEmail", () => {
const ctx = createAPIContext(db); // No session
await expect(
call(router.auth.resendVerificationEmail, undefined, { context: ctx }),
call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
}),
).rejects.toThrow();
});
});
});
});
// =============================================================================
// auth.forgotPassword tests
// =============================================================================
// =============================================================================
// auth.forgotPassword tests
// =============================================================================
describe("auth.forgotPassword", () => {
describe("auth.forgotPassword", () => {
test("creates password reset token for existing user", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -1449,13 +1461,13 @@ describe("auth.forgotPassword", () => {
expect(resets.length).toBe(1);
});
});
});
});
// =============================================================================
// auth.resetPassword tests
// =============================================================================
// =============================================================================
// auth.resetPassword tests
// =============================================================================
describe("auth.resetPassword", () => {
describe("auth.resetPassword", () => {
test("resets password with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -1503,7 +1515,6 @@ describe("auth.resetPassword", () => {
// Create some sessions
await createSession(db, user.id);
await createSession(db, user.id);
const token = await createPasswordReset(db, user.id);
@@ -1604,13 +1615,13 @@ describe("auth.resetPassword", () => {
).rejects.toThrow();
});
});
});
});
// =============================================================================
// auth.logout tests
// =============================================================================
// =============================================================================
// auth.logout tests
// =============================================================================
describe("auth.logout", () => {
describe("auth.logout", () => {
test("revokes current session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -1656,13 +1667,13 @@ describe("auth.logout", () => {
).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 () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with password and trusted device
@@ -1875,7 +1886,10 @@ describe("End-to-end login scenarios", () => {
const ctx2 = createAPIContext(db);
await call(
router.auth.resetPassword,
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" },
{
token: assertDefined(reset).token,
newPassword: "NewSecureP@ss123!",
},
{ context: ctx2 },
);
@@ -1991,7 +2005,8 @@ describe("End-to-end login scenarios", () => {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: ctx2 },
@@ -2104,4 +2119,63 @@ describe("End-to-end login scenarios", () => {
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,21 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
destroySharedDb,
getSharedDb,
initTestDb,
KNOWN_AAGUIDS,
TEST_RP,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.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 */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -198,15 +200,16 @@ async function authenticate(
);
}
beforeAll(async () => {
describeE2E("webauthn", () => {
beforeAll(async () => {
await initTestDb();
});
});
afterAll(async () => {
afterAll(async () => {
await destroySharedDb();
});
});
describe("registration flow", () => {
describe("registration flow", () => {
test("creates registration options with challenge stored in DB via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -419,9 +422,9 @@ describe("registration flow", () => {
}
});
});
});
});
describe("authentication flow", () => {
describe("authentication flow", () => {
test("creates authentication options with user's passkeys via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -483,7 +486,8 @@ describe("authentication flow", () => {
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: loginCtx },
@@ -525,7 +529,8 @@ describe("authentication flow", () => {
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: loginCtx },
@@ -563,7 +568,8 @@ describe("authentication flow", () => {
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call(
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: loginCtx },
@@ -636,9 +642,9 @@ describe("authentication flow", () => {
}
});
});
});
});
describe("security tests", () => {
describe("security tests", () => {
test("rejects replayed credentials (counter check) via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -772,9 +778,9 @@ describe("security tests", () => {
}
});
});
});
});
describe("full passkey lifecycle", () => {
describe("full passkey lifecycle", () => {
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "lifecycle@test.com" });
@@ -834,9 +840,9 @@ describe("full passkey lifecycle", () => {
}
});
});
});
});
describe("passkey management", () => {
describe("passkey management", () => {
test("lists passkeys with correct data via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
@@ -864,7 +870,9 @@ describe("passkey management", () => {
expect(passkeys).toHaveLength(2);
// 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) {
throw new Error("Expected iCloud Keychain passkey to exist");
}
@@ -1003,7 +1011,9 @@ describe("passkey management", () => {
email: "delete-with-password@test.com",
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);
@@ -1019,7 +1029,9 @@ describe("passkey management", () => {
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
// 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);
});
@@ -1052,7 +1064,9 @@ describe("passkey management", () => {
);
// 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);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.id).not.toBe(firstPasskeyId);
@@ -1066,7 +1080,9 @@ describe("passkey management", () => {
email: "delete-last@test.com",
// 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);
@@ -1139,9 +1155,13 @@ describe("passkey management", () => {
}
// 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,
});
},
);
expect(user2PasskeysAfter).toHaveLength(1);
});
@@ -1193,4 +1213,5 @@ describe("passkey management", () => {
expect(firstPasskey.transports).toContain("hybrid");
});
});
});
});
}); // Close outer describe.skipIf

View File

@@ -52,7 +52,8 @@ export async function signupWithPassword(
// Hash password
const passwordHash = await hashPassword(password);
// Create user
// Create user (handle race condition if concurrent signup with same email)
try {
const user = await db
.insertInto("users")
.values({
@@ -63,6 +64,16 @@ export async function signupWithPassword(
.executeTakeFirstOrThrow();
return user.id;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
}
/**
@@ -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) => {
// Create user
const user = await trx
@@ -195,6 +207,16 @@ export async function signupWithPasskey(
});
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);
} else {
// Should never reach here due to schema validation
// Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required",
});

View File

@@ -115,10 +115,11 @@ export async function countOwners(
): Promise<number> {
const result = await db
.selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count"))
.select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId)
.where("role", "=", "owner")
.executeTakeFirstOrThrow();
return result.count;
// PostgreSQL COUNT returns bigint (string), convert to number
return Number(result.count);
}

View File

@@ -13,7 +13,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test": "bun test"
"test": "bun test src/"
},
"dependencies": {
"@noble/hashes": "^2.0.1",

View File

@@ -15,6 +15,8 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
@@ -36,6 +38,7 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
/* Geist Sans - Modern, clean typeface */
@font-face {

View File

@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js";
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
>
{#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
@@ -28,39 +29,6 @@ let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false);
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string | null): string {
if (!date) {
return "Never";
}
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function openRename(passkey: Passkey) {
selectedPasskey = passkey;
renameDialogOpen = true;

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { gotoLogin } from "$lib/utils/navigation";
interface Props {
children: Snippet;
@@ -12,29 +11,29 @@ interface Props {
let { children }: Props = $props();
// Check if current path is an auth page (doesn't require login)
const isAuthPage = $derived(page.url.pathname.startsWith("/auth"));
// Check if current path is a public page (doesn't require login)
const isPublicPage = $derived(
page.url.pathname.startsWith("/auth") ||
page.url.pathname === "/terms" ||
page.url.pathname === "/privacy",
);
// Fetch user to check if logged in (only for non-auth pages)
// Fetch user to check if logged in (only for protected pages)
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
enabled: !isAuthPage,
enabled: !isPublicPage,
retry: false,
}));
// Redirect to login if not authenticated on non-auth pages
// Redirect to login if not authenticated on protected pages
$effect(() => {
if (!isAuthPage && userQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
),
);
if (!isPublicPage && userQuery.error) {
gotoLogin(page.url.pathname);
}
});
</script>
{#if isAuthPage || userQuery.data || userQuery.isPending}
{#if isPublicPage || userQuery.data || userQuery.isPending}
{@render children()}
{/if}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js";
interface Props {
@@ -27,8 +26,8 @@ const filters = [
<div class="divide-y divide-border/50">
{#each filters as filter (filter.label)}
<a
href={resolve(filter.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={filter.href}
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">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
import {
@@ -33,14 +32,15 @@ const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab,
);
function handleTabChange(tabId: string) {
function handleTabChange(tabId: string): void {
const url = new URL($page.url);
if (tabId === defaultTab) {
url.searchParams.delete("tab");
} else {
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>

View File

@@ -6,7 +6,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
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">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active
@@ -113,8 +112,8 @@ function isActive(href: string): boolean {
<div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
@@ -24,31 +25,15 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
@@ -98,8 +83,8 @@ const navItems = [
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"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">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
@@ -20,27 +21,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
@@ -90,8 +75,8 @@ const navItems = [
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive

View File

@@ -74,8 +74,8 @@ const navItems = $derived.by(() => {
? $page.url.pathname === item.href
: $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive
@@ -163,7 +163,7 @@ const navItems = $derived.by(() => {
{#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a
href={resolve(`/dashboard/${currentSlug}/settings`)}
href={resolve("/dashboard/[slug]/settings", { slug: currentSlug })}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -32,28 +33,11 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
// Nav items depend on whether we're in an org context
const navItems = $derived.by(() => {
if (currentSlug) {
// In org context - org-specific navigation
return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{
@@ -68,7 +52,6 @@ const navItems = $derived.by(() => {
},
];
}
// Outside org context - general navigation
return [
{ icon: "home", href: "/", label: "Home" },
{ icon: "building", href: "/dashboard", label: "Organizations" },
@@ -77,16 +60,17 @@ const navItems = $derived.by(() => {
const queryClient = useQueryClient();
function handleNavClick() {
function handleNavClick(): void {
open = false;
}
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
open = false;
goto(resolve("/auth/login"));
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto("/auth/login");
} catch (error) {
console.error("Failed to sign out:", error);
}
@@ -123,8 +107,8 @@ async function handleSignOut() {
{@const isActive =
$page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a
href={resolve(item.href as any)}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"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">
import { Check } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js";
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
}));
const orgs = $derived(orgsQuery.data ?? []);
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}` as any));
goto(resolve("/dashboard/[slug]", { slug }));
}
</script>
@@ -30,8 +33,13 @@ function handleOrgSelect(slug: string) {
<button
{...props}
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
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
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" />
</svg>
</div>
{/if}
</button>
{/snippet}
</DropdownMenu.Trigger>
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
class={cn(isActive && "bg-accent")}
>
<div class="flex items-center gap-2">
{#if org.logoUrl}
<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}
<OrgAvatar {org} size="xs" />
<span class="flex-1 truncate">{org.displayName}</span>
{#if isActive}
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<Check class="h-4 w-4 text-primary" />
{/if}
</div>
</DropdownMenu.Item>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
@@ -19,30 +20,13 @@ const userQuery = createQuery(() => ({
}));
const user = $derived(userQuery.data);
// 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 initials = $derived(getUserInitials(user));
const queryClient = useQueryClient();
async function handleSignOut() {
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
// Clear all cached queries
queryClient.clear();
goto(resolve("/auth/login"));
} catch (error) {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
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 { resolve } from "$app/paths";
import { page } from "$app/stores";
@@ -16,36 +16,37 @@ let { title, children }: Props = $props();
// Get org context from parent layout
const orgContext = getContext<{ slug: string }>("orgContext");
const slug = $derived(orgContext?.slug);
const slug = $derived(orgContext?.slug ?? "");
// Settings navigation items
const navItems = $derived.by(() => [
// Settings navigation items with route patterns for type-safe resolve()
const navItems = [
{
href: `/dashboard/${slug}/settings`,
route: "/dashboard/[slug]/settings",
icon: Settings,
label: "General",
description: "Organization name, logo, and preferences",
},
{
href: `/dashboard/${slug}/settings/members`,
route: "/dashboard/[slug]/settings/members",
icon: Users,
label: "Members",
description: "Manage team members and invitations",
},
{
href: `/dashboard/${slug}/settings/sites`,
route: "/dashboard/[slug]/settings/sites",
icon: Globe,
label: "Sites",
description: "Connected websites and domains",
},
]);
] as const;
// Determine active item
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
if (href === `/dashboard/${slug}/settings`) {
if (route === "/dashboard/[slug]/settings") {
return activeHref === href;
}
// Prefix match for sub-pages
@@ -59,10 +60,10 @@ function isActive(href: string): boolean {
<nav class="w-full shrink-0 lg:w-64">
<!-- Mobile: horizontal scroll -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
{#each navItems as item (item.route)}
{@const active = isActive(item.route)}
<a
href={resolve(item.href as any)}
href={resolve(item.route, { slug })}
class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active
@@ -78,10 +79,10 @@ function isActive(href: string): boolean {
<!-- Desktop: vertical list -->
<div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
{#each navItems as item (item.route)}
{@const active = isActive(item.route)}
<a
href={resolve(item.href as any)}
href={resolve(item.route, { slug })}
class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active

View File

@@ -1,2 +1,3 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as OrgAvatar } from "./org-avatar.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 lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */
let {
let {
class: className,
variant = "default",
size = "default",
@@ -67,7 +66,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
bind:this={ref}
data-slot="button"
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}
role={disabled ? "link" : 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(() => {
if (orgsQuery.error) {
// 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) {
if (orgsQuery.data.length > 0) {
// 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,
});
} else {

View File

@@ -8,6 +8,7 @@ import {
Plus,
Trash2,
} from "@lucide/svelte";
import { formatDate, formatRelativeDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
@@ -59,33 +60,6 @@ let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null);
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) {
e.preventDefault();
if (!newTokenName.trim() || isCreating) {
@@ -261,9 +235,9 @@ async function handleDelete() {
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
Created {formatRelativeDate(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
· Last used {formatRelativeDate(token.lastUsedAt)}
{:else}
· Never used
{/if}

View File

@@ -8,9 +8,9 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -54,31 +54,6 @@ function formatLocation(device: {
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) {
const nameLower = name.toLowerCase();
if (

View File

@@ -2,7 +2,6 @@
import {
AlertCircle,
ArrowLeft,
Building2,
Calendar,
CheckCircle2,
Clock,
@@ -10,6 +9,7 @@ import {
User,
XCircle,
} from "@lucide/svelte";
import { formatLongDate, formatRole } from "@reviq/common";
import {
createMutation,
createQuery,
@@ -20,6 +20,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import {
@@ -48,12 +49,10 @@ const acceptMutation = createMutation(() => ({
mutationFn: () => api.me.invites.accept({ inviteId }),
onSuccess: () => {
toast.success("You've joined the organization!");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard
if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}`));
} else {
goto(resolve("/dashboard"));
}
@@ -70,7 +69,6 @@ const declineMutation = createMutation(() => ({
mutationFn: () => api.me.invites.decline({ inviteId }),
onSuccess: () => {
toast.success("Invitation declined");
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
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)
*/
@@ -141,17 +121,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if invite.org.logoUrl}
<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}
<OrgAvatar org={invite.org} size="xl" />
<div class="flex-1">
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
<CardDescription class="mt-1">
@@ -187,7 +157,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
</div>
<div>
<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 class="flex items-center gap-3">
@@ -197,7 +167,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div>
<p class="text-sm font-medium">Expires on</p>
<p class="text-sm {isExpiringSoon(new Date(invite.expiresAt)) ? 'text-warning' : 'text-muted-foreground'}">
{formatDate(new Date(invite.expiresAt))}
{formatLongDate(invite.expiresAt)}
</p>
</div>
</div>
@@ -207,7 +177,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Alert>
<Clock class="h-4 w-4" />
<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>
</Alert>
{/if}

View File

@@ -10,6 +10,7 @@ import {
Star,
Tablet,
} from "@lucide/svelte";
import { formatDate, formatRelativeTime } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
@@ -56,36 +57,6 @@ function formatLocation(session: {
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): {
browser: string;
os: string;

View File

@@ -6,6 +6,7 @@ import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js";
import { gotoLogin } from "$lib/utils/navigation";
interface Props {
children: Snippet;
@@ -26,11 +27,7 @@ $effect(() => {
goto(resolve("/dashboard"));
}
if (userQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
gotoLogin(window.location.pathname);
}
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
@@ -22,7 +23,6 @@ import {
TableHeader,
TableRow,
} from "$lib/components/ui/table/index.js";
import { formatDate } from "$lib/utils/format-date.js";
/**
* Admin Organizations list page

View File

@@ -3,12 +3,12 @@ import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Building,
Globe,
Loader2,
Plus,
Trash2,
} from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
@@ -16,7 +16,7 @@ import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
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 { Button } from "$lib/components/ui/button";
import {
@@ -37,7 +37,6 @@ import {
TableHeader,
TableRow,
} from "$lib/components/ui/table";
import { formatDate } from "$lib/utils/format-date.js";
/**
* Admin organization details page
@@ -259,19 +258,7 @@ async function executeConfirmAction() {
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if org.logoUrl}
<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}
<OrgAvatar {org} size="xl" />
<div class="flex-1">
<CardTitle class="text-2xl">{org.displayName}</CardTitle>
<p class="mt-1 text-sm text-muted-foreground">

View File

@@ -81,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
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
<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>
</div>
</div>

View File

@@ -59,7 +59,8 @@ const statusQuery = createQuery(() => ({
$effect(() => {
if (statusQuery.data?.status === "completed") {
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,
Mail,
} from "@lucide/svelte";
import { formatRelativeDate, formatRole } from "@reviq/common";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
import { OrgAvatar } from "$lib/components/org";
import { Badge } from "$lib/components/ui/badge";
import {
Card,
@@ -19,6 +20,7 @@ import {
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { gotoLogin } from "$lib/utils/navigation";
/**
* Dashboard page - lists all organizations the user is a member of
@@ -40,48 +42,9 @@ const invitesQuery = createQuery(() => ({
// Redirect to login on auth error
$effect(() => {
if (orgsQuery.error) {
goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
gotoLogin(window.location.pathname);
}
});
/**
* 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>
<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">
{#each invitesQuery.data as invite (invite.id)}
<a
href={resolve(`/account/org-invites/${invite.id}`)}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
class="group block"
>
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3">
{#if invite.org.logoUrl}
<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}
<OrgAvatar org={invite.org} size="lg" />
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{invite.org.displayName}
@@ -133,7 +86,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
From {invite.invitedBy} &middot; {formatDate(new Date(invite.createdAt))}
From {invite.invitedBy} &middot; {formatRelativeDate(invite.createdAt)}
</p>
</CardContent>
</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">
{#each orgsQuery.data as org (org.id)}
<a
href={resolve(`/dashboard/${org.slug}`)}
href={resolve("/dashboard/[slug]", { slug: org.slug })}
class="group block transition-transform hover:scale-[1.02]"
>
<Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-3">
<div class="flex items-start gap-3">
<!-- Logo or placeholder -->
{#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}
<OrgAvatar {org} size="lg" />
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{org.displayName}
@@ -216,7 +158,7 @@ function formatRole(role: string): string {
</CardHeader>
<CardContent class="pt-0">
<p class="text-xs text-muted-foreground">
Created {formatDate(new Date(org.createdAt))}
Created {formatRelativeDate(org.createdAt)}
</p>
</CardContent>
</Card>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import {
AlertCircle,
Building2,
ChevronRight,
Globe,
Loader2,
@@ -13,7 +12,7 @@ import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
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 {
Card,
@@ -98,17 +97,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Header with org info -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
{#if orgQuery.data?.logoUrl}
<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}
<OrgAvatar org={orgQuery.data} size="xl" />
<div>
<h1 class="text-2xl font-semibold">{orgName}</h1>
<p class="text-sm text-muted-foreground">{slug}</p>

View File

@@ -46,9 +46,8 @@ async function acceptInvite(): Promise<void> {
if (!isAuthenticated) {
// Redirect to login with return URL
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
goto(
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any),
);
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent(returnUrl)}`);
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",
"indentWidth": 2
},
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "error"
}
}
},
"overrides": [
{
// Svelte 5 runes require `let` for $props(), template variables/imports appear unused to Biome,

View File

@@ -35,12 +35,11 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3",
"typescript": "catalog:",
},
@@ -77,6 +76,8 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
@@ -98,6 +99,7 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",
@@ -129,6 +131,17 @@
"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": {
"name": "@reviq/db",
"version": "0.0.1",
@@ -167,6 +180,35 @@
"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/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": {
"name": "@reviq/virtual-authenticator",
"version": "0.0.1",
@@ -176,7 +218,7 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "latest",
"@types/bun": "catalog:",
"@types/node": "^25.0.3",
"eslint": "catalog:",
"typescript": "catalog:",
@@ -406,10 +448,16 @@
"@reviq/cli": ["@reviq/cli@workspace:apps/cli"],
"@reviq/common": ["@reviq/common@workspace:packages/common"],
"@reviq/db": ["@reviq/db@workspace:packages/db"],
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
"@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"],
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
"@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/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=="],
"@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-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=="],
@@ -1154,6 +1204,8 @@
"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=="],
"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 by pg_dump version 17.7
@@ -1084,7 +1083,6 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete
--
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
--

View File

@@ -16,6 +16,10 @@
"typecheck": "turbo typecheck",
"clean": "turbo clean",
"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"
},
"devDependencies": {
@@ -32,5 +36,5 @@
"tslib": "^2.8.1",
"typescript": "^5.7.2"
},
"packageManager": "bun@1.1.42"
"packageManager": "bun@1.3.5"
}

View File

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

134
packages/common/README.md Normal file
View File

@@ -0,0 +1,134 @@
# @reviq/common
Shared utilities for all RevIQ applications. This package contains environment-agnostic code that works in browsers, Node.js, Bun, and other JavaScript runtimes.
## 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,15 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

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 `${String(diffDays)} days ago`;
}
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return weeks === 1 ? "1 week ago" : `${String(weeks)} 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,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}

View File

@@ -1,15 +1,7 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,14 +1,7 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"types": ["node", "bun"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

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,
},
},
},
];

View File

@@ -0,0 +1,26 @@
{
"name": "@reviq/frontend-utils",
"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"
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { getOrgColor, getOrgInitials, type OrgLike } from "./org.js";

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "bun:test";
import { getOrgColor, getOrgInitials } from "./org.js";
describe("getOrgInitials", () => {
it("returns first letters of first two words for multi-word names", () => {
expect(getOrgInitials({ displayName: "Acme Corporation" })).toBe("AC");
expect(getOrgInitials({ displayName: "Big Tech Inc" })).toBe("BT");
expect(getOrgInitials({ displayName: "The New York Times" })).toBe("TN");
});
it("returns first two characters for single word names", () => {
expect(getOrgInitials({ displayName: "Acme" })).toBe("AC");
expect(getOrgInitials({ displayName: "Google" })).toBe("GO");
});
it("handles short names", () => {
expect(getOrgInitials({ displayName: "A" })).toBe("A");
expect(getOrgInitials({ displayName: "AB" })).toBe("AB");
});
it("handles null/undefined", () => {
expect(getOrgInitials(null)).toBe("?");
expect(getOrgInitials(undefined)).toBe("?");
});
it("handles empty display name", () => {
expect(getOrgInitials({ displayName: "" })).toBe("?");
expect(getOrgInitials({ displayName: " " })).toBe("?");
});
it("uppercases the result", () => {
expect(getOrgInitials({ displayName: "acme corp" })).toBe("AC");
expect(getOrgInitials({ displayName: "acme" })).toBe("AC");
});
});
describe("getOrgColor", () => {
it("returns a color string", () => {
const color = getOrgColor({ displayName: "Acme Corp" });
expect(color).toMatch(/^from-\w+-\d+ to-\w+-\d+$/);
});
it("returns the same color for the same org name", () => {
const color1 = getOrgColor({ displayName: "Acme Corp" });
const color2 = getOrgColor({ displayName: "Acme Corp" });
expect(color1).toBe(color2);
});
it("returns consistent color regardless of leading/trailing whitespace", () => {
const color1 = getOrgColor({ displayName: "Acme Corp" });
const color2 = getOrgColor({ displayName: " Acme Corp " });
expect(color1).toBe(color2);
});
it("handles null/undefined", () => {
expect(getOrgColor(null)).toMatch(/^from-\w+-\d+ to-\w+-\d+$/);
expect(getOrgColor(undefined)).toMatch(/^from-\w+-\d+ to-\w+-\d+$/);
});
it("handles empty/whitespace display name", () => {
expect(getOrgColor({ displayName: "" })).toMatch(
/^from-\w+-\d+ to-\w+-\d+$/,
);
expect(getOrgColor({ displayName: " " })).toMatch(
/^from-\w+-\d+ to-\w+-\d+$/,
);
});
});

View File

@@ -0,0 +1,86 @@
/**
* Organization-related utility functions for frontend display
*/
/**
* Minimal org shape needed for avatar display
*/
export interface OrgLike {
displayName: string;
logoUrl?: string | null;
}
/**
* Generate initials from an organization's display name.
* - For names with 2+ words: first letter of first two words (e.g., "Acme Corp" -> "AC")
* - For single word names: first 2 characters (e.g., "Acme" -> "AC")
*
* @example
* getOrgInitials({ displayName: "Acme Corporation" }) // "AC"
* getOrgInitials({ displayName: "Acme" }) // "AC"
* getOrgInitials({ displayName: "A" }) // "A"
*/
export function getOrgInitials(org: OrgLike | null | undefined): string {
if (!org?.displayName) {
return "?";
}
const name = org.displayName.trim();
if (!name) {
return "?";
}
const words = name.split(/\s+/).filter(Boolean);
const [first, second] = words;
if (first && second) {
return (first.charAt(0) + second.charAt(0)).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
/**
* Color palette for org avatars. These are Tailwind gradient classes.
*/
const ORG_COLORS = [
"from-blue-500 to-blue-600",
"from-emerald-500 to-emerald-600",
"from-violet-500 to-violet-600",
"from-amber-500 to-amber-600",
"from-rose-500 to-rose-600",
"from-cyan-500 to-cyan-600",
"from-fuchsia-500 to-fuchsia-600",
"from-lime-500 to-lime-600",
] as const;
const DEFAULT_COLOR = ORG_COLORS[0];
/**
* Get a deterministic color class for an organization based on its name.
* The same org name will always return the same color.
* Uses trimmed name for consistency with getOrgInitials.
*
* @example
* getOrgColor({ displayName: "Acme Corp" }) // "from-blue-500 to-blue-600"
*/
export function getOrgColor(org: OrgLike | null | undefined): string {
if (!org?.displayName) {
return DEFAULT_COLOR;
}
const name = org.displayName.trim();
if (!name) {
return DEFAULT_COLOR;
}
// Simple hash based on character codes
let hash = 0;
for (const char of name) {
hash = (hash << 5) - hash + char.charCodeAt(0);
hash &= hash; // Convert to 32-bit integer
}
const index = Math.abs(hash) % ORG_COLORS.length;
return ORG_COLORS[index] ?? DEFAULT_COLOR;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}

View File

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

View File

@@ -0,0 +1,33 @@
{
"name": "@reviq/test-helpers",
"version": "0.0.1",
"private": true,
"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"
},
"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:"
}
}

View File

@@ -0,0 +1,18 @@
export { describeE2E, SKIP_DB_TESTS } from "./skip-db-tests.js";
export {
DEFAULT_TEST_AAGUID,
KNOWN_AAGUIDS,
TEST_RP,
} from "./test-constants.js";
export {
createTestDb,
createTestUser,
destroySharedDb,
destroyTestDb,
getSharedDb,
getTestDatabaseUrl,
initTestDb,
runMigrations,
truncateAllTables,
} from "./test-db.js";
export { withTestTransaction } from "./test-transaction.js";

View File

@@ -0,0 +1,18 @@
import { describe } from "bun:test";
/**
* Skip flag for database-dependent tests.
* Set SKIP_DB_TESTS=1 to skip e2e tests that require a database.
*/
export const SKIP_DB_TESTS: boolean = process.env.SKIP_DB_TESTS === "1";
const _describeSkipIf = describe.skipIf(SKIP_DB_TESTS);
/**
* Use for describe blocks that require database access.
* Automatically prefixes name with [e2e].
* Skips tests when SKIP_DB_TESTS=1 is set.
*/
export function describeE2E(name: string, fn: () => void): void {
_describeSkipIf(`[e2e] ${name}`, fn);
}

View File

@@ -64,20 +64,31 @@ export function getTestDatabaseUrl(): string {
}
/**
* Parses a postgres URL to extract components
* Parses a postgres URL to extract components.
* Supports both TCP and unix socket connections.
*
* Unix socket URL format: postgresql:///dbname?host=/var/run/postgresql
*/
function parsePostgresUrl(url: string): {
host: string;
port: number;
port: number | undefined;
user: string;
password: string;
database: string;
} {
const parsed = new URL(url);
// Unix socket: hostname is empty, socket path in `host` query param
const isUnixSocket = !parsed.hostname;
const socketPath = parsed.searchParams.get("host");
return {
host: parsed.hostname,
port: Number.parseInt(parsed.port || "5432", 10),
user: parsed.username,
host: isUnixSocket
? (socketPath ?? "/var/run/postgresql")
: parsed.hostname,
port: isUnixSocket ? undefined : Number.parseInt(parsed.port || "5432", 10),
// eslint-disable-next-line turbo/no-undeclared-env-vars, @typescript-eslint/prefer-nullish-coalescing -- USER is a system env var, and we want empty string to fall back
user: parsed.username || process.env.USER || "postgres",
password: parsed.password,
database: parsed.pathname.slice(1), // Remove leading /
};

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}

View File

@@ -3,14 +3,19 @@
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"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"
"test": "bun test src/"
},
"dependencies": {
"@simplewebauthn/types": "^12.0.0"
@@ -18,7 +23,7 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "latest",
"@types/bun": "catalog:",
"@types/node": "^25.0.3",
"eslint": "catalog:",
"typescript": "catalog:"

View File

@@ -1,15 +1,7 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"types": ["node", "bun"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -14,7 +14,7 @@
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"test": "bun test"
"test": "bun test src/"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250529.0",

16
scripts/db-dump Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Wrapper for dbmate dump that strips PostgreSQL's \restrict lines.
# PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump output
# (CVE-2025-8714 security fix), causing schema.sql to change on every dump.
set -euo pipefail
SCHEMA_FILE="${DBMATE_SCHEMA_FILE:-./db/schema.sql}"
dbmate dump "$@"
# Strip \restrict and \unrestrict lines (they start with backslash)
if [[ -f "$SCHEMA_FILE" ]]; then
grep -v '^\\' "$SCHEMA_FILE" > "${SCHEMA_FILE}.tmp"
mv "${SCHEMA_FILE}.tmp" "$SCHEMA_FILE"
fi

16
scripts/db-migrate Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Wrapper for dbmate migrate that strips PostgreSQL's \restrict lines.
# PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump output
# (CVE-2025-8714 security fix), causing schema.sql to change on every dump.
set -euo pipefail
SCHEMA_FILE="${DBMATE_SCHEMA_FILE:-./db/schema.sql}"
dbmate migrate "$@"
# Strip \restrict and \unrestrict lines (they start with backslash)
if [[ -f "$SCHEMA_FILE" ]]; then
grep -v '^\\' "$SCHEMA_FILE" > "${SCHEMA_FILE}.tmp"
mv "${SCHEMA_FILE}.tmp" "$SCHEMA_FILE"
fi

View File

@@ -1,6 +1,6 @@
ruleDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rules/
- .ast-grep/rules/
testConfigs:
- testDir: /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rule-tests/
- testDir: .ast-grep/rule-tests/
utilDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/utils/
- .ast-grep/utils/

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["DATABASE_URL", "PORT"],
"globalEnv": ["DATABASE_URL", "PORT", "TEST_DATABASE_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
@@ -33,6 +33,7 @@
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.test.ts"],
"env": ["SKIP_DB_TESTS", "TEST_DATABASE_URL"],
"cache": false
}
}