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)
- Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
## Pull Requests
This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
- Use the `/gitea` skill when creating PRs
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
## macOS sed Syntax
macOS uses BSD sed which differs from GNU sed:

View File

@@ -19,6 +19,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
- **PostgreSQL** database
- **Postmark** for transactional emails
### CLI (`apps/cli`)
- **Stricli** for command parsing
- API token-based authentication
- User, organization, and site management commands
### Shared Packages
- `@reviq/api-contract` - Shared API contract (oRPC)
- `@reviq/db` - Database client and queries
@@ -31,7 +36,7 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
publisher-dashboard/
├── apps/
│ ├── api-server/ # Backend API server
│ ├── cli/ # CLI tools
│ ├── cli/ # Command-line interface
│ └── publisher-dashboard/ # SvelteKit frontend
├── packages/
│ ├── api-contract/ # Shared oRPC contract
@@ -107,6 +112,23 @@ bun run dev
| `bun run test` | Run tests |
| `bun run db:codegen` | Generate database types |
## CLI
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
Quick start:
```bash
# Build the CLI
bun run --cwd apps/cli build
# Login with an API token
./apps/cli/dist/reviq auth login --token <your-token>
# Check status
./apps/cli/dist/reviq auth status
```
## Features
### Authentication

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> {
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()
*/
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
import { meAuthStatus } from "./auth-status.js";
import { meDelete } from "./delete.js";
import {
@@ -54,4 +55,9 @@ export const meRoutes = {
untrust: untrustDevice,
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 { buildCommand } from "@stricli/core";
import { readConfig } from "../../utils/config.js";
import { generateToken, hashToken } from "../../utils/token.js";
import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js";
interface LoginFlags {
email: string;
token: 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> {
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
@@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
return;
}
console.log("Starting login flow...\n");
// Generate a unique callback token for this login request
const callbackToken = generateToken();
const callbackTokenHash = hashToken(callbackToken);
console.log("Validating API token...\n");
try {
// Create login request
const createResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: flags.email }),
},
);
// Create a temporary API client with the provided token
const api = createApiClient(apiUrl, flags.token);
if (!createResponse.ok) {
const text = await createResponse.text();
console.error(`Error creating login request: ${text}`);
this.process.exit(1);
}
// Validate the token by fetching the user's auth status
const authStatus = await api.me.authStatus();
// Construct the login URL
const loginUrl = new URL(`${apiUrl}/login`);
loginUrl.searchParams.set("email", flags.email);
loginUrl.searchParams.set("cli_callback", callbackTokenHash);
// Save credentials
await writeConfig({
apiUrl,
token: flags.token,
email: authStatus.user.email,
});
console.log("Opening browser for authentication...");
console.log(`\nIf the browser doesn't open, visit:`);
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);
console.log(`Logged in as ${authStatus.user.email}`);
console.log("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) {
console.error(
"Error:",
"Login failed:",
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);
}
}
@@ -142,10 +61,10 @@ export const loginCommand = buildCommand({
func: login,
parameters: {
flags: {
email: {
token: {
kind: "parsed",
parse: String,
brief: "Email address to login with",
brief: "API token from the web dashboard",
},
"api-url": {
kind: "parsed",
@@ -156,8 +75,13 @@ export const loginCommand = buildCommand({
},
},
docs: {
brief: "Login to RevIQ",
fullDescription:
"Opens a browser to complete authentication and stores the credentials locally.",
brief: "Login to RevIQ with an API token",
fullDescription: `Authenticates with RevIQ using an API token.
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>;
/**
* 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
* Throws an error if not logged in
*/
export const createApiClient = async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
export function createApiClient(): Promise<ApiClient>;
export function createApiClient(
apiUrl?: string,
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({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
// Otherwise, read from config asynchronously
return (async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
}
// Cast to ApiClient for type-safe API calls
return createORPCClient(link) as unknown as ApiClient;
};
const link = new RPCLink({
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 tsParser from "@typescript-eslint/parser";
import svelte from "eslint-plugin-svelte";
import svelteParser from "svelte-eslint-parser";
export default [
{
ignores: [".svelte-kit/**", "build/**"],
},
...configs.fast,
...svelte.configs["flat/recommended"],
{
files: ["**/*.svelte", "**/*.svelte.ts"],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsParser,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
languageOptions: {
parserOptions: {

View File

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

View File

@@ -1,9 +1,13 @@
<script lang="ts">
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 { cn } from "$lib/utils.js";
interface Props {
@@ -12,13 +16,33 @@ interface 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/auth", label: "Authentication", icon: ShieldCheckIcon },
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
{ 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 {
if (href === "/account") {
return pathname === "/account";
@@ -33,10 +57,10 @@ function isActive(href: string, pathname: string): boolean {
className
)}
>
{#each navItems as item}
{#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={item.href}
href={resolve(item.href as any)}
class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active

View File

@@ -3,6 +3,7 @@ import { AlertTriangle } from "@lucide/svelte";
import { 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 { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
@@ -51,7 +52,7 @@ async function handleDelete(e: Event) {
open = false;
// Redirect to login
goto("/auth/login");
goto(resolve("/auth/login"));
} catch (e) {
error = e instanceof Error ? e.message : "Failed to delete account";
isDeleting = false;

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js";
interface Props {
@@ -25,9 +26,9 @@ const filters = [
</div>
<div class="divide-y divide-border/50">
{#each filters as filter}
{#each filters as filter (filter.label)}
<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"
>
<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">
<!-- Y-axis labels -->
<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>
{/each}
</div>
@@ -55,14 +55,14 @@ function hourToPercent(hour: number): number {
<div class="relative flex-1">
<!-- Grid lines -->
<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>
{/each}
</div>
<!-- Bars container -->
<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 lastMonth = lastMonthData[dayIndex]}
<div class="relative flex justify-center">
@@ -104,7 +104,7 @@ function hourToPercent(hour: number): number {
<!-- X-axis labels -->
<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>
{/each}
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
import {
@@ -39,7 +40,7 @@ function handleTabChange(tabId: string) {
} else {
url.searchParams.set("tab", tabId);
}
goto(url.toString(), { replaceState: true, noScroll: true });
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true });
}
</script>
@@ -60,7 +61,7 @@ function handleTabChange(tabId: string) {
<!-- Tab navigation -->
<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}
<button
role="tab"

View File

@@ -78,7 +78,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row>
</Table.Header>
<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.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">

View File

@@ -77,7 +77,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row>
</Table.Header>
<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.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">

View File

@@ -47,7 +47,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row>
</Table.Header>
<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.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">

View File

@@ -69,7 +69,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row>
</Table.Header>
<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.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">

View File

@@ -63,7 +63,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</Table.Row>
</Table.Header>
<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.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">

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">
import { resolve } from "$app/paths";
import { Badge } from "$lib/components/ui/badge";
import { cn } from "$lib/utils.js";
import AdminMobileNav from "./admin-mobile-nav.svelte";
@@ -27,7 +28,7 @@ let { title, class: className }: Props = $props();
<div class="flex items-center gap-2">
<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"
>
<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">
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
@@ -52,7 +53,7 @@ async function handleSignOut() {
await api.auth.logout();
queryClient.clear();
open = false;
goto("/login");
goto(resolve("/auth/login"));
} catch (error) {
console.error("Failed to sign out:", error);
}
@@ -92,13 +93,13 @@ const navItems = [
<nav class="flex flex-1 flex-col p-4">
<div class="space-y-1">
{#each navItems as item}
{#each navItems as item (item.href)}
{@const isActive =
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={item.href}
href={resolve(item.href as any)}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
@@ -135,7 +136,7 @@ const navItems = [
<div class="mt-6">
<Separator class="bg-zinc-800" />
<a
href="/dashboard"
href={resolve("/dashboard")}
onclick={handleNavClick}
class="mt-4 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200"
>
@@ -165,7 +166,7 @@ const navItems = [
<div class="mt-2 space-y-1">
<a
href="/account"
href={resolve("/account")}
onclick={handleNavClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200"
>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
@@ -43,7 +44,7 @@ async function handleSignOut() {
try {
await api.auth.logout();
queryClient.clear();
goto("/login");
goto(resolve("/auth/login"));
} catch (error) {
console.error("Failed to sign out:", error);
}
@@ -66,7 +67,7 @@ const navItems = [
<!-- Admin Logo -->
<div class="flex h-[94px] items-center justify-center">
<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"
aria-label="Admin Home"
>
@@ -84,13 +85,13 @@ const navItems = [
<!-- Main Navigation -->
<nav class="flex flex-1 flex-col items-center gap-3">
{#each navItems as item}
{#each navItems as item (item.href)}
{@const isActive =
item.href === "/admin"
? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)}
<a
href={item.href}
href={resolve(item.href as any)}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive
@@ -157,7 +158,7 @@ const navItems = [
<div class="flex flex-col items-center gap-3 pb-6">
<!-- Back to Dashboard link -->
<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"
aria-label="Back to Dashboard"
>
@@ -210,7 +211,7 @@ const navItems = [
</div>
</div>
<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">
<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" />

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Settings } from "@lucide/svelte";
import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
import OrgSwitcher from "./org-switcher.svelte";
@@ -67,14 +68,14 @@ const navItems = $derived.by(() => {
<!-- Main Navigation -->
<nav class="flex flex-1 flex-col items-center gap-3">
{#each navItems as item}
{#each navItems as item (item.href)}
{@const isActive =
item.icon === "home"
? $page.url.pathname === item.href
: $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")}
<a
href={item.href}
href={resolve(item.href as any)}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive
@@ -162,7 +163,7 @@ const navItems = $derived.by(() => {
{#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a
href="/dashboard/{currentSlug}/settings"
href={resolve(`/dashboard/${currentSlug}/settings`)}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive

View File

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

View File

@@ -2,6 +2,7 @@
import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js";
@@ -19,7 +20,7 @@ const orgsQuery = createQuery(() => ({
const orgs = $derived(orgsQuery.data ?? []);
function handleOrgSelect(slug: string) {
goto(`/dashboard/${slug}`);
goto(resolve(`/dashboard/${slug}` as any));
}
</script>
@@ -51,7 +52,7 @@ function handleOrgSelect(slug: string) {
{:else if orgs.length === 0}
<DropdownMenu.Item disabled>No organizations</DropdownMenu.Item>
{:else}
{#each orgs as org}
{#each orgs as org (org.slug)}
{@const isActive = currentSlug === org.slug}
<DropdownMenu.Item
onSelect={() => handleOrgSelect(org.slug)}
@@ -76,7 +77,7 @@ function handleOrgSelect(slug: string) {
{/each}
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => goto("/dashboard/new")}>
<DropdownMenu.Item onSelect={() => goto(resolve("/dashboard/new"))}>
<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">
<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 { getContext } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
@@ -43,7 +44,7 @@ async function handleSignOut() {
await api.auth.logout();
// Clear all cached queries
queryClient.clear();
goto("/login");
goto(resolve("/auth/login"));
} catch (error) {
console.error("Failed to sign out:", error);
}
@@ -92,7 +93,7 @@ async function handleSignOut() {
</div>
</div>
<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">
<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" />

View File

@@ -1,3 +1,5 @@
// Account layout components
export { AccountSettingsLayout } from "./account/index.js";
// Admin layout components
export {
AdminHeader,

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import { Loader2 } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
/**
@@ -16,14 +17,16 @@ const orgsQuery = createQuery(() => ({
$effect(() => {
if (orgsQuery.error) {
// Not authenticated, redirect to login
goto(`/auth/login?redirect=${encodeURIComponent("/")}`);
goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any));
} else if (orgsQuery.data) {
if (orgsQuery.data.length > 0) {
// 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 {
// 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">
import type { Snippet } from "svelte";
import { AccountNav } from "$lib/components/account";
import { DashboardLayout } from "$lib/components/layout";
import { AccountSettingsLayout } from "$lib/components/layout";
interface Props {
children: Snippet;
@@ -14,12 +13,6 @@ let { children }: Props = $props();
<title>Account Settings - Publisher Dashboard</title>
</svelte:head>
<DashboardLayout title="Account Settings">
<div class="space-y-6">
<AccountNav />
<div class="max-w-2xl">
{@render children()}
</div>
</div>
</DashboardLayout>
<AccountSettingsLayout>
{@render children()}
</AccountSettingsLayout>

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

View File

@@ -4,6 +4,7 @@ import { createQuery } from "@tanstack/svelte-query";
import { setContext } from "svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js";
interface Props {
@@ -22,11 +23,13 @@ const userQuery = createQuery(() => ({
$effect(() => {
if (userQuery.data && !userQuery.data.isSuperuser) {
toast.error("Access denied. Superuser privileges required.");
goto("/dashboard");
goto(resolve("/dashboard"));
}
if (userQuery.error) {
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">
import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js";
import { AdminLayout } from "$lib/components/layout";
import { Button } from "$lib/components/ui/button/index.js";
@@ -55,7 +56,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
<!-- Summary cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- 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">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
@@ -71,7 +72,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
</a>
<!-- 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">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
@@ -94,7 +95,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
</CardHeader>
<CardContent>
<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" />
New Organization
</Button>

View File

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

View File

@@ -12,6 +12,7 @@ import {
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { AdminLayout } from "$lib/components/layout";
@@ -186,7 +187,7 @@ function handleDelete() {
await api.admin.orgs.delete({ slug: slug ?? "" });
toast.success("Organization deleted");
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
goto("/admin/orgs");
goto(resolve("/admin/orgs"));
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to delete organization",
@@ -235,7 +236,7 @@ async function executeConfirmAction() {
: "Failed to load organization"}
</p>
<a
href="/admin/orgs"
href={resolve("/admin/orgs")}
class="mt-4 text-sm text-muted-foreground hover:text-foreground"
>
<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">
<!-- Back link -->
<a
href="/admin/orgs"
href={resolve("/admin/orgs")}
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="mr-1 h-4 w-4" />

View File

@@ -2,6 +2,7 @@
import { ArrowLeft, Loader2 } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js";
import { AdminLayout } from "$lib/components/layout";
import { Button } from "$lib/components/ui/button/index.js";
@@ -49,7 +50,7 @@ async function handleSubmit() {
ownerEmail: ownerEmail.trim(),
});
toast.success("Organization created successfully");
goto("/admin/orgs");
goto(resolve("/admin/orgs"));
} catch (e) {
toast.error(
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">
<!-- Back link -->
<a
href="/admin/orgs"
href={resolve("/admin/orgs")}
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="mr-1 h-4 w-4" />

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { AlertCircle, Check, Eye, Users, X } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js";
import { SuperuserBadge } from "$lib/components/admin/index.js";
import { AdminLayout } from "$lib/components/layout";
@@ -59,7 +60,7 @@ const usersQuery = createQuery(() => ({
</TableRow>
</TableHeader>
<TableBody>
{#each Array(5) as _}
{#each Array(5) as _, i (i)}
<TableRow>
<TableCell><Skeleton class="h-4 w-40" /></TableCell>
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
@@ -124,7 +125,7 @@ const usersQuery = createQuery(() => ({
<Button
variant="ghost"
size="sm"
href="/admin/users/{encodeURIComponent(user.email)}"
href={resolve(`/admin/users/${encodeURIComponent(user.email)}`)}
>
<Eye class="mr-1 h-4 w-4" />
View

View File

@@ -11,6 +11,7 @@ import {
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client.js";
import { SuperuserBadge } from "$lib/components/admin/index.js";
@@ -150,7 +151,7 @@ async function handleConfirmEmail() {
<AdminLayout title="User Details">
<!-- Back navigation -->
<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" />
Back to users
</Button>
@@ -179,7 +180,7 @@ async function handleConfirmEmail() {
</CardHeader>
<CardContent>
<div class="grid gap-4 sm:grid-cols-2">
{#each Array(5) as _}
{#each Array(5) as _, i (i)}
<div class="space-y-1">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-5 w-32" />

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { resolve } from "$app/paths";
interface Props {
children: Snippet;
@@ -80,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our
<a href="/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
<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>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
} from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
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
$effect(() => {
if (!loginFlowState.email) {
goto("/auth/login");
goto(resolve("/auth/login"));
}
});
@@ -58,7 +59,7 @@ const statusQuery = createQuery(() => ({
$effect(() => {
if (statusQuery.data?.status === "completed") {
clearLoginFlowState();
goto(statusQuery.data.redirectTo || "/");
goto(resolve((statusQuery.data.redirectTo || "/") as any));
}
});
@@ -88,7 +89,7 @@ async function handleResendEmail() {
function handleDifferentEmail() {
clearLoginFlowState();
goto("/auth/login");
goto(resolve("/auth/login"));
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
} from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
import { Badge } from "$lib/components/ui/badge";
@@ -40,7 +41,9 @@ const invitesQuery = createQuery(() => ({
$effect(() => {
if (orgsQuery.error) {
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">
{#each invitesQuery.data as invite (invite.id)}
<a
href="/account/org-invites/{invite.id}"
href={resolve(`/account/org-invites/${invite.id}`)}
class="group block"
>
<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">
{#each orgsQuery.data as org (org.id)}
<a
href="/dashboard/{org.slug}"
href={resolve(`/dashboard/${org.slug}`)}
class="group block transition-transform hover:scale-[1.02]"
>
<Card class="h-full transition-colors group-hover:border-primary/50">

View File

@@ -10,6 +10,7 @@ import {
} from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org";
@@ -86,7 +87,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
: "Failed to load organization"}
</p>
<a
href="/dashboard"
href={resolve("/dashboard")}
class="mt-4 text-sm text-primary underline underline-offset-4 hover:text-primary/80"
>
Back to organizations
@@ -117,7 +118,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
</div>
</div>
{#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
</Button>
@@ -126,7 +127,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Stats cards -->
<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">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<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">
<CardTitle class="text-base">Team Members</CardTitle>
<a
href="/dashboard/{slug}/members"
href={resolve(`/dashboard/${slug}/members`)}
class="flex items-center text-sm text-primary hover:underline"
>
View all

View File

@@ -300,7 +300,7 @@ const availableInviteRoles = $derived.by(() => {
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
</SelectTrigger>
<SelectContent>
{#each availableInviteRoles as role}
{#each availableInviteRoles as role (role)}
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
{/each}
</SelectContent>

View File

@@ -47,7 +47,7 @@ const metrics = [
<!-- Metric Cards -->
<section>
<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
label={metric.label}
value={metric.value}

View File

@@ -11,6 +11,7 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { SettingsLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org";
@@ -124,7 +125,7 @@ function handleLeave() {
await api.orgs.leave({ slug });
toast.success("You have left the organization");
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
goto("/dashboard");
goto(resolve("/dashboard"));
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to leave organization",
@@ -147,7 +148,7 @@ function handleDelete() {
await api.orgs.delete({ slug });
toast.success("Organization deleted");
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
goto("/dashboard");
goto(resolve("/dashboard"));
} catch (e) {
toast.error(
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)}
</SelectTrigger>
<SelectContent>
{#each availableInviteRoles as role}
{#each availableInviteRoles as role (role)}
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
{/each}
</SelectContent>

View File

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

View File

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

View File

@@ -101,9 +101,12 @@
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "catalog:",
"eslint-plugin-svelte": "^3.14.0",
"svelte": "^5.28.2",
"svelte-check": "^4.2.1",
"svelte-eslint-parser": "^1.4.1",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0",
"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=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"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-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-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=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"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=="],
@@ -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=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"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-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-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-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-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=="],
"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=="],
"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=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"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 by pg_dump version 17.7
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete
--
\unrestrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
--

View File

@@ -1,10 +1,16 @@
{ 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; [
nixfmt-rfc-style
biome
git
tea
dbmate
ast-grep
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,
} from "./schemas/admin.js";
import {
apiTokenOutputSchema,
createApiTokenInputSchema,
createApiTokenOutputSchema,
forgotPasswordInputSchema,
loginPasswordInputSchema,
loginRequestInputSchema,
@@ -181,6 +184,17 @@ export const contract = oc.router({
.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({

View File

@@ -81,3 +81,31 @@ export const resetPasswordInputSchema = z.object({
token: z.string(),
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",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",

View File

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