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
|
- Use `lsof -i :6827` or check for existing background tasks
|
||||||
- The dev server runs on port 6827 (may fall back to 6828 if port is in use)
|
- The dev server runs on port 6827 (may fall back to 6828 if port is in use)
|
||||||
- Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
|
- Start with `bun run --cwd apps/publisher-dashboard dev` or `devenv up`
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
This repo uses Gitea (git.rev.iq) with the `tea` CLI for pull requests:
|
||||||
|
- Use the `/gitea` skill when creating PRs
|
||||||
|
- tea 0.10.1 is pinned in `nix/tea.nix` (0.11.x has TTY bugs)
|
||||||
|
- Always specify `-r igm/publisher-dashboard` flag (SSH remote auto-detection doesn't work)
|
||||||
|
|
||||||
|
## macOS sed Syntax
|
||||||
|
|
||||||
|
macOS uses BSD sed which differs from GNU sed:
|
||||||
|
- In-place edit requires empty string for backup: `sed -i '' 's/old/new/g' file`
|
||||||
|
- GNU sed (Linux): `sed -i 's/old/new/g' file`
|
||||||
|
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
|
||||||
|
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
|
||||||
|
|||||||
143
README.md
143
README.md
@@ -1,9 +1,57 @@
|
|||||||
# Reviq Publisher Dashboard
|
# Reviq Publisher Dashboard
|
||||||
|
|
||||||
|
A modern publisher dashboard for managing organizations, members, and sites. Built as a monorepo with SvelteKit frontend and oRPC API server.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Frontend (`apps/publisher-dashboard`)
|
||||||
|
- **SvelteKit** with Svelte 5 (runes)
|
||||||
|
- **Tailwind CSS v4** for styling
|
||||||
|
- **TanStack Query** for data fetching
|
||||||
|
- **bits-ui** for accessible UI primitives
|
||||||
|
- **Lucide** for icons
|
||||||
|
- **WebAuthn/Passkeys** for passwordless authentication
|
||||||
|
|
||||||
|
### Backend (`apps/api-server`)
|
||||||
|
- **Bun** runtime
|
||||||
|
- **oRPC** for type-safe API (contract-first)
|
||||||
|
- **Kysely** for type-safe SQL queries
|
||||||
|
- **PostgreSQL** database
|
||||||
|
- **Postmark** for transactional emails
|
||||||
|
|
||||||
|
### CLI (`apps/cli`)
|
||||||
|
- **Stricli** for command parsing
|
||||||
|
- API token-based authentication
|
||||||
|
- User, organization, and site management commands
|
||||||
|
|
||||||
|
### Shared Packages
|
||||||
|
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||||
|
- `@reviq/db` - Database client and queries
|
||||||
|
- `@reviq/db-schema` - Database schema and codegen
|
||||||
|
- `@reviq/utils` - Shared utilities
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
publisher-dashboard/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api-server/ # Backend API server
|
||||||
|
│ ├── cli/ # Command-line interface
|
||||||
|
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||||
|
├── packages/
|
||||||
|
│ ├── api-contract/ # Shared oRPC contract
|
||||||
|
│ ├── db/ # Database client
|
||||||
|
│ ├── db-schema/ # DB schema & codegen
|
||||||
|
│ ├── testing/ # Test utilities
|
||||||
|
│ └── utils/ # Shared utilities
|
||||||
|
└── db/ # Database migrations
|
||||||
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh/) v1.1.42+
|
||||||
- [devenv](https://devenv.sh/) for development environment management
|
- [devenv](https://devenv.sh/) for development environment management
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
@@ -29,9 +77,104 @@ devenv up
|
|||||||
This starts:
|
This starts:
|
||||||
- PostgreSQL database
|
- PostgreSQL database
|
||||||
- Publisher dashboard dev server (port 6827)
|
- Publisher dashboard dev server (port 6827)
|
||||||
|
- API server
|
||||||
- Package build watcher
|
- Package build watcher
|
||||||
|
|
||||||
The database is automatically initialized with:
|
The database is automatically initialized with:
|
||||||
- Database: `reviq-dashboard`
|
- Database: `reviq-dashboard`
|
||||||
- User: `reviq`
|
- User: `reviq`
|
||||||
- Password: `reviq`
|
- Password: `reviq`
|
||||||
|
|
||||||
|
### Manual Development
|
||||||
|
|
||||||
|
If not using devenv, start services individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Build packages first
|
||||||
|
bun run build:packages
|
||||||
|
|
||||||
|
# Start dev server
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `bun run dev` | Start all dev servers |
|
||||||
|
| `bun run build` | Build all packages and apps |
|
||||||
|
| `bun run typecheck` | Run TypeScript type checking |
|
||||||
|
| `bun run lint` | Run Biome and ESLint |
|
||||||
|
| `bun run lint:fix` | Fix linting issues |
|
||||||
|
| `bun run test` | Run tests |
|
||||||
|
| `bun run db:codegen` | Generate database types |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the CLI
|
||||||
|
bun run --cwd apps/cli build
|
||||||
|
|
||||||
|
# Login with an API token
|
||||||
|
./apps/cli/dist/reviq auth login --token <your-token>
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
./apps/cli/dist/reviq auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Passwordless login with passkeys (WebAuthn)
|
||||||
|
- Email verification
|
||||||
|
- Session management with device tracking
|
||||||
|
|
||||||
|
### Organizations
|
||||||
|
- Create and manage organizations
|
||||||
|
- Member management with roles (owner, admin, member)
|
||||||
|
- Invite members via email
|
||||||
|
- Organization settings
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Organization switcher
|
||||||
|
- Performance metrics
|
||||||
|
- Reports (coming soon)
|
||||||
|
- Site management (coming soon)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
/ # Landing page
|
||||||
|
/login # Login page
|
||||||
|
/dashboard # Organization list
|
||||||
|
/dashboard/[slug] # Organization home
|
||||||
|
/dashboard/[slug]/performance # Performance metrics
|
||||||
|
/dashboard/[slug]/reports # Reports (placeholder)
|
||||||
|
/dashboard/[slug]/settings # Organization settings
|
||||||
|
├── /members # Member management
|
||||||
|
└── /sites # Sites (placeholder)
|
||||||
|
/account # User account settings
|
||||||
|
├── /security # Security settings
|
||||||
|
└── /sessions # Active sessions
|
||||||
|
/admin # Admin panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Structure
|
||||||
|
|
||||||
|
The API uses oRPC with a contract-first approach. Routes are defined in `@reviq/api-contract` and implemented in `apps/api-server`.
|
||||||
|
|
||||||
|
Key API namespaces:
|
||||||
|
- `auth` - Authentication (passkeys, sessions)
|
||||||
|
- `me` - Current user profile
|
||||||
|
- `orgs` - Organization management
|
||||||
|
- `orgs.members` - Member management
|
||||||
|
- `orgs.invites` - Invitation management
|
||||||
|
|||||||
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> {
|
export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Database Singleton (for transaction-based test isolation)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let sharedDb: Kysely<Database> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the shared test database once.
|
||||||
|
* Runs migrations and truncates all tables to start with a clean slate.
|
||||||
|
* Subsequent calls return the existing connection.
|
||||||
|
*
|
||||||
|
* Use this with `withTestTransaction()` for fast test isolation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* beforeAll(async () => {
|
||||||
|
* await initTestDb();
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* test("does something", async () => {
|
||||||
|
* await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
* // test code using db
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function initTestDb(): Promise<Kysely<Database>> {
|
||||||
|
if (!sharedDb) {
|
||||||
|
await runMigrations();
|
||||||
|
sharedDb = createTestDb();
|
||||||
|
await truncateAllTables(sharedDb); // Clean slate once at start
|
||||||
|
}
|
||||||
|
return sharedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shared test database connection.
|
||||||
|
* Must call `initTestDb()` first.
|
||||||
|
*
|
||||||
|
* @throws Error if database not initialized
|
||||||
|
*/
|
||||||
|
export function getSharedDb(): Kysely<Database> {
|
||||||
|
if (!sharedDb) {
|
||||||
|
throw new Error(
|
||||||
|
"Test DB not initialized. Call initTestDb() in beforeAll first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sharedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the shared test database connection.
|
||||||
|
* Call this in a global afterAll if needed.
|
||||||
|
*/
|
||||||
|
export async function destroySharedDb(): Promise<void> {
|
||||||
|
if (sharedDb) {
|
||||||
|
await sharedDb.destroy();
|
||||||
|
sharedDb = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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;
|
reqHeaders: Headers;
|
||||||
/** Response headers (for setting cookies) */
|
/** Response headers (for setting cookies) */
|
||||||
resHeaders: Headers;
|
resHeaders: Headers;
|
||||||
|
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||||
|
clientIP?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME;
|
|||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
port,
|
port,
|
||||||
async fetch(request) {
|
async fetch(request, server) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api/v1/rpc")) {
|
if (url.pathname.startsWith("/api/v1/rpc")) {
|
||||||
@@ -50,6 +50,10 @@ Bun.serve({
|
|||||||
// Create response headers for setting cookies
|
// Create response headers for setting cookies
|
||||||
const resHeaders = new Headers();
|
const resHeaders = new Headers();
|
||||||
|
|
||||||
|
// Get client IP from Bun's server (fallback for when no proxy headers)
|
||||||
|
const socketInfo = server.requestIP(request);
|
||||||
|
const clientIP = socketInfo?.address ?? null;
|
||||||
|
|
||||||
const context: APIContext = {
|
const context: APIContext = {
|
||||||
db,
|
db,
|
||||||
origin,
|
origin,
|
||||||
@@ -57,6 +61,7 @@ Bun.serve({
|
|||||||
rpName,
|
rpName,
|
||||||
reqHeaders: request.headers,
|
reqHeaders: request.headers,
|
||||||
resHeaders,
|
resHeaders,
|
||||||
|
clientIP,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { response } = await handler.handle(request, {
|
const { response } = await handler.handle(request, {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
|||||||
const hasPassword = user.password_hash !== null;
|
const hasPassword = user.password_hash !== null;
|
||||||
|
|
||||||
// Get geo info and user agent
|
// Get geo info and user agent
|
||||||
const geo = getGeoInfo(context.reqHeaders);
|
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||||
const userAgent = getUserAgent(context.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
// Create login request with secure token
|
// Create login request with secure token
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const loginIfRequestIsCompleted =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current request info
|
// Get current request info
|
||||||
const geo = getGeoInfo(context.reqHeaders);
|
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||||
const userAgent = getUserAgent(context.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
// Upsert user device
|
// Upsert user device
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get geo info and user agent for session creation
|
// Get geo info and user agent for session creation
|
||||||
const geo = getGeoInfo(context.reqHeaders);
|
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
|
||||||
const userAgent = getUserAgent(context.reqHeaders);
|
const userAgent = getUserAgent(context.reqHeaders);
|
||||||
|
|
||||||
let userId: number;
|
let userId: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Me routes - consolidated exports for os.router()
|
* Me routes - consolidated exports for os.router()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
|
||||||
import { meAuthStatus } from "./auth-status.js";
|
import { meAuthStatus } from "./auth-status.js";
|
||||||
import { meDelete } from "./delete.js";
|
import { meDelete } from "./delete.js";
|
||||||
import {
|
import {
|
||||||
@@ -54,4 +55,9 @@ export const meRoutes = {
|
|||||||
untrust: untrustDevice,
|
untrust: untrustDevice,
|
||||||
revokeAll: revokeAllTrustedDevices,
|
revokeAll: revokeAllTrustedDevices,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
list: listApiTokens,
|
||||||
|
create: createApiToken,
|
||||||
|
delete: deleteApiToken,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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 {
|
import {
|
||||||
_resetForTesting,
|
_resetForTesting,
|
||||||
_setReaderForTesting,
|
_setReaderForTesting,
|
||||||
extractClientIP,
|
extractClientIP,
|
||||||
getGeoInfo,
|
getGeoInfo,
|
||||||
getUserAgent,
|
getUserAgent,
|
||||||
|
initGeoReader,
|
||||||
lookupGeoFromIP,
|
lookupGeoFromIP,
|
||||||
} from "./geo.js";
|
} from "./geo.js";
|
||||||
|
|
||||||
@@ -220,3 +228,110 @@ describe("getUserAgent", () => {
|
|||||||
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
|
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("initGeoReader", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calling initGeoReader twice does not reinitialize", async () => {
|
||||||
|
// First call initializes
|
||||||
|
await initGeoReader();
|
||||||
|
|
||||||
|
// Second call should return early (covers the early return branch)
|
||||||
|
await initGeoReader();
|
||||||
|
|
||||||
|
// If we get here without error, the early return worked
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles missing database file gracefully", async () => {
|
||||||
|
// Save original env
|
||||||
|
const originalPath = Bun.env.GEOIP_DATABASE_PATH;
|
||||||
|
|
||||||
|
// Point to non-existent file
|
||||||
|
Bun.env.GEOIP_DATABASE_PATH = "/nonexistent/path/to/db.mmdb";
|
||||||
|
|
||||||
|
// Should not throw, just log a warning
|
||||||
|
await initGeoReader();
|
||||||
|
|
||||||
|
// Lookups should return nulls since reader failed to initialize
|
||||||
|
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
country: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore original env
|
||||||
|
if (originalPath) {
|
||||||
|
Bun.env.GEOIP_DATABASE_PATH = originalPath;
|
||||||
|
} else {
|
||||||
|
delete Bun.env.GEOIP_DATABASE_PATH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only run real database tests if GEOIP_DATABASE_PATH is set
|
||||||
|
const hasGeoDatabase = !!Bun.env.GEOIP_DATABASE_PATH;
|
||||||
|
|
||||||
|
describe.skipIf(!hasGeoDatabase)("real GeoIP database", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
_resetForTesting();
|
||||||
|
await initGeoReader();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("looks up Google DNS (8.8.8.8) - US", () => {
|
||||||
|
const result = lookupGeoFromIP("8.8.8.8");
|
||||||
|
expect(result.country).toBe("US");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("looks up Cloudflare DNS (1.1.1.1) - AU", () => {
|
||||||
|
const result = lookupGeoFromIP("1.1.1.1");
|
||||||
|
// Cloudflare's 1.1.1.1 is geolocated to Sydney, Australia
|
||||||
|
expect(result.country).toBe("AU");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("looks up known German IP", () => {
|
||||||
|
// Deutsche Telekom IP range
|
||||||
|
const result = lookupGeoFromIP("80.150.6.143");
|
||||||
|
expect(result.country).toBe("DE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("looks up known UK IP", () => {
|
||||||
|
// BBC IP range
|
||||||
|
const result = lookupGeoFromIP("212.58.244.71");
|
||||||
|
expect(result.country).toBe("GB");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns city data for major IPs", () => {
|
||||||
|
const result = lookupGeoFromIP("8.8.8.8");
|
||||||
|
// DBIP returns "Mountain View" for Google DNS
|
||||||
|
expect(result.city).toBe("Mountain View");
|
||||||
|
expect(result.region).toBe("California");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getGeoInfo uses real database when no CF headers", () => {
|
||||||
|
const headers = createHeaders({ "X-Real-IP": "8.8.8.8" });
|
||||||
|
const result = getGeoInfo(headers);
|
||||||
|
|
||||||
|
expect(result.ip).toBe("8.8.8.8");
|
||||||
|
expect(result.country).toBe("US");
|
||||||
|
expect(result.city).toBe("Mountain View");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns nulls for private/reserved IPs", () => {
|
||||||
|
const result = lookupGeoFromIP("192.168.1.1");
|
||||||
|
expect(result.city).toBeNull();
|
||||||
|
expect(result.country).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns nulls for localhost", () => {
|
||||||
|
const result = lookupGeoFromIP("127.0.0.1");
|
||||||
|
expect(result.city).toBeNull();
|
||||||
|
expect(result.country).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -126,9 +126,16 @@ export const lookupGeoFromIP = (
|
|||||||
/**
|
/**
|
||||||
* Extract geolocation info from request headers.
|
* Extract geolocation info from request headers.
|
||||||
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
|
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
|
||||||
|
*
|
||||||
|
* @param headers - Request headers to extract proxy IP headers from
|
||||||
|
* @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP)
|
||||||
*/
|
*/
|
||||||
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
export const getGeoInfo = (
|
||||||
const ip = extractClientIP(headers);
|
headers: Headers,
|
||||||
|
fallbackIP?: string | null,
|
||||||
|
): GeoInfo => {
|
||||||
|
// Try proxy headers first, then fall back to direct connection IP
|
||||||
|
const ip = extractClientIP(headers) ?? fallbackIP ?? null;
|
||||||
|
|
||||||
// Try Cloudflare geo headers first
|
// Try Cloudflare geo headers first
|
||||||
const cfCountry = headers.get("CF-IPCountry");
|
const cfCountry = headers.get("CF-IPCountry");
|
||||||
|
|||||||
86
apps/cli/README.md
Normal file
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 type { LocalContext } from "../../context.js";
|
||||||
import { buildCommand } from "@stricli/core";
|
import { buildCommand } from "@stricli/core";
|
||||||
import { readConfig } from "../../utils/config.js";
|
import { createApiClient } from "../../utils/api-client.js";
|
||||||
import { generateToken, hashToken } from "../../utils/token.js";
|
import { readConfig, writeConfig } from "../../utils/config.js";
|
||||||
|
|
||||||
interface LoginFlags {
|
interface LoginFlags {
|
||||||
email: string;
|
token: string;
|
||||||
"api-url"?: string;
|
"api-url"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginStatusOutput {
|
/**
|
||||||
status: "pending" | "completed" | "expired";
|
* Login to RevIQ with an API token
|
||||||
}
|
*
|
||||||
|
* To get an API token:
|
||||||
|
* 1. Log in to the web dashboard
|
||||||
|
* 2. Go to Account Settings > API Tokens
|
||||||
|
* 3. Create a new token and copy it
|
||||||
|
* 4. Run: reviq auth login --token <your-token>
|
||||||
|
*/
|
||||||
async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
||||||
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
|
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
|
||||||
|
|
||||||
@@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Starting login flow...\n");
|
console.log("Validating API token...\n");
|
||||||
|
|
||||||
// Generate a unique callback token for this login request
|
|
||||||
const callbackToken = generateToken();
|
|
||||||
const callbackTokenHash = hashToken(callbackToken);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create login request
|
// Create a temporary API client with the provided token
|
||||||
const createResponse = await fetch(
|
const api = createApiClient(apiUrl, flags.token);
|
||||||
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: flags.email }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
// Validate the token by fetching the user's auth status
|
||||||
const text = await createResponse.text();
|
const authStatus = await api.me.authStatus();
|
||||||
console.error(`Error creating login request: ${text}`);
|
|
||||||
this.process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the login URL
|
// Save credentials
|
||||||
const loginUrl = new URL(`${apiUrl}/login`);
|
await writeConfig({
|
||||||
loginUrl.searchParams.set("email", flags.email);
|
apiUrl,
|
||||||
loginUrl.searchParams.set("cli_callback", callbackTokenHash);
|
token: flags.token,
|
||||||
|
email: authStatus.user.email,
|
||||||
|
});
|
||||||
|
|
||||||
console.log("Opening browser for authentication...");
|
console.log(`Logged in as ${authStatus.user.email}`);
|
||||||
console.log(`\nIf the browser doesn't open, visit:`);
|
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||||
console.log(` ${loginUrl.toString()}\n`);
|
|
||||||
|
|
||||||
// Try to open the browser
|
|
||||||
const openCommand =
|
|
||||||
process.platform === "darwin"
|
|
||||||
? "open"
|
|
||||||
: process.platform === "win32"
|
|
||||||
? "start"
|
|
||||||
: "xdg-open";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
});
|
|
||||||
await proc.exited;
|
|
||||||
} catch {
|
|
||||||
// Ignore errors opening browser - user can use the URL
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Waiting for login to complete...");
|
|
||||||
console.log("(Press Ctrl+C to cancel)\n");
|
|
||||||
|
|
||||||
// Poll for completion
|
|
||||||
const maxAttempts = 120; // 2 minutes at 1 second intervals
|
|
||||||
let attempts = 0;
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
attempts++;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusResponse = await fetch(
|
|
||||||
`${apiUrl}/api/v1/rpc/auth.loginIfRequestIsCompleted`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CLI-Callback-Token": callbackToken,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (statusResponse.ok) {
|
|
||||||
const status = (await statusResponse.json()) as LoginStatusOutput;
|
|
||||||
|
|
||||||
if (status.status === "completed") {
|
|
||||||
// Login completed - we should have received a token
|
|
||||||
// For now, we'll need the API to return the token
|
|
||||||
console.log("Login completed successfully!");
|
|
||||||
|
|
||||||
// TODO: The API needs to return the session token when login completes
|
|
||||||
// For now, this is a placeholder
|
|
||||||
console.log(
|
|
||||||
"\nNote: Browser-based login flow requires API integration.",
|
|
||||||
);
|
|
||||||
console.log("Use 'reviq bootstrap' to create initial credentials.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status.status === "expired") {
|
|
||||||
console.error("Login request expired. Please try again.");
|
|
||||||
this.process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore polling errors and continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show progress indicator
|
|
||||||
process.stdout.write(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n\nLogin timed out. Please try again.");
|
|
||||||
this.process.exit(1);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"Error:",
|
"Login failed:",
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
|
console.log("\nMake sure your API token is valid.");
|
||||||
|
console.log("You can create a new token at: /account/api-tokens");
|
||||||
this.process.exit(1);
|
this.process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,10 +61,10 @@ export const loginCommand = buildCommand({
|
|||||||
func: login,
|
func: login,
|
||||||
parameters: {
|
parameters: {
|
||||||
flags: {
|
flags: {
|
||||||
email: {
|
token: {
|
||||||
kind: "parsed",
|
kind: "parsed",
|
||||||
parse: String,
|
parse: String,
|
||||||
brief: "Email address to login with",
|
brief: "API token from the web dashboard",
|
||||||
},
|
},
|
||||||
"api-url": {
|
"api-url": {
|
||||||
kind: "parsed",
|
kind: "parsed",
|
||||||
@@ -156,8 +75,13 @@ export const loginCommand = buildCommand({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
brief: "Login to RevIQ",
|
brief: "Login to RevIQ with an API token",
|
||||||
fullDescription:
|
fullDescription: `Authenticates with RevIQ using an API token.
|
||||||
"Opens a browser to complete authentication and stores the credentials locally.",
|
|
||||||
|
To get an API token:
|
||||||
|
1. Log in to the web dashboard at http://localhost:9861
|
||||||
|
2. Go to Account Settings > API Tokens
|
||||||
|
3. Create a new token and copy it
|
||||||
|
4. Run: reviq auth login --token <your-token>`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,25 +10,48 @@ import { readConfig } from "./config.js";
|
|||||||
|
|
||||||
export type ApiClient = ContractRouterClient<typeof contract>;
|
export type ApiClient = ContractRouterClient<typeof contract>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an oRPC API client with provided credentials
|
||||||
|
*/
|
||||||
|
export function createApiClient(apiUrl: string, token: string): ApiClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an oRPC API client with the stored credentials
|
* Create an oRPC API client with the stored credentials
|
||||||
* Throws an error if not logged in
|
* Throws an error if not logged in
|
||||||
*/
|
*/
|
||||||
export const createApiClient = async (): Promise<ApiClient> => {
|
export function createApiClient(): Promise<ApiClient>;
|
||||||
const config = await readConfig();
|
|
||||||
if (!config) {
|
export function createApiClient(
|
||||||
throw new Error(
|
apiUrl?: string,
|
||||||
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
token?: string,
|
||||||
);
|
): ApiClient | Promise<ApiClient> {
|
||||||
|
// If both arguments are provided, create client directly
|
||||||
|
if (apiUrl !== undefined && token !== undefined) {
|
||||||
|
const link = new RPCLink({
|
||||||
|
url: `${apiUrl}/api/v1/rpc`,
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = new RPCLink({
|
// Otherwise, read from config asynchronously
|
||||||
url: `${config.apiUrl}/api/v1/rpc`,
|
return (async (): Promise<ApiClient> => {
|
||||||
headers: {
|
const config = await readConfig();
|
||||||
"X-API-Key": config.token,
|
if (!config) {
|
||||||
},
|
throw new Error(
|
||||||
});
|
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cast to ApiClient for type-safe API calls
|
const link = new RPCLink({
|
||||||
return createORPCClient(link) as unknown as ApiClient;
|
url: `${config.apiUrl}/api/v1/rpc`,
|
||||||
};
|
headers: {
|
||||||
|
"X-API-Key": config.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { configs } from "@macalinao/eslint-config";
|
import { configs } from "@macalinao/eslint-config";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import svelteParser from "svelte-eslint-parser";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: [".svelte-kit/**", "build/**"],
|
ignores: [".svelte-kit/**", "build/**"],
|
||||||
},
|
},
|
||||||
...configs.fast,
|
...configs.fast,
|
||||||
|
...svelte.configs["flat/recommended"],
|
||||||
|
{
|
||||||
|
files: ["**/*.svelte", "**/*.svelte.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: svelteParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|||||||
@@ -39,9 +39,12 @@
|
|||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
|
"@typescript-eslint/parser": "^8.52.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"svelte": "^5.28.2",
|
"svelte": "^5.28.2",
|
||||||
"svelte-check": "^4.2.1",
|
"svelte-check": "^4.2.1",
|
||||||
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ClockIcon from "@lucide/svelte/icons/clock";
|
import ClockIcon from "@lucide/svelte/icons/clock";
|
||||||
|
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
|
||||||
import MonitorIcon from "@lucide/svelte/icons/monitor";
|
import MonitorIcon from "@lucide/svelte/icons/monitor";
|
||||||
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
|
||||||
import UserIcon from "@lucide/svelte/icons/user";
|
import UserIcon from "@lucide/svelte/icons/user";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,13 +16,33 @@ interface Props {
|
|||||||
|
|
||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
const navItems = [
|
// Fetch current user to check superuser status
|
||||||
|
const userQuery = createQuery(() => ({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.me.get(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseNavItems = [
|
||||||
{ href: "/account", label: "Profile", icon: UserIcon },
|
{ href: "/account", label: "Profile", icon: UserIcon },
|
||||||
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
|
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
|
||||||
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
|
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
|
||||||
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
|
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add API Tokens link for superusers only
|
||||||
|
const navItems = $derived(
|
||||||
|
userQuery.data?.isSuperuser
|
||||||
|
? [
|
||||||
|
...baseNavItems,
|
||||||
|
{
|
||||||
|
href: "/account/api-tokens",
|
||||||
|
label: "API Tokens",
|
||||||
|
icon: KeyRoundIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: baseNavItems,
|
||||||
|
);
|
||||||
|
|
||||||
function isActive(href: string, pathname: string): boolean {
|
function isActive(href: string, pathname: string): boolean {
|
||||||
if (href === "/account") {
|
if (href === "/account") {
|
||||||
return pathname === "/account";
|
return pathname === "/account";
|
||||||
@@ -33,10 +57,10 @@ function isActive(href: string, pathname: string): boolean {
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{#each navItems as item}
|
{#each navItems as item (item.href)}
|
||||||
{@const active = isActive(item.href, $page.url.pathname)}
|
{@const active = isActive(item.href, $page.url.pathname)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={resolve(item.href as any)}
|
||||||
class={cn(
|
class={cn(
|
||||||
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlertTriangle } from "@lucide/svelte";
|
|||||||
import { useQueryClient } from "@tanstack/svelte-query";
|
import { useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -51,7 +52,7 @@ async function handleDelete(e: Event) {
|
|||||||
open = false;
|
open = false;
|
||||||
|
|
||||||
// Redirect to login
|
// Redirect to login
|
||||||
goto("/auth/login");
|
goto(resolve("/auth/login"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Failed to delete account";
|
error = e instanceof Error ? e.message : "Failed to delete account";
|
||||||
isDeleting = false;
|
isDeleting = false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
|
||||||
@@ -25,7 +26,11 @@ const userQuery = createQuery(() => ({
|
|||||||
// Redirect to login if not authenticated on non-auth pages
|
// Redirect to login if not authenticated on non-auth pages
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isAuthPage && userQuery.error) {
|
if (!isAuthPage && userQuery.error) {
|
||||||
goto(`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}`);
|
goto(
|
||||||
|
resolve(
|
||||||
|
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const config = $derived(strengthConfig[score]);
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- Strength bars -->
|
<!-- Strength bars -->
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{#each Array(4) as _, i}
|
{#each Array(4) as _, i (i)}
|
||||||
<div
|
<div
|
||||||
class="h-1 flex-1 rounded-full transition-colors {i < score
|
class="h-1 flex-1 rounded-full transition-colors {i < score
|
||||||
? config.color
|
? config.color
|
||||||
@@ -52,7 +52,7 @@ const config = $derived(strengthConfig[score]);
|
|||||||
{#if result.feedback.warning}
|
{#if result.feedback.warning}
|
||||||
<p class="text-destructive">{result.feedback.warning}</p>
|
<p class="text-destructive">{result.feedback.warning}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each result.feedback.suggestions as suggestion}
|
{#each result.feedback.suggestions as suggestion, i (i)}
|
||||||
<p>{suggestion}</p>
|
<p>{suggestion}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,9 +26,9 @@ const filters = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divide-y divide-border/50">
|
<div class="divide-y divide-border/50">
|
||||||
{#each filters as filter}
|
{#each filters as filter (filter.label)}
|
||||||
<a
|
<a
|
||||||
href={filter.href}
|
href={resolve(filter.href as any)}
|
||||||
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function hourToPercent(hour: number): number {
|
|||||||
<div class="flex">
|
<div class="flex">
|
||||||
<!-- Y-axis labels -->
|
<!-- Y-axis labels -->
|
||||||
<div class="flex w-10 flex-col justify-between pr-2" style="height: 210px">
|
<div class="flex w-10 flex-col justify-between pr-2" style="height: 210px">
|
||||||
{#each hours as hour}
|
{#each hours as hour (hour)}
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
|
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,14 +55,14 @@ function hourToPercent(hour: number): number {
|
|||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<!-- Grid lines -->
|
<!-- Grid lines -->
|
||||||
<div class="absolute inset-0 flex flex-col justify-between" style="height: 210px">
|
<div class="absolute inset-0 flex flex-col justify-between" style="height: 210px">
|
||||||
{#each hours as _}
|
{#each hours as hour (hour)}
|
||||||
<div class="h-px w-full bg-border"></div>
|
<div class="h-px w-full bg-border"></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bars container -->
|
<!-- Bars container -->
|
||||||
<div class="relative grid grid-cols-7 gap-4 px-2" style="height: 210px">
|
<div class="relative grid grid-cols-7 gap-4 px-2" style="height: 210px">
|
||||||
{#each days as _, dayIndex}
|
{#each days as day, dayIndex (day)}
|
||||||
{@const thisMonth = thisMonthData[dayIndex]}
|
{@const thisMonth = thisMonthData[dayIndex]}
|
||||||
{@const lastMonth = lastMonthData[dayIndex]}
|
{@const lastMonth = lastMonthData[dayIndex]}
|
||||||
<div class="relative flex justify-center">
|
<div class="relative flex justify-center">
|
||||||
@@ -104,7 +104,7 @@ function hourToPercent(hour: number): number {
|
|||||||
|
|
||||||
<!-- X-axis labels -->
|
<!-- X-axis labels -->
|
||||||
<div class="mt-2 grid grid-cols-7 gap-4 px-2">
|
<div class="mt-2 grid grid-cols-7 gap-4 px-2">
|
||||||
{#each days as day}
|
{#each days as day (day)}
|
||||||
<div class="text-center text-[11px] text-muted-foreground">{day}</div>
|
<div class="text-center text-[11px] text-muted-foreground">{day}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +40,7 @@ function handleTabChange(tabId: string) {
|
|||||||
} else {
|
} else {
|
||||||
url.searchParams.set("tab", tabId);
|
url.searchParams.set("tab", tabId);
|
||||||
}
|
}
|
||||||
goto(url.toString(), { replaceState: true, noScroll: true });
|
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ function handleTabChange(tabId: string) {
|
|||||||
|
|
||||||
<!-- Tab navigation -->
|
<!-- Tab navigation -->
|
||||||
<div class="flex items-center gap-0.5" role="tablist">
|
<div class="flex items-center gap-0.5" role="tablist">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab (tab.id)}
|
||||||
{@const isActive = activeTab === tab.id}
|
{@const isActive = activeTab === tab.id}
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each tableData as row, i}
|
{#each tableData as row, i (row.id)}
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
<Table.Cell class="w-10 py-3 pl-5">
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each tableData as row, i}
|
{#each tableData as row, i (row.id)}
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
<Table.Cell class="w-10 py-3 pl-5">
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each tableData as row, i}
|
{#each tableData as row, i (row.id)}
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
<Table.Cell class="w-10 py-3 pl-5">
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each tableData as row, i}
|
{#each tableData as row, i (row.id)}
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
<Table.Cell class="w-10 py-3 pl-5">
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each tableData as row, i}
|
{#each tableData as row, i (row.id)}
|
||||||
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
|
||||||
<Table.Cell class="w-10 py-3 pl-5">
|
<Table.Cell class="w-10 py-3 pl-5">
|
||||||
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
|
import { Settings } from "@lucide/svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import OrgSwitcher from "./org-switcher.svelte";
|
import OrgSwitcher from "./org-switcher.svelte";
|
||||||
@@ -66,12 +68,14 @@ const navItems = $derived.by(() => {
|
|||||||
|
|
||||||
<!-- Main Navigation -->
|
<!-- Main Navigation -->
|
||||||
<nav class="flex flex-1 flex-col items-center gap-3">
|
<nav class="flex flex-1 flex-col items-center gap-3">
|
||||||
{#each navItems as item}
|
{#each navItems as item (item.href)}
|
||||||
{@const isActive =
|
{@const isActive =
|
||||||
$page.url.pathname === item.href ||
|
item.icon === "home"
|
||||||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
? $page.url.pathname === item.href
|
||||||
|
: $page.url.pathname === item.href ||
|
||||||
|
$page.url.pathname.startsWith(item.href + "/")}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={resolve(item.href as any)}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
isActive
|
isActive
|
||||||
@@ -153,8 +157,34 @@ const navItems = $derived.by(() => {
|
|||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User Menu -->
|
<!-- Bottom section -->
|
||||||
<div class="flex h-[80px] items-center justify-center">
|
<div class="flex flex-col items-center gap-3 pb-6">
|
||||||
|
<!-- Settings (only in org context) -->
|
||||||
|
{#if currentSlug}
|
||||||
|
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
|
||||||
|
<a
|
||||||
|
href={resolve(`/dashboard/${currentSlug}/settings`)}
|
||||||
|
class={cn(
|
||||||
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
|
isSettingsActive
|
||||||
|
? "bg-sidebar-accent text-sidebar-foreground"
|
||||||
|
: "text-sidebar-muted hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||||
|
)}
|
||||||
|
aria-label="Settings"
|
||||||
|
aria-current={isSettingsActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Settings class="h-4 w-4" />
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -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 { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -85,7 +86,7 @@ async function handleSignOut() {
|
|||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
open = false;
|
open = false;
|
||||||
goto("/login");
|
goto(resolve("/auth/login"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to sign out:", error);
|
console.error("Failed to sign out:", error);
|
||||||
}
|
}
|
||||||
@@ -118,12 +119,12 @@ async function handleSignOut() {
|
|||||||
|
|
||||||
<nav class="flex flex-1 flex-col p-4">
|
<nav class="flex flex-1 flex-col p-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item (item.href)}
|
||||||
{@const isActive =
|
{@const isActive =
|
||||||
$page.url.pathname === item.href ||
|
$page.url.pathname === item.href ||
|
||||||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={resolve(item.href as any)}
|
||||||
onclick={handleNavClick}
|
onclick={handleNavClick}
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
@@ -184,7 +185,7 @@ async function handleSignOut() {
|
|||||||
|
|
||||||
<div class="mt-2 space-y-1">
|
<div class="mt-2 space-y-1">
|
||||||
<a
|
<a
|
||||||
href="/account"
|
href={resolve("/account")}
|
||||||
onclick={handleNavClick}
|
onclick={handleNavClick}
|
||||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
@@ -19,7 +20,7 @@ const orgsQuery = createQuery(() => ({
|
|||||||
const orgs = $derived(orgsQuery.data ?? []);
|
const orgs = $derived(orgsQuery.data ?? []);
|
||||||
|
|
||||||
function handleOrgSelect(slug: string) {
|
function handleOrgSelect(slug: string) {
|
||||||
goto(`/dashboard/${slug}`);
|
goto(resolve(`/dashboard/${slug}` as any));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ function handleOrgSelect(slug: string) {
|
|||||||
{:else if orgs.length === 0}
|
{:else if orgs.length === 0}
|
||||||
<DropdownMenu.Item disabled>No organizations</DropdownMenu.Item>
|
<DropdownMenu.Item disabled>No organizations</DropdownMenu.Item>
|
||||||
{:else}
|
{:else}
|
||||||
{#each orgs as org}
|
{#each orgs as org (org.slug)}
|
||||||
{@const isActive = currentSlug === org.slug}
|
{@const isActive = currentSlug === org.slug}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => handleOrgSelect(org.slug)}
|
onSelect={() => handleOrgSelect(org.slug)}
|
||||||
@@ -76,7 +77,7 @@ function handleOrgSelect(slug: string) {
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item onSelect={() => goto("/dashboard/new")}>
|
<DropdownMenu.Item onSelect={() => goto(resolve("/dashboard/new"))}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" />
|
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" />
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ async function handleSignOut() {
|
|||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
// Clear all cached queries
|
// Clear all cached queries
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
goto("/login");
|
goto(resolve("/auth/login"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to sign out:", error);
|
console.error("Failed to sign out:", error);
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,7 @@ async function handleSignOut() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item onSelect={() => goto("/account")}>
|
<DropdownMenu.Item onSelect={() => goto(resolve("/account"))}>
|
||||||
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
export { default as AppHeader } from "./app-header.svelte";
|
// Account layout components
|
||||||
export { default as AppSidebar } from "./app-sidebar.svelte";
|
export { AccountSettingsLayout } from "./account/index.js";
|
||||||
export { default as DashboardLayout } from "./dashboard-layout.svelte";
|
// Admin layout components
|
||||||
export { default as EmailVerificationBanner } from "./email-verification-banner.svelte";
|
export {
|
||||||
export { default as MobileNav } from "./mobile-nav.svelte";
|
AdminHeader,
|
||||||
|
AdminLayout,
|
||||||
|
AdminMobileNav,
|
||||||
|
AdminSidebar,
|
||||||
|
} from "./admin/index.js";
|
||||||
|
export {
|
||||||
|
AppHeader,
|
||||||
|
AppSidebar,
|
||||||
|
DashboardLayout,
|
||||||
|
EmailVerificationBanner,
|
||||||
|
MobileNav,
|
||||||
|
OrgSwitcher,
|
||||||
|
UserMenu,
|
||||||
|
} from "./dashboard/index.js";
|
||||||
|
// Settings layout components
|
||||||
|
export { SettingsLayout } from "./settings/index.js";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
{#snippet children()}
|
{@render children()}
|
||||||
{@render children()}
|
|
||||||
{/snippet}
|
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
<SvelteQueryDevtools />
|
<SvelteQueryDevtools />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Loader2 } from "@lucide/svelte";
|
import { Loader2 } from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,14 +17,16 @@ const orgsQuery = createQuery(() => ({
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (orgsQuery.error) {
|
if (orgsQuery.error) {
|
||||||
// Not authenticated, redirect to login
|
// Not authenticated, redirect to login
|
||||||
goto(`/auth/login?redirect=${encodeURIComponent("/")}`);
|
goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any));
|
||||||
} else if (orgsQuery.data) {
|
} else if (orgsQuery.data) {
|
||||||
if (orgsQuery.data.length > 0) {
|
if (orgsQuery.data.length > 0) {
|
||||||
// Redirect to first org's dashboard
|
// Redirect to first org's dashboard
|
||||||
goto(`/dashboard/${orgsQuery.data[0].slug}`, { replaceState: true });
|
goto(resolve(`/dashboard/${orgsQuery.data[0].slug}` as any), {
|
||||||
|
replaceState: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// No orgs, show org list (empty state)
|
// No orgs, show org list (empty state)
|
||||||
goto("/dashboard", { replaceState: true });
|
goto(resolve("/dashboard"), { replaceState: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { AccountNav } from "$lib/components/account";
|
import { AccountSettingsLayout } from "$lib/components/layout";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -14,12 +13,6 @@ let { children }: Props = $props();
|
|||||||
<title>Account Settings - Publisher Dashboard</title>
|
<title>Account Settings - Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Account Settings">
|
<AccountSettingsLayout>
|
||||||
<div class="space-y-6">
|
{@render children()}
|
||||||
<AccountNav />
|
</AccountSettingsLayout>
|
||||||
|
|
||||||
<div class="max-w-2xl">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
|
||||||
|
|||||||
@@ -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";
|
} from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
@@ -52,9 +53,9 @@ const acceptMutation = createMutation(() => ({
|
|||||||
queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||||
// Redirect to the org dashboard
|
// Redirect to the org dashboard
|
||||||
if (inviteQuery.data) {
|
if (inviteQuery.data) {
|
||||||
goto(`/dashboard/${inviteQuery.data.org.slug}`);
|
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any));
|
||||||
} else {
|
} else {
|
||||||
goto("/dashboard");
|
goto(resolve("/dashboard"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -71,7 +72,7 @@ const declineMutation = createMutation(() => ({
|
|||||||
toast.success("Invitation declined");
|
toast.success("Invitation declined");
|
||||||
// Invalidate queries
|
// Invalidate queries
|
||||||
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
|
queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
|
||||||
goto("/dashboard");
|
goto(resolve("/dashboard"));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -102,6 +103,7 @@ function formatDate(date: Date): string {
|
|||||||
* Check if invite is expiring soon (within 3 days)
|
* Check if invite is expiring soon (within 3 days)
|
||||||
*/
|
*/
|
||||||
function isExpiringSoon(expiresAt: Date): boolean {
|
function isExpiringSoon(expiresAt: Date): boolean {
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure function, no reactivity needed
|
||||||
const threeDaysFromNow = new Date();
|
const threeDaysFromNow = new Date();
|
||||||
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
|
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
|
||||||
return expiresAt < threeDaysFromNow;
|
return expiresAt < threeDaysFromNow;
|
||||||
@@ -114,7 +116,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<Button variant="ghost" size="sm" href="/dashboard" class="-ml-2">
|
<Button variant="ghost" size="sm" href={resolve("/dashboard")} class="-ml-2">
|
||||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
@@ -131,7 +133,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
|
|||||||
{inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"}
|
{inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button variant="outline" href="/dashboard">
|
<Button variant="outline" href={resolve("/dashboard")}>
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
{:else if inviteQuery.data}
|
{:else if inviteQuery.data}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createQuery } from "@tanstack/svelte-query";
|
|||||||
import { setContext } from "svelte";
|
import { setContext } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,11 +23,13 @@ const userQuery = createQuery(() => ({
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (userQuery.data && !userQuery.data.isSuperuser) {
|
if (userQuery.data && !userQuery.data.isSuperuser) {
|
||||||
toast.error("Access denied. Superuser privileges required.");
|
toast.error("Access denied. Superuser privileges required.");
|
||||||
goto("/dashboard");
|
goto(resolve("/dashboard"));
|
||||||
}
|
}
|
||||||
if (userQuery.error) {
|
if (userQuery.error) {
|
||||||
goto(
|
goto(
|
||||||
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
|
resolve(
|
||||||
|
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte";
|
import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -36,13 +36,8 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
|||||||
<title>Admin Dashboard | Publisher Dashboard</title>
|
<title>Admin Dashboard | Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Admin Dashboard">
|
<AdminLayout title="Dashboard">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Admin badge -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Badge variant="destructive">Admin</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
@@ -61,7 +56,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
|||||||
<!-- Summary cards -->
|
<!-- Summary cards -->
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<!-- Organizations card -->
|
<!-- Organizations card -->
|
||||||
<a href="/admin/orgs" class="group block transition-transform hover:scale-[1.02]">
|
<a href={resolve("/admin/orgs")} class="group block transition-transform hover:scale-[1.02]">
|
||||||
<Card class="h-full transition-colors group-hover:border-primary/50">
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
@@ -77,7 +72,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Users card -->
|
<!-- Users card -->
|
||||||
<a href="/admin/users" class="group block transition-transform hover:scale-[1.02]">
|
<a href={resolve("/admin/users")} class="group block transition-transform hover:scale-[1.02]">
|
||||||
<Card class="h-full transition-colors group-hover:border-primary/50">
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
@@ -100,7 +95,7 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button href="/admin/orgs/new">
|
<Button href={resolve("/admin/orgs/new")}>
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
New Organization
|
New Organization
|
||||||
</Button>
|
</Button>
|
||||||
@@ -109,4 +104,4 @@ const hasError = $derived(orgsQuery.error || usersQuery.error);
|
|||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</AdminLayout>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
|
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
|
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -80,7 +81,7 @@ async function executeConfirmAction() {
|
|||||||
<title>Organizations | Admin | Publisher Dashboard</title>
|
<title>Organizations | Admin | Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Organizations">
|
<AdminLayout title="Organizations">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{#if orgsQuery.isPending}
|
{#if orgsQuery.isPending}
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
@@ -106,7 +107,7 @@ async function executeConfirmAction() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _, i (i)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
|
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
|
||||||
<TableCell><Skeleton class="h-4 w-32" /></TableCell>
|
<TableCell><Skeleton class="h-4 w-32" /></TableCell>
|
||||||
@@ -137,7 +138,7 @@ async function executeConfirmAction() {
|
|||||||
<h2 class="text-lg font-semibold">
|
<h2 class="text-lg font-semibold">
|
||||||
Organizations ({orgsQuery.data.length})
|
Organizations ({orgsQuery.data.length})
|
||||||
</h2>
|
</h2>
|
||||||
<Button href="/admin/orgs/new">
|
<Button href={resolve("/admin/orgs/new")}>
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
New Organization
|
New Organization
|
||||||
</Button>
|
</Button>
|
||||||
@@ -154,7 +155,7 @@ async function executeConfirmAction() {
|
|||||||
<p class="mt-2 text-center text-sm text-muted-foreground">
|
<p class="mt-2 text-center text-sm text-muted-foreground">
|
||||||
Create your first organization to get started.
|
Create your first organization to get started.
|
||||||
</p>
|
</p>
|
||||||
<Button href="/admin/orgs/new" class="mt-4">
|
<Button href={resolve("/admin/orgs/new")} class="mt-4">
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
New Organization
|
New Organization
|
||||||
</Button>
|
</Button>
|
||||||
@@ -192,7 +193,7 @@ async function executeConfirmAction() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
href="/dashboard/{org.slug}"
|
href={resolve(`/dashboard/${org.slug}`)}
|
||||||
title="View organization"
|
title="View organization"
|
||||||
>
|
>
|
||||||
<Eye class="h-4 w-4" />
|
<Eye class="h-4 w-4" />
|
||||||
@@ -221,7 +222,7 @@ async function executeConfirmAction() {
|
|||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href={resolve("/admin")}
|
||||||
class="text-sm text-muted-foreground hover:text-foreground"
|
class="text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
← Back to admin dashboard
|
← Back to admin dashboard
|
||||||
@@ -229,7 +230,7 @@ async function executeConfirmAction() {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</AdminLayout>
|
||||||
|
|
||||||
<!-- Confirmation dialog -->
|
<!-- Confirmation dialog -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog } from "$lib/components/org";
|
import { ConfirmDialog } from "$lib/components/org";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -186,7 +187,7 @@ function handleDelete() {
|
|||||||
await api.admin.orgs.delete({ slug: slug ?? "" });
|
await api.admin.orgs.delete({ slug: slug ?? "" });
|
||||||
toast.success("Organization deleted");
|
toast.success("Organization deleted");
|
||||||
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
|
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
|
||||||
goto("/admin/orgs");
|
goto(resolve("/admin/orgs"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
e instanceof Error ? e.message : "Failed to delete organization",
|
e instanceof Error ? e.message : "Failed to delete organization",
|
||||||
@@ -220,7 +221,7 @@ async function executeConfirmAction() {
|
|||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Organization Details">
|
<AdminLayout title="Organization Details">
|
||||||
{#if orgQuery.isPending}
|
{#if orgQuery.isPending}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -235,7 +236,7 @@ async function executeConfirmAction() {
|
|||||||
: "Failed to load organization"}
|
: "Failed to load organization"}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/admin/orgs"
|
href={resolve("/admin/orgs")}
|
||||||
class="mt-4 text-sm text-muted-foreground hover:text-foreground"
|
class="mt-4 text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="mr-1 inline h-4 w-4" />
|
<ArrowLeft class="mr-1 inline h-4 w-4" />
|
||||||
@@ -247,7 +248,7 @@ async function executeConfirmAction() {
|
|||||||
<div class="mx-auto max-w-2xl space-y-6">
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a
|
<a
|
||||||
href="/admin/orgs"
|
href={resolve("/admin/orgs")}
|
||||||
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="mr-1 h-4 w-4" />
|
<ArrowLeft class="mr-1 h-4 w-4" />
|
||||||
@@ -456,7 +457,7 @@ async function executeConfirmAction() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</DashboardLayout>
|
</AdminLayout>
|
||||||
|
|
||||||
<!-- Confirmation dialog -->
|
<!-- Confirmation dialog -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { ArrowLeft, Loader2 } from "@lucide/svelte";
|
import { ArrowLeft, Loader2 } from "@lucide/svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -49,7 +50,7 @@ async function handleSubmit() {
|
|||||||
ownerEmail: ownerEmail.trim(),
|
ownerEmail: ownerEmail.trim(),
|
||||||
});
|
});
|
||||||
toast.success("Organization created successfully");
|
toast.success("Organization created successfully");
|
||||||
goto("/admin/orgs");
|
goto(resolve("/admin/orgs"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
e instanceof Error ? e.message : "Failed to create organization",
|
e instanceof Error ? e.message : "Failed to create organization",
|
||||||
@@ -74,11 +75,11 @@ function handleSlugInput(event: Event) {
|
|||||||
<title>New Organization | Admin | Publisher Dashboard</title>
|
<title>New Organization | Admin | Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="New Organization">
|
<AdminLayout title="New Organization">
|
||||||
<div class="mx-auto max-w-2xl space-y-6">
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a
|
<a
|
||||||
href="/admin/orgs"
|
href={resolve("/admin/orgs")}
|
||||||
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="mr-1 h-4 w-4" />
|
<ArrowLeft class="mr-1 h-4 w-4" />
|
||||||
@@ -157,4 +158,4 @@ function handleSlugInput(event: Event) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</AdminLayout>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertCircle, Check, Eye, Users, X } from "@lucide/svelte";
|
import { AlertCircle, Check, Eye, Users, X } from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
import { SuperuserBadge } from "$lib/components/admin/index.js";
|
import { SuperuserBadge } from "$lib/components/admin/index.js";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -37,7 +38,7 @@ const usersQuery = createQuery(() => ({
|
|||||||
<title>Users | Admin | Publisher Dashboard</title>
|
<title>Users | Admin | Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Users">
|
<AdminLayout title="Users">
|
||||||
{#if usersQuery.isPending}
|
{#if usersQuery.isPending}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -59,7 +60,7 @@ const usersQuery = createQuery(() => ({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _, i (i)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell><Skeleton class="h-4 w-40" /></TableCell>
|
<TableCell><Skeleton class="h-4 w-40" /></TableCell>
|
||||||
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
|
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
|
||||||
@@ -124,7 +125,7 @@ const usersQuery = createQuery(() => ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href="/admin/users/{encodeURIComponent(user.email)}"
|
href={resolve(`/admin/users/${encodeURIComponent(user.email)}`)}
|
||||||
>
|
>
|
||||||
<Eye class="mr-1 h-4 w-4" />
|
<Eye class="mr-1 h-4 w-4" />
|
||||||
View
|
View
|
||||||
@@ -141,4 +142,4 @@ const usersQuery = createQuery(() => ({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</DashboardLayout>
|
</AdminLayout>
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client.js";
|
import { api } from "$lib/api/client.js";
|
||||||
import { SuperuserBadge } from "$lib/components/admin/index.js";
|
import { SuperuserBadge } from "$lib/components/admin/index.js";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { AdminLayout } from "$lib/components/layout";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js";
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -147,10 +148,10 @@ async function handleConfirmEmail() {
|
|||||||
<title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title>
|
<title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="User Details">
|
<AdminLayout title="User Details">
|
||||||
<!-- Back navigation -->
|
<!-- Back navigation -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<Button variant="ghost" size="sm" href="/admin/users" class="gap-1">
|
<Button variant="ghost" size="sm" href={resolve("/admin/users")} class="gap-1">
|
||||||
<ArrowLeft class="h-4 w-4" />
|
<ArrowLeft class="h-4 w-4" />
|
||||||
Back to users
|
Back to users
|
||||||
</Button>
|
</Button>
|
||||||
@@ -179,7 +180,7 @@ async function handleConfirmEmail() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _, i (i)}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Skeleton class="h-4 w-20" />
|
<Skeleton class="h-4 w-20" />
|
||||||
<Skeleton class="h-5 w-32" />
|
<Skeleton class="h-5 w-32" />
|
||||||
@@ -345,4 +346,4 @@ async function handleConfirmEmail() {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</DashboardLayout>
|
</AdminLayout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -80,9 +81,9 @@ let { children }: Props = $props();
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<p class="text-center text-xs text-muted-foreground">
|
<p class="text-center text-xs text-muted-foreground">
|
||||||
By continuing, you agree to our
|
By continuing, you agree to our
|
||||||
<a href="/terms" class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
|
<a href={resolve("/terms" as any)} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
|
||||||
and
|
and
|
||||||
<a href="/privacy" class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
|
<a href={resolve("/privacy" as any)} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
@@ -40,7 +41,7 @@ async function copyToClipboard() {
|
|||||||
// Guard: redirect to /auth/login if no active login flow
|
// Guard: redirect to /auth/login if no active login flow
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loginFlowState.email) {
|
if (!loginFlowState.email) {
|
||||||
goto("/auth/login");
|
goto(resolve("/auth/login"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ const statusQuery = createQuery(() => ({
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (statusQuery.data?.status === "completed") {
|
if (statusQuery.data?.status === "completed") {
|
||||||
clearLoginFlowState();
|
clearLoginFlowState();
|
||||||
goto(statusQuery.data.redirectTo || "/");
|
goto(resolve((statusQuery.data.redirectTo || "/") as any));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ async function handleResendEmail() {
|
|||||||
|
|
||||||
function handleDifferentEmail() {
|
function handleDifferentEmail() {
|
||||||
clearLoginFlowState();
|
clearLoginFlowState();
|
||||||
goto("/auth/login");
|
goto(resolve("/auth/login"));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CheckCircle2 } from "@lucide/svelte";
|
import { CheckCircle2 } from "@lucide/svelte";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
@@ -119,8 +120,8 @@ async function handleSubmit(e: Event) {
|
|||||||
|
|
||||||
<!-- Back to login link -->
|
<!-- Back to login link -->
|
||||||
<div class="text-center text-sm text-muted-foreground">
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
Remember your password?{" "}
|
Remember your password?
|
||||||
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary">
|
<a href={resolve("/auth/login")} class="text-foreground underline underline-offset-4 hover:text-primary">
|
||||||
Sign in
|
Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
@@ -21,12 +22,12 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
setLoginFlowState(response);
|
setLoginFlowState(response);
|
||||||
|
|
||||||
if (response.hasPasskey) {
|
if (response.hasPasskey) {
|
||||||
goto("/auth/login/passkey");
|
goto(resolve("/auth/login/passkey"));
|
||||||
} else if (response.hasPassword) {
|
} else if (response.hasPassword) {
|
||||||
goto("/auth/login/password");
|
goto(resolve("/auth/login/password"));
|
||||||
} else {
|
} else {
|
||||||
// Anti-enumeration: always redirect to confirm even if user doesn't exist
|
// Anti-enumeration: always redirect to confirm even if user doesn't exist
|
||||||
goto("/auth/confirm");
|
goto(resolve("/auth/confirm"));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : "An unexpected error occurred";
|
error = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||||
@@ -75,7 +76,7 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a href="/auth/signup" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
<a href={resolve("/auth/signup")} class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
||||||
Sign up
|
Sign up
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte";
|
import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte";
|
||||||
import { startAuthentication } from "@simplewebauthn/browser";
|
import { startAuthentication } from "@simplewebauthn/browser";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -43,7 +44,7 @@ async function authenticate(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Success - redirect to confirm for session creation
|
// Success - redirect to confirm for session creation
|
||||||
goto("/auth/confirm");
|
goto(resolve("/auth/confirm"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Authentication failed";
|
error = e instanceof Error ? e.message : "Authentication failed";
|
||||||
hasAttempted = true;
|
hasAttempted = true;
|
||||||
@@ -55,7 +56,7 @@ async function authenticate(): Promise<void> {
|
|||||||
// Guard: redirect to /auth/login if no active login flow
|
// Guard: redirect to /auth/login if no active login flow
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loginFlowState.email) {
|
if (!loginFlowState.email) {
|
||||||
goto("/auth/login");
|
goto(resolve("/auth/login"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Fallback links -->
|
<!-- Fallback links -->
|
||||||
{#if loginFlowState.hasPassword}
|
{#if loginFlowState.hasPassword}
|
||||||
<Button variant="outline" class="h-10 w-full" href="/auth/login/password">
|
<Button variant="outline" class="h-10 w-full" href={resolve("/auth/login/password")}>
|
||||||
Use password instead
|
Use password instead
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -142,7 +143,7 @@ $effect(() => {
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => goto("/auth/login")}
|
onclick={() => goto(resolve("/auth/login"))}
|
||||||
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Use a different email
|
Use a different email
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert, PasswordInput } from "$lib/components/auth";
|
import { ErrorAlert, PasswordInput } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -14,7 +15,7 @@ let error = $state<string | null>(null);
|
|||||||
// Guard: redirect to /auth/login if no active login flow
|
// Guard: redirect to /auth/login if no active login flow
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loginFlowState.email) {
|
if (!loginFlowState.email) {
|
||||||
goto("/auth/login");
|
goto(resolve("/auth/login"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
try {
|
try {
|
||||||
await api.auth.loginPassword({ password });
|
await api.auth.loginPassword({ password });
|
||||||
// On success, redirect to confirm page for email verification
|
// On success, redirect to confirm page for email verification
|
||||||
goto("/auth/confirm");
|
goto(resolve("/auth/confirm"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error =
|
error =
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
@@ -38,7 +39,7 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
|
|
||||||
function handleDifferentEmail() {
|
function handleDifferentEmail() {
|
||||||
clearLoginFlowState();
|
clearLoginFlowState();
|
||||||
goto("/auth/login");
|
goto(resolve("/auth/login"));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ function handleDifferentEmail() {
|
|||||||
<!-- Secondary Links -->
|
<!-- Secondary Links -->
|
||||||
<div class="space-y-3 text-center">
|
<div class="space-y-3 text-center">
|
||||||
<a
|
<a
|
||||||
href="/auth/forgot-password"
|
href={resolve("/auth/forgot-password")}
|
||||||
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
@@ -91,7 +92,7 @@ function handleDifferentEmail() {
|
|||||||
{#if loginFlowState.hasPasskey}
|
{#if loginFlowState.hasPasskey}
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href="/auth/login/passkey"
|
href={resolve("/auth/login/passkey")}
|
||||||
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Use passkey instead
|
Use passkey instead
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlertCircle } from "@lucide/svelte";
|
|||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import zxcvbn from "zxcvbn";
|
import zxcvbn from "zxcvbn";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +57,7 @@ async function handleSubmit(e: Event) {
|
|||||||
toast.success("Password reset successfully", {
|
toast.success("Password reset successfully", {
|
||||||
description: "You can now sign in with your new password.",
|
description: "You can now sign in with your new password.",
|
||||||
});
|
});
|
||||||
await goto("/auth/login");
|
await goto(resolve("/auth/login"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
// Handle specific error cases
|
// Handle specific error cases
|
||||||
@@ -97,7 +98,7 @@ async function handleSubmit(e: Event) {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Button variant="outline" class="h-10 w-full" onclick={() => goto("/auth/forgot-password")}>
|
<Button variant="outline" class="h-10 w-full" onclick={() => goto(resolve("/auth/forgot-password"))}>
|
||||||
Request new reset link
|
Request new reset link
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -147,8 +148,8 @@ async function handleSubmit(e: Event) {
|
|||||||
|
|
||||||
<!-- Back to login link -->
|
<!-- Back to login link -->
|
||||||
<div class="text-center text-sm text-muted-foreground">
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
Remember your password?{" "}
|
Remember your password?
|
||||||
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary">
|
<a href={resolve("/auth/login")} class="text-foreground underline underline-offset-4 hover:text-primary">
|
||||||
Sign in
|
Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlertCircle, Loader2 } from "@lucide/svelte";
|
|||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
@@ -22,7 +23,7 @@ const userQuery = createQuery(() => ({
|
|||||||
// Redirect if user doesn't need setup
|
// Redirect if user doesn't need setup
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (userQuery.data && !userQuery.data.needsSetup) {
|
if (userQuery.data && !userQuery.data.needsSetup) {
|
||||||
goto("/");
|
goto(resolve("/"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ async function handleSubmit(e: Event) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Profile setup complete!");
|
toast.success("Profile setup complete!");
|
||||||
goto("/");
|
goto(resolve("/"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Failed to save profile";
|
error = e instanceof Error ? e.message : "Failed to save profile";
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@simplewebauthn/browser";
|
} from "@simplewebauthn/browser";
|
||||||
import zxcvbn from "zxcvbn";
|
import zxcvbn from "zxcvbn";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import {
|
import {
|
||||||
ErrorAlert,
|
ErrorAlert,
|
||||||
@@ -75,7 +76,7 @@ async function handlePasskeySignup() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to user setup
|
// Redirect to user setup
|
||||||
await goto("/auth/setup/user");
|
await goto(resolve("/auth/setup/user"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
// Handle WebAuthn cancellation
|
// Handle WebAuthn cancellation
|
||||||
@@ -103,7 +104,7 @@ async function handlePasswordSignup() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to user setup
|
// Redirect to user setup
|
||||||
await goto("/auth/setup/user");
|
await goto(resolve("/auth/setup/user"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
@@ -249,8 +250,8 @@ function switchToPasskey() {
|
|||||||
|
|
||||||
<!-- Sign in link -->
|
<!-- Sign in link -->
|
||||||
<div class="text-center text-sm text-muted-foreground">
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
Already have an account?{" "}
|
Already have an account?
|
||||||
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary">
|
<a href={resolve("/auth/login")} class="text-foreground underline underline-offset-4 hover:text-primary">
|
||||||
Sign in
|
Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createQuery } from "@tanstack/svelte-query";
|
|||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { UAParser } from "ua-parser-js";
|
import { UAParser } from "ua-parser-js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -52,7 +53,7 @@ async function handleTrust() {
|
|||||||
try {
|
try {
|
||||||
await api.me.devices.trust({ name: deviceName.trim() });
|
await api.me.devices.trust({ name: deviceName.trim() });
|
||||||
toast.success("Device trusted successfully!");
|
toast.success("Device trusted successfully!");
|
||||||
goto("/");
|
goto(resolve("/"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Failed to trust device";
|
error = e instanceof Error ? e.message : "Failed to trust device";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -61,7 +62,7 @@ async function handleTrust() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSkip() {
|
async function handleSkip() {
|
||||||
goto("/performance");
|
goto(resolve("/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get device icon based on type
|
// Get device icon based on type
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { CheckCircle2, Loader2, Mail, XCircle } from "@lucide/svelte";
|
import { CheckCircle2, Loader2, Mail, XCircle } from "@lucide/svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
@@ -31,7 +32,7 @@ async function verifyEmail(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await api.auth.verifyEmail({ token });
|
await api.auth.verifyEmail({ token });
|
||||||
toast.success("Email verified successfully!");
|
toast.success("Email verified successfully!");
|
||||||
goto("/");
|
goto(resolve("/"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Verification failed";
|
error = e instanceof Error ? e.message : "Verification failed";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -132,7 +133,7 @@ async function resendVerification(): Promise<void> {
|
|||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a
|
<a
|
||||||
href="/auth/login"
|
href={resolve("/auth/login")}
|
||||||
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Back to login
|
Back to login
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -40,7 +41,9 @@ const invitesQuery = createQuery(() => ({
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (orgsQuery.error) {
|
if (orgsQuery.error) {
|
||||||
goto(
|
goto(
|
||||||
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
|
resolve(
|
||||||
|
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,7 +101,7 @@ function formatRole(role: string): string {
|
|||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each invitesQuery.data as invite (invite.id)}
|
{#each invitesQuery.data as invite (invite.id)}
|
||||||
<a
|
<a
|
||||||
href="/account/org-invites/{invite.id}"
|
href={resolve(`/account/org-invites/${invite.id}`)}
|
||||||
class="group block"
|
class="group block"
|
||||||
>
|
>
|
||||||
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
|
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
|
||||||
@@ -183,7 +186,7 @@ function formatRole(role: string): string {
|
|||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each orgsQuery.data as org (org.id)}
|
{#each orgsQuery.data as org (org.id)}
|
||||||
<a
|
<a
|
||||||
href="/dashboard/{org.slug}"
|
href={resolve(`/dashboard/${org.slug}`)}
|
||||||
class="group block transition-transform hover:scale-[1.02]"
|
class="group block transition-transform hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
<Card class="h-full transition-colors group-hover:border-primary/50">
|
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
import { RoleBadge } from "$lib/components/org";
|
import { RoleBadge } from "$lib/components/org";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -86,7 +87,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
|
|||||||
: "Failed to load organization"}
|
: "Failed to load organization"}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href={resolve("/dashboard")}
|
||||||
class="mt-4 text-sm text-primary underline underline-offset-4 hover:text-primary/80"
|
class="mt-4 text-sm text-primary underline underline-offset-4 hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Back to organizations
|
Back to organizations
|
||||||
@@ -117,7 +118,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if canManageOrg}
|
{#if canManageOrg}
|
||||||
<Button variant="outline" href="/dashboard/{slug}/settings">
|
<Button variant="outline" href={resolve(`/dashboard/${slug}/settings`)}>
|
||||||
<Settings class="mr-2 h-4 w-4" />
|
<Settings class="mr-2 h-4 w-4" />
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
@@ -126,7 +127,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
|
|||||||
|
|
||||||
<!-- Stats cards -->
|
<!-- Stats cards -->
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<a href="/dashboard/{slug}/members" class="group">
|
<a href={resolve(`/dashboard/${slug}/members`)} class="group">
|
||||||
<Card class="transition-colors group-hover:border-primary/50">
|
<Card class="transition-colors group-hover:border-primary/50">
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">Members</CardTitle>
|
<CardTitle class="text-sm font-medium">Members</CardTitle>
|
||||||
@@ -163,7 +164,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<CardTitle class="text-base">Team Members</CardTitle>
|
<CardTitle class="text-base">Team Members</CardTitle>
|
||||||
<a
|
<a
|
||||||
href="/dashboard/{slug}/members"
|
href={resolve(`/dashboard/${slug}/members`)}
|
||||||
class="flex items-center text-sm text-primary hover:underline"
|
class="flex items-center text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
|||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -300,7 +300,7 @@ const availableInviteRoles = $derived.by(() => {
|
|||||||
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
|
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each availableInviteRoles as role}
|
{#each availableInviteRoles as role (role)}
|
||||||
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
|
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import FrequentFilters from "$lib/components/dashboard/frequent-filters.svelte";
|
|||||||
import MetricCard from "$lib/components/dashboard/metric-card.svelte";
|
import MetricCard from "$lib/components/dashboard/metric-card.svelte";
|
||||||
import PeakTrafficChart from "$lib/components/dashboard/peak-traffic-chart.svelte";
|
import PeakTrafficChart from "$lib/components/dashboard/peak-traffic-chart.svelte";
|
||||||
import PerformanceTable from "$lib/components/dashboard/performance-table.svelte";
|
import PerformanceTable from "$lib/components/dashboard/performance-table.svelte";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
|
|
||||||
// Get org context (for future filtering by org)
|
// Get org context (for future filtering by org)
|
||||||
const orgContext = getContext<{ slug: string }>("orgContext");
|
const orgContext = getContext<{ slug: string }>("orgContext");
|
||||||
@@ -47,7 +47,7 @@ const metrics = [
|
|||||||
<!-- Metric Cards -->
|
<!-- Metric Cards -->
|
||||||
<section>
|
<section>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{#each metrics as metric}
|
{#each metrics as metric (metric.label)}
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={metric.label}
|
label={metric.label}
|
||||||
value={metric.value}
|
value={metric.value}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { DashboardLayout } from "$lib/components/layout";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
|||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
import { SettingsLayout } from "$lib/components/layout";
|
||||||
import { ConfirmDialog } from "$lib/components/org";
|
import { ConfirmDialog } from "$lib/components/org";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -124,7 +125,7 @@ function handleLeave() {
|
|||||||
await api.orgs.leave({ slug });
|
await api.orgs.leave({ slug });
|
||||||
toast.success("You have left the organization");
|
toast.success("You have left the organization");
|
||||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||||
goto("/dashboard");
|
goto(resolve("/dashboard"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
e instanceof Error ? e.message : "Failed to leave organization",
|
e instanceof Error ? e.message : "Failed to leave organization",
|
||||||
@@ -147,7 +148,7 @@ function handleDelete() {
|
|||||||
await api.orgs.delete({ slug });
|
await api.orgs.delete({ slug });
|
||||||
toast.success("Organization deleted");
|
toast.success("Organization deleted");
|
||||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||||
goto("/dashboard");
|
goto(resolve("/dashboard"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
e instanceof Error ? e.message : "Failed to delete organization",
|
e instanceof Error ? e.message : "Failed to delete organization",
|
||||||
@@ -175,7 +176,7 @@ async function executeConfirmAction() {
|
|||||||
<title>Settings | Publisher Dashboard</title>
|
<title>Settings | Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Organization Settings">
|
<SettingsLayout title="Settings">
|
||||||
{#if isLoading || orgQuery.isPending}
|
{#if isLoading || orgQuery.isPending}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -192,7 +193,7 @@ async function executeConfirmAction() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mx-auto max-w-2xl space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- General Settings (admin+ only) -->
|
<!-- General Settings (admin+ only) -->
|
||||||
{#if canManageOrg}
|
{#if canManageOrg}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -295,18 +296,9 @@ async function executeConfirmAction() {
|
|||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Back link -->
|
|
||||||
<div class="pt-4">
|
|
||||||
<a
|
|
||||||
href="/dashboard/{slug}"
|
|
||||||
class="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← Back to organization
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</DashboardLayout>
|
</SettingsLayout>
|
||||||
|
|
||||||
<!-- Confirmation dialog -->
|
<!-- Confirmation dialog -->
|
||||||
<ConfirmDialog
|
<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 { CheckCircle2, Loader2, UserPlus, XCircle } from "@lucide/svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -45,7 +46,9 @@ async function acceptInvite(): Promise<void> {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Redirect to login with return URL
|
// Redirect to login with return URL
|
||||||
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
|
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
|
||||||
goto(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
|
goto(
|
||||||
|
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ async function acceptInvite(): Promise<void> {
|
|||||||
toast.success("You've joined the organization!");
|
toast.success("You've joined the organization!");
|
||||||
// Redirect to dashboard after a short delay
|
// Redirect to dashboard after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goto("/dashboard");
|
goto(resolve("/dashboard"));
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
@@ -168,13 +171,13 @@ $effect(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button variant="outline" class="h-10 w-full" href="/dashboard">
|
<Button variant="outline" class="h-10 w-full" href={resolve("/dashboard")}>
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a
|
<a
|
||||||
href="/auth/login"
|
href={resolve("/auth/login")}
|
||||||
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Sign in with a different account
|
Sign in with a different account
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
|
||||||
// Redirect old /login route to new /auth/login
|
// Redirect old /login route to new /auth/login
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
goto("/auth/login", { replaceState: true });
|
goto(resolve("/auth/login"), { replaceState: true });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
25
bun.lock
25
bun.lock
@@ -101,9 +101,12 @@
|
|||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
|
"@typescript-eslint/parser": "^8.52.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"svelte": "^5.28.2",
|
"svelte": "^5.28.2",
|
||||||
"svelte-check": "^4.2.1",
|
"svelte-check": "^4.2.1",
|
||||||
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
@@ -619,6 +622,8 @@
|
|||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
@@ -671,6 +676,8 @@
|
|||||||
|
|
||||||
"eslint-config-turbo": ["eslint-config-turbo@2.7.3", "", { "dependencies": { "eslint-plugin-turbo": "2.7.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-1ik3XQLJoE9d9ljhw60wTQf7rlwnz8tc6vnhSL7/Ciep2+qPMJpNg+mapcmGhirfDSceVNI8r9pv+HyvrBXhpQ=="],
|
"eslint-config-turbo": ["eslint-config-turbo@2.7.3", "", { "dependencies": { "eslint-plugin-turbo": "2.7.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-1ik3XQLJoE9d9ljhw60wTQf7rlwnz8tc6vnhSL7/Ciep2+qPMJpNg+mapcmGhirfDSceVNI8r9pv+HyvrBXhpQ=="],
|
||||||
|
|
||||||
|
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.14.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g=="],
|
||||||
|
|
||||||
"eslint-plugin-turbo": ["eslint-plugin-turbo@2.7.3", "", { "dependencies": { "dotenv": "16.0.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-q7kYzJCyvceSLVwHgmn3ZBhqpUihQHxC7LEddq5a1eLe5P+/Ob4TnJrdocP38qO1n9MCuO+cJSUTGUtZb1X3bQ=="],
|
"eslint-plugin-turbo": ["eslint-plugin-turbo@2.7.3", "", { "dependencies": { "dotenv": "16.0.3" }, "peerDependencies": { "eslint": ">6.6.0", "turbo": ">2.0.0" } }, "sha512-q7kYzJCyvceSLVwHgmn3ZBhqpUihQHxC7LEddq5a1eLe5P+/Ob4TnJrdocP38qO1n9MCuO+cJSUTGUtZb1X3bQ=="],
|
||||||
|
|
||||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
@@ -799,6 +806,8 @@
|
|||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
|
||||||
|
|
||||||
"kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="],
|
"kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="],
|
||||||
|
|
||||||
"kysely-codegen": ["kysely-codegen@0.19.0", "", { "dependencies": { "chalk": "4.1.2", "cosmiconfig": "^9.0.0", "dotenv": "^17.2.1", "dotenv-expand": "^12.0.2", "git-diff": "^2.0.6", "micromatch": "^4.0.8", "minimist": "^1.2.8", "pluralize": "^8.0.0", "zod": "^4.1.5" }, "peerDependencies": { "@libsql/kysely-libsql": ">=0.3.0 <0.5.0", "@tediousjs/connection-string": ">=0.5.0 <0.6.0", "better-sqlite3": ">=7.6.2 <13.0.0", "kysely": ">=0.27.0 <1.0.0", "kysely-bun-sqlite": ">=0.3.2 <1.0.0", "kysely-bun-worker": ">=1.2.0 <2.0.0", "mysql2": ">=2.3.3 <4.0.0", "pg": ">=8.8.0 <9.0.0", "tarn": ">=3.0.0 <4.0.0", "tedious": ">=18.0.0 <20.0.0" }, "optionalPeers": ["@libsql/kysely-libsql", "@tediousjs/connection-string", "better-sqlite3", "kysely-bun-sqlite", "kysely-bun-worker", "mysql2", "pg", "tarn", "tedious"], "bin": { "kysely-codegen": "dist/cli/bin.js" } }, "sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w=="],
|
"kysely-codegen": ["kysely-codegen@0.19.0", "", { "dependencies": { "chalk": "4.1.2", "cosmiconfig": "^9.0.0", "dotenv": "^17.2.1", "dotenv-expand": "^12.0.2", "git-diff": "^2.0.6", "micromatch": "^4.0.8", "minimist": "^1.2.8", "pluralize": "^8.0.0", "zod": "^4.1.5" }, "peerDependencies": { "@libsql/kysely-libsql": ">=0.3.0 <0.5.0", "@tediousjs/connection-string": ">=0.5.0 <0.6.0", "better-sqlite3": ">=7.6.2 <13.0.0", "kysely": ">=0.27.0 <1.0.0", "kysely-bun-sqlite": ">=0.3.2 <1.0.0", "kysely-bun-worker": ">=1.2.0 <2.0.0", "mysql2": ">=2.3.3 <4.0.0", "pg": ">=8.8.0 <9.0.0", "tarn": ">=3.0.0 <4.0.0", "tedious": ">=18.0.0 <20.0.0" }, "optionalPeers": ["@libsql/kysely-libsql", "@tediousjs/connection-string", "better-sqlite3", "kysely-bun-sqlite", "kysely-bun-worker", "mysql2", "pg", "tarn", "tedious"], "bin": { "kysely-codegen": "dist/cli/bin.js" } }, "sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w=="],
|
||||||
@@ -831,6 +840,8 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
@@ -929,6 +940,14 @@
|
|||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
||||||
|
|
||||||
|
"postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
|
||||||
|
|
||||||
|
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||||
|
|
||||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||||
@@ -1011,6 +1030,8 @@
|
|||||||
|
|
||||||
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
||||||
|
|
||||||
|
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
|
||||||
|
|
||||||
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
|
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
|
||||||
|
|
||||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||||
@@ -1075,6 +1096,8 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||||
@@ -1087,6 +1110,8 @@
|
|||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
\restrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR
|
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
||||||
|
|
||||||
-- Dumped from database version 17.7
|
-- Dumped from database version 17.7
|
||||||
-- Dumped by pg_dump version 17.7
|
-- Dumped by pg_dump version 17.7
|
||||||
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
|
|||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
||||||
\unrestrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR
|
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# Use tea 0.10.1 to avoid TTY bug in 0.11.x
|
||||||
|
# See: https://gitea.com/gitea/tea/issues/827
|
||||||
|
tea = pkgs.callPackage ./nix/tea.nix { };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
nixfmt-rfc-style
|
nixfmt-rfc-style
|
||||||
biome
|
biome
|
||||||
git
|
git
|
||||||
|
tea
|
||||||
dbmate
|
dbmate
|
||||||
ast-grep
|
ast-grep
|
||||||
|
dbip-city-lite
|
||||||
];
|
];
|
||||||
|
|
||||||
dotenv.enable = true;
|
dotenv.enable = true;
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
env = {
|
env = {
|
||||||
DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard?sslmode=disable";
|
DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard?sslmode=disable";
|
||||||
TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test?sslmode=disable";
|
TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test?sslmode=disable";
|
||||||
|
GEOIP_DATABASE_PATH = "${pkgs.dbip-city-lite}/share/dbip/dbip-city-lite.mmdb";
|
||||||
};
|
};
|
||||||
|
|
||||||
scripts = {
|
scripts = {
|
||||||
|
|||||||
@@ -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,
|
adminUpdateUserInputSchema,
|
||||||
} from "./schemas/admin.js";
|
} from "./schemas/admin.js";
|
||||||
import {
|
import {
|
||||||
|
apiTokenOutputSchema,
|
||||||
|
createApiTokenInputSchema,
|
||||||
|
createApiTokenOutputSchema,
|
||||||
forgotPasswordInputSchema,
|
forgotPasswordInputSchema,
|
||||||
loginPasswordInputSchema,
|
loginPasswordInputSchema,
|
||||||
loginRequestInputSchema,
|
loginRequestInputSchema,
|
||||||
@@ -181,6 +184,17 @@ export const contract = oc.router({
|
|||||||
.output(successResponseSchema),
|
.output(successResponseSchema),
|
||||||
revokeAll: oc.output(successResponseSchema),
|
revokeAll: oc.output(successResponseSchema),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// API tokens for CLI/programmatic access
|
||||||
|
apiTokens: oc.router({
|
||||||
|
list: oc.output(z.array(apiTokenOutputSchema)),
|
||||||
|
create: oc
|
||||||
|
.input(createApiTokenInputSchema)
|
||||||
|
.output(createApiTokenOutputSchema),
|
||||||
|
delete: oc
|
||||||
|
.input(z.object({ tokenId: z.number() }))
|
||||||
|
.output(successResponseSchema),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
orgs: oc.router({
|
orgs: oc.router({
|
||||||
|
|||||||
@@ -81,3 +81,31 @@ export const resetPasswordInputSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
newPassword: z.string().min(8),
|
newPassword: z.string().min(8),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API token creation input schema
|
||||||
|
* Creates an API token for CLI/programmatic access
|
||||||
|
*/
|
||||||
|
export const createApiTokenInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API token creation output schema
|
||||||
|
* Returns the token (only shown once)
|
||||||
|
*/
|
||||||
|
export const createApiTokenOutputSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API token output schema for listing tokens
|
||||||
|
*/
|
||||||
|
export const apiTokenOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
lastUsedAt: z.string().nullable(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
Reference in New Issue
Block a user