Compare commits

...

25 Commits

Author SHA1 Message Date
igm
4d9fbdeed5 Add tea 0.10.1 nix derivation and Gitea PR skill
- Pin tea CLI to 0.10.1 to avoid TTY bug in 0.11.x
- Add .claude/skills/gitea for PR creation workflow
- Document tea CLI usage in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:59:47 +08:00
igm
9a119da96e Update db and db-schema packages to export from dist/
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:22:04 +08:00
igm
7358129802 Fix TypeScript and linting errors across publisher-dashboard
- Add type assertions for dynamic route paths in goto() and resolve()
- Add missing key attributes to {#each} blocks
- Wrap navigation hrefs with resolve() for SvelteKit compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:10:27 +08:00
igm
a02e1f0862 Merge branch 'tea-cli' 2026-01-10 19:47:11 +08:00
igm
2fb42c0fa5 add gitea cli 2026-01-10 19:47:06 +08:00
igm
3d42324750 Merge branch 'svelte-lint'
# Conflicts:
#	apps/publisher-dashboard/src/lib/components/account/account-nav.svelte
2026-01-10 19:42:12 +08:00
igm
ac4b8dc99a Add eslint-plugin-svelte and fix all Svelte linting errors
- Configure eslint-plugin-svelte with TypeScript parser support
- Add keys to all {#each} blocks for proper reactivity
- Wrap navigation paths with resolve() from $app/paths
- Remove unnecessary children snippets and useless mustaches
- Add @typescript-eslint/parser and svelte-eslint-parser dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:34:25 +08:00
igm
cf71cb63d7 Update account settings layout to match org settings
Add left nav with descriptions on desktop and horizontal tabs on mobile,
consistent with the organization settings layout pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:31:16 +08:00
igm
730021a5ea Merge branch 'parallelize-tests-better' 2026-01-10 19:17:50 +08:00
igm
c698a85cc1 update readme 2026-01-10 19:17:48 +08:00
igm
462799ca3d Apply linting fixes and update schema
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:17:39 +08:00
igm
dcb48a5d5e Migrate e2e tests to transaction-based isolation
Replace table truncation with transaction rollback for test isolation.
Each test now runs in a transaction that auto-rolls back, improving
test performance and isolation. Tests that call procedures with internal
transactions use getSharedDb() directly with appropriate comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:16:47 +08:00
igm
8f3a1f2962 Merge origin/master into reviq-auth-login-command
Resolved conflicts:
- apps/api-server/src/router.ts: Use meRoutes from master
- packages/api-contract/src/contract.ts: Keep master's nested sessions/devices/invites structure, add apiTokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:03:37 +08:00
igm
a7d6beaf5a Add API token management for CLI authentication
- Add reviq auth login --token <token> command for CLI authentication
- Create /account/api-tokens page for token management (superuser only)
- Add me.apiTokens endpoints (list, create, delete)
- Require superuser status and trusted session for token creation
- Show API Tokens nav link only for superusers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:58:27 +08:00
igm
48ffba6c5f Apply linting fixes to layout components
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:49:49 +08:00
igm
1b46fc0ecc delete unsued 2026-01-10 18:27:39 +08:00
igm
587d17c39c Update README with comprehensive project documentation
- Add tech stack overview (frontend, backend, shared packages)
- Document project structure with directory tree
- Expand setup instructions with manual development option
- Add scripts reference table
- Document features (auth, organizations, dashboard)
- Add frontend routes overview
- Document API structure and namespaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:25:22 +08:00
RevIQ
cca901a9b9 Merge branch 'whats-left' 2026-01-10 18:10:40 +08:00
RevIQ
42badf3c52 Add DBIP city database and improve geo test coverage
- Add dbip-city-lite package to devenv for GeoIP testing
- Set GEOIP_DATABASE_PATH env var to point to the MMDB database
- Add tests for initGeoReader double-init and error handling
- Add real database tests for IP lookups (US, AU, DE, GB)
- Make real database tests conditional with describe.skipIf
- Improve test coverage from ~97% to 98.82%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:10:30 +08:00
RevIQ
bd53a60497 Merge branch 'more-testing-stuff' 2026-01-10 18:10:03 +08:00
RevIQ
d486e2444e Add org settings layout with responsive nav and member management
- Create SettingsLayout component with left sidebar nav (desktop) and
  horizontal scroll nav (mobile)
- Add settings gear icon to sidebar (Lucide icon, only in org context)
- Fix home icon highlighting to only match exact org home path
- Create /settings/members route with full member management
- Create /settings/sites placeholder route
- Update general settings to use new SettingsLayout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:09:17 +08:00
RevIQ
319edf70db Fix IP address not being set on sessions from localhost
The extractClientIP() function only checked proxy headers (X-Forwarded-For,
CF-Connecting-IP, etc.) which don't exist when running locally without a proxy.

Changes:
- Add clientIP field to APIContext
- Use Bun's server.requestIP() to get client IP from direct socket connection
- Update getGeoInfo() to accept fallback IP parameter
- Pass context.clientIP to getGeoInfo() in auth procedures

Now sessions will have IP address set even for local development (::1 or 127.0.0.1).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:08:21 +08:00
RevIQ
74b26818ca Add comprehensive e2e tests for all auth procedures
Tests cover all login scenarios from docs/initial-app.md:
- Signup with password and passkey
- Password login with trusted device (immediate completion)
- Password login with untrusted device (email confirmation)
- Full passkey authentication flow
- User with no auth methods (stays pending)
- Non-existent email (anti-enumeration with fake token)
- Email verification and resend flows
- Password reset with session revocation
- Logout

All auth procedures now have 100% function coverage.
127 tests passing across 3 e2e test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:55:39 +08:00
RevIQ
b93f5e0b69 lint 2026-01-10 17:52:00 +08:00
RevIQ
fb68f341dd Reorganize layouts with dedicated admin sidebar (dark theme)
- Create admin layout with dark sidebar (zinc-900 background, light text)
- Move dashboard components to layout/dashboard/ subfolder
- Move admin components to layout/admin/ subfolder
- Admin sidebar has: Dashboard, Organizations, Users nav items
- Admin header shows "Admin" badge and "Exit Admin" link
- Update all route imports to use new barrel exports
- Add macOS sed syntax reference to CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:45:17 +08:00
95 changed files with 6673 additions and 2229 deletions

View File

@@ -0,0 +1,77 @@
---
name: gitea
description: Create pull requests on Gitea using the tea CLI. Use when the user asks to "create a PR", "open a pull request", "make a PR", "submit PR", or any variation involving pull requests for this repository.
---
# Gitea Pull Requests
This project uses Gitea (git.rev.iq) for hosting and the `tea` CLI for creating pull requests.
## Prerequisites
- The `tea` CLI is installed via devenv (pinned to 0.10.1 to avoid TTY bugs in 0.11.x)
- Login is configured via `~/.config/tea/config.yml`
## Creating a Pull Request
When asked to create a PR, follow these steps:
### 1. Check current state
```bash
git status
git log --oneline -5
git diff master...HEAD --stat
```
### 2. Ensure changes are committed and pushed
If there are uncommitted changes, commit them first. Then push:
```bash
git push -u origin <branch-name>
```
### 3. Create the PR using tea
```bash
tea pr create \
-r igm/publisher-dashboard \
--title "PR title here" \
--description "## Summary
- Change 1
- Change 2
🤖 Generated with [Claude Code](https://claude.ai/code)" \
--head <source-branch> \
--base master
```
**Important flags:**
- `-r igm/publisher-dashboard` - Always specify the repo explicitly (required due to SSH remote detection issues)
- `--head` - The source branch (your feature branch)
- `--base` - The target branch (usually `master`)
### 4. Return the PR URL
The command outputs the PR URL. Always share this with the user.
## Example Output
```
# #1 Update packages to export from dist/ (open)
@igm created 2024-01-11 **master** <- **fix-exports**
--------
• No Conflicts
• Maintainers are allowed to edit
https://git.rev.iq/igm/publisher-dashboard/pulls/1
```
## Troubleshooting
- If tea fails with TTY errors, ensure you're using tea 0.10.1 (configured in `nix/tea.nix`)
- The repo flag `-r igm/publisher-dashboard` is required because the SSH remote isn't auto-detected

View File

@@ -6,3 +6,18 @@ Before starting the dev server, check if it's already running:
- Use `lsof -i :6827` or check for existing background tasks - Use `lsof -i :6827` or check for existing background tasks
- The dev server runs on port 6827 (may fall back to 6828 if port is in use) - The dev server runs on port 6827 (may fall back to 6828 if port is in use)
- Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up` - Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
## Pull Requests
This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
- Use the `/gitea` skill when creating PRs
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
## macOS sed Syntax
macOS uses BSD sed which differs from GNU sed:
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
- GNU sed (Linux): `sed -i 's/old/new/g' file`
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`

143
README.md
View File

@@ -1,9 +1,57 @@
# Reviq Publisher Dashboard # Reviq Publisher Dashboard
A modern publisher dashboard for managing organizations, members, and sites. Built as a monorepo with SvelteKit frontend and oRPC API server.
## Tech Stack
### Frontend (`apps/publisher-dashboard`)
- **SvelteKit** with Svelte 5 (runes)
- **Tailwind CSS v4** for styling
- **TanStack Query** for data fetching
- **bits-ui** for accessible UI primitives
- **Lucide** for icons
- **WebAuthn/Passkeys** for passwordless authentication
### Backend (`apps/api-server`)
- **Bun** runtime
- **oRPC** for type-safe API (contract-first)
- **Kysely** for type-safe SQL queries
- **PostgreSQL** database
- **Postmark** for transactional emails
### CLI (`apps/cli`)
- **Stricli** for command parsing
- API token-based authentication
- User, organization, and site management commands
### Shared Packages
- `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/db` - Database client and queries
- `@reviq/db-schema` - Database schema and codegen
- `@reviq/utils` - Shared utilities
## Project Structure
```
publisher-dashboard/
├── apps/
│ ├── api-server/ # Backend API server
│ ├── cli/ # Command-line interface
│ └── publisher-dashboard/ # SvelteKit frontend
├── packages/
│ ├── api-contract/ # Shared oRPC contract
│ ├── db/ # Database client
│ ├── db-schema/ # DB schema & codegen
│ ├── testing/ # Test utilities
│ └── utils/ # Shared utilities
└── db/ # Database migrations
```
## Setup ## Setup
### Prerequisites ### Prerequisites
- [Bun](https://bun.sh/) v1.1.42+
- [devenv](https://devenv.sh/) for development environment management - [devenv](https://devenv.sh/) for development environment management
### Environment Variables ### Environment Variables
@@ -29,9 +77,104 @@ devenv up
This starts: This starts:
- PostgreSQL database - PostgreSQL database
- Publisher dashboard dev server (port 6827) - Publisher dashboard dev server (port 6827)
- API server
- Package build watcher - Package build watcher
The database is automatically initialized with: The database is automatically initialized with:
- Database: `reviq-dashboard` - Database: `reviq-dashboard`
- User: `reviq` - User: `reviq`
- Password: `reviq` - Password: `reviq`
### Manual Development
If not using devenv, start services individually:
```bash
# Install dependencies
bun install
# Build packages first
bun run build:packages
# Start dev server
bun run dev
```
## Scripts
| Script | Description |
|--------|-------------|
| `bun run dev` | Start all dev servers |
| `bun run build` | Build all packages and apps |
| `bun run typecheck` | Run TypeScript type checking |
| `bun run lint` | Run Biome and ESLint |
| `bun run lint:fix` | Fix linting issues |
| `bun run test` | Run tests |
| `bun run db:codegen` | Generate database types |
## CLI
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
Quick start:
```bash
# Build the CLI
bun run --cwd apps/cli build
# Login with an API token
./apps/cli/dist/reviq auth login --token <your-token>
# Check status
./apps/cli/dist/reviq auth status
```
## Features
### Authentication
- Passwordless login with passkeys (WebAuthn)
- Email verification
- Session management with device tracking
### Organizations
- Create and manage organizations
- Member management with roles (owner, admin, member)
- Invite members via email
- Organization settings
### Dashboard
- Organization switcher
- Performance metrics
- Reports (coming soon)
- Site management (coming soon)
## Architecture
### Frontend Routes
```
/ # Landing page
/login # Login page
/dashboard # Organization list
/dashboard/[slug] # Organization home
/dashboard/[slug]/performance # Performance metrics
/dashboard/[slug]/reports # Reports (placeholder)
/dashboard/[slug]/settings # Organization settings
├── /members # Member management
└── /sites # Sites (placeholder)
/account # User account settings
├── /security # Security settings
└── /sessions # Active sessions
/admin # Admin panel
```
### API Structure
The API uses oRPC with a contract-first approach. Routes are defined in `@reviq/api-contract` and implemented in `apps/api-server`.
Key API namespaces:
- `auth` - Authentication (passkeys, sessions)
- `me` - Current user profile
- `orgs` - Organization management
- `orgs.members` - Member management
- `orgs.invites` - Invitation management

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -238,3 +238,64 @@ export async function createTestUser(
export async function destroyTestDb(db: Kysely<Database>): Promise<void> { export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
await db.destroy(); await db.destroy();
} }
// ============================================================================
// Shared Database Singleton (for transaction-based test isolation)
// ============================================================================
let sharedDb: Kysely<Database> | null = null;
/**
* Initialize the shared test database once.
* Runs migrations and truncates all tables to start with a clean slate.
* Subsequent calls return the existing connection.
*
* Use this with `withTestTransaction()` for fast test isolation.
*
* @example
* ```typescript
* beforeAll(async () => {
* await initTestDb();
* });
*
* test("does something", async () => {
* await withTestTransaction(getSharedDb(), async (db) => {
* // test code using db
* });
* });
* ```
*/
export async function initTestDb(): Promise<Kysely<Database>> {
if (!sharedDb) {
await runMigrations();
sharedDb = createTestDb();
await truncateAllTables(sharedDb); // Clean slate once at start
}
return sharedDb;
}
/**
* Get the shared test database connection.
* Must call `initTestDb()` first.
*
* @throws Error if database not initialized
*/
export function getSharedDb(): Kysely<Database> {
if (!sharedDb) {
throw new Error(
"Test DB not initialized. Call initTestDb() in beforeAll first.",
);
}
return sharedDb;
}
/**
* Destroy the shared test database connection.
* Call this in a global afterAll if needed.
*/
export async function destroySharedDb(): Promise<void> {
if (sharedDb) {
await sharedDb.destroy();
sharedDb = null;
}
}

View File

@@ -0,0 +1,60 @@
/**
* Transaction-based test isolation helper
*
* Wraps test code in a transaction that auto-rollbacks, providing
* fast test isolation without truncating tables between tests.
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
/**
* Signal used to trigger transaction rollback after test completes
*/
class RollbackSignal extends Error {
constructor() {
super("RollbackSignal");
this.name = "RollbackSignal";
}
}
/**
* Runs a test function inside a transaction that auto-rollbacks.
*
* The transaction implements the same interface as Kysely<Database>,
* so it can be passed to context builders and used for all queries.
* After the test completes, the transaction is rolled back, providing
* instant cleanup without truncating tables.
*
* @example
* ```typescript
* test("creates user", async () => {
* await withTestTransaction(getSharedDb(), async (db) => {
* const user = await createTestUser(db, { email: "test@example.com" });
* const ctx = createAPIContext({ db });
* // ... test code
* }); // Auto-rollback here
* });
* ```
*/
export async function withTestTransaction<T>(
db: Kysely<Database>,
testFn: (trx: Kysely<Database>) => Promise<T>,
): Promise<T | undefined> {
let result: T | undefined;
try {
await db.transaction().execute(async (trx) => {
result = await testFn(trx);
// Force rollback by throwing after test completes successfully
throw new RollbackSignal();
});
} catch (e) {
// Swallow the rollback signal - this is expected behavior
if (!(e instanceof RollbackSignal)) {
throw e;
}
}
return result;
}

View File

@@ -21,6 +21,8 @@ export interface APIContext {
reqHeaders: Headers; reqHeaders: Headers;
/** Response headers (for setting cookies) */ /** Response headers (for setting cookies) */
resHeaders: Headers; resHeaders: Headers;
/** Client IP address from direct connection (fallback when no proxy headers) */
clientIP?: string | null;
} }
/** /**

View File

@@ -39,7 +39,7 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME;
Bun.serve({ Bun.serve({
port, port,
async fetch(request) { async fetch(request, server) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname.startsWith("/api/v1/rpc")) { if (url.pathname.startsWith("/api/v1/rpc")) {
@@ -50,6 +50,10 @@ Bun.serve({
// Create response headers for setting cookies // Create response headers for setting cookies
const resHeaders = new Headers(); const resHeaders = new Headers();
// Get client IP from Bun's server (fallback for when no proxy headers)
const socketInfo = server.requestIP(request);
const clientIP = socketInfo?.address ?? null;
const context: APIContext = { const context: APIContext = {
db, db,
origin, origin,
@@ -57,6 +61,7 @@ Bun.serve({
rpName, rpName,
reqHeaders: request.headers, reqHeaders: request.headers,
resHeaders, resHeaders,
clientIP,
}; };
const { response } = await handler.handle(request, { const { response } = await handler.handle(request, {

View File

@@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
const hasPassword = user.password_hash !== null; const hasPassword = user.password_hash !== null;
// Get geo info and user agent // Get geo info and user agent
const geo = getGeoInfo(context.reqHeaders); const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
// Create login request with secure token // Create login request with secure token

View File

@@ -86,7 +86,7 @@ export const loginIfRequestIsCompleted =
} }
// Get current request info // Get current request info
const geo = getGeoInfo(context.reqHeaders); const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
// Upsert user device // Upsert user device

View File

@@ -225,7 +225,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
} }
// Get geo info and user agent for session creation // Get geo info and user agent for session creation
const geo = getGeoInfo(context.reqHeaders); const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
let userId: number; let userId: number;

View File

@@ -2,6 +2,7 @@
* Me routes - consolidated exports for os.router() * Me routes - consolidated exports for os.router()
*/ */
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
import { meAuthStatus } from "./auth-status.js"; import { meAuthStatus } from "./auth-status.js";
import { meDelete } from "./delete.js"; import { meDelete } from "./delete.js";
import { import {
@@ -54,4 +55,9 @@ export const meRoutes = {
untrust: untrustDevice, untrust: untrustDevice,
revokeAll: revokeAllTrustedDevices, revokeAll: revokeAllTrustedDevices,
}, },
apiTokens: {
list: listApiTokens,
create: createApiToken,
delete: deleteApiToken,
},
}; };

View File

@@ -0,0 +1,109 @@
/**
* API token management procedures
* Allows users to create and manage API tokens for CLI/programmatic access
*/
import { ORPCError } from "@orpc/server";
import {
generateSecureBase58Token,
hashToken,
TOKEN_PREFIX,
} from "../../utils/crypto.js";
import { authMiddleware, os } from "../base.js";
/** Token expiration: 365 days */
const TOKEN_EXPIRATION_DAYS = 365;
/**
* List all API tokens for the current user
* Returns token metadata (not the actual token values)
*/
export const listApiTokens = os.me.apiTokens.list
.use(authMiddleware)
.handler(async ({ context }) => {
const tokens = await context.db
.selectFrom("api_tokens")
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
.where("user_id", "=", context.user.id)
.orderBy("created_at", "desc")
.execute();
return tokens.map((token) => ({
id: Number(token.id),
name: token.name,
lastUsedAt: token.last_used_at?.toISOString() ?? null,
createdAt: token.created_at.toISOString(),
expiresAt: token.expires_at.toISOString(),
}));
});
/**
* Create a new API token
* Requires superuser status and trusted session
*/
export const createApiToken = os.me.apiTokens.create
.use(authMiddleware)
.handler(async ({ input, context }) => {
// Require superuser status
if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Only superusers can create API tokens.",
});
}
// Require trusted session for creating API tokens
if (!context.session.trustedMode) {
throw new ORPCError("FORBIDDEN", {
message:
"Creating API tokens requires a trusted session. Please re-authenticate.",
});
}
const { name } = input;
// Generate a new API token
const token = generateSecureBase58Token(TOKEN_PREFIX);
const tokenHash = await hashToken(token);
// Calculate expiration
const expiresAt = new Date(
Date.now() + TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000,
);
// Insert into api_tokens table
await context.db
.insertInto("api_tokens")
.values({
user_id: context.user.id,
token_hash: tokenHash,
name,
expires_at: expiresAt,
})
.execute();
return {
token,
expiresAt: expiresAt.toISOString(),
};
});
/**
* Delete an API token
*/
export const deleteApiToken = os.me.apiTokens.delete
.use(authMiddleware)
.handler(async ({ input, context }) => {
const result = await context.db
.deleteFrom("api_tokens")
.where("id", "=", String(input.tokenId))
.where("user_id", "=", context.user.id)
.executeTakeFirst();
if (result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", {
message: "API token not found",
});
}
return { success: true };
});

View File

@@ -1,10 +1,18 @@
import { beforeEach, describe, expect, test } from "bun:test"; import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
test,
} from "bun:test";
import { import {
_resetForTesting, _resetForTesting,
_setReaderForTesting, _setReaderForTesting,
extractClientIP, extractClientIP,
getGeoInfo, getGeoInfo,
getUserAgent, getUserAgent,
initGeoReader,
lookupGeoFromIP, lookupGeoFromIP,
} from "./geo.js"; } from "./geo.js";
@@ -220,3 +228,110 @@ describe("getUserAgent", () => {
expect(getUserAgent(createHeaders({}))).toBe("Unknown"); expect(getUserAgent(createHeaders({}))).toBe("Unknown");
}); });
}); });
describe("initGeoReader", () => {
beforeEach(() => {
_resetForTesting();
});
test("calling initGeoReader twice does not reinitialize", async () => {
// First call initializes
await initGeoReader();
// Second call should return early (covers the early return branch)
await initGeoReader();
// If we get here without error, the early return worked
expect(true).toBe(true);
});
test("handles missing database file gracefully", async () => {
// Save original env
const originalPath = Bun.env.GEOIP_DATABASE_PATH;
// Point to non-existent file
Bun.env.GEOIP_DATABASE_PATH = "/nonexistent/path/to/db.mmdb";
// Should not throw, just log a warning
await initGeoReader();
// Lookups should return nulls since reader failed to initialize
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
city: null,
region: null,
country: null,
});
// Restore original env
if (originalPath) {
Bun.env.GEOIP_DATABASE_PATH = originalPath;
} else {
delete Bun.env.GEOIP_DATABASE_PATH;
}
});
});
// Only run real database tests if GEOIP_DATABASE_PATH is set
const hasGeoDatabase = !!Bun.env.GEOIP_DATABASE_PATH;
describe.skipIf(!hasGeoDatabase)("real GeoIP database", () => {
beforeAll(async () => {
_resetForTesting();
await initGeoReader();
});
afterAll(() => {
_resetForTesting();
});
test("looks up Google DNS (8.8.8.8) - US", () => {
const result = lookupGeoFromIP("8.8.8.8");
expect(result.country).toBe("US");
});
test("looks up Cloudflare DNS (1.1.1.1) - AU", () => {
const result = lookupGeoFromIP("1.1.1.1");
// Cloudflare's 1.1.1.1 is geolocated to Sydney, Australia
expect(result.country).toBe("AU");
});
test("looks up known German IP", () => {
// Deutsche Telekom IP range
const result = lookupGeoFromIP("80.150.6.143");
expect(result.country).toBe("DE");
});
test("looks up known UK IP", () => {
// BBC IP range
const result = lookupGeoFromIP("212.58.244.71");
expect(result.country).toBe("GB");
});
test("returns city data for major IPs", () => {
const result = lookupGeoFromIP("8.8.8.8");
// DBIP returns "Mountain View" for Google DNS
expect(result.city).toBe("Mountain View");
expect(result.region).toBe("California");
});
test("getGeoInfo uses real database when no CF headers", () => {
const headers = createHeaders({ "X-Real-IP": "8.8.8.8" });
const result = getGeoInfo(headers);
expect(result.ip).toBe("8.8.8.8");
expect(result.country).toBe("US");
expect(result.city).toBe("Mountain View");
});
test("returns nulls for private/reserved IPs", () => {
const result = lookupGeoFromIP("192.168.1.1");
expect(result.city).toBeNull();
expect(result.country).toBeNull();
});
test("returns nulls for localhost", () => {
const result = lookupGeoFromIP("127.0.0.1");
expect(result.city).toBeNull();
expect(result.country).toBeNull();
});
});

View File

@@ -126,9 +126,16 @@ export const lookupGeoFromIP = (
/** /**
* Extract geolocation info from request headers. * Extract geolocation info from request headers.
* Uses Cloudflare headers when available, falls back to GeoIP database lookup. * Uses Cloudflare headers when available, falls back to GeoIP database lookup.
*
* @param headers - Request headers to extract proxy IP headers from
* @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP)
*/ */
export const getGeoInfo = (headers: Headers): GeoInfo => { export const getGeoInfo = (
const ip = extractClientIP(headers); headers: Headers,
fallbackIP?: string | null,
): GeoInfo => {
// Try proxy headers first, then fall back to direct connection IP
const ip = extractClientIP(headers) ?? fallbackIP ?? null;
// Try Cloudflare geo headers first // Try Cloudflare geo headers first
const cfCountry = headers.get("CF-IPCountry"); const cfCountry = headers.get("CF-IPCountry");

86
apps/cli/README.md Normal file
View File

@@ -0,0 +1,86 @@
# RevIQ CLI
Command-line interface for RevIQ database and user management.
## Installation
```bash
# Build the CLI
bun run build
# The compiled binary will be at dist/reviq
```
## Usage
```bash
# Run directly with bun
bun run cli <command>
# Or use the compiled binary
./dist/reviq <command>
```
## Commands
### Authentication
```bash
# Login with an API token
reviq auth login --token <your-token>
# Check authentication status
reviq auth status
# Logout
reviq auth logout
```
To get an API token:
1. Log in to the web dashboard
2. Go to Account Settings > API Tokens
3. Create a new token and copy it
### User Management
```bash
# Create a new user
reviq user create --email <email>
# Confirm email
reviq user confirm-email --code <code>
```
### Organization Management
```bash
# List organizations
reviq org list
# Create an organization
reviq org create --name <name> --slug <slug>
# Add a site to an organization
reviq org add-site --org <slug> --domain <domain>
```
### Admin Commands
```bash
# Complete login (admin)
reviq admin complete-login
```
### Other Commands
```bash
# Bootstrap the database
reviq bootstrap
# Generate shell completions
reviq completions
```
## Configuration
Credentials are stored at `~/.config/reviq/credentials.json`.

View File

@@ -1,17 +1,22 @@
import type { LocalContext } from "../../context.js"; import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core"; import { buildCommand } from "@stricli/core";
import { readConfig } from "../../utils/config.js"; import { createApiClient } from "../../utils/api-client.js";
import { generateToken, hashToken } from "../../utils/token.js"; import { readConfig, writeConfig } from "../../utils/config.js";
interface LoginFlags { interface LoginFlags {
email: string; token: string;
"api-url"?: string; "api-url"?: string;
} }
interface LoginStatusOutput { /**
status: "pending" | "completed" | "expired"; * Login to RevIQ with an API token
} *
* To get an API token:
* 1. Log in to the web dashboard
* 2. Go to Account Settings > API Tokens
* 3. Create a new token and copy it
* 4. Run: reviq auth login --token <your-token>
*/
async function login(this: LocalContext, flags: LoginFlags): Promise<void> { async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
const apiUrl = flags["api-url"] ?? "http://localhost:9861"; const apiUrl = flags["api-url"] ?? "http://localhost:9861";
@@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
return; return;
} }
console.log("Starting login flow...\n"); console.log("Validating API token...\n");
// Generate a unique callback token for this login request
const callbackToken = generateToken();
const callbackTokenHash = hashToken(callbackToken);
try { try {
// Create login request // Create a temporary API client with the provided token
const createResponse = await fetch( const api = createApiClient(apiUrl, flags.token);
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: flags.email }),
},
);
if (!createResponse.ok) { // Validate the token by fetching the user's auth status
const text = await createResponse.text(); const authStatus = await api.me.authStatus();
console.error(`Error creating login request: ${text}`);
this.process.exit(1);
}
// Construct the login URL // Save credentials
const loginUrl = new URL(`${apiUrl}/login`); await writeConfig({
loginUrl.searchParams.set("email", flags.email); apiUrl,
loginUrl.searchParams.set("cli_callback", callbackTokenHash); token: flags.token,
email: authStatus.user.email,
});
console.log("Opening browser for authentication..."); console.log(`Logged in as ${authStatus.user.email}`);
console.log(`\nIf the browser doesn't open, visit:`); console.log("Credentials saved to ~/.config/reviq/credentials.json");
console.log(` ${loginUrl.toString()}\n`);
// Try to open the browser
const openCommand =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
try {
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {
// Ignore errors opening browser - user can use the URL
}
console.log("Waiting for login to complete...");
console.log("(Press Ctrl+C to cancel)\n");
// Poll for completion
const maxAttempts = 120; // 2 minutes at 1 second intervals
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts++;
try {
const statusResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.loginIfRequestIsCompleted`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CLI-Callback-Token": callbackToken,
},
},
);
if (statusResponse.ok) {
const status = (await statusResponse.json()) as LoginStatusOutput;
if (status.status === "completed") {
// Login completed - we should have received a token
// For now, we'll need the API to return the token
console.log("Login completed successfully!");
// TODO: The API needs to return the session token when login completes
// For now, this is a placeholder
console.log(
"\nNote: Browser-based login flow requires API integration.",
);
console.log("Use 'reviq bootstrap' to create initial credentials.");
return;
}
if (status.status === "expired") {
console.error("Login request expired. Please try again.");
this.process.exit(1);
}
}
} catch {
// Ignore polling errors and continue
}
// Show progress indicator
process.stdout.write(".");
}
console.log("\n\nLogin timed out. Please try again.");
this.process.exit(1);
} catch (error) { } catch (error) {
console.error( console.error(
"Error:", "Login failed:",
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
); );
console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1); this.process.exit(1);
} }
} }
@@ -142,10 +61,10 @@ export const loginCommand = buildCommand({
func: login, func: login,
parameters: { parameters: {
flags: { flags: {
email: { token: {
kind: "parsed", kind: "parsed",
parse: String, parse: String,
brief: "Email address to login with", brief: "API token from the web dashboard",
}, },
"api-url": { "api-url": {
kind: "parsed", kind: "parsed",
@@ -156,8 +75,13 @@ export const loginCommand = buildCommand({
}, },
}, },
docs: { docs: {
brief: "Login to RevIQ", brief: "Login to RevIQ with an API token",
fullDescription: fullDescription: `Authenticates with RevIQ using an API token.
"Opens a browser to complete authentication and stores the credentials locally.",
To get an API token:
1. Log in to the web dashboard at http://localhost:9861
2. Go to Account Settings > API Tokens
3. Create a new token and copy it
4. Run: reviq auth login --token <your-token>`,
}, },
}); });

View File

@@ -10,25 +10,48 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>; export type ApiClient = ContractRouterClient<typeof contract>;
/**
* Create an oRPC API client with provided credentials
*/
export function createApiClient(apiUrl: string, token: string): ApiClient;
/** /**
* Create an oRPC API client with the stored credentials * Create an oRPC API client with the stored credentials
* Throws an error if not logged in * Throws an error if not logged in
*/ */
export const createApiClient = async (): Promise<ApiClient> => { export function createApiClient(): Promise<ApiClient>;
const config = await readConfig();
if (!config) { export function createApiClient(
throw new Error( apiUrl?: string,
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.", token?: string,
); ): ApiClient | Promise<ApiClient> {
// If both arguments are provided, create client directly
if (apiUrl !== undefined && token !== undefined) {
const link = new RPCLink({
url: `${apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": token,
},
});
return createORPCClient(link) as unknown as ApiClient;
} }
const link = new RPCLink({ // Otherwise, read from config asynchronously
url: `${config.apiUrl}/api/v1/rpc`, return (async (): Promise<ApiClient> => {
headers: { const config = await readConfig();
"X-API-Key": config.token, if (!config) {
}, throw new Error(
}); "Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
}
// Cast to ApiClient for type-safe API calls const link = new RPCLink({
return createORPCClient(link) as unknown as ApiClient; url: `${config.apiUrl}/api/v1/rpc`,
}; headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})();
}

View File

@@ -1,10 +1,24 @@
import { configs } from "@macalinao/eslint-config"; import { configs } from "@macalinao/eslint-config";
import tsParser from "@typescript-eslint/parser";
import svelte from "eslint-plugin-svelte";
import svelteParser from "svelte-eslint-parser";
export default [ export default [
{ {
ignores: [".svelte-kit/**", "build/**"], ignores: [".svelte-kit/**", "build/**"],
}, },
...configs.fast, ...configs.fast,
...svelte.configs["flat/recommended"],
{
files: ["**/*.svelte", "**/*.svelte.ts"],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsParser,
tsconfigRootDir: import.meta.dirname,
},
},
},
{ {
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {

View File

@@ -39,9 +39,12 @@
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-plugin-svelte": "^3.14.0",
"svelte": "^5.28.2", "svelte": "^5.28.2",
"svelte-check": "^4.2.1", "svelte-check": "^4.2.1",
"svelte-eslint-parser": "^1.4.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "catalog:", "typescript": "catalog:",

View File

@@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import ClockIcon from "@lucide/svelte/icons/clock"; import ClockIcon from "@lucide/svelte/icons/clock";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
import MonitorIcon from "@lucide/svelte/icons/monitor"; import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -12,13 +16,33 @@ interface Props {
let { class: className }: Props = $props(); let { class: className }: Props = $props();
const navItems = [ // Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const baseNavItems = [
{ href: "/account", label: "Profile", icon: UserIcon }, { href: "/account", label: "Profile", icon: UserIcon },
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon }, { href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
{ href: "/account/devices", label: "Devices", icon: MonitorIcon }, { href: "/account/devices", label: "Devices", icon: MonitorIcon },
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon }, { href: "/account/sessions", label: "Sessions", icon: ClockIcon },
]; ];
// Add API Tokens link for superusers only
const navItems = $derived(
userQuery.data?.isSuperuser
? [
...baseNavItems,
{
href: "/account/api-tokens",
label: "API Tokens",
icon: KeyRoundIcon,
},
]
: baseNavItems,
);
function isActive(href: string, pathname: string): boolean { function isActive(href: string, pathname: string): boolean {
if (href === "/account") { if (href === "/account") {
return pathname === "/account"; return pathname === "/account";
@@ -33,10 +57,10 @@ function isActive(href: string, pathname: string): boolean {
className className
)} )}
> >
{#each navItems as item} {#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)} {@const active = isActive(item.href, $page.url.pathname)}
<a <a
href={item.href} href={resolve(item.href as any)}
class={cn( class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]", "inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active active

View File

@@ -3,6 +3,7 @@ import { AlertTriangle } from "@lucide/svelte";
import { useQueryClient } from "@tanstack/svelte-query"; import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -51,7 +52,7 @@ async function handleDelete(e: Event) {
open = false; open = false;
// Redirect to login // Redirect to login
goto("/auth/login"); goto(resolve("/auth/login"));
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Failed to delete account"; error = e instanceof Error ? e.message : "Failed to delete account";
isDeleting = false; isDeleting = false;

View File

@@ -2,6 +2,7 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
@@ -25,7 +26,11 @@ const userQuery = createQuery(() => ({
// Redirect to login if not authenticated on non-auth pages // Redirect to login if not authenticated on non-auth pages
$effect(() => { $effect(() => {
if (!isAuthPage && userQuery.error) { if (!isAuthPage && userQuery.error) {
goto(`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}`); goto(
resolve(
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
),
);
} }
}); });
</script> </script>

View File

@@ -32,7 +32,7 @@ const config = $derived(strengthConfig[score]);
<div class="space-y-2"> <div class="space-y-2">
<!-- Strength bars --> <!-- Strength bars -->
<div class="flex gap-1"> <div class="flex gap-1">
{#each Array(4) as _, i} {#each Array(4) as _, i (i)}
<div <div
class="h-1 flex-1 rounded-full transition-colors {i < score class="h-1 flex-1 rounded-full transition-colors {i < score
? config.color ? config.color
@@ -52,7 +52,7 @@ const config = $derived(strengthConfig[score]);
{#if result.feedback.warning} {#if result.feedback.warning}
<p class="text-destructive">{result.feedback.warning}</p> <p class="text-destructive">{result.feedback.warning}</p>
{/if} {/if}
{#each result.feedback.suggestions as suggestion} {#each result.feedback.suggestions as suggestion, i (i)}
<p>{suggestion}</p> <p>{suggestion}</p>
{/each} {/each}
</div> </div>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -25,9 +26,9 @@ const filters = [
</div> </div>
<div class="divide-y divide-border/50"> <div class="divide-y divide-border/50">
{#each filters as filter} {#each filters as filter (filter.label)}
<a <a
href={filter.href} href={resolve(filter.href as any)}
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30" class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
> >
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground"> <div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">

View File

@@ -46,7 +46,7 @@ function hourToPercent(hour: number): number {
<div class="flex"> <div class="flex">
<!-- Y-axis labels --> <!-- Y-axis labels -->
<div class="flex w-10 flex-col justify-between pr-2" style="height: 210px"> <div class="flex w-10 flex-col justify-between pr-2" style="height: 210px">
{#each hours as hour} {#each hours as hour (hour)}
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span> <span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
{/each} {/each}
</div> </div>
@@ -55,14 +55,14 @@ function hourToPercent(hour: number): number {
<div class="relative flex-1"> <div class="relative flex-1">
<!-- Grid lines --> <!-- Grid lines -->
<div class="absolute inset-0 flex flex-col justify-between" style="height: 210px"> <div class="absolute inset-0 flex flex-col justify-between" style="height: 210px">
{#each hours as _} {#each hours as hour (hour)}
<div class="h-px w-full bg-border"></div> <div class="h-px w-full bg-border"></div>
{/each} {/each}
</div> </div>
<!-- Bars container --> <!-- Bars container -->
<div class="relative grid grid-cols-7 gap-4 px-2" style="height: 210px"> <div class="relative grid grid-cols-7 gap-4 px-2" style="height: 210px">
{#each days as _, dayIndex} {#each days as day, dayIndex (day)}
{@const thisMonth = thisMonthData[dayIndex]} {@const thisMonth = thisMonthData[dayIndex]}
{@const lastMonth = lastMonthData[dayIndex]} {@const lastMonth = lastMonthData[dayIndex]}
<div class="relative flex justify-center"> <div class="relative flex justify-center">
@@ -104,7 +104,7 @@ function hourToPercent(hour: number): number {
<!-- X-axis labels --> <!-- X-axis labels -->
<div class="mt-2 grid grid-cols-7 gap-4 px-2"> <div class="mt-2 grid grid-cols-7 gap-4 px-2">
{#each days as day} {#each days as day (day)}
<div class="text-center text-[11px] text-muted-foreground">{day}</div> <div class="text-center text-[11px] text-muted-foreground">{day}</div>
{/each} {/each}
</div> </div>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { import {
@@ -39,7 +40,7 @@ function handleTabChange(tabId: string) {
} else { } else {
url.searchParams.set("tab", tabId); url.searchParams.set("tab", tabId);
} }
goto(url.toString(), { replaceState: true, noScroll: true }); goto(resolve(url.toString() as any), { replaceState: true, noScroll: true });
} }
</script> </script>
@@ -60,7 +61,7 @@ function handleTabChange(tabId: string) {
<!-- Tab navigation --> <!-- Tab navigation -->
<div class="flex items-center gap-0.5" role="tablist"> <div class="flex items-center gap-0.5" role="tablist">
{#each tabs as tab} {#each tabs as tab (tab.id)}
{@const isActive = activeTab === tab.id} {@const isActive = activeTab === tab.id}
<button <button
role="tab" role="tab"

View File

@@ -78,7 +78,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each tableData as row, i} {#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30"> <Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5"> <Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground"> <div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">

View File

@@ -77,7 +77,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each tableData as row, i} {#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30"> <Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5"> <Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground"> <div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">

View File

@@ -47,7 +47,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each tableData as row, i} {#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30"> <Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5"> <Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground"> <div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">

View File

@@ -69,7 +69,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each tableData as row, i} {#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30"> <Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5"> <Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground"> <div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">

View File

@@ -63,7 +63,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each tableData as row, i} {#each tableData as row, i (row.id)}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30"> <Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5"> <Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground"> <div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import ClockIcon from "@lucide/svelte/icons/clock";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
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";
import { cn } from "$lib/utils.js";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
interface NavItem {
href: string;
icon: Component;
label: string;
description: string;
}
const baseNavItems: NavItem[] = [
{
href: "/account",
icon: UserIcon,
label: "Profile",
description: "Your personal information and avatar",
},
{
href: "/account/auth",
icon: ShieldCheckIcon,
label: "Authentication",
description: "Passwords, passkeys, and login methods",
},
{
href: "/account/devices",
icon: MonitorIcon,
label: "Devices",
description: "Manage your trusted devices",
},
{
href: "/account/sessions",
icon: ClockIcon,
label: "Sessions",
description: "Active sessions and login history",
},
];
// Add API Tokens link for superusers only
const navItems = $derived(
userQuery.data?.isSuperuser
? [
...baseNavItems,
{
href: "/account/api-tokens",
icon: KeyRoundIcon,
label: "API Tokens",
description: "Manage API access tokens",
},
]
: baseNavItems,
);
// Determine active item
const activeHref = $derived($page.url.pathname);
function isActive(href: string): boolean {
// Exact match for base account path
if (href === "/account") {
return activeHref === "/account";
}
// Prefix match for sub-pages
return activeHref.startsWith(href);
}
</script>
<DashboardLayout title="Account Settings">
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<!-- Account Navigation -->
<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)}
<a
href={resolve(item.href as any)}
class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active
? "border-primary bg-primary/5 text-primary"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon class="h-4 w-4" />
{item.label}
</a>
{/each}
</div>
<!-- Desktop: vertical list -->
<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)}
class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active
? "bg-primary/5 text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<div
class={cn(
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted-foreground/20",
)}
>
<item.icon class="h-4 w-4" />
</div>
<div class="flex-1 space-y-0.5">
<p class="text-sm font-medium">{item.label}</p>
<p class="text-xs text-muted-foreground">{item.description}</p>
</div>
</a>
{/each}
</div>
</nav>
<!-- Content -->
<div class="min-w-0 flex-1">
{@render children()}
</div>
</div>
</DashboardLayout>

View File

@@ -0,0 +1 @@
export { default as AccountSettingsLayout } from "./account-settings-layout.svelte";

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { Badge } from "$lib/components/ui/badge";
import { cn } from "$lib/utils.js";
import AdminMobileNav from "./admin-mobile-nav.svelte";
interface Props {
title: string;
class?: string;
}
let { title, class: className }: Props = $props();
</script>
<header
class={cn(
"flex h-14 items-center justify-between border-b border-border bg-card px-4 lg:px-6",
className,
)}
>
<div class="flex items-center gap-3">
<!-- Mobile menu button -->
<AdminMobileNav class="lg:hidden" />
<h1 class="text-base font-semibold tracking-tight text-foreground lg:text-lg">{title}</h1>
<Badge variant="destructive" class="hidden sm:inline-flex">Admin</Badge>
</div>
<div class="flex items-center gap-2">
<a
href={resolve("/dashboard")}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M19 12H5M12 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="hidden sm:inline">Exit Admin</span>
</a>
</div>
</header>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils.js";
import AdminHeader from "./admin-header.svelte";
import AdminMobileNav from "./admin-mobile-nav.svelte";
import AdminSidebar from "./admin-sidebar.svelte";
interface Props {
title: string;
children: Snippet;
class?: string;
}
let { title, children, class: className }: Props = $props();
</script>
<div class="flex h-screen overflow-hidden bg-background">
<!-- Desktop sidebar - hidden on mobile -->
<div class="hidden lg:block">
<AdminSidebar />
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<AdminHeader {title} />
<main class="flex-1 overflow-auto p-4 lg:p-6">
<div class={cn("mx-auto max-w-7xl", className)}>
{@render children()}
</div>
</main>
</div>
</div>

View File

@@ -0,0 +1,194 @@
<script lang="ts">
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js";
interface Props {
class?: string;
}
let { class: className }: Props = $props();
let open = $state(false);
// Fetch current user
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
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 queryClient = useQueryClient();
function handleNavClick() {
open = false;
}
async function handleSignOut() {
try {
await api.auth.logout();
queryClient.clear();
open = false;
goto(resolve("/auth/login"));
} catch (error) {
console.error("Failed to sign out:", error);
}
}
// Admin nav items
const navItems = [
{ icon: "dashboard", href: "/admin", label: "Dashboard" },
{ icon: "building", href: "/admin/orgs", label: "Organizations" },
{ icon: "users", href: "/admin/users", label: "Users" },
];
</script>
<Sheet.Root bind:open>
<Sheet.Trigger>
{#snippet child({ props })}
<Button variant="ghost" size="icon" class={cn("h-9 w-9 lg:hidden", className)} {...props}>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 12h18M3 6h18M3 18h18" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="sr-only">Open menu</span>
</Button>
{/snippet}
</Sheet.Trigger>
<Sheet.Content side="left" class="w-72 border-zinc-800 bg-zinc-900 p-0">
<Sheet.Header class="border-b border-zinc-800 px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-red-600">
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<Sheet.Title class="text-lg font-semibold text-white">Admin Panel</Sheet.Title>
</div>
</Sheet.Header>
<nav class="flex flex-1 flex-col p-4">
<div class="space-y-1">
{#each navItems as item (item.href)}
{@const isActive =
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200",
)}
>
{#if item.icon === "dashboard"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<rect x="3" y="3" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
<rect x="14" y="3" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
<rect x="14" y="12" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
<rect x="3" y="16" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "building"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "users"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
{item.label}
</a>
{/each}
</div>
<!-- Separator and back to dashboard -->
<div class="mt-6">
<Separator class="bg-zinc-800" />
<a
href={resolve("/dashboard")}
onclick={handleNavClick}
class="mt-4 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M19 12H5M12 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Back to Dashboard
</a>
</div>
<!-- User section at bottom -->
<div class="mt-auto pt-4">
<Separator class="mb-4 bg-zinc-800" />
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
{#if user?.avatarUrl}
<img src={user.avatarUrl} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-red-700 text-xs font-semibold text-white">
{initials}
</div>
{/if}
<div class="flex-1">
<p class="text-sm font-medium text-white">{user?.displayName ?? user?.email ?? "Loading..."}</p>
<p class="text-xs text-zinc-400">Admin</p>
</div>
</div>
<div class="mt-2 space-y-1">
<a
href={resolve("/account")}
onclick={handleNavClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" />
</svg>
Account Settings
</a>
<button
onclick={handleSignOut}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-500/10"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="16,17 21,12 16,7" stroke-linecap="round" stroke-linejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sign out
</button>
</div>
</div>
</nav>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js";
interface Props {
class?: string;
}
let { class: className }: Props = $props();
// Fetch current user
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
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 queryClient = useQueryClient();
async function handleSignOut() {
try {
await api.auth.logout();
queryClient.clear();
goto(resolve("/auth/login"));
} catch (error) {
console.error("Failed to sign out:", error);
}
}
// Admin nav items
const navItems = [
{ icon: "dashboard", href: "/admin", label: "Dashboard" },
{ icon: "building", href: "/admin/orgs", label: "Organizations" },
{ icon: "users", href: "/admin/users", label: "Users" },
];
</script>
<aside
class={cn(
"flex h-screen w-[80px] flex-col items-center bg-zinc-900",
className,
)}
>
<!-- Admin Logo -->
<div class="flex h-[94px] items-center justify-center">
<a
href={resolve("/admin")}
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-red-600 shadow-sm transition-transform duration-200 hover:scale-105"
aria-label="Admin Home"
>
<svg
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</div>
<!-- Main Navigation -->
<nav class="flex flex-1 flex-col items-center gap-3">
{#each navItems as item (item.href)}
{@const isActive =
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={resolve(item.href as any)}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive
? "bg-zinc-700 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
)}
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
>
{#if item.icon === "dashboard"}
{#if isActive}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
</svg>
{:else}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<rect x="3" y="3" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
<rect x="14" y="3" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
<rect x="14" y="12" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
<rect x="3" y="16" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
{:else if item.icon === "building"}
{#if isActive}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M4.5 2.25a.75.75 0 000 1.5v16.5h-.75a.75.75 0 000 1.5h16.5a.75.75 0 000-1.5h-.75V3.75a.75.75 0 000-1.5h-15zM9 6a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H9zm-.75 3.75A.75.75 0 019 9h1.5a.75.75 0 010 1.5H9a.75.75 0 01-.75-.75zM9 12a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H9zm3.75-5.25A.75.75 0 0113.5 6H15a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zM13.5 9a.75.75 0 000 1.5H15A.75.75 0 0015 9h-1.5zm-.75 3.75a.75.75 0 01.75-.75H15a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zM9 19.5v-2.25a.75.75 0 01.75-.75h4.5a.75.75 0 01.75.75v2.25H9z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
{:else if item.icon === "users"}
{#if isActive}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.25 6.75a3.75 3.75 0 117.5 0 3.75 3.75 0 01-7.5 0zM15.75 9.75a3 3 0 116 0 3 3 0 01-6 0zM2.25 9.75a3 3 0 116 0 3 3 0 01-6 0zM6.31 15.117A6.745 6.745 0 0112 12a6.745 6.745 0 016.709 7.498.75.75 0 01-.372.568A12.696 12.696 0 0112 21.75c-2.305 0-4.47-.612-6.337-1.684a.75.75 0 01-.372-.568 6.787 6.787 0 011.019-4.38z" />
<path d="M5.082 14.254a8.287 8.287 0 00-1.308 5.135 9.687 9.687 0 01-1.764-.44l-.115-.04a.563.563 0 01-.373-.487l-.01-.121a3.75 3.75 0 013.57-4.047zM20.226 19.389a8.287 8.287 0 00-1.308-5.135 3.75 3.75 0 013.57 4.047l-.01.121a.563.563 0 01-.373.486l-.115.04c-.567.2-1.156.349-1.764.441z" />
</svg>
{:else}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
{/if}
<!-- Tooltip -->
<span
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-zinc-700 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
>
{item.label}
</span>
</a>
{/each}
</nav>
<!-- Bottom section -->
<div class="flex flex-col items-center gap-3 pb-6">
<!-- Back to Dashboard link -->
<a
href={resolve("/dashboard")}
class="group relative flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 transition-all duration-150 hover:bg-zinc-800 hover:text-zinc-200"
aria-label="Back to Dashboard"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M19 12H5M12 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-zinc-700 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
>
Back to Dashboard
</span>
</a>
<!-- User Menu -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button
{...props}
class="relative h-6 w-6 overflow-hidden rounded-full ring-1 ring-zinc-700 transition-transform duration-150 hover:scale-110"
aria-label="User menu"
>
{#if user?.avatarUrl}
<img src={user.avatarUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-red-500 to-red-700 text-[10px] font-semibold text-white"
>
{initials}
</div>
{/if}
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-64" side="right" align="end" sideOffset={8}>
<!-- User info header -->
<div class="flex items-center gap-3 p-2">
{#if user?.avatarUrl}
<img src={user.avatarUrl} alt="" class="h-10 w-10 rounded-full object-cover" />
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-red-700 text-sm font-semibold text-white"
>
{initials}
</div>
{/if}
<div class="flex flex-col">
<span class="text-sm font-medium">{user?.displayName ?? user?.email ?? "Loading..."}</span>
<span class="text-xs text-muted-foreground">Admin</span>
</div>
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => goto(resolve("/account"))}>
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" />
</svg>
Account Settings
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={handleSignOut} variant="destructive">
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="16,17 21,12 16,7" stroke-linecap="round" stroke-linejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</aside>

View File

@@ -0,0 +1,4 @@
export { default as AdminHeader } from "./admin-header.svelte";
export { default as AdminLayout } from "./admin-layout.svelte";
export { default as AdminMobileNav } from "./admin-mobile-nav.svelte";
export { default as AdminSidebar } from "./admin-sidebar.svelte";

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Settings } from "@lucide/svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import OrgSwitcher from "./org-switcher.svelte"; import OrgSwitcher from "./org-switcher.svelte";
@@ -66,12 +68,14 @@ const navItems = $derived.by(() => {
<!-- Main Navigation --> <!-- Main Navigation -->
<nav class="flex flex-1 flex-col items-center gap-3"> <nav class="flex flex-1 flex-col items-center gap-3">
{#each navItems as item} {#each navItems as item (item.href)}
{@const isActive = {@const isActive =
$page.url.pathname === item.href || item.icon === "home"
(item.href !== "/" && $page.url.pathname.startsWith(item.href))} ? $page.url.pathname === item.href
: $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")}
<a <a
href={item.href} href={resolve(item.href as any)}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive isActive
@@ -153,8 +157,34 @@ const navItems = $derived.by(() => {
</nav> </nav>
<!-- User Menu --> <!-- Bottom section -->
<div class="flex h-[80px] items-center justify-center"> <div class="flex flex-col items-center gap-3 pb-6">
<!-- Settings (only in org context) -->
{#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a
href={resolve(`/dashboard/${currentSlug}/settings`)}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive
? "bg-sidebar-accent text-sidebar-foreground"
: "text-sidebar-muted hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
aria-label="Settings"
aria-current={isSettingsActive ? "page" : undefined}
>
<Settings class="h-4 w-4" />
<!-- Tooltip -->
<span
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
>
Settings
</span>
</a>
{/if}
<!-- User Menu -->
<UserMenu /> <UserMenu />
</div> </div>
</aside> </aside>

View File

@@ -0,0 +1,7 @@
export { default as AppHeader } from "./app-header.svelte";
export { default as AppSidebar } from "./app-sidebar.svelte";
export { default as DashboardLayout } from "./dashboard-layout.svelte";
export { default as EmailVerificationBanner } from "./email-verification-banner.svelte";
export { default as MobileNav } from "./mobile-nav.svelte";
export { default as OrgSwitcher } from "./org-switcher.svelte";
export { default as UserMenu } from "./user-menu.svelte";

View File

@@ -2,6 +2,7 @@
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -85,7 +86,7 @@ async function handleSignOut() {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();
open = false; open = false;
goto("/login"); goto(resolve("/auth/login"));
} catch (error) { } catch (error) {
console.error("Failed to sign out:", error); console.error("Failed to sign out:", error);
} }
@@ -118,12 +119,12 @@ async function handleSignOut() {
<nav class="flex flex-1 flex-col p-4"> <nav class="flex flex-1 flex-col p-4">
<div class="space-y-1"> <div class="space-y-1">
{#each navItems as item} {#each navItems as item (item.href)}
{@const isActive = {@const isActive =
$page.url.pathname === item.href || $page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))} (item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a <a
href={item.href} href={resolve(item.href as any)}
onclick={handleNavClick} onclick={handleNavClick}
class={cn( class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
@@ -184,7 +185,7 @@ async function handleSignOut() {
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
<a <a
href="/account" href={resolve("/account")}
onclick={handleNavClick} onclick={handleNavClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
> >

View File

@@ -2,6 +2,7 @@
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -19,7 +20,7 @@ const orgsQuery = createQuery(() => ({
const orgs = $derived(orgsQuery.data ?? []); const orgs = $derived(orgsQuery.data ?? []);
function handleOrgSelect(slug: string) { function handleOrgSelect(slug: string) {
goto(`/dashboard/${slug}`); goto(resolve(`/dashboard/${slug}` as any));
} }
</script> </script>
@@ -51,7 +52,7 @@ function handleOrgSelect(slug: string) {
{:else if orgs.length === 0} {:else if orgs.length === 0}
<DropdownMenu.Item disabled>No organizations</DropdownMenu.Item> <DropdownMenu.Item disabled>No organizations</DropdownMenu.Item>
{:else} {:else}
{#each orgs as org} {#each orgs as org (org.slug)}
{@const isActive = currentSlug === org.slug} {@const isActive = currentSlug === org.slug}
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => handleOrgSelect(org.slug)} onSelect={() => handleOrgSelect(org.slug)}
@@ -76,7 +77,7 @@ function handleOrgSelect(slug: string) {
{/each} {/each}
{/if} {/if}
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => goto("/dashboard/new")}> <DropdownMenu.Item onSelect={() => goto(resolve("/dashboard/new"))}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" /> <line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" />

View File

@@ -2,6 +2,7 @@
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
@@ -43,7 +44,7 @@ async function handleSignOut() {
await api.auth.logout(); await api.auth.logout();
// Clear all cached queries // Clear all cached queries
queryClient.clear(); queryClient.clear();
goto("/login"); goto(resolve("/auth/login"));
} catch (error) { } catch (error) {
console.error("Failed to sign out:", error); console.error("Failed to sign out:", error);
} }
@@ -92,7 +93,7 @@ async function handleSignOut() {
</div> </div>
</div> </div>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => goto("/account")}> <DropdownMenu.Item onSelect={() => goto(resolve("/account"))}>
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" /> <circle cx="12" cy="7" r="4" />

View File

@@ -1,5 +1,20 @@
export { default as AppHeader } from "./app-header.svelte"; // Account layout components
export { default as AppSidebar } from "./app-sidebar.svelte"; export { AccountSettingsLayout } from "./account/index.js";
export { default as DashboardLayout } from "./dashboard-layout.svelte"; // Admin layout components
export { default as EmailVerificationBanner } from "./email-verification-banner.svelte"; export {
export { default as MobileNav } from "./mobile-nav.svelte"; AdminHeader,
AdminLayout,
AdminMobileNav,
AdminSidebar,
} from "./admin/index.js";
export {
AppHeader,
AppSidebar,
DashboardLayout,
EmailVerificationBanner,
MobileNav,
OrgSwitcher,
UserMenu,
} from "./dashboard/index.js";
// Settings layout components
export { SettingsLayout } from "./settings/index.js";

View File

@@ -0,0 +1 @@
export { default as SettingsLayout } from "./settings-layout.svelte";

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { Building2, Globe, Settings, Users } from "@lucide/svelte";
import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { DashboardLayout } from "$lib/components/layout";
import { cn } from "$lib/utils.js";
interface Props {
title: string;
children: Snippet;
}
let { title, children }: Props = $props();
// Get org context from parent layout
const orgContext = getContext<{ slug: string }>("orgContext");
const slug = $derived(orgContext?.slug);
// Settings navigation items
const navItems = $derived.by(() => [
{
href: `/dashboard/${slug}/settings`,
icon: Settings,
label: "General",
description: "Organization name, logo, and preferences",
},
{
href: `/dashboard/${slug}/settings/members`,
icon: Users,
label: "Members",
description: "Manage team members and invitations",
},
{
href: `/dashboard/${slug}/settings/sites`,
icon: Globe,
label: "Sites",
description: "Connected websites and domains",
},
]);
// Determine active item
const activeHref = $derived($page.url.pathname);
function isActive(href: string): boolean {
// Exact match for base settings path
if (href === `/dashboard/${slug}/settings`) {
return activeHref === href;
}
// Prefix match for sub-pages
return activeHref.startsWith(href);
}
</script>
<DashboardLayout title={title}>
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<!-- Settings Navigation -->
<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)}
<a
href={resolve(item.href as any)}
class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active
? "border-primary bg-primary/5 text-primary"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon class="h-4 w-4" />
{item.label}
</a>
{/each}
</div>
<!-- Desktop: vertical list -->
<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)}
class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active
? "bg-primary/5 text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<div
class={cn(
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted-foreground/20",
)}
>
<item.icon class="h-4 w-4" />
</div>
<div class="flex-1 space-y-0.5">
<p class="text-sm font-medium">{item.label}</p>
<p class="text-xs text-muted-foreground">{item.description}</p>
</div>
</a>
{/each}
</div>
</nav>
<!-- Content -->
<div class="min-w-0 flex-1">
{@render children()}
</div>
</div>
</DashboardLayout>

View File

@@ -48,6 +48,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
</script> </script>
<script lang="ts"> <script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */
let { let {
class: className, class: className,
variant = "default", variant = "default",

View File

@@ -24,9 +24,7 @@ const queryClient = new QueryClient({
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthGuard> <AuthGuard>
{#snippet children()} {@render children()}
{@render children()}
{/snippet}
</AuthGuard> </AuthGuard>
<SvelteQueryDevtools /> <SvelteQueryDevtools />
</QueryClientProvider> </QueryClientProvider>

View File

@@ -2,6 +2,7 @@
import { Loader2 } from "@lucide/svelte"; import { Loader2 } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
/** /**
@@ -16,14 +17,16 @@ const orgsQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
// Not authenticated, redirect to login // Not authenticated, redirect to login
goto(`/auth/login?redirect=${encodeURIComponent("/")}`); goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any));
} else if (orgsQuery.data) { } else if (orgsQuery.data) {
if (orgsQuery.data.length > 0) { if (orgsQuery.data.length > 0) {
// Redirect to first org's dashboard // Redirect to first org's dashboard
goto(`/dashboard/${orgsQuery.data[0].slug}`, { replaceState: true }); goto(resolve(`/dashboard/${orgsQuery.data[0].slug}` as any), {
replaceState: true,
});
} else { } else {
// No orgs, show org list (empty state) // No orgs, show org list (empty state)
goto("/dashboard", { replaceState: true }); goto(resolve("/dashboard"), { replaceState: true });
} }
} }
}); });

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { AccountNav } from "$lib/components/account"; import { AccountSettingsLayout } from "$lib/components/layout";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -14,12 +13,6 @@ let { children }: Props = $props();
<title>Account Settings - Publisher Dashboard</title> <title>Account Settings - Publisher Dashboard</title>
</svelte:head> </svelte:head>
<DashboardLayout title="Account Settings"> <AccountSettingsLayout>
<div class="space-y-6"> {@render children()}
<AccountNav /> </AccountSettingsLayout>
<div class="max-w-2xl">
{@render children()}
</div>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import {
AlertCircle,
Check,
Copy,
KeyRound,
Loader2,
Plus,
Trash2,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
const queryClient = useQueryClient();
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Redirect non-superusers
$effect(() => {
if (userQuery.data && !userQuery.data.isSuperuser) {
toast.error("Access denied. Superuser privileges required.");
goto(resolve("/account"));
}
});
const tokensQuery = createQuery(() => ({
queryKey: ["api-tokens"],
queryFn: () => api.me.apiTokens.list(),
enabled: userQuery.data?.isSuperuser ?? false,
}));
let confirmDialogOpen = $state(false);
let selectedTokenId = $state<number | null>(null);
let isDeleting = $state(false);
// Create token form state
let newTokenName = $state("");
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) {
return;
}
isCreating = true;
try {
const result = await api.me.apiTokens.create({ name: newTokenName.trim() });
newlyCreatedToken = result.token;
newTokenName = "";
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
toast.success("API token created");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
isCreating = false;
}
}
async function copyToken() {
if (!newlyCreatedToken) {
return;
}
try {
await navigator.clipboard.writeText(newlyCreatedToken);
tokenCopied = true;
toast.success("Token copied to clipboard");
setTimeout(() => {
tokenCopied = false;
}, 2000);
} catch {
toast.error("Failed to copy token");
}
}
function dismissNewToken() {
newlyCreatedToken = null;
tokenCopied = false;
}
async function handleDelete() {
if (!selectedTokenId || isDeleting) {
return;
}
isDeleting = true;
try {
await api.me.apiTokens.delete({ tokenId: selectedTokenId });
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
toast.success("API token deleted");
confirmDialogOpen = false;
selectedTokenId = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete token");
} finally {
isDeleting = false;
}
}
</script>
{#if userQuery.isPending}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if !userQuery.data?.isSuperuser}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Access denied. Superuser privileges required.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<!-- Newly Created Token Banner -->
{#if newlyCreatedToken}
<Alert class="border-green-500 bg-green-50 dark:bg-green-950">
<KeyRound class="h-4 w-4 text-green-600" />
<AlertDescription>
<div class="space-y-2">
<p class="font-medium text-green-800 dark:text-green-200">
Your new API token has been created!
</p>
<p class="text-sm text-green-700 dark:text-green-300">
Copy it now - you won't be able to see it again.
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-green-100 px-2 py-1 font-mono text-sm text-green-900 dark:bg-green-900 dark:text-green-100">
{newlyCreatedToken}
</code>
<Button
size="sm"
variant="outline"
onclick={copyToken}
class="shrink-0"
>
{#if tokenCopied}
<Check class="h-4 w-4" />
{:else}
<Copy class="h-4 w-4" />
{/if}
</Button>
</div>
<Button
size="sm"
variant="ghost"
onclick={dismissNewToken}
class="mt-2"
>
I've copied my token
</Button>
</div>
</AlertDescription>
</Alert>
{/if}
<!-- Create Token -->
<Card>
<CardHeader>
<CardTitle>Create API Token</CardTitle>
<CardDescription>
Create a new API token for CLI or programmatic access.
</CardDescription>
</CardHeader>
<CardContent>
<form onsubmit={handleCreateToken} class="flex gap-3">
<div class="flex-1">
<Label for="token-name" class="sr-only">Token name</Label>
<Input
id="token-name"
type="text"
placeholder="Token name (e.g., CLI, CI/CD)"
bind:value={newTokenName}
disabled={isCreating}
/>
</div>
<Button type="submit" disabled={!newTokenName.trim() || isCreating}>
{#if isCreating}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Create Token
</Button>
</form>
</CardContent>
</Card>
<!-- Existing Tokens -->
<Card>
<CardHeader>
<CardTitle>API Tokens</CardTitle>
<CardDescription>
Manage your API tokens. Use these with the CLI: <code class="rounded bg-muted px-1 py-0.5 text-xs">reviq auth login --token &lt;token&gt;</code>
</CardDescription>
</CardHeader>
<CardContent>
{#if tokensQuery.isPending}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if tokensQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load tokens. Please try again.</AlertDescription>
</Alert>
{:else if tokensQuery.data && tokensQuery.data.length > 0}
<div class="divide-y">
{#each tokensQuery.data as token (token.id)}
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<KeyRound class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
{:else}
· Never used
{/if}
</p>
<Badge variant="outline" class="text-xs">
Expires {formatDate(token.expiresAt)}
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onclick={() => { selectedTokenId = token.id; confirmDialogOpen = true; }}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-center">
<KeyRound class="mb-2 h-8 w-8 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">No API tokens yet.</p>
<p class="text-xs text-muted-foreground">Create one to use with the CLI.</p>
</div>
{/if}
</CardContent>
</Card>
</div>
<ConfirmDialog
bind:open={confirmDialogOpen}
title="Delete this API token?"
description="This will immediately revoke access for any applications using this token. This action cannot be undone."
confirmText="Delete token"
variant="destructive"
loading={isDeleting}
onConfirm={handleDelete}
/>
{/if}

View File

@@ -17,6 +17,7 @@ import {
} from "@tanstack/svelte-query"; } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -52,9 +53,9 @@ const acceptMutation = createMutation(() => ({
queryClient.invalidateQueries({ queryKey: ["orgs"] }); queryClient.invalidateQueries({ queryKey: ["orgs"] });
// Redirect to the org dashboard // Redirect to the org dashboard
if (inviteQuery.data) { if (inviteQuery.data) {
goto(`/dashboard/${inviteQuery.data.org.slug}`); goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
} else { } else {
goto("/dashboard"); goto(resolve("/dashboard"));
} }
}, },
onError: (error) => { onError: (error) => {
@@ -71,7 +72,7 @@ const declineMutation = createMutation(() => ({
toast.success("Invitation declined"); toast.success("Invitation declined");
// Invalidate queries // Invalidate queries
queryClient.invalidateQueries({ queryKey: ["me", "invites"] }); queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
goto("/dashboard"); goto(resolve("/dashboard"));
}, },
onError: (error) => { onError: (error) => {
toast.error( toast.error(
@@ -102,6 +103,7 @@ function formatDate(date: Date): string {
* Check if invite is expiring soon (within 3 days) * Check if invite is expiring soon (within 3 days)
*/ */
function isExpiringSoon(expiresAt: Date): boolean { function isExpiringSoon(expiresAt: Date): boolean {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure function, no reactivity needed
const threeDaysFromNow = new Date(); const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
return expiresAt < threeDaysFromNow; return expiresAt < threeDaysFromNow;
@@ -114,7 +116,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<div class="space-y-6"> <div class="space-y-6">
<!-- Back link --> <!-- Back link -->
<Button variant="ghost" size="sm" href="/dashboard" class="-ml-2"> <Button variant="ghost" size="sm" href={resolve("/dashboard")} class="-ml-2">
<ArrowLeft class="mr-2 h-4 w-4" /> <ArrowLeft class="mr-2 h-4 w-4" />
Back to Dashboard Back to Dashboard
</Button> </Button>
@@ -131,7 +133,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
{inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"} {inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button variant="outline" href="/dashboard"> <Button variant="outline" href={resolve("/dashboard")}>
Go to Dashboard Go to Dashboard
</Button> </Button>
{:else if inviteQuery.data} {:else if inviteQuery.data}

View File

@@ -4,6 +4,7 @@ import { createQuery } from "@tanstack/svelte-query";
import { setContext } from "svelte"; import { setContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
interface Props { interface Props {
@@ -22,11 +23,13 @@ const userQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (userQuery.data && !userQuery.data.isSuperuser) { if (userQuery.data && !userQuery.data.isSuperuser) {
toast.error("Access denied. Superuser privileges required."); toast.error("Access denied. Superuser privileges required.");
goto("/dashboard"); goto(resolve("/dashboard"));
} }
if (userQuery.error) { if (userQuery.error) {
goto( goto(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`, resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
); );
} }
}); });

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte"; import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { AdminLayout } from "$lib/components/layout";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -36,13 +36,8 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
<title>Admin Dashboard | Publisher Dashboard</title> <title>Admin Dashboard | Publisher Dashboard</title>
</svelte:head> </svelte:head>
<DashboardLayout title="Admin Dashboard"> <AdminLayout title="Dashboard">
<div class="space-y-6"> <div class="space-y-6">
<!-- Admin badge -->
<div class="flex items-center gap-2">
<Badge variant="destructive">Admin</Badge>
</div>
{#if isLoading} {#if isLoading}
<!-- Loading state --> <!-- Loading state -->
<div class="flex flex-col items-center justify-center py-16"> <div class="flex flex-col items-center justify-center py-16">
@@ -61,7 +56,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
<!-- Summary cards --> <!-- Summary cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Organizations card --> <!-- Organizations card -->
<a href="/admin/orgs" class="group block transition-transform hover:scale-[1.02]"> <a href={resolve("/admin/orgs")} class="group block transition-transform hover:scale-[1.02]">
<Card class="h-full transition-colors group-hover:border-primary/50"> <Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2"> <CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
@@ -77,7 +72,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
</a> </a>
<!-- Users card --> <!-- Users card -->
<a href="/admin/users" class="group block transition-transform hover:scale-[1.02]"> <a href={resolve("/admin/users")} class="group block transition-transform hover:scale-[1.02]">
<Card class="h-full transition-colors group-hover:border-primary/50"> <Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2"> <CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
@@ -100,7 +95,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button href="/admin/orgs/new"> <Button href={resolve("/admin/orgs/new")}>
<Plus class="mr-2 h-4 w-4" /> <Plus class="mr-2 h-4 w-4" />
New Organization New Organization
</Button> </Button>
@@ -109,4 +104,4 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
</Card> </Card>
{/if} {/if}
</div> </div>
</DashboardLayout> </AdminLayout>

View File

@@ -2,8 +2,9 @@
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte"; import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { AdminLayout } from "$lib/components/layout";
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte"; import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
@@ -80,7 +81,7 @@ async function executeConfirmAction() {
<title>Organizations | Admin | Publisher Dashboard</title> <title>Organizations | Admin | Publisher Dashboard</title>
</svelte:head> </svelte:head>
<DashboardLayout title="Organizations"> <AdminLayout title="Organizations">
<div class="space-y-6"> <div class="space-y-6">
{#if orgsQuery.isPending} {#if orgsQuery.isPending}
<!-- Loading skeleton --> <!-- Loading skeleton -->
@@ -106,7 +107,7 @@ async function executeConfirmAction() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each Array(5) as _} {#each Array(5) as _, i (i)}
<TableRow> <TableRow>
<TableCell><Skeleton class="h-4 w-24" /></TableCell> <TableCell><Skeleton class="h-4 w-24" /></TableCell>
<TableCell><Skeleton class="h-4 w-32" /></TableCell> <TableCell><Skeleton class="h-4 w-32" /></TableCell>
@@ -137,7 +138,7 @@ async function executeConfirmAction() {
<h2 class="text-lg font-semibold"> <h2 class="text-lg font-semibold">
Organizations ({orgsQuery.data.length}) Organizations ({orgsQuery.data.length})
</h2> </h2>
<Button href="/admin/orgs/new"> <Button href={resolve("/admin/orgs/new")}>
<Plus class="mr-2 h-4 w-4" /> <Plus class="mr-2 h-4 w-4" />
New Organization New Organization
</Button> </Button>
@@ -154,7 +155,7 @@ async function executeConfirmAction() {
<p class="mt-2 text-center text-sm text-muted-foreground"> <p class="mt-2 text-center text-sm text-muted-foreground">
Create your first organization to get started. Create your first organization to get started.
</p> </p>
<Button href="/admin/orgs/new" class="mt-4"> <Button href={resolve("/admin/orgs/new")} class="mt-4">
<Plus class="mr-2 h-4 w-4" /> <Plus class="mr-2 h-4 w-4" />
New Organization New Organization
</Button> </Button>
@@ -192,7 +193,7 @@ async function executeConfirmAction() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
href="/dashboard/{org.slug}" href={resolve(`/dashboard/${org.slug}`)}
title="View organization" title="View organization"
> >
<Eye class="h-4 w-4" /> <Eye class="h-4 w-4" />
@@ -221,7 +222,7 @@ async function executeConfirmAction() {
<!-- Back link --> <!-- Back link -->
<div class="pt-4"> <div class="pt-4">
<a <a
href="/admin" href={resolve("/admin")}
class="text-sm text-muted-foreground hover:text-foreground" class="text-sm text-muted-foreground hover:text-foreground"
> >
&larr; Back to admin dashboard &larr; Back to admin dashboard
@@ -229,7 +230,7 @@ async function executeConfirmAction() {
</div> </div>
{/if} {/if}
</div> </div>
</DashboardLayout> </AdminLayout>
<!-- Confirmation dialog --> <!-- Confirmation dialog -->
<ConfirmDialog <ConfirmDialog

View File

@@ -12,9 +12,10 @@ import {
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { AdminLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { ConfirmDialog } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -186,7 +187,7 @@ function handleDelete() {
await api.admin.orgs.delete({ slug: slug ?? "" }); await api.admin.orgs.delete({ slug: slug ?? "" });
toast.success("Organization deleted"); toast.success("Organization deleted");
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
goto("/admin/orgs"); goto(resolve("/admin/orgs"));
} catch (e) { } catch (e) {
toast.error( toast.error(
e instanceof Error ? e.message : "Failed to delete organization", e instanceof Error ? e.message : "Failed to delete organization",
@@ -220,7 +221,7 @@ async function executeConfirmAction() {
</title> </title>
</svelte:head> </svelte:head>
<DashboardLayout title="Organization Details"> <AdminLayout title="Organization Details">
{#if orgQuery.isPending} {#if orgQuery.isPending}
<div class="flex flex-col items-center justify-center py-16"> <div class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
@@ -235,7 +236,7 @@ async function executeConfirmAction() {
: "Failed to load organization"} : "Failed to load organization"}
</p> </p>
<a <a
href="/admin/orgs" href={resolve("/admin/orgs")}
class="mt-4 text-sm text-muted-foreground hover:text-foreground" class="mt-4 text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft class="mr-1 inline h-4 w-4" /> <ArrowLeft class="mr-1 inline h-4 w-4" />
@@ -247,7 +248,7 @@ async function executeConfirmAction() {
<div class="mx-auto max-w-2xl space-y-6"> <div class="mx-auto max-w-2xl space-y-6">
<!-- Back link --> <!-- Back link -->
<a <a
href="/admin/orgs" href={resolve("/admin/orgs")}
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft class="mr-1 h-4 w-4" /> <ArrowLeft class="mr-1 h-4 w-4" />
@@ -456,7 +457,7 @@ async function executeConfirmAction() {
</Card> </Card>
</div> </div>
{/if} {/if}
</DashboardLayout> </AdminLayout>
<!-- Confirmation dialog --> <!-- Confirmation dialog -->
<ConfirmDialog <ConfirmDialog

View File

@@ -2,8 +2,9 @@
import { ArrowLeft, Loader2 } from "@lucide/svelte"; import { ArrowLeft, Loader2 } from "@lucide/svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { AdminLayout } from "$lib/components/layout";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -49,7 +50,7 @@ async function handleSubmit() {
ownerEmail: ownerEmail.trim(), ownerEmail: ownerEmail.trim(),
}); });
toast.success("Organization created successfully"); toast.success("Organization created successfully");
goto("/admin/orgs"); goto(resolve("/admin/orgs"));
} catch (e) { } catch (e) {
toast.error( toast.error(
e instanceof Error ? e.message : "Failed to create organization", e instanceof Error ? e.message : "Failed to create organization",
@@ -74,11 +75,11 @@ function handleSlugInput(event: Event) {
<title>New Organization | Admin | Publisher Dashboard</title> <title>New Organization | Admin | Publisher Dashboard</title>
</svelte:head> </svelte:head>
<DashboardLayout title="New Organization"> <AdminLayout title="New Organization">
<div class="mx-auto max-w-2xl space-y-6"> <div class="mx-auto max-w-2xl space-y-6">
<!-- Back link --> <!-- Back link -->
<a <a
href="/admin/orgs" href={resolve("/admin/orgs")}
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft class="mr-1 h-4 w-4" /> <ArrowLeft class="mr-1 h-4 w-4" />
@@ -157,4 +158,4 @@ function handleSlugInput(event: Event) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</DashboardLayout> </AdminLayout>

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, Check, Eye, Users, X } from "@lucide/svelte"; import { AlertCircle, Check, Eye, Users, X } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { SuperuserBadge } from "$lib/components/admin/index.js"; import { SuperuserBadge } from "$lib/components/admin/index.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { AdminLayout } from "$lib/components/layout";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
Card, Card,
@@ -37,7 +38,7 @@ const usersQuery = createQuery(() => ({
<title>Users | Admin | Publisher Dashboard</title> <title>Users | Admin | Publisher Dashboard</title>
</svelte:head> </svelte:head>
<DashboardLayout title="Users"> <AdminLayout title="Users">
{#if usersQuery.isPending} {#if usersQuery.isPending}
<div class="space-y-6"> <div class="space-y-6">
<Card> <Card>
@@ -59,7 +60,7 @@ const usersQuery = createQuery(() => ({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each Array(5) as _} {#each Array(5) as _, i (i)}
<TableRow> <TableRow>
<TableCell><Skeleton class="h-4 w-40" /></TableCell> <TableCell><Skeleton class="h-4 w-40" /></TableCell>
<TableCell><Skeleton class="h-4 w-24" /></TableCell> <TableCell><Skeleton class="h-4 w-24" /></TableCell>
@@ -124,7 +125,7 @@ const usersQuery = createQuery(() => ({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
href="/admin/users/{encodeURIComponent(user.email)}" href={resolve(`/admin/users/${encodeURIComponent(user.email)}`)}
> >
<Eye class="mr-1 h-4 w-4" /> <Eye class="mr-1 h-4 w-4" />
View View
@@ -141,4 +142,4 @@ const usersQuery = createQuery(() => ({
</Card> </Card>
</div> </div>
{/if} {/if}
</DashboardLayout> </AdminLayout>

View File

@@ -11,10 +11,11 @@ import {
} from "@lucide/svelte"; } from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { SuperuserBadge } from "$lib/components/admin/index.js"; import { SuperuserBadge } from "$lib/components/admin/index.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { AdminLayout } from "$lib/components/layout";
import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js"; import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { import {
@@ -147,10 +148,10 @@ async function handleConfirmEmail() {
<title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title> <title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title>
</svelte:head> </svelte:head>
<DashboardLayout title="User Details"> <AdminLayout title="User Details">
<!-- Back navigation --> <!-- Back navigation -->
<div class="mb-6"> <div class="mb-6">
<Button variant="ghost" size="sm" href="/admin/users" class="gap-1"> <Button variant="ghost" size="sm" href={resolve("/admin/users")} class="gap-1">
<ArrowLeft class="h-4 w-4" /> <ArrowLeft class="h-4 w-4" />
Back to users Back to users
</Button> </Button>
@@ -179,7 +180,7 @@ async function handleConfirmEmail() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
{#each Array(5) as _} {#each Array(5) as _, i (i)}
<div class="space-y-1"> <div class="space-y-1">
<Skeleton class="h-4 w-20" /> <Skeleton class="h-4 w-20" />
<Skeleton class="h-5 w-32" /> <Skeleton class="h-5 w-32" />
@@ -345,4 +346,4 @@ async function handleConfirmEmail() {
{/if} {/if}
</div> </div>
{/if} {/if}
</DashboardLayout> </AdminLayout>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { resolve } from "$app/paths";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -80,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer --> <!-- Footer -->
<p class="text-center text-xs text-muted-foreground"> <p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our By continuing, you agree to our
<a href="/terms" class="underline underline-offset-4 hover:text-foreground">Terms of Service</a> <a href={resolve("/terms" as any)} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and and
<a href="/privacy" class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a> <a href={resolve("/privacy" as any)} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
} from "@lucide/svelte"; } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -40,7 +41,7 @@ async function copyToClipboard() {
// Guard: redirect to /auth/login if no active login flow // Guard: redirect to /auth/login if no active login flow
$effect(() => { $effect(() => {
if (!loginFlowState.email) { if (!loginFlowState.email) {
goto("/auth/login"); goto(resolve("/auth/login"));
} }
}); });
@@ -58,7 +59,7 @@ const statusQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (statusQuery.data?.status === "completed") { if (statusQuery.data?.status === "completed") {
clearLoginFlowState(); clearLoginFlowState();
goto(statusQuery.data.redirectTo || "/"); goto(resolve((statusQuery.data.redirectTo || "/") as any));
} }
}); });
@@ -88,7 +89,7 @@ async function handleResendEmail() {
function handleDifferentEmail() { function handleDifferentEmail() {
clearLoginFlowState(); clearLoginFlowState();
goto("/auth/login"); goto(resolve("/auth/login"));
} }
</script> </script>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { CheckCircle2 } from "@lucide/svelte"; import { CheckCircle2 } from "@lucide/svelte";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -119,8 +120,8 @@ async function handleSubmit(e: Event) {
<!-- Back to login link --> <!-- Back to login link -->
<div class="text-center text-sm text-muted-foreground"> <div class="text-center text-sm text-muted-foreground">
Remember your password?{" "} Remember your password?
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary"> <a href={resolve("/auth/login")} class="text-foreground underline underline-offset-4 hover:text-primary">
Sign in Sign in
</a> </a>
</div> </div>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
@@ -21,12 +22,12 @@ async function handleSubmit(e: SubmitEvent) {
setLoginFlowState(response); setLoginFlowState(response);
if (response.hasPasskey) { if (response.hasPasskey) {
goto("/auth/login/passkey"); goto(resolve("/auth/login/passkey"));
} else if (response.hasPassword) { } else if (response.hasPassword) {
goto("/auth/login/password"); goto(resolve("/auth/login/password"));
} else { } else {
// Anti-enumeration: always redirect to confirm even if user doesn't exist // Anti-enumeration: always redirect to confirm even if user doesn't exist
goto("/auth/confirm"); goto(resolve("/auth/confirm"));
} }
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : "An unexpected error occurred"; error = err instanceof Error ? err.message : "An unexpected error occurred";
@@ -75,7 +76,7 @@ async function handleSubmit(e: SubmitEvent) {
<div class="text-center"> <div class="text-center">
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Don't have an account? Don't have an account?
<a href="/auth/signup" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"> <a href={resolve("/auth/signup")} class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">
Sign up Sign up
</a> </a>
</p> </p>

View File

@@ -2,6 +2,7 @@
import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte"; import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte";
import { startAuthentication } from "@simplewebauthn/browser"; import { startAuthentication } from "@simplewebauthn/browser";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -43,7 +44,7 @@ async function authenticate(): Promise<void> {
}); });
// Success - redirect to confirm for session creation // Success - redirect to confirm for session creation
goto("/auth/confirm"); goto(resolve("/auth/confirm"));
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Authentication failed"; error = e instanceof Error ? e.message : "Authentication failed";
hasAttempted = true; hasAttempted = true;
@@ -55,7 +56,7 @@ async function authenticate(): Promise<void> {
// Guard: redirect to /auth/login if no active login flow // Guard: redirect to /auth/login if no active login flow
$effect(() => { $effect(() => {
if (!loginFlowState.email) { if (!loginFlowState.email) {
goto("/auth/login"); goto(resolve("/auth/login"));
} }
}); });
@@ -134,7 +135,7 @@ $effect(() => {
<!-- Fallback links --> <!-- Fallback links -->
{#if loginFlowState.hasPassword} {#if loginFlowState.hasPassword}
<Button variant="outline" class="h-10 w-full" href="/auth/login/password"> <Button variant="outline" class="h-10 w-full" href={resolve("/auth/login/password")}>
Use password instead Use password instead
</Button> </Button>
{/if} {/if}
@@ -142,7 +143,7 @@ $effect(() => {
<div class="text-center"> <div class="text-center">
<button <button
type="button" type="button"
onclick={() => goto("/auth/login")} onclick={() => goto(resolve("/auth/login"))}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
> >
Use a different email Use a different email

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert, PasswordInput } from "$lib/components/auth"; import { ErrorAlert, PasswordInput } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -14,7 +15,7 @@ let error = $state<string | null>(null);
// Guard: redirect to /auth/login if no active login flow // Guard: redirect to /auth/login if no active login flow
$effect(() => { $effect(() => {
if (!loginFlowState.email) { if (!loginFlowState.email) {
goto("/auth/login"); goto(resolve("/auth/login"));
} }
}); });
@@ -26,7 +27,7 @@ async function handleSubmit(e: SubmitEvent) {
try { try {
await api.auth.loginPassword({ password }); await api.auth.loginPassword({ password });
// On success, redirect to confirm page for email verification // On success, redirect to confirm page for email verification
goto("/auth/confirm"); goto(resolve("/auth/confirm"));
} catch (err) { } catch (err) {
error = error =
err instanceof Error err instanceof Error
@@ -38,7 +39,7 @@ async function handleSubmit(e: SubmitEvent) {
function handleDifferentEmail() { function handleDifferentEmail() {
clearLoginFlowState(); clearLoginFlowState();
goto("/auth/login"); goto(resolve("/auth/login"));
} }
</script> </script>
@@ -82,7 +83,7 @@ function handleDifferentEmail() {
<!-- Secondary Links --> <!-- Secondary Links -->
<div class="space-y-3 text-center"> <div class="space-y-3 text-center">
<a <a
href="/auth/forgot-password" href={resolve("/auth/forgot-password")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
> >
Forgot password? Forgot password?
@@ -91,7 +92,7 @@ function handleDifferentEmail() {
{#if loginFlowState.hasPasskey} {#if loginFlowState.hasPasskey}
<div> <div>
<a <a
href="/auth/login/passkey" href={resolve("/auth/login/passkey")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
> >
Use passkey instead Use passkey instead

View File

@@ -3,6 +3,7 @@ import { AlertCircle } from "@lucide/svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import zxcvbn from "zxcvbn"; import zxcvbn from "zxcvbn";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { import {
@@ -56,7 +57,7 @@ async function handleSubmit(e: Event) {
toast.success("Password reset successfully", { toast.success("Password reset successfully", {
description: "You can now sign in with your new password.", description: "You can now sign in with your new password.",
}); });
await goto("/auth/login"); await goto(resolve("/auth/login"));
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
// Handle specific error cases // Handle specific error cases
@@ -97,7 +98,7 @@ async function handleSubmit(e: Event) {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button variant="outline" class="h-10 w-full" onclick={() => goto("/auth/forgot-password")}> <Button variant="outline" class="h-10 w-full" onclick={() => goto(resolve("/auth/forgot-password"))}>
Request new reset link Request new reset link
</Button> </Button>
{:else} {:else}
@@ -147,8 +148,8 @@ async function handleSubmit(e: Event) {
<!-- Back to login link --> <!-- Back to login link -->
<div class="text-center text-sm text-muted-foreground"> <div class="text-center text-sm text-muted-foreground">
Remember your password?{" "} Remember your password?
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary"> <a href={resolve("/auth/login")} class="text-foreground underline underline-offset-4 hover:text-primary">
Sign in Sign in
</a> </a>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { AlertCircle, Loader2 } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
@@ -22,7 +23,7 @@ const userQuery = createQuery(() => ({
// Redirect if user doesn't need setup // Redirect if user doesn't need setup
$effect(() => { $effect(() => {
if (userQuery.data && !userQuery.data.needsSetup) { if (userQuery.data && !userQuery.data.needsSetup) {
goto("/"); goto(resolve("/"));
} }
}); });
@@ -68,7 +69,7 @@ async function handleSubmit(e: Event) {
}); });
toast.success("Profile setup complete!"); toast.success("Profile setup complete!");
goto("/"); goto(resolve("/"));
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Failed to save profile"; error = e instanceof Error ? e.message : "Failed to save profile";
} finally { } finally {

View File

@@ -5,6 +5,7 @@ import {
} from "@simplewebauthn/browser"; } from "@simplewebauthn/browser";
import zxcvbn from "zxcvbn"; import zxcvbn from "zxcvbn";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { import {
ErrorAlert, ErrorAlert,
@@ -75,7 +76,7 @@ async function handlePasskeySignup() {
}); });
// Redirect to user setup // Redirect to user setup
await goto("/auth/setup/user"); await goto(resolve("/auth/setup/user"));
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
// Handle WebAuthn cancellation // Handle WebAuthn cancellation
@@ -103,7 +104,7 @@ async function handlePasswordSignup() {
}); });
// Redirect to user setup // Redirect to user setup
await goto("/auth/setup/user"); await goto(resolve("/auth/setup/user"));
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
error = err.message; error = err.message;
@@ -249,8 +250,8 @@ function switchToPasskey() {
<!-- Sign in link --> <!-- Sign in link -->
<div class="text-center text-sm text-muted-foreground"> <div class="text-center text-sm text-muted-foreground">
Already have an account?{" "} Already have an account?
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary"> <a href={resolve("/auth/login")} class="text-foreground underline underline-offset-4 hover:text-primary">
Sign in Sign in
</a> </a>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { createQuery } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -52,7 +53,7 @@ async function handleTrust() {
try { try {
await api.me.devices.trust({ name: deviceName.trim() }); await api.me.devices.trust({ name: deviceName.trim() });
toast.success("Device trusted successfully!"); toast.success("Device trusted successfully!");
goto("/"); goto(resolve("/"));
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Failed to trust device"; error = e instanceof Error ? e.message : "Failed to trust device";
} finally { } finally {
@@ -61,7 +62,7 @@ async function handleTrust() {
} }
async function handleSkip() { async function handleSkip() {
goto("/performance"); goto(resolve("/"));
} }
// Get device icon based on type // Get device icon based on type

View File

@@ -2,6 +2,7 @@
import { CheckCircle2, Loader2, Mail, XCircle } from "@lucide/svelte"; import { CheckCircle2, Loader2, Mail, XCircle } from "@lucide/svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth"; import { ErrorAlert } from "$lib/components/auth";
@@ -31,7 +32,7 @@ async function verifyEmail(): Promise<void> {
try { try {
await api.auth.verifyEmail({ token }); await api.auth.verifyEmail({ token });
toast.success("Email verified successfully!"); toast.success("Email verified successfully!");
goto("/"); goto(resolve("/"));
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Verification failed"; error = e instanceof Error ? e.message : "Verification failed";
} finally { } finally {
@@ -132,7 +133,7 @@ async function resendVerification(): Promise<void> {
<div class="text-center"> <div class="text-center">
<a <a
href="/auth/login" href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
> >
Back to login Back to login

View File

@@ -8,8 +8,9 @@ import {
} from "@lucide/svelte"; } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { DashboardLayout } from "$lib/components/layout";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { import {
Card, Card,
@@ -40,7 +41,9 @@ const invitesQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
goto( goto(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`, resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
); );
} }
}); });
@@ -98,7 +101,7 @@ function formatRole(role: string): string {
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each invitesQuery.data as invite (invite.id)} {#each invitesQuery.data as invite (invite.id)}
<a <a
href="/account/org-invites/{invite.id}" href={resolve(`/account/org-invites/${invite.id}`)}
class="group block" class="group block"
> >
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50"> <Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
@@ -183,7 +186,7 @@ function formatRole(role: string): string {
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each orgsQuery.data as org (org.id)} {#each orgsQuery.data as org (org.id)}
<a <a
href="/dashboard/{org.slug}" href={resolve(`/dashboard/${org.slug}`)}
class="group block transition-transform hover:scale-[1.02]" class="group block transition-transform hover:scale-[1.02]"
> >
<Card class="h-full transition-colors group-hover:border-primary/50"> <Card class="h-full transition-colors group-hover:border-primary/50">

View File

@@ -10,8 +10,9 @@ import {
} from "@lucide/svelte"; } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { DashboardLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -86,7 +87,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
: "Failed to load organization"} : "Failed to load organization"}
</p> </p>
<a <a
href="/dashboard" href={resolve("/dashboard")}
class="mt-4 text-sm text-primary underline underline-offset-4 hover:text-primary/80" class="mt-4 text-sm text-primary underline underline-offset-4 hover:text-primary/80"
> >
Back to organizations Back to organizations
@@ -117,7 +118,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
</div> </div>
</div> </div>
{#if canManageOrg} {#if canManageOrg}
<Button variant="outline" href="/dashboard/{slug}/settings"> <Button variant="outline" href={resolve(`/dashboard/${slug}/settings`)}>
<Settings class="mr-2 h-4 w-4" /> <Settings class="mr-2 h-4 w-4" />
Settings Settings
</Button> </Button>
@@ -126,7 +127,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Stats cards --> <!-- Stats cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<a href="/dashboard/{slug}/members" class="group"> <a href={resolve(`/dashboard/${slug}/members`)} class="group">
<Card class="transition-colors group-hover:border-primary/50"> <Card class="transition-colors group-hover:border-primary/50">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Members</CardTitle> <CardTitle class="text-sm font-medium">Members</CardTitle>
@@ -163,7 +164,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<CardTitle class="text-base">Team Members</CardTitle> <CardTitle class="text-base">Team Members</CardTitle>
<a <a
href="/dashboard/{slug}/members" href={resolve(`/dashboard/${slug}/members`)}
class="flex items-center text-sm text-primary hover:underline" class="flex items-center text-sm text-primary hover:underline"
> >
View all View all

View File

@@ -11,7 +11,7 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { DashboardLayout } from "$lib/components/layout";
import { ConfirmDialog, RoleBadge } from "$lib/components/org"; import { ConfirmDialog, RoleBadge } from "$lib/components/org";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -300,7 +300,7 @@ const availableInviteRoles = $derived.by(() => {
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)} {inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{#each availableInviteRoles as role} {#each availableInviteRoles as role (role)}
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} /> <SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
{/each} {/each}
</SelectContent> </SelectContent>

View File

@@ -4,7 +4,7 @@ import FrequentFilters from "$lib/components/dashboard/frequent-filters.svelte";
import MetricCard from "$lib/components/dashboard/metric-card.svelte"; import MetricCard from "$lib/components/dashboard/metric-card.svelte";
import PeakTrafficChart from "$lib/components/dashboard/peak-traffic-chart.svelte"; import PeakTrafficChart from "$lib/components/dashboard/peak-traffic-chart.svelte";
import PerformanceTable from "$lib/components/dashboard/performance-table.svelte"; import PerformanceTable from "$lib/components/dashboard/performance-table.svelte";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { DashboardLayout } from "$lib/components/layout";
// Get org context (for future filtering by org) // Get org context (for future filtering by org)
const orgContext = getContext<{ slug: string }>("orgContext"); const orgContext = getContext<{ slug: string }>("orgContext");
@@ -47,7 +47,7 @@ const metrics = [
<!-- Metric Cards --> <!-- Metric Cards -->
<section> <section>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#each metrics as metric} {#each metrics as metric (metric.label)}
<MetricCard <MetricCard
label={metric.label} label={metric.label}
value={metric.value} value={metric.value}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { DashboardLayout } from "$lib/components/layout";
</script> </script>
<svelte:head> <svelte:head>

View File

@@ -11,8 +11,9 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { ConfirmDialog } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -124,7 +125,7 @@ function handleLeave() {
await api.orgs.leave({ slug }); await api.orgs.leave({ slug });
toast.success("You have left the organization"); toast.success("You have left the organization");
await queryClient.invalidateQueries({ queryKey: ["orgs"] }); await queryClient.invalidateQueries({ queryKey: ["orgs"] });
goto("/dashboard"); goto(resolve("/dashboard"));
} catch (e) { } catch (e) {
toast.error( toast.error(
e instanceof Error ? e.message : "Failed to leave organization", e instanceof Error ? e.message : "Failed to leave organization",
@@ -147,7 +148,7 @@ function handleDelete() {
await api.orgs.delete({ slug }); await api.orgs.delete({ slug });
toast.success("Organization deleted"); toast.success("Organization deleted");
await queryClient.invalidateQueries({ queryKey: ["orgs"] }); await queryClient.invalidateQueries({ queryKey: ["orgs"] });
goto("/dashboard"); goto(resolve("/dashboard"));
} catch (e) { } catch (e) {
toast.error( toast.error(
e instanceof Error ? e.message : "Failed to delete organization", e instanceof Error ? e.message : "Failed to delete organization",
@@ -175,7 +176,7 @@ async function executeConfirmAction() {
<title>Settings | Publisher Dashboard</title> <title>Settings | Publisher Dashboard</title>
</svelte:head> </svelte:head>
<DashboardLayout title="Organization Settings"> <SettingsLayout title="Settings">
{#if isLoading || orgQuery.isPending} {#if isLoading || orgQuery.isPending}
<div class="flex flex-col items-center justify-center py-16"> <div class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
@@ -192,7 +193,7 @@ async function executeConfirmAction() {
</p> </p>
</div> </div>
{:else} {:else}
<div class="mx-auto max-w-2xl space-y-6"> <div class="space-y-6">
<!-- General Settings (admin+ only) --> <!-- General Settings (admin+ only) -->
{#if canManageOrg} {#if canManageOrg}
<Card> <Card>
@@ -295,18 +296,9 @@ async function executeConfirmAction() {
</Card> </Card>
{/if} {/if}
<!-- Back link -->
<div class="pt-4">
<a
href="/dashboard/{slug}"
class="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Back to organization
</a>
</div>
</div> </div>
{/if} {/if}
</DashboardLayout> </SettingsLayout>
<!-- Confirmation dialog --> <!-- Confirmation dialog -->
<ConfirmDialog <ConfirmDialog

View File

@@ -0,0 +1,469 @@
<script lang="ts">
import {
AlertCircle,
Clock,
Loader2,
UserPlus,
Users,
X,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "$lib/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table";
/**
* Members management settings page
*/
// Types from API contract
type OrgMemberOutput = Awaited<
ReturnType<typeof api.orgs.members.list>
>[number];
type OrgInviteOutput = Awaited<
ReturnType<typeof api.orgs.invites.list>
>[number];
type UserProfile = Awaited<ReturnType<typeof api.me.get>>;
// Get org context from layout
const orgContext = getContext<{
slug: string;
userQuery: { data: UserProfile | undefined };
membersQuery: { data: OrgMemberOutput[] | undefined; isPending: boolean };
currentUserRole: "owner" | "admin" | "member" | null;
canManageOrg: boolean;
isOwner: boolean;
isLoading: boolean;
error: Error | null;
}>("orgContext");
const slug = $derived(orgContext.slug);
const userData = $derived(orgContext.userQuery.data);
const membersData = $derived(orgContext.membersQuery.data);
const currentUserRole = $derived(orgContext.currentUserRole);
const canManageOrg = $derived(orgContext.canManageOrg);
const isOwner = $derived(orgContext.isOwner);
const isLoading = $derived(orgContext.isLoading);
const error = $derived(orgContext.error);
const currentUserId = $derived(userData?.id);
const queryClient = useQueryClient();
// Fetch invites (only for admins+)
const invitesQuery = createQuery(() => ({
queryKey: ["org", slug, "invites"],
queryFn: () => api.orgs.invites.list({ slug }),
enabled: !!slug && canManageOrg,
}));
// Invite form state
let inviteEmail = $state("");
let inviteRole = $state<"member" | "admin" | "owner">("member");
let isInviting = $state(false);
// Confirmation dialog state
let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state("");
let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
let isConfirmLoading = $state(false);
/**
* Send invite to email
*/
async function handleInvite() {
if (!inviteEmail.trim()) {
toast.error("Please enter an email address");
return;
}
isInviting = true;
try {
await api.orgs.invites.create({
slug,
email: inviteEmail.trim(),
role: inviteRole,
});
toast.success("Invitation sent!");
inviteEmail = "";
inviteRole = "member";
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to send invitation");
} finally {
isInviting = false;
}
}
/**
* Cancel a pending invite
*/
async function handleCancelInvite(inviteId: number, email: string) {
confirmDialogTitle = "Cancel Invitation";
confirmDialogDescription = `Are you sure you want to cancel the invitation to ${email}?`;
confirmDialogVariant = "destructive";
confirmAction = async () => {
try {
await api.orgs.invites.cancel({ slug, inviteId });
toast.success("Invitation cancelled");
await queryClient.invalidateQueries({
queryKey: ["org", slug, "invites"],
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to cancel invitation",
);
}
};
confirmDialogOpen = true;
}
/**
* Update member role
*/
async function handleUpdateRole(
userId: number,
newRole: "owner" | "admin" | "member",
) {
try {
await api.orgs.members.updateRole({ slug, userId, role: newRole });
toast.success("Role updated");
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update role");
}
}
/**
* Remove member
*/
async function handleRemoveMember(
userId: number,
displayName: string | null,
email: string,
) {
confirmDialogTitle = "Remove Member";
confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`;
confirmDialogVariant = "destructive";
confirmAction = async () => {
try {
await api.orgs.members.remove({ slug, userId });
toast.success("Member removed");
await queryClient.invalidateQueries({
queryKey: ["org", slug, "members"],
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");
}
};
confirmDialogOpen = true;
}
/**
* Execute confirm action
*/
async function executeConfirmAction() {
isConfirmLoading = true;
try {
await confirmAction();
confirmDialogOpen = false;
} finally {
isConfirmLoading = false;
}
}
/**
* Format relative time
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) {
return "Expired";
}
if (days === 0) {
return "Today";
}
if (days === 1) {
return "Tomorrow";
}
return `${days} days`;
}
/**
* Check if user can remove a member
*/
function canRemoveMember(memberRole: string, memberId: number): boolean {
if (memberId === currentUserId) {
return false;
}
if (isOwner) {
return true;
}
if (currentUserRole === "admin" && memberRole === "member") {
return true;
}
return false;
}
/**
* Get available roles for invite based on current user's role
*/
const availableInviteRoles = $derived.by(() => {
if (isOwner) {
return ["member", "admin", "owner"] as const;
}
if (currentUserRole === "admin") {
return ["member", "admin"] as const;
}
return ["member"] as const;
});
</script>
<svelte:head>
<title>Members | Publisher Dashboard</title>
</svelte:head>
<SettingsLayout title="Settings">
{#if isLoading}
<div class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
<p class="mt-4 text-sm text-muted-foreground">Loading members...</p>
</div>
{:else if error}
<div class="flex flex-col items-center justify-center py-16">
<AlertCircle class="h-8 w-8 text-destructive" />
<p class="mt-4 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load members"}
</p>
</div>
{:else}
<div class="space-y-6">
<!-- Invite form (admin+ only) -->
{#if canManageOrg}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<UserPlus class="h-4 w-4" />
Invite Member
</CardTitle>
</CardHeader>
<CardContent>
<form onsubmit={(e) => { e.preventDefault(); handleInvite(); }} class="flex flex-col gap-4 sm:flex-row sm:items-end">
<div class="flex-1 space-y-2">
<Label for="invite-email">Email address</Label>
<Input
id="invite-email"
type="email"
placeholder="colleague@example.com"
bind:value={inviteEmail}
disabled={isInviting}
/>
</div>
<div class="w-full space-y-2 sm:w-32">
<Label for="invite-role">Role</Label>
<Select
type="single"
value={inviteRole}
onValueChange={(v) => { if (v) inviteRole = v as typeof inviteRole; }}
disabled={isInviting}
>
<SelectTrigger id="invite-role" class="w-full">
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
</SelectTrigger>
<SelectContent>
{#each availableInviteRoles as role (role)}
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
{/each}
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isInviting || !inviteEmail.trim()}>
{#if isInviting}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Send Invite
</Button>
</form>
</CardContent>
</Card>
{/if}
<!-- Pending invites (admin+ only) -->
{#if canManageOrg && invitesQuery.data && invitesQuery.data.length > 0}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Clock class="h-4 w-4" />
Pending Invitations ({invitesQuery.data.length})
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Invited by</TableHead>
<TableHead>Expires</TableHead>
<TableHead class="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each invitesQuery.data as invite (invite.id)}
<TableRow>
<TableCell class="font-medium">{invite.email}</TableCell>
<TableCell><RoleBadge role={invite.role} /></TableCell>
<TableCell class="text-muted-foreground">{invite.invitedBy}</TableCell>
<TableCell class="text-muted-foreground">
{formatRelativeTime(new Date(invite.expiresAt))}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onclick={() => handleCancelInvite(invite.id, invite.email)}
>
<X class="h-4 w-4" />
<span class="sr-only">Cancel</span>
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
<!-- Members list -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Users class="h-4 w-4" />
Members ({membersData?.length ?? 0})
</CardTitle>
</CardHeader>
<CardContent>
{#if membersData && membersData.length > 0}
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
{#if canManageOrg}
<TableHead class="w-[100px]"></TableHead>
{/if}
</TableRow>
</TableHeader>
<TableBody>
{#each membersData as member (member.id)}
{@const isCurrentUser = member.userId === currentUserId}
<TableRow>
<TableCell>
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xs font-medium">
{(member.displayName || member.email).charAt(0).toUpperCase()}
</div>
<div>
<p class="font-medium">
{member.displayName || member.email}
{#if isCurrentUser}
<span class="ml-1 text-xs text-muted-foreground">(You)</span>
{/if}
</p>
{#if member.displayName}
<p class="text-xs text-muted-foreground">{member.email}</p>
{/if}
</div>
</div>
</TableCell>
<TableCell>
{#if isOwner && !isCurrentUser}
<Select
type="single"
value={member.role}
onValueChange={(v) => { if (v) handleUpdateRole(member.userId, v as "owner" | "admin" | "member"); }}
>
<SelectTrigger size="sm" class="h-7 w-24 text-xs">
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</SelectTrigger>
<SelectContent>
<SelectItem value="member" label="Member" />
<SelectItem value="admin" label="Admin" />
<SelectItem value="owner" label="Owner" />
</SelectContent>
</Select>
{:else}
<RoleBadge role={member.role} />
{/if}
</TableCell>
<TableCell class="text-muted-foreground">
{new Date(member.createdAt).toLocaleDateString()}
</TableCell>
{#if canManageOrg}
<TableCell>
{#if canRemoveMember(member.role, member.userId)}
<Button
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive"
onclick={() => handleRemoveMember(member.userId, member.displayName, member.email)}
>
Remove
</Button>
{/if}
</TableCell>
{/if}
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="text-sm text-muted-foreground">No members yet</p>
{/if}
</CardContent>
</Card>
</div>
{/if}
</SettingsLayout>
<!-- Confirmation dialog -->
<ConfirmDialog
bind:open={confirmDialogOpen}
title={confirmDialogTitle}
description={confirmDialogDescription}
variant={confirmDialogVariant}
loading={isConfirmLoading}
onconfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { Globe } from "@lucide/svelte";
import { SettingsLayout } from "$lib/components/layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
</script>
<svelte:head>
<title>Sites | Publisher Dashboard</title>
</svelte:head>
<SettingsLayout title="Settings">
<Card class="border-dashed">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Globe class="h-5 w-5 text-muted-foreground" />
Sites
</CardTitle>
<CardDescription>
Manage your connected websites and domains.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col items-center justify-center py-8 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Globe class="h-6 w-6 text-muted-foreground" />
</div>
<h3 class="mt-4 text-sm font-medium">Coming Soon</h3>
<p class="mt-1 text-sm text-muted-foreground">
Site management features are currently in development.
</p>
</div>
</CardContent>
</Card>
</SettingsLayout>

View File

@@ -2,6 +2,7 @@
import { CheckCircle2, Loader2, UserPlus, XCircle } from "@lucide/svelte"; import { CheckCircle2, Loader2, UserPlus, XCircle } from "@lucide/svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
@@ -45,7 +46,9 @@ async function acceptInvite(): Promise<void> {
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to login with return URL // Redirect to login with return URL
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`; const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
goto(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`); goto(
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any),
);
return; return;
} }
@@ -55,7 +58,7 @@ async function acceptInvite(): Promise<void> {
toast.success("You've joined the organization!"); toast.success("You've joined the organization!");
// Redirect to dashboard after a short delay // Redirect to dashboard after a short delay
setTimeout(() => { setTimeout(() => {
goto("/dashboard"); goto(resolve("/dashboard"));
}, 1500); }, 1500);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
@@ -168,13 +171,13 @@ $effect(() => {
</Button> </Button>
{/if} {/if}
<Button variant="outline" class="h-10 w-full" href="/dashboard"> <Button variant="outline" class="h-10 w-full" href={resolve("/dashboard")}>
Go to Dashboard Go to Dashboard
</Button> </Button>
<div class="text-center"> <div class="text-center">
<a <a
href="/auth/login" href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
> >
Sign in with a different account Sign in with a different account

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
// Redirect old /login route to new /auth/login // Redirect old /login route to new /auth/login
$effect(() => { $effect(() => {
goto("/auth/login", { replaceState: true }); goto(resolve("/auth/login"), { replaceState: true });
}); });
</script> </script>

View File

@@ -101,9 +101,12 @@
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-plugin-svelte": "^3.14.0",
"svelte": "^5.28.2", "svelte": "^5.28.2",
"svelte-check": "^4.2.1", "svelte-check": "^4.2.1",
"svelte-eslint-parser": "^1.4.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
@@ -619,6 +622,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -671,6 +676,8 @@
"eslint-config-turbo": ["eslint-config-turbo@2.7.3", "", { "dependencies": { "eslint-plugin-turbo": "2.7.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-1ik3XQLJoE9d9ljhw60wTQf7rlwnz8tc6vnhSL7/Ciep2+qPMJpNg+mapcmGhirfDSceVNI8r9pv+HyvrBXhpQ=="], "eslint-config-turbo": ["eslint-config-turbo@2.7.3", "", { "dependencies": { "eslint-plugin-turbo": "2.7.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-1ik3XQLJoE9d9ljhw60wTQf7rlwnz8tc6vnhSL7/Ciep2+qPMJpNg+mapcmGhirfDSceVNI8r9pv+HyvrBXhpQ=="],
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.14.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g=="],
"eslint-plugin-turbo": ["eslint-plugin-turbo@2.7.3", "", { "dependencies": { "dotenv": "16.0.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-q7kYzJCyvceSLVwHgmn3ZBhqpUihQHxC7LEddq5a1eLe5P+/Ob4TnJrdocP38qO1n9MCuO+cJSUTGUtZb1X3bQ=="], "eslint-plugin-turbo": ["eslint-plugin-turbo@2.7.3", "", { "dependencies": { "dotenv": "16.0.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-q7kYzJCyvceSLVwHgmn3ZBhqpUihQHxC7LEddq5a1eLe5P+/Ob4TnJrdocP38qO1n9MCuO+cJSUTGUtZb1X3bQ=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
@@ -799,6 +806,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="],
"kysely-codegen": ["kysely-codegen@0.19.0", "", { "dependencies": { "chalk": "4.1.2", "cosmiconfig": "^9.0.0", "dotenv": "^17.2.1", "dotenv-expand": "^12.0.2", "git-diff": "^2.0.6", "micromatch": "^4.0.8", "minimist": "^1.2.8", "pluralize": "^8.0.0", "zod": "^4.1.5" }, "peerDependencies": { "@libsql/kysely-libsql": ">=0.3.0 <0.5.0", "@tediousjs/connection-string": ">=0.5.0 <0.6.0", "better-sqlite3": ">=7.6.2 <13.0.0", "kysely": ">=0.27.0 <1.0.0", "kysely-bun-sqlite": ">=0.3.2 <1.0.0", "kysely-bun-worker": ">=1.2.0 <2.0.0", "mysql2": ">=2.3.3 <4.0.0", "pg": ">=8.8.0 <9.0.0", "tarn": ">=3.0.0 <4.0.0", "tedious": ">=18.0.0 <20.0.0" }, "optionalPeers": ["@libsql/kysely-libsql", "@tediousjs/connection-string", "better-sqlite3", "kysely-bun-sqlite", "kysely-bun-worker", "mysql2", "pg", "tarn", "tedious"], "bin": { "kysely-codegen": "dist/cli/bin.js" } }, "sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w=="], "kysely-codegen": ["kysely-codegen@0.19.0", "", { "dependencies": { "chalk": "4.1.2", "cosmiconfig": "^9.0.0", "dotenv": "^17.2.1", "dotenv-expand": "^12.0.2", "git-diff": "^2.0.6", "micromatch": "^4.0.8", "minimist": "^1.2.8", "pluralize": "^8.0.0", "zod": "^4.1.5" }, "peerDependencies": { "@libsql/kysely-libsql": ">=0.3.0 <0.5.0", "@tediousjs/connection-string": ">=0.5.0 <0.6.0", "better-sqlite3": ">=7.6.2 <13.0.0", "kysely": ">=0.27.0 <1.0.0", "kysely-bun-sqlite": ">=0.3.2 <1.0.0", "kysely-bun-worker": ">=1.2.0 <2.0.0", "mysql2": ">=2.3.3 <4.0.0", "pg": ">=8.8.0 <9.0.0", "tarn": ">=3.0.0 <4.0.0", "tedious": ">=18.0.0 <20.0.0" }, "optionalPeers": ["@libsql/kysely-libsql", "@tediousjs/connection-string", "better-sqlite3", "kysely-bun-sqlite", "kysely-bun-worker", "mysql2", "pg", "tarn", "tedious"], "bin": { "kysely-codegen": "dist/cli/bin.js" } }, "sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w=="],
@@ -831,6 +840,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
@@ -929,6 +940,14 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
"postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
"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=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
@@ -1011,6 +1030,8 @@
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="], "svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="], "svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
@@ -1075,6 +1096,8 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@@ -1087,6 +1110,8 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],

View File

@@ -1,4 +1,4 @@
\restrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR \restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
-- Dumped from database version 17.7 -- Dumped from database version 17.7
-- Dumped by pg_dump version 17.7 -- Dumped by pg_dump version 17.7
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR \unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
-- --

View File

@@ -1,12 +1,19 @@
{ pkgs, ... }: { pkgs, ... }:
let
# Use tea 0.10.1 to avoid TTY bug in 0.11.x
# See: https://gitea.com/gitea/tea/issues/827
tea = pkgs.callPackage ./nix/tea.nix { };
in
{ {
packages = with pkgs; [ packages = with pkgs; [
nixfmt-rfc-style nixfmt-rfc-style
biome biome
git git
tea
dbmate dbmate
ast-grep ast-grep
dbip-city-lite
]; ];
dotenv.enable = true; dotenv.enable = true;
@@ -39,6 +46,7 @@
env = { env = {
DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard?sslmode=disable"; DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard?sslmode=disable";
TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test?sslmode=disable"; TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test?sslmode=disable";
GEOIP_DATABASE_PATH = "${pkgs.dbip-city-lite}/share/dbip/dbip-city-lite.mmdb";
}; };
scripts = { scripts = {

View File

@@ -1,300 +0,0 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/types";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { TRPCError } from "@trpc/server";
import { uniq } from "lodash-es";
const KNOWN_AAGUIDS: Record<string, string> = {
"ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager",
"adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac",
"08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello",
"9ddd1817-af5a-4672-a2b9-3e3dd95000a9": "Windows Hello",
"6028b017-b1d4-4c02-b4b3-afcdafc96bb2": "Windows Hello",
"dd4ec289-e01d-41c9-bb89-70fa845d4bf2": "iCloud Keychain (Managed)",
"531126d6-e717-415c-9320-3d9aa6981239": "Dashlane",
"bada5566-a7aa-401f-bd96-45619a55120d": "1Password",
"b84e4048-15dc-4dd0-8640-f4f60813c8af": "NordPass",
"0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": "Keeper",
"891494da-2c90-4d31-a9cd-4eab0aed1309": "Sésame",
"f3809540-7f14-49c1-a8b3-8f813b225541": "Enpass",
"b5397666-4885-aa6b-cebf-e52262a439a2": "Chromium Browser",
"771b48fd-d3d4-4f74-9232-fc157ab0507a": "Edge on Mac",
"39a5647e-1853-446c-a1f6-a79bae9f5bc7": "IDmelon",
"d548826e-79b4-db40-a3d8-11116f7e8349": "Bitwarden",
"fbfc3007-154e-4ecc-8c0b-6e020557d7bd": "iCloud Keychain",
"53414d53-554e-4700-0000-000000000000": "Samsung Pass",
"66a0ccb3-bd6a-191f-ee06-e375c50b9846": "Thales Bio iOS SDK",
"8836336a-f590-0921-301d-46427531eee6": "Thales Bio Android SDK",
"cd69adb5-3c7a-deb9-3177-6800ea6cb72a": "Thales PIN Android SDK",
"17290f1e-c212-34d0-1423-365d729f09d9": "Thales PIN iOS SDK",
"50726f74-6f6e-5061-7373-50726f746f6e": "Proton Pass",
"fdb141b2-5d84-443e-8a35-4698c205a502": "KeePassXC",
"cc45f64e-52a2-451b-831a-4edd8022a202": "ToothPic Passkey Provider",
"bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0": "iPasswords",
"b35a26b2-8f6e-4697-ab1d-d44db4da28c6": "Zoho Vault",
"b78a0a55-6ef8-d246-a042-ba0f6d55050c": "LastPass",
"de503f9c-21a4-4f76-b4b7-558eb55c6f89": "Devolutions",
};
export const getRPInfo = (
ctx: APIContext,
): {
rpName: string;
rpID: string;
origins: string[];
} => {
// RP must always be the frontend URL.
const rpID = ctx.origin.includes("oval.ph")
? "oval.ph"
: new URL(ctx.origin).hostname;
const origins = uniq(
ctx.env.ALLOWED_WEBAUTHN_ORIGINS.split(",").map((o) => new URL(o).origin),
);
return {
rpName: `Oval Business${rpID !== "oval.ph" ? ` (${rpID})` : ""}`,
rpID,
origins,
};
};
export const getUserPasskeys = async (ctx: APIContext, userId: string) => {
const userPasskeys = await fetchPasskeyQuery(ctx.db)
.where("passkeys.user_id", "=", userId)
.execute();
return userPasskeys.map(parsePasskey);
};
export const createRegistrationOptions = async (
ctx: ProtectedAPIContext,
): Promise<{
options: PublicKeyCredentialCreationOptionsJSON;
challengeId: PublicId<"passkey_challenges">;
}> => {
const { rpID, rpName } = getRPInfo(ctx);
const userPasskeys = await getUserPasskeys(ctx, ctx.user.id);
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
rpName,
rpID,
userName: ctx.user.display_name,
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: "direct",
// Prevent users from re-registering existing authenticators
excludeCredentials: userPasskeys.map((passkey) => ({
id: passkey.credentialId,
// Optional
transports: passkey.transports ?? undefined,
})),
// See "Guiding use of authenticators via authenticatorSelection" below
authenticatorSelection: {
// Defaults
residentKey: "preferred",
userVerification: "preferred",
// Optional
authenticatorAttachment: "platform",
},
});
const { public_id } = await ctx.db
.insertInto("passkey_challenges")
.values({
options: JSON.stringify(options),
})
.returning("public_id")
.executeTakeFirstOrThrow();
return {
options,
challengeId: public_id,
};
};
export const verifyRegistration = async (
ctx: ProtectedAPIContext,
{
challengeId,
response,
}: {
challengeId: PublicId<"passkey_challenges">;
response: RegistrationResponseJSON;
},
): Promise<void> => {
const { rpID, origins } = getRPInfo(ctx);
const optionsRaw = await ctx.db
.selectFrom("passkey_challenges")
.where("public_id", "=", challengeId)
.select("options")
.executeTakeFirst();
if (!optionsRaw) {
throw new TRPCError({
code: "TIMEOUT",
message: "Registration timed out. Please try again.",
});
}
const options =
optionsRaw.options as unknown as PublicKeyCredentialCreationOptionsJSON;
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: options.challenge,
expectedOrigin: origins,
expectedRPID: rpID,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid registration response. Please try again. ${(error as { message?: string }).message ?? ""}`,
});
} finally {
await ctx.db
.deleteFrom("passkey_challenges")
.where("public_id", "=", challengeId)
.execute();
}
const { verified, registrationInfo } = verification;
if (!(verified && registrationInfo)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unable to verify your device.",
});
}
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo;
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
const insert: PasskeyInsert = {
credentialId: credential.id,
webAuthnUserId: options.user.id,
counter: BigInt(credential.counter),
deviceType: credentialDeviceType,
backupStatus: credentialBackedUp,
transports: response.response.transports ?? null,
rpid: rpID,
name: `${guidName ?? "Key"} registered at ${formatDateTime(new Date())}`,
publicKey: credential.publicKey,
};
await ctx.db
.insertInto("passkeys")
.values(
passkeyToInsert(insert, {
rawUserId: ctx.user.id,
}),
)
.execute();
};
export const createAuthenticationOptions = async (
ctx: APIContext,
userId: string,
): Promise<{
options: PublicKeyCredentialRequestOptionsJSON;
challengeId: PublicId<"passkey_challenges">;
}> => {
const { rpID } = getRPInfo(ctx);
const userPasskeys = await getUserPasskeys(ctx, userId);
const options = await generateAuthenticationOptions({
rpID,
// Require users to use a previously-registered authenticator
allowCredentials: userPasskeys.map((passkey) => ({
id: passkey.credentialId,
transports: passkey.transports ?? undefined,
})),
});
const { public_id: challengeId } = await ctx.db
.insertInto("passkey_challenges")
.values({
options: JSON.stringify(options),
})
.returning("public_id")
.executeTakeFirstOrThrow();
return {
options,
challengeId: challengeId,
};
};
export const verifyAuthentication = async (
ctx: APIContext,
{
userId,
challengeId,
response,
}: {
userId: string;
challengeId: PublicId<"passkey_challenges">;
response: AuthenticationResponseJSON;
},
): Promise<boolean> => {
const { rpID, origins } = getRPInfo(ctx);
const optionsRaw = await ctx.db
.selectFrom("passkey_challenges")
.where("public_id", "=", challengeId)
.select("options")
.executeTakeFirst();
if (!optionsRaw) {
throw new TRPCError({
code: "TIMEOUT",
message: "Registration timed out. Please try again.",
});
}
const options =
optionsRaw.options as unknown as PublicKeyCredentialRequestOptionsJSON;
try {
const userPasskeys = await getUserPasskeys(ctx, userId);
const passkey = userPasskeys.find(
(passkey) => passkey.credentialId === response.id,
);
if (!passkey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unknown passkey.",
});
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: options.challenge,
expectedOrigin: origins,
expectedRPID: rpID,
credential: {
id: passkey.credentialId,
publicKey: passkey.publicKey,
counter: Number.parseInt(passkey.counter.toString(), 10),
transports: passkey.transports ?? undefined,
},
});
if (!verification.verified) {
return false;
}
await ctx.db
.updateTable("passkeys")
.set((eb) => ({
counter: verification.authenticationInfo.newCounter.toString(),
last_used_at: eb.fn("NOW"),
}))
.where("passkeys.id", "=", passkey.id)
.execute();
} finally {
await ctx.db
.deleteFrom("passkey_challenges")
.where("public_id", "=", challengeId)
.execute();
}
return true;
};

53
nix/tea.nix Normal file
View File

@@ -0,0 +1,53 @@
{
lib,
stdenv,
fetchurl,
}:
let
version = "0.10.1";
sources = {
x86_64-linux = {
url = "https://dl.gitea.com/tea/${version}/tea-${version}-linux-amd64";
sha256 = "sha256-QcODwFm2T8hVCqBkp8FAnQ3KbNw8P0ZHv0iJ4zSP5mA=";
};
aarch64-linux = {
url = "https://dl.gitea.com/tea/${version}/tea-${version}-linux-arm64";
sha256 = "sha256-qfvJ4FJSHt1+sMG4hPwGNFLChqhNNf+l3ELQ97zZm50=";
};
x86_64-darwin = {
url = "https://dl.gitea.com/tea/${version}/tea-${version}-darwin-amd64";
sha256 = "sha256-WKjZKhFKWjZqnrdxPv00fzTIc0z4xrLSsL+jqLQ1huc=";
};
aarch64-darwin = {
url = "https://dl.gitea.com/tea/${version}/tea-${version}-darwin-arm64";
sha256 = "sha256-SMwxMEDKmhbLvLn1ZR1MmbjutZPk0P9QAfvNKCvrSk0=";
};
};
src = sources.${stdenv.hostPlatform.system} or (throw "Unsupported system: ${stdenv.hostPlatform.system}");
in
stdenv.mkDerivation {
pname = "tea";
inherit version;
src = fetchurl {
inherit (src) url sha256;
};
dontUnpack = true;
installPhase = ''
runHook preInstall
install -D $src $out/bin/tea
runHook postInstall
'';
meta = with lib; {
description = "A command line tool to interact with Gitea servers";
homepage = "https://gitea.com/gitea/tea";
license = licenses.mit;
platforms = builtins.attrNames sources;
};
}

View File

@@ -13,6 +13,9 @@ import {
adminUpdateUserInputSchema, adminUpdateUserInputSchema,
} from "./schemas/admin.js"; } from "./schemas/admin.js";
import { import {
apiTokenOutputSchema,
createApiTokenInputSchema,
createApiTokenOutputSchema,
forgotPasswordInputSchema, forgotPasswordInputSchema,
loginPasswordInputSchema, loginPasswordInputSchema,
loginRequestInputSchema, loginRequestInputSchema,
@@ -181,6 +184,17 @@ export const contract = oc.router({
.output(successResponseSchema), .output(successResponseSchema),
revokeAll: oc.output(successResponseSchema), revokeAll: oc.output(successResponseSchema),
}), }),
// API tokens for CLI/programmatic access
apiTokens: oc.router({
list: oc.output(z.array(apiTokenOutputSchema)),
create: oc
.input(createApiTokenInputSchema)
.output(createApiTokenOutputSchema),
delete: oc
.input(z.object({ tokenId: z.number() }))
.output(successResponseSchema),
}),
}), }),
orgs: oc.router({ orgs: oc.router({

View File

@@ -81,3 +81,31 @@ export const resetPasswordInputSchema = z.object({
token: z.string(), token: z.string(),
newPassword: z.string().min(8), newPassword: z.string().min(8),
}); });
/**
* API token creation input schema
* Creates an API token for CLI/programmatic access
*/
export const createApiTokenInputSchema = z.object({
name: z.string().min(1).max(100),
});
/**
* API token creation output schema
* Returns the token (only shown once)
*/
export const createApiTokenOutputSchema = z.object({
token: z.string(),
expiresAt: z.string(),
});
/**
* API token output schema for listing tokens
*/
export const apiTokenOutputSchema = z.object({
id: z.number(),
name: z.string(),
lastUsedAt: z.string().nullable(),
createdAt: z.string(),
expiresAt: z.string(),
});

View File

@@ -3,8 +3,13 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts" ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",

View File

@@ -3,8 +3,13 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts" ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",