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
@@ -19,39 +19,30 @@ import { hashToken } from "../../utils/crypto.js";
|
|||||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
|
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
|
||||||
import {
|
import {
|
||||||
createTestDb,
|
|
||||||
createTestUser,
|
createTestUser,
|
||||||
destroyTestDb,
|
destroySharedDb,
|
||||||
runMigrations,
|
getSharedDb,
|
||||||
truncateAllTables,
|
initTestDb,
|
||||||
} from "../helpers/test-db.js";
|
} from "../helpers/test-db.js";
|
||||||
|
import { withTestTransaction } from "../helpers/test-transaction.js";
|
||||||
|
|
||||||
/** Session expiry duration: 24 hours in milliseconds */
|
/** Session expiry duration: 24 hours in milliseconds */
|
||||||
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
let db: Kysely<Database> | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the database connection, throwing if not initialized
|
|
||||||
*/
|
|
||||||
function getDb(): Kysely<Database> {
|
|
||||||
if (!db) {
|
|
||||||
throw new Error("Database not initialized");
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an API context with optional session token
|
* Create an API context with optional session token
|
||||||
*/
|
*/
|
||||||
function createAPIContext(sessionToken?: string): APIContext {
|
function createAPIContext(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
sessionToken?: string,
|
||||||
|
): APIContext {
|
||||||
const reqHeaders = new Headers();
|
const reqHeaders = new Headers();
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`);
|
reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: getDb(),
|
db,
|
||||||
origin: TEST_RP.origin,
|
origin: TEST_RP.origin,
|
||||||
allowedOrigins: [...TEST_RP.allowedOrigins],
|
allowedOrigins: [...TEST_RP.allowedOrigins],
|
||||||
rpName: TEST_RP.rpName,
|
rpName: TEST_RP.rpName,
|
||||||
@@ -63,12 +54,15 @@ function createAPIContext(sessionToken?: string): APIContext {
|
|||||||
/**
|
/**
|
||||||
* Create a real session in the database and return the token
|
* Create a real session in the database and return the token
|
||||||
*/
|
*/
|
||||||
async function createSession(userId: number): Promise<string> {
|
async function createSession(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
userId: number,
|
||||||
|
): Promise<string> {
|
||||||
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
|
||||||
const tokenHashValue = await hashToken(token);
|
const tokenHashValue = await hashToken(token);
|
||||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||||
|
|
||||||
await getDb()
|
await db
|
||||||
.insertInto("sessions")
|
.insertInto("sessions")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -87,13 +81,14 @@ async function createSession(userId: number): Promise<string> {
|
|||||||
* Create a login request in the database and return ID and token
|
* Create a login request in the database and return ID and token
|
||||||
*/
|
*/
|
||||||
async function createLoginRequest(
|
async function createLoginRequest(
|
||||||
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<{ id: number; token: string }> {
|
): Promise<{ id: number; token: string }> {
|
||||||
const token = `test-login-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-login-${String(Date.now())}${String(Math.random())}`;
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||||
|
|
||||||
const result = await getDb()
|
const result = await db
|
||||||
.insertInto("login_requests")
|
.insertInto("login_requests")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -110,20 +105,26 @@ async function createLoginRequest(
|
|||||||
/**
|
/**
|
||||||
* Create an authenticated API context for a user (creates session + context)
|
* Create an authenticated API context for a user (creates session + context)
|
||||||
*/
|
*/
|
||||||
async function createUserAPIContext(userId: number): Promise<APIContext> {
|
async function createUserAPIContext(
|
||||||
const sessionToken = await createSession(userId);
|
db: Kysely<Database>,
|
||||||
return createAPIContext(sessionToken);
|
userId: number,
|
||||||
|
): Promise<APIContext> {
|
||||||
|
const sessionToken = await createSession(db, userId);
|
||||||
|
return createAPIContext(db, sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an API context with login request cookie
|
* Create an API context with login request cookie
|
||||||
*/
|
*/
|
||||||
function createLoginRequestContext(loginToken: string): APIContext {
|
function createLoginRequestContext(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
loginToken: string,
|
||||||
|
): APIContext {
|
||||||
const reqHeaders = new Headers();
|
const reqHeaders = new Headers();
|
||||||
reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`);
|
reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: getDb(),
|
db,
|
||||||
origin: TEST_RP.origin,
|
origin: TEST_RP.origin,
|
||||||
allowedOrigins: [...TEST_RP.allowedOrigins],
|
allowedOrigins: [...TEST_RP.allowedOrigins],
|
||||||
rpName: TEST_RP.rpName,
|
rpName: TEST_RP.rpName,
|
||||||
@@ -149,12 +150,13 @@ function expectFirst<T>(arr: T[], message: string): T {
|
|||||||
* Shared helper to avoid duplication across test suites.
|
* Shared helper to avoid duplication across test suites.
|
||||||
*/
|
*/
|
||||||
async function registerPasskey(
|
async function registerPasskey(
|
||||||
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
authenticator: VirtualAuthenticator,
|
authenticator: VirtualAuthenticator,
|
||||||
) {
|
) {
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const authCtx = await createUserAPIContext(userId);
|
const authCtx = await createUserAPIContext(db, userId);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
@@ -175,12 +177,13 @@ async function registerPasskey(
|
|||||||
* Shared helper to avoid duplication across test suites.
|
* Shared helper to avoid duplication across test suites.
|
||||||
*/
|
*/
|
||||||
async function authenticate(
|
async function authenticate(
|
||||||
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
authenticator: VirtualAuthenticator,
|
authenticator: VirtualAuthenticator,
|
||||||
) {
|
) {
|
||||||
const { token: loginToken } = await createLoginRequest(userId, email);
|
const { token: loginToken } = await createLoginRequest(db, userId, email);
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
@@ -196,27 +199,20 @@ async function authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Run migrations and create test database connection
|
await initTestDb();
|
||||||
await runMigrations();
|
|
||||||
db = createTestDb();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (db) {
|
await destroySharedDb();
|
||||||
await destroyTestDb(db);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("registration flow", () => {
|
describe("registration flow", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates registration options with challenge stored in DB via router", async () => {
|
test("creates registration options with challenge stored in DB via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "reg-options@test.com",
|
email: "reg-options@test.com",
|
||||||
});
|
});
|
||||||
const ctx = createAPIContext();
|
const ctx = createAPIContext(db);
|
||||||
|
|
||||||
// Call router handler directly
|
// Call router handler directly
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
@@ -235,16 +231,18 @@ describe("registration flow", () => {
|
|||||||
|
|
||||||
// Verify challenge is stored in database
|
// Verify challenge is stored in database
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
?.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", String(challengeId))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeDefined();
|
expect(challengeRow).toBeDefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("verifies valid registration and stores passkey via router", async () => {
|
test("verifies valid registration and stores passkey via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "reg-verify@test.com",
|
email: "reg-verify@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({
|
const authenticator = new VirtualAuthenticator({
|
||||||
@@ -252,7 +250,7 @@ describe("registration flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create registration options via router
|
// Create registration options via router
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
{ email: user.email },
|
{ email: user.email },
|
||||||
@@ -263,7 +261,7 @@ describe("registration flow", () => {
|
|||||||
const response = authenticator.createCredential(options);
|
const response = authenticator.createCredential(options);
|
||||||
|
|
||||||
// Verify registration via router (requires authenticated session)
|
// Verify registration via router (requires authenticated session)
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
await call(
|
await call(
|
||||||
router.auth.webauthn.verifyRegistration,
|
router.auth.webauthn.verifyRegistration,
|
||||||
{ challengeId, response },
|
{ challengeId, response },
|
||||||
@@ -271,20 +269,24 @@ describe("registration flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify passkey is stored in database
|
// Verify passkey is stored in database
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.rpid).toBe(TEST_RP.rpID);
|
expect(firstPasskey.rpid).toBe(TEST_RP.rpID);
|
||||||
expect(firstPasskey.counter).toBe(0);
|
expect(firstPasskey.counter).toBe(0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("excludes existing passkeys for returning users via router", async () => {
|
test("excludes existing passkeys for returning users via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "exclude-test@test.com",
|
email: "exclude-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
const apiCtx = createAPIContext();
|
origin: TEST_RP.origin,
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
});
|
||||||
|
const apiCtx = createAPIContext(db);
|
||||||
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
// Register first passkey via router
|
// Register first passkey via router
|
||||||
const { options: options1, challengeId: challengeId1 } = await call(
|
const { options: options1, challengeId: challengeId1 } = await call(
|
||||||
@@ -314,9 +316,11 @@ describe("registration flow", () => {
|
|||||||
);
|
);
|
||||||
expect(excludedCred.id).toBe(response1.id);
|
expect(excludedCred.id).toBe(response1.id);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("assigns friendly name from known AAGUID via router", async () => {
|
test("assigns friendly name from known AAGUID via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "aaguid-test@test.com",
|
email: "aaguid-test@test.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,8 +330,8 @@ describe("registration flow", () => {
|
|||||||
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
|
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
@@ -341,19 +345,23 @@ describe("registration flow", () => {
|
|||||||
{ context: authCtx },
|
{ context: authCtx },
|
||||||
);
|
);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.name).toBe("iCloud Keychain");
|
expect(firstPasskey.name).toBe("iCloud Keychain");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("cleans up challenge after verification via router", async () => {
|
test("cleans up challenge after verification via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "cleanup-test@test.com",
|
email: "cleanup-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
const apiCtx = createAPIContext();
|
origin: TEST_RP.origin,
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
});
|
||||||
|
const apiCtx = createAPIContext(db);
|
||||||
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
@@ -369,21 +377,25 @@ describe("registration flow", () => {
|
|||||||
|
|
||||||
// Challenge should be deleted
|
// Challenge should be deleted
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
?.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", String(challengeId))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeUndefined();
|
expect(challengeRow).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects expired/missing challenges via router", async () => {
|
test("rejects expired/missing challenges via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "expired-test@test.com",
|
email: "expired-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
const apiCtx = createAPIContext();
|
origin: TEST_RP.origin,
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
});
|
||||||
|
const apiCtx = createAPIContext(db);
|
||||||
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
// Create options via router
|
// Create options via router
|
||||||
const { options } = await call(
|
const { options } = await call(
|
||||||
@@ -407,28 +419,33 @@ describe("registration flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("authentication flow", () => {
|
describe("authentication flow", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates authentication options with user's passkeys via router", async () => {
|
test("creates authentication options with user's passkeys via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "auth-options@test.com",
|
email: "auth-options@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register a passkey first via router
|
// Register a passkey first via router
|
||||||
const regResponse = await registerPasskey(
|
const regResponse = await registerPasskey(
|
||||||
|
db,
|
||||||
user.id,
|
user.id,
|
||||||
user.email,
|
user.email,
|
||||||
authenticator,
|
authenticator,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create authentication options via router
|
// Create authentication options via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -445,19 +462,27 @@ describe("authentication flow", () => {
|
|||||||
expect(allowedCred.id).toBe(regResponse.id);
|
expect(allowedCred.id).toBe(regResponse.id);
|
||||||
expect(challengeId).toBeGreaterThan(0);
|
expect(challengeId).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("verifies valid authentication and updates counter via router", async () => {
|
test("verifies valid authentication and updates counter via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "auth-verify@test.com",
|
email: "auth-verify@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Authenticate via router
|
// Authenticate via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -472,26 +497,34 @@ describe("authentication flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify counter was updated
|
// Verify counter was updated
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(1);
|
expect(firstPasskey.counter).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("updates last_used_at timestamp via router", async () => {
|
test("updates last_used_at timestamp via router", async () => {
|
||||||
const user = await createTestUser(getDb(), { email: "last-used@test.com" });
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const user = await createTestUser(db, { email: "last-used@test.com" });
|
||||||
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Check initial state
|
// Check initial state
|
||||||
let passkeys = await getUserPasskeys(getDb(), user.id);
|
let passkeys = await getUserPasskeys(db, user.id);
|
||||||
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.lastUsedAt).toBeNull();
|
expect(firstPasskey.lastUsedAt).toBeNull();
|
||||||
|
|
||||||
// Authenticate via router
|
// Authenticate via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -505,23 +538,31 @@ describe("authentication flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check last_used_at is now set
|
// Check last_used_at is now set
|
||||||
passkeys = await getUserPasskeys(getDb(), user.id);
|
passkeys = await getUserPasskeys(db, user.id);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.lastUsedAt).not.toBeNull();
|
expect(firstPasskey.lastUsedAt).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("cleans up challenge after authentication via router", async () => {
|
test("cleans up challenge after authentication via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "auth-cleanup@test.com",
|
email: "auth-cleanup@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Authenticate via router
|
// Authenticate via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -536,26 +577,34 @@ describe("authentication flow", () => {
|
|||||||
|
|
||||||
// Challenge should be deleted
|
// Challenge should be deleted
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
?.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(authChallengeId))
|
.where("id", "=", String(authChallengeId))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeUndefined();
|
expect(challengeRow).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects unknown credential IDs", async () => {
|
test("rejects unknown credential IDs", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "unknown-cred@test.com",
|
email: "unknown-cred@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Create auth options via router
|
// Create auth options via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions } = await call(
|
const { options: authOptions } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -567,7 +616,7 @@ describe("authentication flow", () => {
|
|||||||
origin: TEST_RP.origin,
|
origin: TEST_RP.origin,
|
||||||
});
|
});
|
||||||
// First create a credential so the authenticator has something (use same registration options)
|
// First create a credential so the authenticator has something (use same registration options)
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const { options: regOptions } = await call(
|
const { options: regOptions } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
{ email: user.email },
|
{ email: user.email },
|
||||||
@@ -587,30 +636,31 @@ describe("authentication flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("security tests", () => {
|
describe("security tests", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects replayed credentials (counter check) via router", async () => {
|
test("rejects replayed credentials (counter check) via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "counter-replay@test.com",
|
email: "counter-replay@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
const regResponse = await registerPasskey(
|
const regResponse = await registerPasskey(
|
||||||
|
db,
|
||||||
user.id,
|
user.id,
|
||||||
user.email,
|
user.email,
|
||||||
authenticator,
|
authenticator,
|
||||||
);
|
);
|
||||||
|
|
||||||
// First authentication should succeed
|
// First authentication should succeed
|
||||||
await authenticate(user.id, user.email, authenticator);
|
await authenticate(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Verify counter was updated to 1
|
// Verify counter was updated to 1
|
||||||
let passkeys = await getUserPasskeys(getDb(), user.id);
|
let passkeys = await getUserPasskeys(db, user.id);
|
||||||
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(1);
|
expect(firstPasskey.counter).toBe(1);
|
||||||
|
|
||||||
@@ -618,8 +668,12 @@ describe("security tests", () => {
|
|||||||
authenticator.setSignCount(regResponse.id, 0);
|
authenticator.setSignCount(regResponse.id, 0);
|
||||||
|
|
||||||
// Create a new authentication challenge
|
// Create a new authentication challenge
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -643,23 +697,31 @@ describe("security tests", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Counter should not have changed
|
// Counter should not have changed
|
||||||
passkeys = await getUserPasskeys(getDb(), user.id);
|
passkeys = await getUserPasskeys(db, user.id);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(1);
|
expect(firstPasskey.counter).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects tampered authentication response", async () => {
|
test("rejects tampered authentication response", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "tampered-response@test.com",
|
email: "tampered-response@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Create authentication challenge
|
// Create authentication challenge
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -710,70 +772,74 @@ describe("security tests", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("full passkey lifecycle", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("full passkey lifecycle", () => {
|
||||||
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
|
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
|
||||||
const user = await createTestUser(getDb(), { email: "lifecycle@test.com" });
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
const authenticator1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const user = await createTestUser(db, { email: "lifecycle@test.com" });
|
||||||
const authenticator2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator1 = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
const authenticator2 = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register first passkey via router
|
// Register first passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator1);
|
await registerPasskey(db, user.id, user.email, authenticator1);
|
||||||
|
|
||||||
// Authenticate with first passkey via router
|
// Authenticate with first passkey via router
|
||||||
await authenticate(user.id, user.email, authenticator1);
|
await authenticate(db, user.id, user.email, authenticator1);
|
||||||
|
|
||||||
// Register second passkey via router
|
// Register second passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator2);
|
await registerPasskey(db, user.id, user.email, authenticator2);
|
||||||
|
|
||||||
// Verify user now has 2 passkeys
|
// Verify user now has 2 passkeys
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(2);
|
expect(passkeys).toHaveLength(2);
|
||||||
|
|
||||||
// Authenticate with second passkey via router
|
// Authenticate with second passkey via router
|
||||||
await authenticate(user.id, user.email, authenticator2);
|
await authenticate(db, user.id, user.email, authenticator2);
|
||||||
|
|
||||||
// Authenticate with first passkey again via router
|
// Authenticate with first passkey again via router
|
||||||
await authenticate(user.id, user.email, authenticator1);
|
await authenticate(db, user.id, user.email, authenticator1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("register → authenticate multiple times → counter increments via router", async () => {
|
test("register → authenticate multiple times → counter increments via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "counter-test@test.com",
|
email: "counter-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Verify initial counter
|
// Verify initial counter
|
||||||
let passkeys = await getUserPasskeys(getDb(), user.id);
|
let passkeys = await getUserPasskeys(db, user.id);
|
||||||
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(0);
|
expect(firstPasskey.counter).toBe(0);
|
||||||
|
|
||||||
// Authenticate 5 times via router
|
// Authenticate 5 times via router
|
||||||
for (let i = 1; i <= 5; i++) {
|
for (let i = 1; i <= 5; i++) {
|
||||||
await authenticate(user.id, user.email, authenticator);
|
await authenticate(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Verify counter incremented
|
// Verify counter incremented
|
||||||
passkeys = await getUserPasskeys(getDb(), user.id);
|
passkeys = await getUserPasskeys(db, user.id);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(i);
|
expect(firstPasskey.counter).toBe(i);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("passkey management", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("passkey management", () => {
|
||||||
test("lists passkeys with correct data via router", async () => {
|
test("lists passkeys with correct data via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "list-passkeys@test.com",
|
email: "list-passkeys@test.com",
|
||||||
});
|
});
|
||||||
const authenticator1 = new VirtualAuthenticator({
|
const authenticator1 = new VirtualAuthenticator({
|
||||||
@@ -786,11 +852,11 @@ describe("passkey management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register two passkeys
|
// Register two passkeys
|
||||||
await registerPasskey(user.id, user.email, authenticator1);
|
await registerPasskey(db, user.id, user.email, authenticator1);
|
||||||
await registerPasskey(user.id, user.email, authenticator2);
|
await registerPasskey(db, user.id, user.email, authenticator2);
|
||||||
|
|
||||||
// List passkeys via router handler
|
// List passkeys via router handler
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
const passkeys = await call(router.me.passkeys.list, undefined, {
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -814,16 +880,20 @@ describe("passkey management", () => {
|
|||||||
throw new Error("Expected Google Password Manager passkey to exist");
|
throw new Error("Expected Google Password Manager passkey to exist");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("passkey stores correct device type and backup status", async () => {
|
test("passkey stores correct device type and backup status", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "device-type@test.com",
|
email: "device-type@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
|
|
||||||
@@ -832,16 +902,20 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.backupEligible).toBe(false);
|
expect(firstPasskey.backupEligible).toBe(false);
|
||||||
expect(firstPasskey.backupStatus).toBe(false);
|
expect(firstPasskey.backupStatus).toBe(false);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("renames passkey successfully via router", async () => {
|
test("renames passkey successfully via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "rename-test@test.com",
|
email: "rename-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
let passkeys = await call(router.me.passkeys.list, undefined, {
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -858,27 +932,31 @@ describe("passkey management", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify name changed
|
// Verify name changed
|
||||||
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx });
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.name).toBe(newName);
|
expect(firstPasskey.name).toBe(newName);
|
||||||
expect(firstPasskey.name).not.toBe(originalName);
|
expect(firstPasskey.name).not.toBe(originalName);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rename does not affect other user's passkeys", async () => {
|
test("rename does not affect other user's passkeys", async () => {
|
||||||
const user1 = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user1 = await createTestUser(db, {
|
||||||
email: "rename-user1@test.com",
|
email: "rename-user1@test.com",
|
||||||
});
|
});
|
||||||
const user2 = await createTestUser(getDb(), {
|
const user2 = await createTestUser(db, {
|
||||||
email: "rename-user2@test.com",
|
email: "rename-user2@test.com",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user1.id, user1.email, auth1);
|
await registerPasskey(db, user1.id, user1.email, auth1);
|
||||||
await registerPasskey(user2.id, user2.email, auth2);
|
await registerPasskey(db, user2.id, user2.email, auth2);
|
||||||
|
|
||||||
const ctx1 = await createUserAPIContext(user1.id);
|
const ctx1 = await createUserAPIContext(db, user1.id);
|
||||||
const ctx2 = await createUserAPIContext(user2.id);
|
const ctx2 = await createUserAPIContext(db, user2.id);
|
||||||
|
|
||||||
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
@@ -902,26 +980,34 @@ describe("passkey management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User2's passkey should be unchanged
|
// User2's passkey should be unchanged
|
||||||
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, {
|
const user2PasskeysAfter = await call(
|
||||||
|
router.me.passkeys.list,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const user2FirstPasskeyAfter = user2PasskeysAfter[0];
|
const user2FirstPasskeyAfter = user2PasskeysAfter[0];
|
||||||
if (!user2FirstPasskeyAfter) {
|
if (!user2FirstPasskeyAfter) {
|
||||||
throw new Error("Expected user2 passkey to exist after");
|
throw new Error("Expected user2 passkey to exist after");
|
||||||
}
|
}
|
||||||
expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name);
|
expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("deletes passkey when user has password via router", async () => {
|
test("deletes passkey when user has password via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "delete-with-password@test.com",
|
email: "delete-with-password@test.com",
|
||||||
passwordHash: "fake-password-hash",
|
passwordHash: "fake-password-hash",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
let passkeys = await call(router.me.passkeys.list, undefined, {
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -937,17 +1023,20 @@ describe("passkey management", () => {
|
|||||||
expect(passkeys).toHaveLength(0);
|
expect(passkeys).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("deletes passkey when user has multiple passkeys via router", async () => {
|
test("deletes passkey when user has multiple passkeys via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "delete-multi@test.com",
|
email: "delete-multi@test.com",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, auth1);
|
await registerPasskey(db, user.id, user.email, auth1);
|
||||||
await registerPasskey(user.id, user.email, auth2);
|
await registerPasskey(db, user.id, user.email, auth2);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
let passkeys = await call(router.me.passkeys.list, undefined, {
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -969,16 +1058,19 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("prevents deleting last passkey without password via router", async () => {
|
test("prevents deleting last passkey without password via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "delete-last@test.com",
|
email: "delete-last@test.com",
|
||||||
// No password set
|
// No password set
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
const passkeys = await call(router.me.passkeys.list, undefined, {
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -1004,23 +1096,26 @@ describe("passkey management", () => {
|
|||||||
expect(passkeysAfter).toHaveLength(1);
|
expect(passkeysAfter).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("delete does not affect other user's passkeys via router", async () => {
|
test("delete does not affect other user's passkeys via router", async () => {
|
||||||
const user1 = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user1 = await createTestUser(db, {
|
||||||
email: "delete-user1@test.com",
|
email: "delete-user1@test.com",
|
||||||
passwordHash: "fake-hash",
|
passwordHash: "fake-hash",
|
||||||
});
|
});
|
||||||
const user2 = await createTestUser(getDb(), {
|
const user2 = await createTestUser(db, {
|
||||||
email: "delete-user2@test.com",
|
email: "delete-user2@test.com",
|
||||||
passwordHash: "fake-hash",
|
passwordHash: "fake-hash",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user1.id, user1.email, auth1);
|
await registerPasskey(db, user1.id, user1.email, auth1);
|
||||||
await registerPasskey(user2.id, user2.email, auth2);
|
await registerPasskey(db, user2.id, user2.email, auth2);
|
||||||
|
|
||||||
const ctx1 = await createUserAPIContext(user1.id);
|
const ctx1 = await createUserAPIContext(db, user1.id);
|
||||||
const ctx2 = await createUserAPIContext(user2.id);
|
const ctx2 = await createUserAPIContext(db, user2.id);
|
||||||
|
|
||||||
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
@@ -1051,16 +1146,17 @@ describe("passkey management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("passkey credentialId is unique and stored correctly", async () => {
|
test("passkey credentialId is unique and stored correctly", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "credential-id@test.com",
|
email: "credential-id@test.com",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, auth1);
|
await registerPasskey(db, user.id, user.email, auth1);
|
||||||
await registerPasskey(user.id, user.email, auth2);
|
await registerPasskey(db, user.id, user.email, auth2);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(2);
|
expect(passkeys).toHaveLength(2);
|
||||||
const firstPasskey = passkeys[0];
|
const firstPasskey = passkeys[0];
|
||||||
const secondPasskey = passkeys[1];
|
const secondPasskey = passkeys[1];
|
||||||
@@ -1075,16 +1171,20 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("passkey transports are stored and retrieved correctly", async () => {
|
test("passkey transports are stored and retrieved correctly", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "transports@test.com",
|
email: "transports@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
|
|
||||||
@@ -1093,3 +1193,4 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.transports).toContain("hybrid");
|
expect(firstPasskey.transports).toContain("hybrid");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(`\nIf the browser doesn't open, visit:`);
|
|
||||||
console.log(` ${loginUrl.toString()}\n`);
|
|
||||||
|
|
||||||
// Try to open the browser
|
|
||||||
const openCommand =
|
|
||||||
process.platform === "darwin"
|
|
||||||
? "open"
|
|
||||||
: process.platform === "win32"
|
|
||||||
? "start"
|
|
||||||
: "xdg-open";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
});
|
});
|
||||||
await proc.exited;
|
|
||||||
} catch {
|
|
||||||
// Ignore errors opening browser - user can use the URL
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Waiting for login to complete...");
|
console.log(`Logged in as ${authStatus.user.email}`);
|
||||||
console.log("(Press Ctrl+C to cancel)\n");
|
console.log("Credentials saved to ~/.config/reviq/credentials.json");
|
||||||
|
|
||||||
// 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,11 +10,34 @@ 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>;
|
||||||
|
|
||||||
|
export function createApiClient(
|
||||||
|
apiUrl?: string,
|
||||||
|
token?: string,
|
||||||
|
): ApiClient | Promise<ApiClient> {
|
||||||
|
// If both arguments are provided, create client directly
|
||||||
|
if (apiUrl !== undefined && token !== undefined) {
|
||||||
|
const link = new RPCLink({
|
||||||
|
url: `${apiUrl}/api/v1/rpc`,
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createORPCClient(link) as unknown as ApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, read from config asynchronously
|
||||||
|
return (async (): Promise<ApiClient> => {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -29,6 +52,6 @@ export const createApiClient = async (): Promise<ApiClient> => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cast to ApiClient for type-safe API calls
|
|
||||||
return createORPCClient(link) as unknown as ApiClient;
|
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>
|
||||||
|
|
||||||
|
<!-- Bottom section -->
|
||||||
|
<div class="flex flex-col items-center gap-3 pb-6">
|
||||||
|
<!-- Settings (only in org context) -->
|
||||||
|
{#if currentSlug}
|
||||||
|
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
|
||||||
|
<a
|
||||||
|
href={resolve(`/dashboard/${currentSlug}/settings`)}
|
||||||
|
class={cn(
|
||||||
|
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||||
|
isSettingsActive
|
||||||
|
? "bg-sidebar-accent text-sidebar-foreground"
|
||||||
|
: "text-sidebar-muted hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||||
|
)}
|
||||||
|
aria-label="Settings"
|
||||||
|
aria-current={isSettingsActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Settings class="h-4 w-4" />
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
<div class="flex h-[80px] items-center justify-center">
|
|
||||||
<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">
|
|
||||||
<AccountNav />
|
|
||||||
|
|
||||||
<div class="max-w-2xl">
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</AccountSettingsLayout>
|
||||||
</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