Compare commits
25 Commits
bd5bb5f5ef
...
fix-export
| Author | SHA1 | Date | |
|---|---|---|---|
|
4d9fbdeed5
|
|||
|
9a119da96e
|
|||
|
7358129802
|
|||
|
a02e1f0862
|
|||
|
2fb42c0fa5
|
|||
|
3d42324750
|
|||
|
ac4b8dc99a
|
|||
|
cf71cb63d7
|
|||
|
730021a5ea
|
|||
|
c698a85cc1
|
|||
|
462799ca3d
|
|||
|
dcb48a5d5e
|
|||
|
8f3a1f2962
|
|||
|
a7d6beaf5a
|
|||
|
48ffba6c5f
|
|||
|
1b46fc0ecc
|
|||
|
587d17c39c
|
|||
|
|
cca901a9b9 | ||
|
|
42badf3c52 | ||
|
|
bd53a60497 | ||
|
|
d486e2444e | ||
|
|
319edf70db | ||
|
|
74b26818ca | ||
|
|
b93f5e0b69 | ||
|
|
fb68f341dd |
77
.claude/skills/gitea/SKILL.md
Normal file
77
.claude/skills/gitea/SKILL.md
Normal 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
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -6,3 +6,18 @@ Before starting the dev server, check if it's already running:
|
||||
- Use `lsof -i :6827` or check for existing background tasks
|
||||
- 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:
|
||||
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
|
||||
- GNU sed (Linux): `sed -i 's/old/new/g' file`
|
||||
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
|
||||
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
|
||||
|
||||
143
README.md
143
README.md
@@ -1,9 +1,57 @@
|
||||
# Reviq Publisher Dashboard
|
||||
|
||||
A modern publisher dashboard for managing organizations, members, and sites. Built as a monorepo with SvelteKit frontend and oRPC API server.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend (`apps/publisher-dashboard`)
|
||||
- **SvelteKit** with Svelte 5 (runes)
|
||||
- **Tailwind CSS v4** for styling
|
||||
- **TanStack Query** for data fetching
|
||||
- **bits-ui** for accessible UI primitives
|
||||
- **Lucide** for icons
|
||||
- **WebAuthn/Passkeys** for passwordless authentication
|
||||
|
||||
### Backend (`apps/api-server`)
|
||||
- **Bun** runtime
|
||||
- **oRPC** for type-safe API (contract-first)
|
||||
- **Kysely** for type-safe SQL queries
|
||||
- **PostgreSQL** database
|
||||
- **Postmark** for transactional emails
|
||||
|
||||
### CLI (`apps/cli`)
|
||||
- **Stricli** for command parsing
|
||||
- API token-based authentication
|
||||
- User, organization, and site management commands
|
||||
|
||||
### Shared Packages
|
||||
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||
- `@reviq/db` - Database client and queries
|
||||
- `@reviq/db-schema` - Database schema and codegen
|
||||
- `@reviq/utils` - Shared utilities
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
publisher-dashboard/
|
||||
├── apps/
|
||||
│ ├── api-server/ # Backend API server
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||
├── packages/
|
||||
│ ├── api-contract/ # Shared oRPC contract
|
||||
│ ├── db/ # Database client
|
||||
│ ├── db-schema/ # DB schema & codegen
|
||||
│ ├── testing/ # Test utilities
|
||||
│ └── utils/ # Shared utilities
|
||||
└── db/ # Database migrations
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) v1.1.42+
|
||||
- [devenv](https://devenv.sh/) for development environment management
|
||||
|
||||
### Environment Variables
|
||||
@@ -29,9 +77,104 @@ devenv up
|
||||
This starts:
|
||||
- PostgreSQL database
|
||||
- Publisher dashboard dev server (port 6827)
|
||||
- API server
|
||||
- Package build watcher
|
||||
|
||||
The database is automatically initialized with:
|
||||
- Database: `reviq-dashboard`
|
||||
- User: `reviq`
|
||||
- Password: `reviq`
|
||||
|
||||
### Manual Development
|
||||
|
||||
If not using devenv, start services individually:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build packages first
|
||||
bun run build:packages
|
||||
|
||||
# Start dev server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `bun run dev` | Start all dev servers |
|
||||
| `bun run build` | Build all packages and apps |
|
||||
| `bun run typecheck` | Run TypeScript type checking |
|
||||
| `bun run lint` | Run Biome and ESLint |
|
||||
| `bun run lint:fix` | Fix linting issues |
|
||||
| `bun run test` | Run tests |
|
||||
| `bun run db:codegen` | Generate database types |
|
||||
|
||||
## CLI
|
||||
|
||||
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
|
||||
|
||||
Quick start:
|
||||
|
||||
```bash
|
||||
# Build the CLI
|
||||
bun run --cwd apps/cli build
|
||||
|
||||
# Login with an API token
|
||||
./apps/cli/dist/reviq auth login --token <your-token>
|
||||
|
||||
# Check status
|
||||
./apps/cli/dist/reviq auth status
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
- Passwordless login with passkeys (WebAuthn)
|
||||
- Email verification
|
||||
- Session management with device tracking
|
||||
|
||||
### Organizations
|
||||
- Create and manage organizations
|
||||
- Member management with roles (owner, admin, member)
|
||||
- Invite members via email
|
||||
- Organization settings
|
||||
|
||||
### Dashboard
|
||||
- Organization switcher
|
||||
- Performance metrics
|
||||
- Reports (coming soon)
|
||||
- Site management (coming soon)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Routes
|
||||
|
||||
```
|
||||
/ # Landing page
|
||||
/login # Login page
|
||||
/dashboard # Organization list
|
||||
/dashboard/[slug] # Organization home
|
||||
/dashboard/[slug]/performance # Performance metrics
|
||||
/dashboard/[slug]/reports # Reports (placeholder)
|
||||
/dashboard/[slug]/settings # Organization settings
|
||||
├── /members # Member management
|
||||
└── /sites # Sites (placeholder)
|
||||
/account # User account settings
|
||||
├── /security # Security settings
|
||||
└── /sessions # Active sessions
|
||||
/admin # Admin panel
|
||||
```
|
||||
|
||||
### API Structure
|
||||
|
||||
The API uses oRPC with a contract-first approach. Routes are defined in `@reviq/api-contract` and implemented in `apps/api-server`.
|
||||
|
||||
Key API namespaces:
|
||||
- `auth` - Authentication (passkeys, sessions)
|
||||
- `me` - Current user profile
|
||||
- `orgs` - Organization management
|
||||
- `orgs.members` - Member management
|
||||
- `orgs.invites` - Invitation management
|
||||
|
||||
2107
apps/api-server/src/__tests__/e2e/auth.test.ts
Normal file
2107
apps/api-server/src/__tests__/e2e/auth.test.ts
Normal file
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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
60
apps/api-server/src/__tests__/helpers/test-transaction.ts
Normal file
60
apps/api-server/src/__tests__/helpers/test-transaction.ts
Normal 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;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface APIContext {
|
||||
reqHeaders: Headers;
|
||||
/** Response headers (for setting cookies) */
|
||||
resHeaders: Headers;
|
||||
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||
clientIP?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,7 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME;
|
||||
|
||||
Bun.serve({
|
||||
port,
|
||||
async fetch(request) {
|
||||
async fetch(request, server) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith("/api/v1/rpc")) {
|
||||
@@ -50,6 +50,10 @@ Bun.serve({
|
||||
// Create response headers for setting cookies
|
||||
const resHeaders = new Headers();
|
||||
|
||||
// Get client IP from Bun's server (fallback for when no proxy headers)
|
||||
const socketInfo = server.requestIP(request);
|
||||
const clientIP = socketInfo?.address ?? null;
|
||||
|
||||
const context: APIContext = {
|
||||
db,
|
||||
origin,
|
||||
@@ -57,6 +61,7 @@ Bun.serve({
|
||||
rpName,
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
clientIP,
|
||||
};
|
||||
|
||||
const { response } = await handler.handle(request, {
|
||||
|
||||
@@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
const hasPassword = user.password_hash !== null;
|
||||
|
||||
// Get geo info and user agent
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
// Create login request with secure token
|
||||
|
||||
@@ -86,7 +86,7 @@ export const loginIfRequestIsCompleted =
|
||||
}
|
||||
|
||||
// Get current request info
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
// Upsert user device
|
||||
|
||||
@@ -225,7 +225,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
}
|
||||
|
||||
// Get geo info and user agent for session creation
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
let userId: number;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
109
apps/api-server/src/procedures/me/api-tokens.ts
Normal file
109
apps/api-server/src/procedures/me/api-tokens.ts
Normal 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 };
|
||||
});
|
||||
@@ -1,10 +1,18 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import {
|
||||
_resetForTesting,
|
||||
_setReaderForTesting,
|
||||
extractClientIP,
|
||||
getGeoInfo,
|
||||
getUserAgent,
|
||||
initGeoReader,
|
||||
lookupGeoFromIP,
|
||||
} from "./geo.js";
|
||||
|
||||
@@ -220,3 +228,110 @@ describe("getUserAgent", () => {
|
||||
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initGeoReader", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
test("calling initGeoReader twice does not reinitialize", async () => {
|
||||
// First call initializes
|
||||
await initGeoReader();
|
||||
|
||||
// Second call should return early (covers the early return branch)
|
||||
await initGeoReader();
|
||||
|
||||
// If we get here without error, the early return worked
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("handles missing database file gracefully", async () => {
|
||||
// Save original env
|
||||
const originalPath = Bun.env.GEOIP_DATABASE_PATH;
|
||||
|
||||
// Point to non-existent file
|
||||
Bun.env.GEOIP_DATABASE_PATH = "/nonexistent/path/to/db.mmdb";
|
||||
|
||||
// Should not throw, just log a warning
|
||||
await initGeoReader();
|
||||
|
||||
// Lookups should return nulls since reader failed to initialize
|
||||
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
||||
city: null,
|
||||
region: null,
|
||||
country: null,
|
||||
});
|
||||
|
||||
// Restore original env
|
||||
if (originalPath) {
|
||||
Bun.env.GEOIP_DATABASE_PATH = originalPath;
|
||||
} else {
|
||||
delete Bun.env.GEOIP_DATABASE_PATH;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only run real database tests if GEOIP_DATABASE_PATH is set
|
||||
const hasGeoDatabase = !!Bun.env.GEOIP_DATABASE_PATH;
|
||||
|
||||
describe.skipIf(!hasGeoDatabase)("real GeoIP database", () => {
|
||||
beforeAll(async () => {
|
||||
_resetForTesting();
|
||||
await initGeoReader();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
test("looks up Google DNS (8.8.8.8) - US", () => {
|
||||
const result = lookupGeoFromIP("8.8.8.8");
|
||||
expect(result.country).toBe("US");
|
||||
});
|
||||
|
||||
test("looks up Cloudflare DNS (1.1.1.1) - AU", () => {
|
||||
const result = lookupGeoFromIP("1.1.1.1");
|
||||
// Cloudflare's 1.1.1.1 is geolocated to Sydney, Australia
|
||||
expect(result.country).toBe("AU");
|
||||
});
|
||||
|
||||
test("looks up known German IP", () => {
|
||||
// Deutsche Telekom IP range
|
||||
const result = lookupGeoFromIP("80.150.6.143");
|
||||
expect(result.country).toBe("DE");
|
||||
});
|
||||
|
||||
test("looks up known UK IP", () => {
|
||||
// BBC IP range
|
||||
const result = lookupGeoFromIP("212.58.244.71");
|
||||
expect(result.country).toBe("GB");
|
||||
});
|
||||
|
||||
test("returns city data for major IPs", () => {
|
||||
const result = lookupGeoFromIP("8.8.8.8");
|
||||
// DBIP returns "Mountain View" for Google DNS
|
||||
expect(result.city).toBe("Mountain View");
|
||||
expect(result.region).toBe("California");
|
||||
});
|
||||
|
||||
test("getGeoInfo uses real database when no CF headers", () => {
|
||||
const headers = createHeaders({ "X-Real-IP": "8.8.8.8" });
|
||||
const result = getGeoInfo(headers);
|
||||
|
||||
expect(result.ip).toBe("8.8.8.8");
|
||||
expect(result.country).toBe("US");
|
||||
expect(result.city).toBe("Mountain View");
|
||||
});
|
||||
|
||||
test("returns nulls for private/reserved IPs", () => {
|
||||
const result = lookupGeoFromIP("192.168.1.1");
|
||||
expect(result.city).toBeNull();
|
||||
expect(result.country).toBeNull();
|
||||
});
|
||||
|
||||
test("returns nulls for localhost", () => {
|
||||
const result = lookupGeoFromIP("127.0.0.1");
|
||||
expect(result.city).toBeNull();
|
||||
expect(result.country).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,9 +126,16 @@ export const lookupGeoFromIP = (
|
||||
/**
|
||||
* Extract geolocation info from request headers.
|
||||
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
|
||||
*
|
||||
* @param headers - Request headers to extract proxy IP headers from
|
||||
* @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP)
|
||||
*/
|
||||
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
||||
const ip = extractClientIP(headers);
|
||||
export const getGeoInfo = (
|
||||
headers: Headers,
|
||||
fallbackIP?: string | null,
|
||||
): GeoInfo => {
|
||||
// Try proxy headers first, then fall back to direct connection IP
|
||||
const ip = extractClientIP(headers) ?? fallbackIP ?? null;
|
||||
|
||||
// Try Cloudflare geo headers first
|
||||
const cfCountry = headers.get("CF-IPCountry");
|
||||
|
||||
86
apps/cli/README.md
Normal file
86
apps/cli/README.md
Normal 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`.
|
||||
@@ -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>`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AccountSettingsLayout } from "./account-settings-layout.svelte";
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import AdminMobileNav from "./admin-mobile-nav.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class={cn(
|
||||
"flex h-14 items-center justify-between border-b border-border bg-card px-4 lg:px-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Mobile menu button -->
|
||||
<AdminMobileNav class="lg:hidden" />
|
||||
|
||||
<h1 class="text-base font-semibold tracking-tight text-foreground lg:text-lg">{title}</h1>
|
||||
<Badge variant="destructive" class="hidden sm:inline-flex">Admin</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={resolve("/dashboard")}
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Exit Admin</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import AdminHeader from "./admin-header.svelte";
|
||||
import AdminMobileNav from "./admin-mobile-nav.svelte";
|
||||
import AdminSidebar from "./admin-sidebar.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden bg-background">
|
||||
<!-- Desktop sidebar - hidden on mobile -->
|
||||
<div class="hidden lg:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<AdminHeader {title} />
|
||||
|
||||
<main class="flex-1 overflow-auto p-4 lg:p-6">
|
||||
<div class={cn("mx-auto max-w-7xl", className)}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/stores";
|
||||
import { api } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import * as Sheet from "$lib/components/ui/sheet";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Fetch current user
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
}));
|
||||
|
||||
const user = $derived(userQuery.data);
|
||||
|
||||
// Generate initials from display name or email
|
||||
const initials = $derived.by(() => {
|
||||
if (!user) {
|
||||
return "??";
|
||||
}
|
||||
if (user.displayName) {
|
||||
const parts = user.displayName.split(" ");
|
||||
if (parts.length >= 2) {
|
||||
return (
|
||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
||||
).toUpperCase();
|
||||
}
|
||||
return user.displayName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return user.email.slice(0, 2).toUpperCase();
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function handleNavClick() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
try {
|
||||
await api.auth.logout();
|
||||
queryClient.clear();
|
||||
open = false;
|
||||
goto(resolve("/auth/login"));
|
||||
} catch (error) {
|
||||
console.error("Failed to sign out:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Admin nav items
|
||||
const navItems = [
|
||||
{ icon: "dashboard", href: "/admin", label: "Dashboard" },
|
||||
{ icon: "building", href: "/admin/orgs", label: "Organizations" },
|
||||
{ icon: "users", href: "/admin/users", label: "Users" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="ghost" size="icon" class={cn("h-9 w-9 lg:hidden", className)} {...props}>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span class="sr-only">Open menu</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Sheet.Trigger>
|
||||
|
||||
<Sheet.Content side="left" class="w-72 border-zinc-800 bg-zinc-900 p-0">
|
||||
<Sheet.Header class="border-b border-zinc-800 px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-red-600">
|
||||
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<Sheet.Title class="text-lg font-semibold text-white">Admin Panel</Sheet.Title>
|
||||
</div>
|
||||
</Sheet.Header>
|
||||
|
||||
<nav class="flex flex-1 flex-col p-4">
|
||||
<div class="space-y-1">
|
||||
{#each navItems as item (item.href)}
|
||||
{@const isActive =
|
||||
item.href === "/admin"
|
||||
? $page.url.pathname === "/admin"
|
||||
: $page.url.pathname.startsWith(item.href)}
|
||||
<a
|
||||
href={resolve(item.href as any)}
|
||||
onclick={handleNavClick}
|
||||
class={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200",
|
||||
)}
|
||||
>
|
||||
{#if item.icon === "dashboard"}
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if item.icon === "building"}
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if item.icon === "users"}
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Separator and back to dashboard -->
|
||||
<div class="mt-6">
|
||||
<Separator class="bg-zinc-800" />
|
||||
<a
|
||||
href={resolve("/dashboard")}
|
||||
onclick={handleNavClick}
|
||||
class="mt-4 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- User section at bottom -->
|
||||
<div class="mt-auto pt-4">
|
||||
<Separator class="mb-4 bg-zinc-800" />
|
||||
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||
{#if user?.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-red-700 text-xs font-semibold text-white">
|
||||
{initials}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-white">{user?.displayName ?? user?.email ?? "Loading..."}</p>
|
||||
<p class="text-xs text-zinc-400">Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1">
|
||||
<a
|
||||
href={resolve("/account")}
|
||||
onclick={handleNavClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-zinc-800/50 hover:text-zinc-200"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
Account Settings
|
||||
</a>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="16,17 21,12 16,7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/stores";
|
||||
import { api } from "$lib/api/client";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
// Fetch current user
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
}));
|
||||
|
||||
const user = $derived(userQuery.data);
|
||||
|
||||
// Generate initials from display name or email
|
||||
const initials = $derived.by(() => {
|
||||
if (!user) {
|
||||
return "??";
|
||||
}
|
||||
if (user.displayName) {
|
||||
const parts = user.displayName.split(" ");
|
||||
if (parts.length >= 2) {
|
||||
return (
|
||||
parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
|
||||
).toUpperCase();
|
||||
}
|
||||
return user.displayName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return user.email.slice(0, 2).toUpperCase();
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function handleSignOut() {
|
||||
try {
|
||||
await api.auth.logout();
|
||||
queryClient.clear();
|
||||
goto(resolve("/auth/login"));
|
||||
} catch (error) {
|
||||
console.error("Failed to sign out:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Admin nav items
|
||||
const navItems = [
|
||||
{ icon: "dashboard", href: "/admin", label: "Dashboard" },
|
||||
{ icon: "building", href: "/admin/orgs", label: "Organizations" },
|
||||
{ icon: "users", href: "/admin/users", label: "Users" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class={cn(
|
||||
"flex h-screen w-[80px] flex-col items-center bg-zinc-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<!-- Admin Logo -->
|
||||
<div class="flex h-[94px] items-center justify-center">
|
||||
<a
|
||||
href={resolve("/admin")}
|
||||
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-red-600 shadow-sm transition-transform duration-200 hover:scale-105"
|
||||
aria-label="Admin Home"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Main Navigation -->
|
||||
<nav class="flex flex-1 flex-col items-center gap-3">
|
||||
{#each navItems as item (item.href)}
|
||||
{@const isActive =
|
||||
item.href === "/admin"
|
||||
? $page.url.pathname === "/admin"
|
||||
: $page.url.pathname.startsWith(item.href)}
|
||||
<a
|
||||
href={resolve(item.href as any)}
|
||||
class={cn(
|
||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||
isActive
|
||||
? "bg-zinc-700 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
|
||||
)}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{#if item.icon === "dashboard"}
|
||||
{#if isActive}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{:else if item.icon === "building"}
|
||||
{#if isActive}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.5 2.25a.75.75 0 000 1.5v16.5h-.75a.75.75 0 000 1.5h16.5a.75.75 0 000-1.5h-.75V3.75a.75.75 0 000-1.5h-15zM9 6a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H9zm-.75 3.75A.75.75 0 019 9h1.5a.75.75 0 010 1.5H9a.75.75 0 01-.75-.75zM9 12a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H9zm3.75-5.25A.75.75 0 0113.5 6H15a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zM13.5 9a.75.75 0 000 1.5H15A.75.75 0 0015 9h-1.5zm-.75 3.75a.75.75 0 01.75-.75H15a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zM9 19.5v-2.25a.75.75 0 01.75-.75h4.5a.75.75 0 01.75.75v2.25H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{:else if item.icon === "users"}
|
||||
{#if isActive}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.25 6.75a3.75 3.75 0 117.5 0 3.75 3.75 0 01-7.5 0zM15.75 9.75a3 3 0 116 0 3 3 0 01-6 0zM2.25 9.75a3 3 0 116 0 3 3 0 01-6 0zM6.31 15.117A6.745 6.745 0 0112 12a6.745 6.745 0 016.709 7.498.75.75 0 01-.372.568A12.696 12.696 0 0112 21.75c-2.305 0-4.47-.612-6.337-1.684a.75.75 0 01-.372-.568 6.787 6.787 0 011.019-4.38z" />
|
||||
<path d="M5.082 14.254a8.287 8.287 0 00-1.308 5.135 9.687 9.687 0 01-1.764-.44l-.115-.04a.563.563 0 01-.373-.487l-.01-.121a3.75 3.75 0 013.57-4.047zM20.226 19.389a8.287 8.287 0 00-1.308-5.135 3.75 3.75 0 013.57 4.047l-.01.121a.563.563 0 01-.373.486l-.115.04c-.567.2-1.156.349-1.764.441z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Tooltip -->
|
||||
<span
|
||||
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-zinc-700 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div class="flex flex-col items-center gap-3 pb-6">
|
||||
<!-- Back to Dashboard link -->
|
||||
<a
|
||||
href={resolve("/dashboard")}
|
||||
class="group relative flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 transition-all duration-150 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
aria-label="Back to Dashboard"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-zinc-700 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||
>
|
||||
Back to Dashboard
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="relative h-6 w-6 overflow-hidden rounded-full ring-1 ring-zinc-700 transition-transform duration-150 hover:scale-110"
|
||||
aria-label="User menu"
|
||||
>
|
||||
{#if user?.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-red-500 to-red-700 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="w-64" side="right" align="end" sideOffset={8}>
|
||||
<!-- User info header -->
|
||||
<div class="flex items-center gap-3 p-2">
|
||||
{#if user?.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="" class="h-10 w-10 rounded-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-red-700 text-sm font-semibold text-white"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium">{user?.displayName ?? user?.email ?? "Loading..."}</span>
|
||||
<span class="text-xs text-muted-foreground">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => goto(resolve("/account"))}>
|
||||
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
Account Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={handleSignOut} variant="destructive">
|
||||
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline points="16,17 21,12 16,7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Sign out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AdminHeader } from "./admin-header.svelte";
|
||||
export { default as AdminLayout } from "./admin-layout.svelte";
|
||||
export { default as AdminMobileNav } from "./admin-mobile-nav.svelte";
|
||||
export { default as AdminSidebar } from "./admin-sidebar.svelte";
|
||||
@@ -1,5 +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";
|
||||
@@ -66,12 +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 =
|
||||
$page.url.pathname === item.href ||
|
||||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
||||
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
|
||||
@@ -153,8 +157,34 @@ const navItems = $derived.by(() => {
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="flex h-[80px] items-center justify-center">
|
||||
<!-- Bottom section -->
|
||||
<div class="flex flex-col items-center gap-3 pb-6">
|
||||
<!-- Settings (only in org context) -->
|
||||
{#if currentSlug}
|
||||
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
|
||||
<a
|
||||
href={resolve(`/dashboard/${currentSlug}/settings`)}
|
||||
class={cn(
|
||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||
isSettingsActive
|
||||
? "bg-sidebar-accent text-sidebar-foreground"
|
||||
: "text-sidebar-muted hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||
)}
|
||||
aria-label="Settings"
|
||||
aria-current={isSettingsActive ? "page" : undefined}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
|
||||
<!-- Tooltip -->
|
||||
<span
|
||||
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||
>
|
||||
Settings
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- User Menu -->
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as AppHeader } from "./app-header.svelte";
|
||||
export { default as AppSidebar } from "./app-sidebar.svelte";
|
||||
export { default as DashboardLayout } from "./dashboard-layout.svelte";
|
||||
export { default as EmailVerificationBanner } from "./email-verification-banner.svelte";
|
||||
export { default as MobileNav } from "./mobile-nav.svelte";
|
||||
export { default as OrgSwitcher } from "./org-switcher.svelte";
|
||||
export { default as UserMenu } from "./user-menu.svelte";
|
||||
@@ -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"
|
||||
>
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -1,5 +1,20 @@
|
||||
export { default as AppHeader } from "./app-header.svelte";
|
||||
export { default as AppSidebar } from "./app-sidebar.svelte";
|
||||
export { default as DashboardLayout } from "./dashboard-layout.svelte";
|
||||
export { default as EmailVerificationBanner } from "./email-verification-banner.svelte";
|
||||
export { default as MobileNav } from "./mobile-nav.svelte";
|
||||
// Account layout components
|
||||
export { AccountSettingsLayout } from "./account/index.js";
|
||||
// Admin layout components
|
||||
export {
|
||||
AdminHeader,
|
||||
AdminLayout,
|
||||
AdminMobileNav,
|
||||
AdminSidebar,
|
||||
} from "./admin/index.js";
|
||||
export {
|
||||
AppHeader,
|
||||
AppSidebar,
|
||||
DashboardLayout,
|
||||
EmailVerificationBanner,
|
||||
MobileNav,
|
||||
OrgSwitcher,
|
||||
UserMenu,
|
||||
} from "./dashboard/index.js";
|
||||
// Settings layout components
|
||||
export { SettingsLayout } from "./settings/index.js";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SettingsLayout } from "./settings-layout.svelte";
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { Building2, Globe, Settings, Users } from "@lucide/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/stores";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
|
||||
// Get org context from parent layout
|
||||
const orgContext = getContext<{ slug: string }>("orgContext");
|
||||
const slug = $derived(orgContext?.slug);
|
||||
|
||||
// Settings navigation items
|
||||
const navItems = $derived.by(() => [
|
||||
{
|
||||
href: `/dashboard/${slug}/settings`,
|
||||
icon: Settings,
|
||||
label: "General",
|
||||
description: "Organization name, logo, and preferences",
|
||||
},
|
||||
{
|
||||
href: `/dashboard/${slug}/settings/members`,
|
||||
icon: Users,
|
||||
label: "Members",
|
||||
description: "Manage team members and invitations",
|
||||
},
|
||||
{
|
||||
href: `/dashboard/${slug}/settings/sites`,
|
||||
icon: Globe,
|
||||
label: "Sites",
|
||||
description: "Connected websites and domains",
|
||||
},
|
||||
]);
|
||||
|
||||
// Determine active item
|
||||
const activeHref = $derived($page.url.pathname);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
// Exact match for base settings path
|
||||
if (href === `/dashboard/${slug}/settings`) {
|
||||
return activeHref === href;
|
||||
}
|
||||
// Prefix match for sub-pages
|
||||
return activeHref.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DashboardLayout title={title}>
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<!-- Settings Navigation -->
|
||||
<nav class="w-full shrink-0 lg:w-64">
|
||||
<!-- Mobile: horizontal scroll -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
||||
{#each navItems as item (item.href)}
|
||||
{@const active = isActive(item.href)}
|
||||
<a
|
||||
href={resolve(item.href as any)}
|
||||
class={cn(
|
||||
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon class="h-4 w-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: vertical list -->
|
||||
<div class="hidden space-y-1 lg:block">
|
||||
{#each navItems as item (item.href)}
|
||||
{@const active = isActive(item.href)}
|
||||
<a
|
||||
href={resolve(item.href as any)}
|
||||
class={cn(
|
||||
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||
active
|
||||
? "bg-primary/5 text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-muted-foreground/20",
|
||||
)}
|
||||
>
|
||||
<item.icon class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-0.5">
|
||||
<p class="text-sm font-medium">{item.label}</p>
|
||||
<p class="text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
@@ -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",
|
||||
|
||||
@@ -24,9 +24,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthGuard>
|
||||
{#snippet children()}
|
||||
{@render children()}
|
||||
{/snippet}
|
||||
{@render children()}
|
||||
</AuthGuard>
|
||||
<SvelteQueryDevtools />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { AccountNav } from "$lib/components/account";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
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>
|
||||
|
||||
@@ -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 <token></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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { AdminLayout } from "$lib/components/layout";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import {
|
||||
Card,
|
||||
@@ -36,13 +36,8 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
||||
<title>Admin Dashboard | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Admin Dashboard">
|
||||
<AdminLayout title="Dashboard">
|
||||
<div class="space-y-6">
|
||||
<!-- Admin badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="destructive">Admin</Badge>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<!-- Loading state -->
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
@@ -61,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">
|
||||
@@ -77,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">
|
||||
@@ -100,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>
|
||||
@@ -109,4 +104,4 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { AdminLayout } from "$lib/components/layout";
|
||||
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import {
|
||||
@@ -80,7 +81,7 @@ async function executeConfirmAction() {
|
||||
<title>Organizations | Admin | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Organizations">
|
||||
<AdminLayout title="Organizations">
|
||||
<div class="space-y-6">
|
||||
{#if orgsQuery.isPending}
|
||||
<!-- Loading skeleton -->
|
||||
@@ -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"
|
||||
>
|
||||
← Back to admin dashboard
|
||||
@@ -229,7 +230,7 @@ async function executeConfirmAction() {
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AdminLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -12,9 +12,10 @@ 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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { AdminLayout } from "$lib/components/layout";
|
||||
import { ConfirmDialog } from "$lib/components/org";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -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",
|
||||
@@ -220,7 +221,7 @@ async function executeConfirmAction() {
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Organization Details">
|
||||
<AdminLayout title="Organization Details">
|
||||
{#if orgQuery.isPending}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -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" />
|
||||
@@ -456,7 +457,7 @@ async function executeConfirmAction() {
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
</AdminLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { AdminLayout } from "$lib/components/layout";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import {
|
||||
Card,
|
||||
@@ -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",
|
||||
@@ -74,11 +75,11 @@ function handleSlugInput(event: Event) {
|
||||
<title>New Organization | Admin | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="New Organization">
|
||||
<AdminLayout title="New Organization">
|
||||
<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" />
|
||||
@@ -157,4 +158,4 @@ function handleSlugInput(event: Event) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { AdminLayout } from "$lib/components/layout";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import {
|
||||
Card,
|
||||
@@ -37,7 +38,7 @@ const usersQuery = createQuery(() => ({
|
||||
<title>Users | Admin | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Users">
|
||||
<AdminLayout title="Users">
|
||||
{#if usersQuery.isPending}
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
@@ -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
|
||||
@@ -141,4 +142,4 @@ const usersQuery = createQuery(() => ({
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -11,10 +11,11 @@ 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";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { AdminLayout } from "$lib/components/layout";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import {
|
||||
@@ -147,10 +148,10 @@ async function handleConfirmEmail() {
|
||||
<title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="User Details">
|
||||
<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" />
|
||||
@@ -345,4 +346,4 @@ async function handleConfirmEmail() {
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,8 +8,9 @@ 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/dashboard-layout.svelte";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -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">
|
||||
|
||||
@@ -10,8 +10,9 @@ 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/dashboard-layout.svelte";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
import { RoleBadge } from "$lib/components/org";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { getContext } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import FrequentFilters from "$lib/components/dashboard/frequent-filters.svelte";
|
||||
import MetricCard from "$lib/components/dashboard/metric-card.svelte";
|
||||
import PeakTrafficChart from "$lib/components/dashboard/peak-traffic-chart.svelte";
|
||||
import PerformanceTable from "$lib/components/dashboard/performance-table.svelte";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
|
||||
// Get org context (for future filtering by org)
|
||||
const orgContext = getContext<{ slug: string }>("orgContext");
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -11,8 +11,9 @@ 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 DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { SettingsLayout } from "$lib/components/layout";
|
||||
import { ConfirmDialog } from "$lib/components/org";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -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",
|
||||
@@ -175,7 +176,7 @@ async function executeConfirmAction() {
|
||||
<title>Settings | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Organization Settings">
|
||||
<SettingsLayout title="Settings">
|
||||
{#if isLoading || orgQuery.isPending}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -192,7 +193,7 @@ async function executeConfirmAction() {
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="space-y-6">
|
||||
<!-- General Settings (admin+ only) -->
|
||||
{#if canManageOrg}
|
||||
<Card>
|
||||
@@ -295,18 +296,9 @@ async function executeConfirmAction() {
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="pt-4">
|
||||
<a
|
||||
href="/dashboard/{slug}"
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to organization
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
</SettingsLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { getContext } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { SettingsLayout } from "$lib/components/layout";
|
||||
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "$lib/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "$lib/components/ui/table";
|
||||
|
||||
/**
|
||||
* Members management settings page
|
||||
*/
|
||||
|
||||
// Types from API contract
|
||||
type OrgMemberOutput = Awaited<
|
||||
ReturnType<typeof api.orgs.members.list>
|
||||
>[number];
|
||||
type OrgInviteOutput = Awaited<
|
||||
ReturnType<typeof api.orgs.invites.list>
|
||||
>[number];
|
||||
type UserProfile = Awaited<ReturnType<typeof api.me.get>>;
|
||||
|
||||
// Get org context from layout
|
||||
const orgContext = getContext<{
|
||||
slug: string;
|
||||
userQuery: { data: UserProfile | undefined };
|
||||
membersQuery: { data: OrgMemberOutput[] | undefined; isPending: boolean };
|
||||
currentUserRole: "owner" | "admin" | "member" | null;
|
||||
canManageOrg: boolean;
|
||||
isOwner: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}>("orgContext");
|
||||
|
||||
const slug = $derived(orgContext.slug);
|
||||
const userData = $derived(orgContext.userQuery.data);
|
||||
const membersData = $derived(orgContext.membersQuery.data);
|
||||
const currentUserRole = $derived(orgContext.currentUserRole);
|
||||
const canManageOrg = $derived(orgContext.canManageOrg);
|
||||
const isOwner = $derived(orgContext.isOwner);
|
||||
const isLoading = $derived(orgContext.isLoading);
|
||||
const error = $derived(orgContext.error);
|
||||
const currentUserId = $derived(userData?.id);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch invites (only for admins+)
|
||||
const invitesQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "invites"],
|
||||
queryFn: () => api.orgs.invites.list({ slug }),
|
||||
enabled: !!slug && canManageOrg,
|
||||
}));
|
||||
|
||||
// Invite form state
|
||||
let inviteEmail = $state("");
|
||||
let inviteRole = $state<"member" | "admin" | "owner">("member");
|
||||
let isInviting = $state(false);
|
||||
|
||||
// Confirmation dialog state
|
||||
let confirmDialogOpen = $state(false);
|
||||
let confirmDialogTitle = $state("");
|
||||
let confirmDialogDescription = $state("");
|
||||
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
||||
let isConfirmLoading = $state(false);
|
||||
|
||||
/**
|
||||
* Send invite to email
|
||||
*/
|
||||
async function handleInvite() {
|
||||
if (!inviteEmail.trim()) {
|
||||
toast.error("Please enter an email address");
|
||||
return;
|
||||
}
|
||||
|
||||
isInviting = true;
|
||||
try {
|
||||
await api.orgs.invites.create({
|
||||
slug,
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
});
|
||||
toast.success("Invitation sent!");
|
||||
inviteEmail = "";
|
||||
inviteRole = "member";
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to send invitation");
|
||||
} finally {
|
||||
isInviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending invite
|
||||
*/
|
||||
async function handleCancelInvite(inviteId: number, email: string) {
|
||||
confirmDialogTitle = "Cancel Invitation";
|
||||
confirmDialogDescription = `Are you sure you want to cancel the invitation to ${email}?`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.invites.cancel({ slug, inviteId });
|
||||
toast.success("Invitation cancelled");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["org", slug, "invites"],
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to cancel invitation",
|
||||
);
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
async function handleUpdateRole(
|
||||
userId: number,
|
||||
newRole: "owner" | "admin" | "member",
|
||||
) {
|
||||
try {
|
||||
await api.orgs.members.updateRole({ slug, userId, role: newRole });
|
||||
toast.success("Role updated");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update role");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member
|
||||
*/
|
||||
async function handleRemoveMember(
|
||||
userId: number,
|
||||
displayName: string | null,
|
||||
email: string,
|
||||
) {
|
||||
confirmDialogTitle = "Remove Member";
|
||||
confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.members.remove({ slug, userId });
|
||||
toast.success("Member removed");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["org", slug, "members"],
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute confirm action
|
||||
*/
|
||||
async function executeConfirmAction() {
|
||||
isConfirmLoading = true;
|
||||
try {
|
||||
await confirmAction();
|
||||
confirmDialogOpen = false;
|
||||
} finally {
|
||||
isConfirmLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) {
|
||||
return "Expired";
|
||||
}
|
||||
if (days === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (days === 1) {
|
||||
return "Tomorrow";
|
||||
}
|
||||
return `${days} days`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can remove a member
|
||||
*/
|
||||
function canRemoveMember(memberRole: string, memberId: number): boolean {
|
||||
if (memberId === currentUserId) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner) {
|
||||
return true;
|
||||
}
|
||||
if (currentUserRole === "admin" && memberRole === "member") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles for invite based on current user's role
|
||||
*/
|
||||
const availableInviteRoles = $derived.by(() => {
|
||||
if (isOwner) {
|
||||
return ["member", "admin", "owner"] as const;
|
||||
}
|
||||
if (currentUserRole === "admin") {
|
||||
return ["member", "admin"] as const;
|
||||
}
|
||||
return ["member"] as const;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Members | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<SettingsLayout title="Settings">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Loading members...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||
<p class="mt-4 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load members"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Invite form (admin+ only) -->
|
||||
{#if canManageOrg}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<UserPlus class="h-4 w-4" />
|
||||
Invite Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleInvite(); }} class="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div class="flex-1 space-y-2">
|
||||
<Label for="invite-email">Email address</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="colleague@example.com"
|
||||
bind:value={inviteEmail}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full space-y-2 sm:w-32">
|
||||
<Label for="invite-role">Role</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={inviteRole}
|
||||
onValueChange={(v) => { if (v) inviteRole = v as typeof inviteRole; }}
|
||||
disabled={isInviting}
|
||||
>
|
||||
<SelectTrigger id="invite-role" class="w-full">
|
||||
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each availableInviteRoles as role (role)}
|
||||
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isInviting || !inviteEmail.trim()}>
|
||||
{#if isInviting}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Send Invite
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Pending invites (admin+ only) -->
|
||||
{#if canManageOrg && invitesQuery.data && invitesQuery.data.length > 0}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Clock class="h-4 w-4" />
|
||||
Pending Invitations ({invitesQuery.data.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Invited by</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead class="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each invitesQuery.data as invite (invite.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium">{invite.email}</TableCell>
|
||||
<TableCell><RoleBadge role={invite.role} /></TableCell>
|
||||
<TableCell class="text-muted-foreground">{invite.invitedBy}</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{formatRelativeTime(new Date(invite.expiresAt))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => handleCancelInvite(invite.id, invite.email)}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Cancel</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Members list -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Users class="h-4 w-4" />
|
||||
Members ({membersData?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if membersData && membersData.length > 0}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
{#if canManageOrg}
|
||||
<TableHead class="w-[100px]"></TableHead>
|
||||
{/if}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each membersData as member (member.id)}
|
||||
{@const isCurrentUser = member.userId === currentUserId}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xs font-medium">
|
||||
{(member.displayName || member.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{member.displayName || member.email}
|
||||
{#if isCurrentUser}
|
||||
<span class="ml-1 text-xs text-muted-foreground">(You)</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if member.displayName}
|
||||
<p class="text-xs text-muted-foreground">{member.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if isOwner && !isCurrentUser}
|
||||
<Select
|
||||
type="single"
|
||||
value={member.role}
|
||||
onValueChange={(v) => { if (v) handleUpdateRole(member.userId, v as "owner" | "admin" | "member"); }}
|
||||
>
|
||||
<SelectTrigger size="sm" class="h-7 w-24 text-xs">
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member" label="Member" />
|
||||
<SelectItem value="admin" label="Admin" />
|
||||
<SelectItem value="owner" label="Owner" />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{:else}
|
||||
<RoleBadge role={member.role} />
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{new Date(member.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
{#if canManageOrg}
|
||||
<TableCell>
|
||||
{#if canRemoveMember(member.role, member.userId)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
onclick={() => handleRemoveMember(member.userId, member.displayName, member.email)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No members yet</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingsLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
bind:open={confirmDialogOpen}
|
||||
title={confirmDialogTitle}
|
||||
description={confirmDialogDescription}
|
||||
variant={confirmDialogVariant}
|
||||
loading={isConfirmLoading}
|
||||
onconfirm={executeConfirmAction}
|
||||
oncancel={() => confirmDialogOpen = false}
|
||||
/>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Globe } from "@lucide/svelte";
|
||||
import { SettingsLayout } from "$lib/components/layout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sites | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<SettingsLayout title="Settings">
|
||||
<Card class="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Globe class="h-5 w-5 text-muted-foreground" />
|
||||
Sites
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your connected websites and domains.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Globe class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-sm font-medium">Coming Soon</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Site management features are currently in development.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
25
bun.lock
25
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
--
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
{ 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
|
||||
];
|
||||
|
||||
dotenv.enable = true;
|
||||
@@ -39,6 +46,7 @@
|
||||
env = {
|
||||
DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard?sslmode=disable";
|
||||
TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test?sslmode=disable";
|
||||
GEOIP_DATABASE_PATH = "${pkgs.dbip-city-lite}/share/dbip/dbip-city-lite.mmdb";
|
||||
};
|
||||
|
||||
scripts = {
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/types";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { uniq } from "lodash-es";
|
||||
|
||||
const KNOWN_AAGUIDS: Record<string, string> = {
|
||||
"ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager",
|
||||
"adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac",
|
||||
"08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello",
|
||||
"9ddd1817-af5a-4672-a2b9-3e3dd95000a9": "Windows Hello",
|
||||
"6028b017-b1d4-4c02-b4b3-afcdafc96bb2": "Windows Hello",
|
||||
"dd4ec289-e01d-41c9-bb89-70fa845d4bf2": "iCloud Keychain (Managed)",
|
||||
"531126d6-e717-415c-9320-3d9aa6981239": "Dashlane",
|
||||
"bada5566-a7aa-401f-bd96-45619a55120d": "1Password",
|
||||
"b84e4048-15dc-4dd0-8640-f4f60813c8af": "NordPass",
|
||||
"0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": "Keeper",
|
||||
"891494da-2c90-4d31-a9cd-4eab0aed1309": "Sésame",
|
||||
"f3809540-7f14-49c1-a8b3-8f813b225541": "Enpass",
|
||||
"b5397666-4885-aa6b-cebf-e52262a439a2": "Chromium Browser",
|
||||
"771b48fd-d3d4-4f74-9232-fc157ab0507a": "Edge on Mac",
|
||||
"39a5647e-1853-446c-a1f6-a79bae9f5bc7": "IDmelon",
|
||||
"d548826e-79b4-db40-a3d8-11116f7e8349": "Bitwarden",
|
||||
"fbfc3007-154e-4ecc-8c0b-6e020557d7bd": "iCloud Keychain",
|
||||
"53414d53-554e-4700-0000-000000000000": "Samsung Pass",
|
||||
"66a0ccb3-bd6a-191f-ee06-e375c50b9846": "Thales Bio iOS SDK",
|
||||
"8836336a-f590-0921-301d-46427531eee6": "Thales Bio Android SDK",
|
||||
"cd69adb5-3c7a-deb9-3177-6800ea6cb72a": "Thales PIN Android SDK",
|
||||
"17290f1e-c212-34d0-1423-365d729f09d9": "Thales PIN iOS SDK",
|
||||
"50726f74-6f6e-5061-7373-50726f746f6e": "Proton Pass",
|
||||
"fdb141b2-5d84-443e-8a35-4698c205a502": "KeePassXC",
|
||||
"cc45f64e-52a2-451b-831a-4edd8022a202": "ToothPic Passkey Provider",
|
||||
"bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0": "iPasswords",
|
||||
"b35a26b2-8f6e-4697-ab1d-d44db4da28c6": "Zoho Vault",
|
||||
"b78a0a55-6ef8-d246-a042-ba0f6d55050c": "LastPass",
|
||||
"de503f9c-21a4-4f76-b4b7-558eb55c6f89": "Devolutions",
|
||||
};
|
||||
|
||||
export const getRPInfo = (
|
||||
ctx: APIContext,
|
||||
): {
|
||||
rpName: string;
|
||||
rpID: string;
|
||||
origins: string[];
|
||||
} => {
|
||||
// RP must always be the frontend URL.
|
||||
const rpID = ctx.origin.includes("oval.ph")
|
||||
? "oval.ph"
|
||||
: new URL(ctx.origin).hostname;
|
||||
const origins = uniq(
|
||||
ctx.env.ALLOWED_WEBAUTHN_ORIGINS.split(",").map((o) => new URL(o).origin),
|
||||
);
|
||||
return {
|
||||
rpName: `Oval Business${rpID !== "oval.ph" ? ` (${rpID})` : ""}`,
|
||||
rpID,
|
||||
origins,
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserPasskeys = async (ctx: APIContext, userId: string) => {
|
||||
const userPasskeys = await fetchPasskeyQuery(ctx.db)
|
||||
.where("passkeys.user_id", "=", userId)
|
||||
.execute();
|
||||
return userPasskeys.map(parsePasskey);
|
||||
};
|
||||
|
||||
export const createRegistrationOptions = async (
|
||||
ctx: ProtectedAPIContext,
|
||||
): Promise<{
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
challengeId: PublicId<"passkey_challenges">;
|
||||
}> => {
|
||||
const { rpID, rpName } = getRPInfo(ctx);
|
||||
const userPasskeys = await getUserPasskeys(ctx, ctx.user.id);
|
||||
const options: PublicKeyCredentialCreationOptionsJSON =
|
||||
await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: ctx.user.display_name,
|
||||
// Don't prompt users for additional information about the authenticator
|
||||
// (Recommended for smoother UX)
|
||||
attestationType: "direct",
|
||||
// Prevent users from re-registering existing authenticators
|
||||
excludeCredentials: userPasskeys.map((passkey) => ({
|
||||
id: passkey.credentialId,
|
||||
// Optional
|
||||
transports: passkey.transports ?? undefined,
|
||||
})),
|
||||
// See "Guiding use of authenticators via authenticatorSelection" below
|
||||
authenticatorSelection: {
|
||||
// Defaults
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
// Optional
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
});
|
||||
const { public_id } = await ctx.db
|
||||
.insertInto("passkey_challenges")
|
||||
.values({
|
||||
options: JSON.stringify(options),
|
||||
})
|
||||
.returning("public_id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return {
|
||||
options,
|
||||
challengeId: public_id,
|
||||
};
|
||||
};
|
||||
export const verifyRegistration = async (
|
||||
ctx: ProtectedAPIContext,
|
||||
{
|
||||
challengeId,
|
||||
response,
|
||||
}: {
|
||||
challengeId: PublicId<"passkey_challenges">;
|
||||
response: RegistrationResponseJSON;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const { rpID, origins } = getRPInfo(ctx);
|
||||
const optionsRaw = await ctx.db
|
||||
.selectFrom("passkey_challenges")
|
||||
.where("public_id", "=", challengeId)
|
||||
.select("options")
|
||||
.executeTakeFirst();
|
||||
if (!optionsRaw) {
|
||||
throw new TRPCError({
|
||||
code: "TIMEOUT",
|
||||
message: "Registration timed out. Please try again.",
|
||||
});
|
||||
}
|
||||
const options =
|
||||
optionsRaw.options as unknown as PublicKeyCredentialCreationOptionsJSON;
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: options.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpID,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid registration response. Please try again. ${(error as { message?: string }).message ?? ""}`,
|
||||
});
|
||||
} finally {
|
||||
await ctx.db
|
||||
.deleteFrom("passkey_challenges")
|
||||
.where("public_id", "=", challengeId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
if (!(verified && registrationInfo)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Unable to verify your device.",
|
||||
});
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
|
||||
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
|
||||
|
||||
const insert: PasskeyInsert = {
|
||||
credentialId: credential.id,
|
||||
webAuthnUserId: options.user.id,
|
||||
counter: BigInt(credential.counter),
|
||||
deviceType: credentialDeviceType,
|
||||
backupStatus: credentialBackedUp,
|
||||
transports: response.response.transports ?? null,
|
||||
rpid: rpID,
|
||||
name: `${guidName ?? "Key"} registered at ${formatDateTime(new Date())}`,
|
||||
publicKey: credential.publicKey,
|
||||
};
|
||||
|
||||
await ctx.db
|
||||
.insertInto("passkeys")
|
||||
.values(
|
||||
passkeyToInsert(insert, {
|
||||
rawUserId: ctx.user.id,
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const createAuthenticationOptions = async (
|
||||
ctx: APIContext,
|
||||
userId: string,
|
||||
): Promise<{
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
challengeId: PublicId<"passkey_challenges">;
|
||||
}> => {
|
||||
const { rpID } = getRPInfo(ctx);
|
||||
const userPasskeys = await getUserPasskeys(ctx, userId);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
// Require users to use a previously-registered authenticator
|
||||
allowCredentials: userPasskeys.map((passkey) => ({
|
||||
id: passkey.credentialId,
|
||||
transports: passkey.transports ?? undefined,
|
||||
})),
|
||||
});
|
||||
const { public_id: challengeId } = await ctx.db
|
||||
.insertInto("passkey_challenges")
|
||||
.values({
|
||||
options: JSON.stringify(options),
|
||||
})
|
||||
.returning("public_id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return {
|
||||
options,
|
||||
challengeId: challengeId,
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyAuthentication = async (
|
||||
ctx: APIContext,
|
||||
{
|
||||
userId,
|
||||
challengeId,
|
||||
response,
|
||||
}: {
|
||||
userId: string;
|
||||
challengeId: PublicId<"passkey_challenges">;
|
||||
response: AuthenticationResponseJSON;
|
||||
},
|
||||
): Promise<boolean> => {
|
||||
const { rpID, origins } = getRPInfo(ctx);
|
||||
const optionsRaw = await ctx.db
|
||||
.selectFrom("passkey_challenges")
|
||||
.where("public_id", "=", challengeId)
|
||||
.select("options")
|
||||
.executeTakeFirst();
|
||||
if (!optionsRaw) {
|
||||
throw new TRPCError({
|
||||
code: "TIMEOUT",
|
||||
message: "Registration timed out. Please try again.",
|
||||
});
|
||||
}
|
||||
const options =
|
||||
optionsRaw.options as unknown as PublicKeyCredentialRequestOptionsJSON;
|
||||
try {
|
||||
const userPasskeys = await getUserPasskeys(ctx, userId);
|
||||
const passkey = userPasskeys.find(
|
||||
(passkey) => passkey.credentialId === response.id,
|
||||
);
|
||||
if (!passkey) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Unknown passkey.",
|
||||
});
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: options.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: passkey.publicKey,
|
||||
counter: Number.parseInt(passkey.counter.toString(), 10),
|
||||
transports: passkey.transports ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.updateTable("passkeys")
|
||||
.set((eb) => ({
|
||||
counter: verification.authenticationInfo.newCounter.toString(),
|
||||
last_used_at: eb.fn("NOW"),
|
||||
}))
|
||||
.where("passkeys.id", "=", passkey.id)
|
||||
.execute();
|
||||
} finally {
|
||||
await ctx.db
|
||||
.deleteFrom("passkey_challenges")
|
||||
.where("public_id", "=", challengeId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
53
nix/tea.nix
Normal file
53
nix/tea.nix
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user