Compare commits

...

14 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
78 changed files with 4606 additions and 3266 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

@@ -7,6 +7,13 @@ Before starting the dev server, check if it's already running:
- 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 sed Syntax
macOS uses BSD sed which differs from GNU sed: macOS uses BSD sed which differs from GNU sed:

View File

@@ -19,6 +19,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
- **PostgreSQL** database - **PostgreSQL** database
- **Postmark** for transactional emails - **Postmark** for transactional emails
### CLI (`apps/cli`)
- **Stricli** for command parsing
- API token-based authentication
- User, organization, and site management commands
### Shared Packages ### Shared Packages
- `@reviq/api-contract` - Shared API contract (oRPC) - `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/db` - Database client and queries - `@reviq/db` - Database client and queries
@@ -31,7 +36,7 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
publisher-dashboard/ publisher-dashboard/
├── apps/ ├── apps/
│ ├── api-server/ # Backend API server │ ├── api-server/ # Backend API server
│ ├── cli/ # CLI tools │ ├── cli/ # Command-line interface
│ └── publisher-dashboard/ # SvelteKit frontend │ └── publisher-dashboard/ # SvelteKit frontend
├── packages/ ├── packages/
│ ├── api-contract/ # Shared oRPC contract │ ├── api-contract/ # Shared oRPC contract
@@ -107,6 +112,23 @@ bun run dev
| `bun run test` | Run tests | | `bun run test` | Run tests |
| `bun run db:codegen` | Generate database types | | `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 ## Features
### Authentication ### Authentication

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

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

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

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import AdminMobileNav from "./admin-mobile-nav.svelte"; import AdminMobileNav from "./admin-mobile-nav.svelte";
@@ -27,7 +28,7 @@ let { title, class: className }: Props = $props();
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a <a
href="/dashboard" 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" 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"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { 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";
@@ -52,7 +53,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);
} }
@@ -92,13 +93,13 @@ const navItems = [
<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 =
item.href === "/admin" item.href === "/admin"
? $page.url.pathname === "/admin" ? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)} : $page.url.pathname.startsWith(item.href)}
<a <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",
@@ -135,7 +136,7 @@ const navItems = [
<div class="mt-6"> <div class="mt-6">
<Separator class="bg-zinc-800" /> <Separator class="bg-zinc-800" />
<a <a
href="/dashboard" href={resolve("/dashboard")}
onclick={handleNavClick} 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" 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"
> >
@@ -165,7 +166,7 @@ const navItems = [
<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-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200" 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"
> >

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
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() {
try { try {
await api.auth.logout(); await api.auth.logout();
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);
} }
@@ -66,7 +67,7 @@ const navItems = [
<!-- Admin Logo --> <!-- Admin Logo -->
<div class="flex h-[94px] items-center justify-center"> <div class="flex h-[94px] items-center justify-center">
<a <a
href="/admin" 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" 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" aria-label="Admin Home"
> >
@@ -84,13 +85,13 @@ const navItems = [
<!-- 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 =
item.href === "/admin" item.href === "/admin"
? $page.url.pathname === "/admin" ? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)} : $page.url.pathname.startsWith(item.href)}
<a <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
@@ -157,7 +158,7 @@ const navItems = [
<div class="flex flex-col items-center gap-3 pb-6"> <div class="flex flex-col items-center gap-3 pb-6">
<!-- Back to Dashboard link --> <!-- Back to Dashboard link -->
<a <a
href="/dashboard" 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" 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" aria-label="Back to Dashboard"
> >
@@ -210,7 +211,7 @@ const navItems = [
</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,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Settings } from "@lucide/svelte"; 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";
@@ -67,14 +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 =
item.icon === "home" item.icon === "home"
? $page.url.pathname === item.href ? $page.url.pathname === item.href
: $page.url.pathname === item.href || : $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")} $page.url.pathname.startsWith(item.href + "/")}
<a <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
@@ -162,7 +163,7 @@ const navItems = $derived.by(() => {
{#if currentSlug} {#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)} {@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a <a
href="/dashboard/{currentSlug}/settings" href={resolve(`/dashboard/${currentSlug}/settings`)}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive isSettingsActive

View File

@@ -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,3 +1,5 @@
// Account layout components
export { AccountSettingsLayout } from "./account/index.js";
// Admin layout components // Admin layout components
export { export {
AdminHeader, AdminHeader,

View File

@@ -2,6 +2,7 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { Building2, Globe, Settings, Users } from "@lucide/svelte"; import { Building2, Globe, Settings, Users } 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 { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -58,10 +59,10 @@ function isActive(href: string): boolean {
<nav class="w-full shrink-0 lg:w-64"> <nav class="w-full shrink-0 lg:w-64">
<!-- Mobile: horizontal scroll --> <!-- Mobile: horizontal scroll -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden"> <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item} {#each navItems as item (item.href)}
{@const active = isActive(item.href)} {@const active = isActive(item.href)}
<a <a
href={item.href} href={resolve(item.href as any)}
class={cn( class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors", "flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active active
@@ -77,10 +78,10 @@ function isActive(href: string): boolean {
<!-- Desktop: vertical list --> <!-- Desktop: vertical list -->
<div class="hidden space-y-1 lg:block"> <div class="hidden space-y-1 lg:block">
{#each navItems as item} {#each navItems as item (item.href)}
{@const active = isActive(item.href)} {@const active = isActive(item.href)}
<a <a
href={item.href} href={resolve(item.href as any)}
class={cn( class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors", "group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active active

View File

@@ -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";
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,6 +1,7 @@
<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 { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
@@ -55,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">
@@ -71,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">
@@ -94,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>

View File

@@ -2,6 +2,7 @@
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 { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte"; import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
@@ -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

View File

@@ -12,6 +12,7 @@ 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 { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
@@ -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",
@@ -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" />

View File

@@ -2,6 +2,7 @@
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 { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
@@ -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",
@@ -78,7 +79,7 @@ function handleSlugInput(event: Event) {
<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" />

View File

@@ -1,6 +1,7 @@
<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 { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
@@ -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

View File

@@ -11,6 +11,7 @@ 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";
@@ -150,7 +151,7 @@ async function handleConfirmEmail() {
<AdminLayout 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" />

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,6 +8,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 { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
@@ -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,6 +10,7 @@ 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"; import { DashboardLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org"; import { RoleBadge } from "$lib/components/org";
@@ -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

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

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

@@ -11,6 +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 { 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 { SettingsLayout } from "$lib/components/layout"; import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { ConfirmDialog } from "$lib/components/org";
@@ -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",

View File

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

@@ -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,10 +1,16 @@
{ 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 dbip-city-lite

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