feat: session manager

This commit is contained in:
Edqe14 2026-05-15 03:50:46 +07:00
parent 8a84a660b3
commit 0aef5bca86
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
52 changed files with 4224 additions and 1100 deletions

View file

@ -67,3 +67,46 @@ Update docs when adding endpoints, changing the schema, or changing security beh
**Database** — singleton `db` exported from `src/db/index.ts`, schema defined in `src/db/schema.ts`, migrations output to `./drizzle/`. DB credentials read from `DATABASE_URL` in `.env.local` (takes priority) or `.env`.
**UI components** — shadcn "new-york" style, zinc base color, CSS variables enabled. Components install to `#/components/ui`. Use `cn()` from `#/lib/utils` for className merging.
## Code Style
### Domain actions
All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`).
| File | Domain |
|------|--------|
| `actions/apps.ts` | App CRUD |
| `actions/api-keys.ts` | API key lifecycle |
| `actions/sessions.ts` | Session lifecycle |
| `actions/auth.ts` | User lookup, TOTP |
| `actions/audit-log.ts` | Audit log queries |
**Scope pattern** — when a function needs different authorization checks (user-owned vs app-scoped), use a discriminated union scope parameter instead of separate functions:
```ts
type Scope = { appId: string } | { userId: string }
export async function findSession(sessionId: string, scope: Scope): Promise<Session | null>
```
**Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
```ts
export async function createSession(appId: string, via: 'qr' | 'pairing', ownerUserId?: string)
// admin: createSession(appId, via, sess.userId)
// api key: createSession(appId, via)
```
**Result type** — actions that can fail return a discriminated union, not thrown errors:
```ts
return { ok: false, error: 'App not found', status: 404 }
return { ok: true, session }
// route: if (!result.ok) return jsonError(result.error, result.status)
```
**Audit context** — actions that write audit logs accept an optional `audit?: { ip?: string; userAgent?: string }` param. Routes extract these from request headers and pass them through.
### Route handlers
Routes handle: auth check → body parse → input validation → call action → map to HTTP response. No DB calls, no business logic inline.

147
bun.lock
View file

@ -29,6 +29,7 @@
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@ -223,6 +224,14 @@
"@faker-js/faker": ["@faker-js/faker@10.4.0", "", {}, "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hapi/boom": ["@hapi/boom@9.1.4", "", { "dependencies": { "@hapi/hoek": "9.x.x" } }, "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw=="],
"@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="],
@ -385,6 +394,126 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
@ -663,6 +792,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@ -775,6 +906,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
@ -877,6 +1010,8 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@ -1143,6 +1278,8 @@
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"rate-limiter-flexible": ["rate-limiter-flexible@11.1.0", "", {}, "sha512-lyyC0SqKz+dE5JoHZ4JMqdrM3LSZKBxzuAFAyKCYAnmHnPz/Rb6iDquxoL4CMipDXoR0G+QRhOzYWL3JKihbNw=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
@ -1151,6 +1288,12 @@
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
@ -1301,6 +1444,10 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],

View file

@ -44,6 +44,7 @@
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View file

@ -0,0 +1,213 @@
import { Link, useLocation, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
LayoutGrid, Zap, ScrollText, LogOut, MessageSquare,
ChevronLeft, Key,
} from 'lucide-react'
import { appQuery } from '#/lib/query'
import { cn } from '#/lib/utils'
export function Sidebar() {
const location = useLocation()
const router = useRouter()
// Detect app context: /apps/<uuid>[/*]
const appContextMatch = location.pathname.match(
/^\/apps\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\/|$)/i,
)
const currentAppId = appContextMatch?.[1] ?? null
const { data: currentApp } = useQuery({
...appQuery(currentAppId ?? ''),
enabled: !!currentAppId,
})
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
router.navigate({ to: '/login' })
}
return (
<aside className="w-[220px] h-screen flex flex-col bg-zinc-950 border-r border-zinc-900 flex-shrink-0">
{/* Logo */}
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-zinc-900">
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/30 flex-shrink-0">
<MessageSquare size={14} className="text-white fill-white" strokeWidth={1.5} />
</div>
<span
className="text-white font-semibold text-[15px] tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
>
wahoo
</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-3 overflow-y-auto">
{currentAppId ? (
<AppContextNav
appId={currentAppId}
appName={currentApp?.name}
pathname={location.pathname}
/>
) : (
<GlobalNav pathname={location.pathname} />
)}
</nav>
{/* Footer */}
<div className="px-2 pb-3 border-t border-zinc-900 pt-2">
<button
onClick={handleLogout}
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium text-zinc-500 hover:text-white hover:bg-zinc-900 w-full transition-colors duration-150 cursor-pointer"
>
<LogOut size={14} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
)
}
function GlobalNav({ pathname }: { pathname: string }) {
const items = [
{ to: '/apps' as const, label: 'Apps', icon: LayoutGrid },
{ to: '/audit-log' as const, label: 'Audit Log', icon: ScrollText },
]
return (
<div className="space-y-0.5">
{items.map(({ to, label, icon: Icon }) => {
const isActive =
to === '/apps'
? pathname === '/apps' || pathname === '/apps/'
: to === '/sessions'
? pathname.startsWith('/sessions')
: pathname === to
return (
<Link
key={to}
to={to}
className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full',
isActive
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
)}
>
<Icon
size={14}
strokeWidth={isActive ? 2 : 1.75}
className={isActive ? 'text-emerald-400' : ''}
/>
{label}
</Link>
)
})}
</div>
)
}
function AppContextNav({
appId,
appName,
pathname,
}: {
appId: string
appName: string | undefined
pathname: string
}) {
const keysPath = `/apps/${appId}/`
const sessionsPath = `/apps/${appId}/sessions`
const isKeys =
pathname === `/apps/${appId}` ||
pathname === `/apps/${appId}/` ||
(pathname.startsWith(`/apps/${appId}/`) && !pathname.startsWith(sessionsPath))
const isSessions = pathname.startsWith(sessionsPath)
const isAuditLog = pathname === '/audit-log'
return (
<div className="space-y-1">
{/* Back to Apps */}
<Link
to="/apps"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900 transition-colors duration-150 mb-1"
>
<ChevronLeft size={12} strokeWidth={2} />
All apps
</Link>
{/* App name header */}
<div className="px-3 py-1.5 mb-0.5">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center flex-shrink-0">
<LayoutGrid size={10} className="text-emerald-400" strokeWidth={2} />
</div>
<span className="text-[12px] font-semibold text-zinc-300 truncate">
{appName ?? '…'}
</span>
</div>
</div>
{/* App-scoped items */}
<Link
to="/apps/$appId/"
params={{ appId }}
className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1',
isKeys
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
)}
>
<Key
size={13}
strokeWidth={isKeys ? 2 : 1.75}
className={isKeys ? 'text-emerald-400' : ''}
/>
API Keys
</Link>
<Link
to="/apps/$appId/sessions/"
params={{ appId }}
className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1',
isSessions
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
)}
>
<Zap
size={13}
strokeWidth={isSessions ? 2 : 1.75}
className={isSessions ? 'text-emerald-400' : ''}
/>
Sessions
</Link>
{/* Separator */}
<div className="border-t border-zinc-900 my-2 mx-1" />
{/* Global items still accessible */}
<Link
to="/audit-log"
className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150',
isAuditLog
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
)}
>
<ScrollText
size={13}
strokeWidth={isAuditLog ? 2 : 1.75}
className={isAuditLog ? 'text-emerald-400' : ''}
/>
Audit Log
</Link>
</div>
)
}

View file

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "#/lib/utils.ts"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
import { Button } from "#/components/ui/button.tsx"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "#/lib/utils.ts"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

114
src/components/ui/table.tsx Normal file
View file

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "#/lib/utils.ts"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -0,0 +1,71 @@
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { apiKeys } from '#/db/schema'
import { generateApiKey, hashApiKey } from '#/lib/api-keys'
import { writeAudit } from '#/lib/audit'
export async function listAppApiKeys(appId: string) {
const keys = await db.query.apiKeys.findMany({
where: eq(apiKeys.appId, appId),
columns: { id: true, keyPrefix: true, label: true, revokedAt: true, createdAt: true },
})
return keys.map((k) => ({ ...k, display: `••••${k.keyPrefix}` }))
}
export async function createAppApiKey(
appId: string,
userId: string,
label?: string,
env?: 'live' | 'test',
audit?: { ip?: string; userAgent?: string },
) {
const { key, prefix } = generateApiKey(env ?? 'live')
const keyHash = await hashApiKey(key)
const [created] = await db.insert(apiKeys).values({
appId,
keyHash,
keyPrefix: prefix,
label: label ?? '',
}).returning({ id: apiKeys.id, keyPrefix: apiKeys.keyPrefix, label: apiKeys.label, createdAt: apiKeys.createdAt })
await writeAudit({
actorType: 'user',
actorId: userId,
action: 'api_key.create',
resourceType: 'api_key',
resourceId: created.id,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffAfter: { label: created.label, appId },
})
return { ...created, key }
}
export async function revokeAppApiKey(
keyId: string,
appId: string,
userId: string,
audit?: { ip?: string; userAgent?: string },
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const key = await db.query.apiKeys.findFirst({
where: and(eq(apiKeys.id, keyId), eq(apiKeys.appId, appId)),
})
if (!key) return { ok: false, error: 'Key not found', status: 404 }
if (key.revokedAt) return { ok: false, error: 'Key already revoked', status: 409 }
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId))
await writeAudit({
actorType: 'user',
actorId: userId,
action: 'api_key.revoke',
resourceType: 'api_key',
resourceId: keyId,
ip: audit?.ip,
userAgent: audit?.userAgent,
})
return { ok: true }
}

112
src/lib/actions/apps.ts Normal file
View file

@ -0,0 +1,112 @@
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { generateDek, wrapDek } from '#/lib/crypto'
import { writeAudit } from '#/lib/audit'
type App = typeof apps.$inferSelect
export async function listUserApps(userId: string) {
return db.query.apps.findMany({
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
columns: { id: true, name: true, maxSessions: true, createdAt: true },
})
}
export async function findUserApp(appId: string, userId: string): Promise<App | null> {
return (await db.query.apps.findFirst({
where: and(eq(apps.id, appId), eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
})) ?? null
}
export async function createUserApp(
userId: string,
name: string,
maxSessions?: number,
audit?: { ip?: string; userAgent?: string },
) {
const dek = generateDek()
const dekEncrypted = wrapDek(dek)
const [app] = await db.insert(apps).values({
name: name.trim(),
ownerUserId: userId,
dekEncrypted,
maxSessions: String(maxSessions ?? 10),
}).returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt })
await writeAudit({
actorType: 'user',
actorId: userId,
action: 'app.create',
resourceType: 'app',
resourceId: app.id,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffAfter: { name: app.name },
})
return app
}
export async function updateUserApp(
appId: string,
userId: string,
updates: { name?: string; maxSessions?: number },
audit?: { ip?: string; userAgent?: string },
): Promise<typeof apps.$inferSelect & { maxSessions: string } | null> {
const app = await findUserApp(appId, userId)
if (!app) return null
const dbUpdates: Partial<typeof apps.$inferInsert> = {}
if (updates.name !== undefined) dbUpdates.name = updates.name.trim()
if (updates.maxSessions !== undefined) dbUpdates.maxSessions = String(updates.maxSessions)
const [updated] = await db.update(apps).set(dbUpdates).where(eq(apps.id, appId)).returning({
id: apps.id, name: apps.name, maxSessions: apps.maxSessions,
})
await writeAudit({
actorType: 'user',
actorId: userId,
action: 'app.update',
resourceType: 'app',
resourceId: appId,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffBefore: { name: app.name, maxSessions: app.maxSessions },
diffAfter: dbUpdates,
})
return updated as App & { maxSessions: string }
}
export async function deleteUserApp(
appId: string,
userId: string,
audit?: { ip?: string; userAgent?: string },
): Promise<boolean> {
const app = await findUserApp(appId, userId)
if (!app) return false
const now = new Date()
await db.transaction(async (tx) => {
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, appId))
await tx.update(apiKeys).set({ revokedAt: now }).where(
and(eq(apiKeys.appId, appId), isNull(apiKeys.revokedAt)),
)
})
await writeAudit({
actorType: 'user',
actorId: userId,
action: 'app.delete',
resourceType: 'app',
resourceId: appId,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffBefore: { name: app.name },
})
return true
}

View file

@ -0,0 +1,32 @@
import { and, desc, eq, gte, lte } from 'drizzle-orm'
import { db } from '#/db/index'
import { auditLog } from '#/db/schema'
interface AuditLogFilters {
page?: number
limit?: number
action?: string | null
resourceType?: string | null
since?: string | null
until?: string | null
}
export async function queryAuditLog(userId: string, filters: AuditLogFilters = {}) {
const page = Math.max(1, filters.page ?? 1)
const limit = Math.min(100, Math.max(1, filters.limit ?? 50))
const conditions = [eq(auditLog.actorId, userId)]
if (filters.action) conditions.push(eq(auditLog.action, filters.action))
if (filters.resourceType) conditions.push(eq(auditLog.resourceType, filters.resourceType))
if (filters.since) conditions.push(gte(auditLog.createdAt, new Date(filters.since)))
if (filters.until) conditions.push(lte(auditLog.createdAt, new Date(filters.until)))
const rows = await db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset: (page - 1) * limit,
})
return { data: rows, page, limit }
}

41
src/lib/actions/auth.ts Normal file
View file

@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { users } from '#/db/schema'
import { generateTotpSecret, getTotpUri, verifyTotp } from '#/lib/totp'
export async function findUserByEmail(email: string) {
return (await db.query.users.findFirst({ where: eq(users.email, email.toLowerCase()) })) ?? null
}
export async function findUserById(userId: string) {
return (await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
}
export async function enrollTotpSecret(
userId: string,
): Promise<{ ok: true; secret: string; uri: string } | { ok: false; error: string; status: number }> {
const user = await findUserById(userId)
if (!user) return { ok: false, error: 'User not found', status: 404 }
if (user.totpEnabled) return { ok: false, error: 'TOTP already enabled', status: 409 }
const secret = generateTotpSecret()
await db.update(users).set({ totpSecret: secret }).where(eq(users.id, userId))
return { ok: true, secret, uri: getTotpUri(user.email, secret) }
}
export async function confirmTotpEnrollment(
userId: string,
token: string,
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const user = await findUserById(userId)
if (!user?.totpSecret) return { ok: false, error: 'Enroll first', status: 400 }
if (user.totpEnabled) return { ok: false, error: 'TOTP already enabled', status: 409 }
if (!verifyTotp(user.totpSecret, token)) return { ok: false, error: 'Invalid token', status: 401 }
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, userId))
return { ok: true }
}
export async function disableTotp(userId: string): Promise<void> {
await db.update(users).set({ totpEnabled: false, totpSecret: null }).where(eq(users.id, userId))
}

117
src/lib/actions/sessions.ts Normal file
View file

@ -0,0 +1,117 @@
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, sessions, sessionKeys } from '#/db/schema'
import { startSession, stopSession, emitWebhook } from '#/lib/baileys'
type Session = typeof sessions.$inferSelect
type SessionStatus = Session['status']
type SessionScope = { appId: string } | { userId: string }
const ACTIVE_STATUSES = ['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'] as const
export async function findSession(sessionId: string, scope: SessionScope): Promise<Session | null> {
if ('appId' in scope) {
return (await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
})) ?? null
}
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, scope.userId), isNull(apps.deletedAt)),
columns: { id: true },
})
if (userApps.length === 0) return null
const appIds = userApps.map((a) => a.id)
return (await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
})) ?? null
}
export async function listUserSessions(userId: string) {
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
columns: { id: true, name: true },
})
if (userApps.length === 0) return []
const appIds = userApps.map((a) => a.id)
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]))
const rows = await db.query.sessions.findMany({
where: (s, { inArray: _inArray }) => _inArray(s.appId, appIds),
columns: { id: true, appId: true, status: true, reconnectCount: true, lastSeen: true, createdAt: true },
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || '' }))
}
export async function listAppSessions(appId: string) {
return db.query.sessions.findMany({
where: eq(sessions.appId, appId),
columns: { id: true, status: true, lastSeen: true, createdAt: true, reconnectCount: true },
})
}
export async function createSession(
appId: string,
via: 'qr' | 'pairing',
ownerUserId?: string,
): Promise<{ ok: true; session: { id: string; status: string; createdAt: Date } } | { ok: false; error: string; status: number }> {
const appWhere = ownerUserId
? and(eq(apps.id, appId), eq(apps.ownerUserId, ownerUserId), isNull(apps.deletedAt))
: and(eq(apps.id, appId), isNull(apps.deletedAt))
const app = await db.query.apps.findFirst({ where: appWhere, columns: { id: true, maxSessions: true } })
if (!app) return { ok: false, error: 'App not found', status: 404 }
const existing = await db.query.sessions.findMany({
where: eq(sessions.appId, appId),
columns: { status: true },
})
const activeCount = existing.filter((s) => (ACTIVE_STATUSES as readonly string[]).includes(s.status)).length
if (activeCount >= Number(app.maxSessions)) {
return { ok: false, error: `Session limit reached (max ${app.maxSessions})`, status: 429 }
}
const initialStatus: SessionStatus = via === 'pairing' ? 'PENDING_PAIRING' : 'PENDING_QR'
const [session] = await db
.insert(sessions)
.values({ appId, status: initialStatus })
.returning({ id: sessions.id, status: sessions.status, createdAt: sessions.createdAt })
if (via === 'qr') startSession(session.id, appId).catch(console.error)
return { ok: true, session }
}
export async function setSessionStatus(sessionId: string, status: SessionStatus): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId))
}
export async function disconnectSession(sessionId: string, appId: string): Promise<void> {
await stopSession(sessionId)
await setSessionStatus(sessionId, 'DISCONNECTED')
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' })
}
export async function deleteSession(sessionId: string, appId: string): Promise<void> {
await stopSession(sessionId)
await db.delete(sessions).where(eq(sessions.id, sessionId))
emitWebhook(appId, 'session.deleted', { sessionId })
}
export async function logoutAppSession(sessionId: string, appId: string): Promise<void> {
await stopSession(sessionId)
await db.transaction(async (tx) => {
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId))
await tx.update(sessions).set({
status: 'LOGGED_OUT',
credentialsEncrypted: null,
phoneNumber: null,
displayName: null,
}).where(eq(sessions.id, sessionId))
})
emitWebhook(appId, 'session.deleted', { sessionId })
}

View file

@ -161,7 +161,11 @@ export async function startSession(sessionId: string, appId: string): Promise<vo
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
?.output?.statusCode
if (code === DisconnectReason.loggedOut || code === DisconnectReason.forbidden) {
if (
code === DisconnectReason.loggedOut ||
code === DisconnectReason.forbidden ||
code === DisconnectReason.connectionReplaced
) {
reconnecting.delete(sessionId)
await setStatus(sessionId, 'LOGGED_OUT')
emitWebhook(appId, 'session.status', { sessionId, status: 'LOGGED_OUT' })

View file

@ -41,3 +41,29 @@ export const auditLogQuery = queryOptions({
queryKey: ['audit-log'],
queryFn: () => apiFetch('/api/v1/admin/audit-log?limit=50'),
})
interface SessionRow {
id: string
appId: string
appName: string
status: string
lastSeen: string | null
createdAt: string
reconnectCount: number
}
export const adminSessionDetailQuery = (sessionId: string) =>
queryOptions({
queryKey: ['sessions', sessionId, 'admin-detail'],
queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`),
staleTime: 5_000,
})
export const appSessionsQuery = (appId: string) =>
queryOptions({
queryKey: ['apps', appId, 'sessions'],
queryFn: async (): Promise<SessionRow[]> => {
const data: SessionRow[] = await apiFetch('/api/v1/admin/sessions')
return data.filter(s => s.appId === appId)
},
})

View file

@ -19,10 +19,14 @@ import { Route as SessionsIdRouteImport } from './routes/sessions/$id'
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
import { Route as ApiReadyRouteImport } from './routes/api/ready'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AppsAppIdIndexRouteImport } from './routes/apps/$appId/index'
import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me'
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
import { Route as AppsAppIdSessionsIndexRouteImport } from './routes/apps/$appId/sessions/index'
import { Route as ApiV1SessionsIndexRouteImport } from './routes/api/v1/sessions/index'
import { Route as AppsAppIdSessionsNewRouteImport } from './routes/apps/$appId/sessions/new'
import { Route as AppsAppIdSessionsSessionIdRouteImport } from './routes/apps/$appId/sessions/$sessionId'
import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
@ -32,7 +36,11 @@ import { Route as ApiV1SessionsIdQrRouteImport } from './routes/api/v1/sessions/
import { Route as ApiV1SessionsIdPairRouteImport } from './routes/api/v1/sessions/$id/pair'
import { Route as ApiV1SessionsIdHealthRouteImport } from './routes/api/v1/sessions/$id/health'
import { Route as ApiV1SessionsIdDisconnectRouteImport } from './routes/api/v1/sessions/$id/disconnect'
import { Route as ApiV1AdminSessionsIdIndexRouteImport } from './routes/api/v1/admin/sessions/$id/index'
import { Route as ApiV1AdminAppsIdIndexRouteImport } from './routes/api/v1/admin/apps/$id/index'
import { Route as ApiV1AdminSessionsIdQrRouteImport } from './routes/api/v1/admin/sessions/$id/qr'
import { Route as ApiV1AdminSessionsIdPairRouteImport } from './routes/api/v1/admin/sessions/$id/pair'
import { Route as ApiV1AdminSessionsIdDisconnectRouteImport } from './routes/api/v1/admin/sessions/$id/disconnect'
import { Route as ApiV1AdminAppsIdApiKeysIndexRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/index'
import { Route as ApiV1AdminAppsIdApiKeysKeyIdRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/$keyId'
@ -86,6 +94,11 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdIndexRoute = AppsAppIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
id: '/api/auth/me',
path: '/api/auth/me',
@ -101,11 +114,27 @@ const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
path: '/api/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdSessionsIndexRoute = AppsAppIdSessionsIndexRouteImport.update({
id: '/sessions/',
path: '/sessions/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
id: '/api/v1/sessions/',
path: '/api/v1/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdSessionsNewRoute = AppsAppIdSessionsNewRouteImport.update({
id: '/sessions/new',
path: '/sessions/new',
getParentRoute: () => AppsAppIdRoute,
} as any)
const AppsAppIdSessionsSessionIdRoute =
AppsAppIdSessionsSessionIdRouteImport.update({
id: '/sessions/$sessionId',
path: '/sessions/$sessionId',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1AdminAuditLogRoute = ApiV1AdminAuditLogRouteImport.update({
id: '/api/v1/admin/audit-log',
path: '/api/v1/admin/audit-log',
@ -152,11 +181,34 @@ const ApiV1SessionsIdDisconnectRoute =
path: '/api/v1/sessions/$id/disconnect',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdIndexRoute =
ApiV1AdminSessionsIdIndexRouteImport.update({
id: '/api/v1/admin/sessions/$id/',
path: '/api/v1/admin/sessions/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/',
path: '/api/v1/admin/apps/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdQrRoute = ApiV1AdminSessionsIdQrRouteImport.update({
id: '/api/v1/admin/sessions/$id/qr',
path: '/api/v1/admin/sessions/$id/qr',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdPairRoute =
ApiV1AdminSessionsIdPairRouteImport.update({
id: '/api/v1/admin/sessions/$id/pair',
path: '/api/v1/admin/sessions/$id/pair',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdDisconnectRoute =
ApiV1AdminSessionsIdDisconnectRouteImport.update({
id: '/api/v1/admin/sessions/$id/disconnect',
path: '/api/v1/admin/sessions/$id/disconnect',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysIndexRoute =
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/',
@ -176,7 +228,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/apps/$appId': typeof AppsAppIdRouteWithChildren
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps/': typeof AppsIndexRoute
@ -184,9 +236,13 @@ export interface FileRoutesByFullPath {
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/apps/$appId/': typeof AppsAppIdIndexRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
@ -194,7 +250,11 @@ export interface FileRoutesByFullPath {
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
@ -204,7 +264,6 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps': typeof AppsIndexRoute
@ -212,9 +271,13 @@ export interface FileRoutesByTo {
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/apps/$appId': typeof AppsAppIdIndexRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
'/apps/$appId/sessions': typeof AppsAppIdSessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
@ -222,7 +285,11 @@ export interface FileRoutesByTo {
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/admin/apps/$id': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/sessions/$id': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
@ -233,7 +300,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/apps/$appId': typeof AppsAppIdRouteWithChildren
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps/': typeof AppsIndexRoute
@ -241,9 +308,13 @@ export interface FileRoutesById {
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/apps/$appId/': typeof AppsAppIdIndexRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
@ -251,7 +322,11 @@ export interface FileRoutesById {
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
@ -271,9 +346,13 @@ export interface FileRouteTypes {
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/apps/$appId/'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/apps/$appId/sessions/$sessionId'
| '/apps/$appId/sessions/new'
| '/api/v1/sessions/'
| '/apps/$appId/sessions/'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
@ -281,7 +360,11 @@ export interface FileRouteTypes {
| '/api/v1/admin/apps/'
| '/api/v1/admin/sessions/'
| '/api/v1/sessions/$id/'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys/'
fileRoutesByTo: FileRoutesByTo
@ -291,7 +374,6 @@ export interface FileRouteTypes {
| '/login'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/sessions/$id'
| '/sessions/new'
| '/apps'
@ -299,9 +381,13 @@ export interface FileRouteTypes {
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/apps/$appId'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/apps/$appId/sessions/$sessionId'
| '/apps/$appId/sessions/new'
| '/api/v1/sessions'
| '/apps/$appId/sessions'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
@ -309,7 +395,11 @@ export interface FileRouteTypes {
| '/api/v1/admin/apps'
| '/api/v1/admin/sessions'
| '/api/v1/sessions/$id'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/admin/apps/$id'
| '/api/v1/admin/sessions/$id'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys'
id:
@ -327,9 +417,13 @@ export interface FileRouteTypes {
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/apps/$appId/'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/apps/$appId/sessions/$sessionId'
| '/apps/$appId/sessions/new'
| '/api/v1/sessions/'
| '/apps/$appId/sessions/'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
@ -337,7 +431,11 @@ export interface FileRouteTypes {
| '/api/v1/admin/apps/'
| '/api/v1/admin/sessions/'
| '/api/v1/sessions/$id/'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys/'
fileRoutesById: FileRoutesById
@ -348,7 +446,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiReadyRoute: typeof ApiReadyRoute
AppsAppIdRoute: typeof AppsAppIdRoute
AppsAppIdRoute: typeof AppsAppIdRouteWithChildren
SessionsIdRoute: typeof SessionsIdRoute
SessionsNewRoute: typeof SessionsNewRoute
AppsIndexRoute: typeof AppsIndexRoute
@ -366,7 +464,11 @@ export interface RootRouteChildren {
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
ApiV1AdminSessionsIdDisconnectRoute: typeof ApiV1AdminSessionsIdDisconnectRoute
ApiV1AdminSessionsIdPairRoute: typeof ApiV1AdminSessionsIdPairRoute
ApiV1AdminSessionsIdQrRoute: typeof ApiV1AdminSessionsIdQrRoute
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
ApiV1AdminSessionsIdIndexRoute: typeof ApiV1AdminSessionsIdIndexRoute
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
@ -443,6 +545,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/': {
id: '/apps/$appId/'
path: '/'
fullPath: '/apps/$appId/'
preLoaderRoute: typeof AppsAppIdIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/auth/me': {
id: '/api/auth/me'
path: '/api/auth/me'
@ -464,6 +573,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/sessions/': {
id: '/apps/$appId/sessions/'
path: '/sessions'
fullPath: '/apps/$appId/sessions/'
preLoaderRoute: typeof AppsAppIdSessionsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/sessions/': {
id: '/api/v1/sessions/'
path: '/api/v1/sessions'
@ -471,6 +587,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1SessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/sessions/new': {
id: '/apps/$appId/sessions/new'
path: '/sessions/new'
fullPath: '/apps/$appId/sessions/new'
preLoaderRoute: typeof AppsAppIdSessionsNewRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/apps/$appId/sessions/$sessionId': {
id: '/apps/$appId/sessions/$sessionId'
path: '/sessions/$sessionId'
fullPath: '/apps/$appId/sessions/$sessionId'
preLoaderRoute: typeof AppsAppIdSessionsSessionIdRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/admin/audit-log': {
id: '/api/v1/admin/audit-log'
path: '/api/v1/admin/audit-log'
@ -534,6 +664,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1SessionsIdDisconnectRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/': {
id: '/api/v1/admin/sessions/$id/'
path: '/api/v1/admin/sessions/$id'
fullPath: '/api/v1/admin/sessions/$id/'
preLoaderRoute: typeof ApiV1AdminSessionsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/': {
id: '/api/v1/admin/apps/$id/'
path: '/api/v1/admin/apps/$id'
@ -541,6 +678,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1AdminAppsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/qr': {
id: '/api/v1/admin/sessions/$id/qr'
path: '/api/v1/admin/sessions/$id/qr'
fullPath: '/api/v1/admin/sessions/$id/qr'
preLoaderRoute: typeof ApiV1AdminSessionsIdQrRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/pair': {
id: '/api/v1/admin/sessions/$id/pair'
path: '/api/v1/admin/sessions/$id/pair'
fullPath: '/api/v1/admin/sessions/$id/pair'
preLoaderRoute: typeof ApiV1AdminSessionsIdPairRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/disconnect': {
id: '/api/v1/admin/sessions/$id/disconnect'
path: '/api/v1/admin/sessions/$id/disconnect'
fullPath: '/api/v1/admin/sessions/$id/disconnect'
preLoaderRoute: typeof ApiV1AdminSessionsIdDisconnectRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/': {
id: '/api/v1/admin/apps/$id/api-keys/'
path: '/api/v1/admin/apps/$id/api-keys'
@ -558,13 +716,31 @@ declare module '@tanstack/react-router' {
}
}
interface AppsAppIdRouteChildren {
AppsAppIdIndexRoute: typeof AppsAppIdIndexRoute
AppsAppIdSessionsSessionIdRoute: typeof AppsAppIdSessionsSessionIdRoute
AppsAppIdSessionsNewRoute: typeof AppsAppIdSessionsNewRoute
AppsAppIdSessionsIndexRoute: typeof AppsAppIdSessionsIndexRoute
}
const AppsAppIdRouteChildren: AppsAppIdRouteChildren = {
AppsAppIdIndexRoute: AppsAppIdIndexRoute,
AppsAppIdSessionsSessionIdRoute: AppsAppIdSessionsSessionIdRoute,
AppsAppIdSessionsNewRoute: AppsAppIdSessionsNewRoute,
AppsAppIdSessionsIndexRoute: AppsAppIdSessionsIndexRoute,
}
const AppsAppIdRouteWithChildren = AppsAppIdRoute._addFileChildren(
AppsAppIdRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditLogRoute: AuditLogRoute,
LoginRoute: LoginRoute,
ApiHealthRoute: ApiHealthRoute,
ApiReadyRoute: ApiReadyRoute,
AppsAppIdRoute: AppsAppIdRoute,
AppsAppIdRoute: AppsAppIdRouteWithChildren,
SessionsIdRoute: SessionsIdRoute,
SessionsNewRoute: SessionsNewRoute,
AppsIndexRoute: AppsIndexRoute,
@ -582,7 +758,11 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
ApiV1AdminSessionsIdDisconnectRoute: ApiV1AdminSessionsIdDisconnectRoute,
ApiV1AdminSessionsIdPairRoute: ApiV1AdminSessionsIdPairRoute,
ApiV1AdminSessionsIdQrRoute: ApiV1AdminSessionsIdQrRoute,
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
ApiV1AdminSessionsIdIndexRoute: ApiV1AdminSessionsIdIndexRoute,
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
}

View file

@ -1,35 +1,43 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { HeadContent, Outlet, Scripts, createRootRoute, useLocation } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '#/lib/query'
import { Sidebar } from '#/components/layout/sidebar'
import appCss from '../styles.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Wahoo — WhatsApp API Gateway',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Wahoo — WhatsApp API Gateway' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
shellComponent: RootDocument,
component: RootLayout,
})
function RootLayout() {
const location = useLocation()
const isLogin = location.pathname === '/login'
if (isLogin) {
return <Outlet />
}
return (
<div className="flex h-screen bg-gray-50 overflow-hidden">
<Sidebar />
<div className="flex-1 overflow-auto">
<Outlet />
</div>
</div>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
@ -40,15 +48,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
config={{ position: 'bottom-right' }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
/>
<Scripts />
</QueryClientProvider>

View file

@ -1,11 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { users } from '#/db/schema'
import { verifyPassword } from '#/lib/password'
import { verifyTotp } from '#/lib/totp'
import { createSessionCookie, json, jsonError } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
import { findUserByEmail } from '#/lib/actions/auth'
export const Route = createFileRoute('/api/auth/login')({
server: {
@ -21,7 +19,7 @@ export const Route = createFileRoute('/api/auth/login')({
const { email, password, totpToken } = body
if (!email || !password) return jsonError('email and password required', 400)
const user = await db.query.users.findFirst({ where: eq(users.email, email.toLowerCase()) })
const user = await findUserByEmail(email)
if (!user) return jsonError('Invalid credentials', 401)
const valid = await verifyPassword(password, user.passwordHash)

View file

@ -1,9 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { users } from '#/db/schema'
import { generateTotpSecret, getTotpUri, verifyTotp } from '#/lib/totp'
import { json, jsonError, readSession } from '#/lib/session-api'
import { enrollTotpSecret, confirmTotpEnrollment, disableTotp } from '#/lib/actions/auth'
export const Route = createFileRoute('/api/auth/totp/enroll')({
server: {
@ -12,13 +9,9 @@ export const Route = createFileRoute('/api/auth/totp/enroll')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) })
if (!user) return jsonError('User not found', 404)
if (user.totpEnabled) return jsonError('TOTP already enabled', 409)
const secret = generateTotpSecret()
await db.update(users).set({ totpSecret: secret }).where(eq(users.id, session.userId))
return json({ secret, uri: getTotpUri(user.email, secret) })
const result = await enrollTotpSecret(session.userId)
if (!result.ok) return jsonError(result.error, result.status)
return json({ secret: result.secret, uri: result.uri })
},
PUT: async ({ request }) => {
@ -29,12 +22,8 @@ export const Route = createFileRoute('/api/auth/totp/enroll')({
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.token) return jsonError('token required', 400)
const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) })
if (!user?.totpSecret) return jsonError('Enroll first', 400)
if (user.totpEnabled) return jsonError('TOTP already enabled', 409)
if (!verifyTotp(user.totpSecret, body.token)) return jsonError('Invalid token', 401)
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, session.userId))
const result = await confirmTotpEnrollment(session.userId, body.token)
if (!result.ok) return jsonError(result.error, result.status)
return json({ ok: true })
},
@ -42,7 +31,7 @@ export const Route = createFileRoute('/api/auth/totp/enroll')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
await db.update(users).set({ totpEnabled: false, totpSecret: null }).where(eq(users.id, session.userId))
await disableTotp(session.userId)
return json({ ok: true })
},
},

View file

@ -1,9 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
import { findUserApp } from '#/lib/actions/apps'
import { revokeAppApiKey } from '#/lib/actions/api-keys'
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
server: {
@ -12,28 +10,14 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await db.query.apps.findFirst({
where: and(eq(apps.id, params.id), eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
})
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const key = await db.query.apiKeys.findFirst({
where: and(eq(apiKeys.id, params.keyId), eq(apiKeys.appId, params.id)),
})
if (!key) return jsonError('Key not found', 404)
if (key.revokedAt) return jsonError('Key already revoked', 409)
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, params.keyId))
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'api_key.revoke',
resourceType: 'api_key',
resourceId: params.keyId,
const result = await revokeAppApiKey(params.keyId, params.id, session.userId, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
})
if (!result.ok) return jsonError(result.error, result.status)
return json({ ok: true })
},

View file

@ -1,16 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { generateApiKey, hashApiKey } from '#/lib/api-keys'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
async function getOwnedApp(appId: string, userId: string) {
return db.query.apps.findFirst({
where: and(eq(apps.id, appId), eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
})
}
import { findUserApp } from '#/lib/actions/apps'
import { listAppApiKeys, createAppApiKey } from '#/lib/actions/api-keys'
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
server: {
@ -19,49 +10,28 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const keys = await db.query.apiKeys.findMany({
where: eq(apiKeys.appId, params.id),
columns: { id: true, keyPrefix: true, label: true, revokedAt: true, createdAt: true },
})
return json(keys.map(k => ({ ...k, display: `••••${k.keyPrefix}` })))
return json(await listAppApiKeys(params.id))
},
POST: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
let body: { label?: string; env?: 'live' | 'test' } = {}
try { body = await request.json() } catch {}
const { key, prefix } = generateApiKey(body.env ?? 'live')
const keyHash = await hashApiKey(key)
const [created] = await db.insert(apiKeys).values({
appId: params.id,
keyHash,
keyPrefix: prefix,
label: body.label ?? '',
}).returning({ id: apiKeys.id, keyPrefix: apiKeys.keyPrefix, label: apiKeys.label, createdAt: apiKeys.createdAt })
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'api_key.create',
resourceType: 'api_key',
resourceId: created.id,
const created = await createAppApiKey(params.id, session.userId, body.label, body.env, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffAfter: { label: created.label, appId: params.id },
})
return json({ ...created, key }, 201)
return json(created, 201)
},
},
},

View file

@ -1,15 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, apiKeys } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
async function getOwnedApp(appId: string, userId: string) {
return db.query.apps.findFirst({
where: and(eq(apps.id, appId), eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
})
}
import { findUserApp, updateUserApp, deleteUserApp } from '#/lib/actions/apps'
export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
server: {
@ -18,7 +9,7 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
return json({ id: app.id, name: app.name, maxSessions: app.maxSessions, createdAt: app.createdAt })
},
@ -27,32 +18,19 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
let body: { name?: string; maxSessions?: number }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
const updates: Partial<typeof apps.$inferInsert> = {}
if (body.name !== undefined) updates.name = body.name.trim()
if (body.maxSessions !== undefined) updates.maxSessions = String(body.maxSessions)
const updates: { name?: string; maxSessions?: number } = {}
if (body.name !== undefined) updates.name = body.name
if (body.maxSessions !== undefined) updates.maxSessions = body.maxSessions
if (Object.keys(updates).length === 0) return jsonError('Nothing to update', 400)
const [updated] = await db.update(apps).set(updates).where(eq(apps.id, params.id)).returning({
id: apps.id, name: apps.name, maxSessions: apps.maxSessions,
})
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'app.update',
resourceType: 'app',
resourceId: params.id,
const updated = await updateUserApp(params.id, session.userId, updates, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffBefore: { name: app.name, maxSessions: app.maxSessions },
diffAfter: updates,
})
if (!updated) return jsonError('Not found', 404)
return json(updated)
},
@ -61,27 +39,11 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const app = await getOwnedApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const now = new Date()
await db.transaction(async (tx) => {
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, params.id))
await tx.update(apiKeys).set({ revokedAt: now }).where(
and(eq(apiKeys.appId, params.id), isNull(apiKeys.revokedAt))
)
})
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'app.delete',
resourceType: 'app',
resourceId: params.id,
const deleted = await deleteUserApp(params.id, session.userId, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffBefore: { name: app.name },
})
if (!deleted) return jsonError('Not found', 404)
return json({ ok: true })
},

View file

@ -1,10 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps } from '#/db/schema'
import { generateDek, wrapDek } from '#/lib/crypto'
import { json, jsonError, readSession } from '#/lib/session-api'
import { writeAudit } from '#/lib/audit'
import { listUserApps, createUserApp } from '#/lib/actions/apps'
export const Route = createFileRoute('/api/v1/admin/apps/')({
server: {
@ -13,10 +9,7 @@ export const Route = createFileRoute('/api/v1/admin/apps/')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const list = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
columns: { id: true, name: true, maxSessions: true, createdAt: true },
})
const list = await listUserApps(session.userId)
return json(list)
},
@ -28,25 +21,9 @@ export const Route = createFileRoute('/api/v1/admin/apps/')({
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.name?.trim()) return jsonError('name required', 400)
const dek = generateDek()
const dekEncrypted = wrapDek(dek)
const [app] = await db.insert(apps).values({
name: body.name.trim(),
ownerUserId: session.userId,
dekEncrypted,
maxSessions: String(body.maxSessions ?? 10),
}).returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt })
await writeAudit({
actorType: 'user',
actorId: session.userId,
action: 'app.create',
resourceType: 'app',
resourceId: app.id,
const app = await createUserApp(session.userId, body.name, body.maxSessions, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
diffAfter: { name: app.name },
})
return json(app, 201)

View file

@ -1,8 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, desc, eq, gte, lte } from 'drizzle-orm'
import { db } from '#/db/index'
import { auditLog } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
import { queryAuditLog } from '#/lib/actions/audit-log'
export const Route = createFileRoute('/api/v1/admin/audit-log')({
server: {
@ -12,27 +10,16 @@ export const Route = createFileRoute('/api/v1/admin/audit-log')({
if (!session?.userId) return jsonError('Unauthorized', 401)
const url = new URL(request.url)
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1))
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50)))
const action = url.searchParams.get('action')
const resourceType = url.searchParams.get('resourceType')
const since = url.searchParams.get('since')
const until = url.searchParams.get('until')
const conditions = [eq(auditLog.actorId, session.userId)]
if (action) conditions.push(eq(auditLog.action, action))
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType))
if (since) conditions.push(gte(auditLog.createdAt, new Date(since)))
if (until) conditions.push(lte(auditLog.createdAt, new Date(until)))
const rows = await db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset: (page - 1) * limit,
const result = await queryAuditLog(session.userId, {
page: Number(url.searchParams.get('page') ?? 1),
limit: Number(url.searchParams.get('limit') ?? 50),
action: url.searchParams.get('action'),
resourceType: url.searchParams.get('resourceType'),
since: url.searchParams.get('since'),
until: url.searchParams.get('until'),
})
return json({ data: rows, page, limit })
return json(result)
},
},
},

View file

@ -0,0 +1,24 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
import { findSession, disconnectSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/admin/sessions/$id/disconnect')({
server: {
handlers: {
POST: async ({ request, params }) => {
const sess = await readSession(request)
if (!sess?.userId) return jsonError('Unauthorized', 401)
const row = await findSession(params.id, { userId: sess.userId })
if (!row) return jsonError('Not found', 404)
if (['DISCONNECTED', 'LOGGED_OUT', 'DEAD'].includes(row.status)) {
return json({ ok: true, status: row.status })
}
await disconnectSession(params.id, row.appId)
return json({ ok: true, status: 'DISCONNECTED' })
},
},
},
})

View file

@ -0,0 +1,40 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
import { getDecryptedField } from '#/lib/baileys'
import { findSession, deleteSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/admin/sessions/$id/')({
server: {
handlers: {
GET: async ({ request, params }) => {
const sess = await readSession(request)
if (!sess?.userId) return jsonError('Unauthorized', 401)
const row = await findSession(params.id, { userId: sess.userId })
if (!row) return jsonError('Not found', 404)
return json({
id: row.id,
appId: row.appId,
status: row.status,
phoneNumber: getDecryptedField(row.id, row.phoneNumber),
displayName: getDecryptedField(row.id, row.displayName),
reconnectCount: row.reconnectCount,
lastSeen: row.lastSeen,
createdAt: row.createdAt,
})
},
DELETE: async ({ request, params }) => {
const sess = await readSession(request)
if (!sess?.userId) return jsonError('Unauthorized', 401)
const row = await findSession(params.id, { userId: sess.userId })
if (!row) return jsonError('Not found', 404)
await deleteSession(params.id, row.appId)
return json({ ok: true })
},
},
},
})

View file

@ -0,0 +1,47 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
import { startSession, getSocket } from '#/lib/baileys'
import { findSession, setSessionStatus } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/admin/sessions/$id/pair')({
server: {
handlers: {
POST: async ({ request, params }) => {
const sess = await readSession(request)
if (!sess?.userId) return jsonError('Unauthorized', 401)
const row = await findSession(params.id, { userId: sess.userId })
if (!row) return jsonError('Not found', 404)
if (row.status === 'CONNECTED') return jsonError('Already connected', 409)
if (row.status === 'DEAD' || row.status === 'LOGGED_OUT') {
return jsonError('Session is no longer active', 410)
}
let body: { phone?: string }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.phone) return jsonError('phone required', 400)
const phoneDigits = body.phone.replace(/\D/g, '')
if (phoneDigits.length < 7) return jsonError('Invalid phone number', 400)
await setSessionStatus(params.id, 'PENDING_PAIRING')
if (!getSocket(params.id)) {
await startSession(params.id, row.appId)
await new Promise((r) => setTimeout(r, 2000))
}
const sock = getSocket(params.id)
if (!sock) return jsonError('Session not ready — retry in a moment', 503)
try {
const pairingCode = await sock.requestPairingCode(phoneDigits)
return json({ pairingCode })
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
return jsonError(`Pairing code request failed: ${msg}`, 502)
}
},
},
},
})

View file

@ -0,0 +1,30 @@
import { createFileRoute } from '@tanstack/react-router'
import QRCode from 'qrcode'
import { json, jsonError, readSession } from '#/lib/session-api'
import { getQrCode } from '#/lib/baileys'
import { findSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/admin/sessions/$id/qr')({
server: {
handlers: {
GET: async ({ request, params }) => {
const sess = await readSession(request)
if (!sess?.userId) return jsonError('Unauthorized', 401)
const row = await findSession(params.id, { userId: sess.userId })
if (!row) return jsonError('Not found', 404)
if (row.status === 'CONNECTED') return json({ error: 'Already connected' }, 409)
if (row.status === 'DEAD' || row.status === 'LOGGED_OUT') {
return json({ error: 'Session is no longer active' }, 410)
}
const qrRaw = getQrCode(params.id)
if (!qrRaw) return json({ waiting: true }, 202)
const qrPng = await QRCode.toDataURL(qrRaw)
return json({ qr: qrPng, qrRaw })
},
},
},
})

View file

@ -1,8 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, sessions } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
import { listUserSessions, createSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/admin/sessions/')({
server: {
@ -11,30 +9,24 @@ export const Route = createFileRoute('/api/v1/admin/sessions/')({
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
// All apps owned by this user
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
columns: { id: true, name: true },
})
if (userApps.length === 0) return json([])
const appIds = userApps.map((a) => a.id)
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]))
const rows = await db.query.sessions.findMany({
where: (s, { inArray }) => inArray(s.appId, appIds),
columns: {
id: true,
appId: true,
status: true,
reconnectCount: true,
lastSeen: true,
createdAt: true,
const rows = await listUserSessions(session.userId)
return json(rows)
},
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
return json(rows.map((r) => ({ ...r, appName: appNameMap[r.appId] ?? '' })))
POST: async ({ request }) => {
const sess = await readSession(request)
if (!sess?.userId) return jsonError('Unauthorized', 401)
let body: { appId?: string; connect_via?: 'qr' | 'pairing' } = {}
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.appId) return jsonError('appId required', 400)
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
const result = await createSession(body.appId, via, sess.userId)
if (!result.ok) return jsonError(result.error, result.status)
return json(result.session, 201)
},
},
},

View file

@ -1,10 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { stopSession, emitWebhook } from '#/lib/baileys'
import { findSession, disconnectSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/sessions/$id/disconnect')({
server: {
@ -13,20 +10,14 @@ export const Route = createFileRoute('/api/v1/sessions/$id/disconnect')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { id: true, status: true },
})
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
if (session.status === 'DISCONNECTED' || session.status === 'LOGGED_OUT' || session.status === 'DEAD') {
return json({ ok: true, status: session.status })
}
await stopSession(params.id)
await db.update(sessions).set({ status: 'DISCONNECTED' }).where(eq(sessions.id, params.id))
emitWebhook(auth.appId, 'session.status', { sessionId: params.id, status: 'DISCONNECTED' })
await disconnectSession(params.id, auth.appId)
return json({ ok: true, status: 'DISCONNECTED' })
},
},

View file

@ -1,10 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { isActive } from '#/lib/baileys'
import { findSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/sessions/$id/health')({
server: {
@ -13,10 +11,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/health')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { id: true, status: true, lastSeen: true, reconnectCount: true },
})
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
return json({

View file

@ -1,16 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions, sessionKeys } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { stopSession, getDecryptedField, emitWebhook } from '#/lib/baileys'
async function getSessionForApp(sessionId: string, appId: string) {
return db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), eq(sessions.appId, appId)),
})
}
import { getDecryptedField } from '#/lib/baileys'
import { findSession, logoutAppSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/sessions/$id/')({
server: {
@ -19,7 +11,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await getSessionForApp(params.id, auth.appId)
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
return json({
@ -37,22 +29,10 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await getSessionForApp(params.id, auth.appId)
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
await stopSession(params.id)
await db.transaction(async (tx) => {
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, params.id))
await tx.update(sessions).set({
status: 'LOGGED_OUT',
credentialsEncrypted: null,
phoneNumber: null,
displayName: null,
}).where(eq(sessions.id, params.id))
})
emitWebhook(auth.appId, 'session.deleted', { sessionId: params.id })
await logoutAppSession(params.id, auth.appId)
return json({ ok: true })
},
},

View file

@ -1,10 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { startSession, getSocket } from '#/lib/baileys'
import { findSession, setSessionStatus } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
server: {
@ -13,10 +11,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { id: true, status: true },
})
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
if (session.status === 'CONNECTED') return jsonError('Already connected', 409)
@ -31,11 +26,10 @@ export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
const phoneDigits = body.phone.replace(/\D/g, '')
if (phoneDigits.length < 7) return jsonError('Invalid phone number', 400)
await db.update(sessions).set({ status: 'PENDING_PAIRING' }).where(eq(sessions.id, params.id))
await setSessionStatus(params.id, 'PENDING_PAIRING')
if (!getSocket(params.id)) {
await startSession(params.id, auth.appId)
// allow socket to initialize
await new Promise((r) => setTimeout(r, 2000))
}

View file

@ -1,11 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import QRCode from 'qrcode'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { getQrCode } from '#/lib/baileys'
import { findSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
server: {
@ -14,10 +12,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { status: true },
})
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
if (session.status === 'CONNECTED') {
@ -28,12 +23,9 @@ export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
}
const qrRaw = getQrCode(params.id)
if (!qrRaw) {
return json({ waiting: true }, 202)
}
if (!qrRaw) return json({ waiting: true }, 202)
const qrPng = await QRCode.toDataURL(qrRaw)
return json({ qr: qrPng, qrRaw })
},
},

View file

@ -1,10 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { startSession } from '#/lib/baileys'
import { listAppSessions, createSession } from '#/lib/actions/sessions'
export const Route = createFileRoute('/api/v1/sessions/')({
server: {
@ -13,17 +10,7 @@ export const Route = createFileRoute('/api/v1/sessions/')({
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const list = await db.query.sessions.findMany({
where: eq(sessions.appId, auth.appId),
columns: {
id: true,
status: true,
lastSeen: true,
createdAt: true,
reconnectCount: true,
},
})
return json(list)
return json(await listAppSessions(auth.appId))
},
POST: async ({ request }) => {
@ -34,37 +21,10 @@ export const Route = createFileRoute('/api/v1/sessions/')({
try { body = await request.json() } catch { /* default */ }
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
const initialStatus = via === 'pairing' ? 'PENDING_PAIRING' : 'PENDING_QR'
const result = await createSession(auth.appId, via)
if (!result.ok) return jsonError(result.error, result.status)
const app = await db.query.apps.findFirst({
where: and(eq(apps.id, auth.appId), isNull(apps.deletedAt)),
columns: { id: true, maxSessions: true },
})
if (!app) return jsonError('App not found', 404)
const existing = await db.query.sessions.findMany({
where: and(
eq(sessions.appId, auth.appId),
),
columns: { status: true },
})
const activeSessions = existing.filter((s) =>
['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'].includes(s.status),
)
if (activeSessions.length >= Number(app.maxSessions)) {
return jsonError(`Session limit reached (max ${app.maxSessions})`, 429)
}
const [session] = await db
.insert(sessions)
.values({ appId: auth.appId, status: initialStatus as typeof sessions.$inferInsert['status'] })
.returning({ id: sessions.id, status: sessions.status, createdAt: sessions.createdAt })
if (via === 'qr') {
startSession(session.id, auth.appId).catch(console.error)
}
return json(session, 201)
return json(result.session, 201)
},
},
},

View file

@ -1,132 +1,10 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { getSessionFn } from '#/server/auth'
import { appQuery, apiKeysQuery } from '#/lib/query'
export const Route = createFileRoute('/apps/$appId')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
component: AppDetailPage,
component: () => <Outlet />,
})
interface ApiKey {
id: string
display: string
label: string
revokedAt: string | null
createdAt: string
}
function AppDetailPage() {
const { appId } = Route.useParams()
const { data: app } = useQuery(appQuery(appId))
const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(apiKeysQuery(appId))
const queryClient = useQueryClient()
const router = useRouter()
const [newKeyLabel, setNewKeyLabel] = useState('')
const [showingKey, setShowingKey] = useState<string | null>(null)
const [error, setError] = useState('')
async function generateKey(e: React.FormEvent) {
e.preventDefault()
setError('')
const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ label: newKeyLabel || undefined }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setShowingKey(data.key)
setNewKeyLabel('')
refetchKeys()
}
async function revokeKey(keyId: string) {
if (!confirm('Revoke this API key? This cannot be undone.')) return
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, { method: 'DELETE', credentials: 'include' })
refetchKeys()
}
async function deleteApp() {
if (!confirm(`Delete app "${app?.name}"? All API keys will be revoked.`)) return
await fetch(`/api/v1/admin/apps/${appId}`, { method: 'DELETE', credentials: 'include' })
queryClient.invalidateQueries({ queryKey: ['apps'] })
router.navigate({ to: '/apps' })
}
if (!app) return <div className="min-h-screen bg-zinc-950" />
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
<Link to="/apps" className="text-zinc-400 hover:text-white text-sm">← Apps</Link>
<span className="text-zinc-700">/</span>
<h1 className="font-semibold">{app.name}</h1>
</header>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
{showingKey && (
<div className="border border-amber-600 rounded-lg p-4 space-y-2 bg-amber-950/30">
<p className="text-sm font-medium text-amber-400">Copy this key — it won't be shown again</p>
<code className="block text-sm break-all bg-zinc-900 rounded px-3 py-2 text-amber-300 select-all">{showingKey}</code>
<button onClick={() => setShowingKey(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
</div>
)}
<section className="space-y-4">
<h2 className="text-base font-medium">API Keys</h2>
<form onSubmit={generateKey} className="flex gap-2">
<input
placeholder="Label (optional)"
value={newKeyLabel}
onChange={e => setNewKeyLabel(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm focus:border-zinc-500 focus:outline-none"
/>
<button type="submit" className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100">
Generate key
</button>
</form>
{error && <p className="text-sm text-red-400">{error}</p>}
{keys.length === 0 ? (
<p className="text-zinc-500 text-sm">No keys yet.</p>
) : (
<ul className="space-y-2">
{keys.map(k => (
<li key={k.id} className="border border-zinc-800 rounded-lg p-3 flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<code className="text-sm text-zinc-300">{k.display}</code>
{k.label && <span className="text-xs text-zinc-500">{k.label}</span>}
{k.revokedAt && <span className="text-xs text-red-400 bg-red-950/40 px-1.5 py-0.5 rounded">Revoked</span>}
</div>
<p className="text-xs text-zinc-600 mt-0.5">Created {new Date(k.createdAt).toLocaleDateString()}</p>
</div>
{!k.revokedAt && (
<button onClick={() => revokeKey(k.id)} className="text-xs text-zinc-500 hover:text-red-400">
Revoke
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="border-t border-zinc-800 pt-6">
<h2 className="text-base font-medium text-red-400 mb-3">Danger zone</h2>
<button onClick={deleteApp} className="rounded-md border border-red-800 px-3 py-1.5 text-sm text-red-400 hover:bg-red-950/40">
Delete app
</button>
</section>
</main>
</div>
)
}

View file

@ -0,0 +1,357 @@
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Key, Plus, Copy, Check, Trash2, AlertTriangle } from 'lucide-react'
import { appQuery, apiKeysQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { Badge } from '#/components/ui/badge'
import { Separator } from '#/components/ui/separator'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '#/components/ui/dialog'
import { cn } from '#/lib/utils'
export const Route = createFileRoute('/apps/$appId/')({
component: AppKeysPage,
})
interface ApiKey {
id: string
display: string
label: string
revokedAt: string | null
createdAt: string
}
function AppKeysPage() {
const { appId } = Route.useParams()
const { data: app } = useQuery(appQuery(appId))
const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(apiKeysQuery(appId))
const queryClient = useQueryClient()
const router = useRouter()
const [genLabel, setGenLabel] = useState('')
const [showingKey, setShowingKey] = useState<string | null>(null)
const [genOpen, setGenOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [error, setError] = useState('')
const [copied, setCopied] = useState(false)
const [generating, setGenerating] = useState(false)
const [deleting, setDeleting] = useState(false)
async function generateKey(e: React.FormEvent) {
e.preventDefault()
setError('')
setGenerating(true)
try {
const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ label: genLabel || undefined }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setShowingKey(data.key)
setGenLabel('')
setGenOpen(false)
refetchKeys()
} finally {
setGenerating(false)
}
}
async function revokeKey(keyId: string) {
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, {
method: 'DELETE',
credentials: 'include',
})
refetchKeys()
}
async function deleteApp() {
setDeleting(true)
try {
await fetch(`/api/v1/admin/apps/${appId}`, { method: 'DELETE', credentials: 'include' })
queryClient.invalidateQueries({ queryKey: ['apps'] })
router.navigate({ to: '/apps' })
} finally {
setDeleting(false)
}
}
async function copyKey() {
if (!showingKey) return
await navigator.clipboard.writeText(showingKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!app) return <div className="min-h-full" />
return (
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Key size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">API Keys</h1>
</div>
<Button
size="sm"
onClick={() => setGenOpen(true)}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New API key
</Button>
</div>
{/* New key banner */}
{showingKey && (
<div className="mx-8 mt-5 rounded-xl border border-amber-200 bg-amber-50 p-4 animate-fade-up">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center flex-shrink-0">
<Key size={14} className="text-amber-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold text-amber-800 mb-1">
Copy this key — it won't be shown again
</p>
<code className="block text-[12px] font-mono text-amber-700 bg-amber-100/60 border border-amber-200 rounded-lg px-3 py-2 break-all mb-2">
{showingKey}
</code>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={copyKey}
className="h-7 px-2.5 text-[12px] border-amber-300 text-amber-700 hover:bg-amber-100 gap-1.5"
>
{copied ? <Check size={11} /> : <Copy size={11} />}
{copied ? 'Copied!' : 'Copy'}
</Button>
<button
onClick={() => setShowingKey(null)}
className="text-[12px] text-amber-600 hover:text-amber-800 transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
)}
{/* Content */}
<div className="px-8 py-6 space-y-6 animate-fade-up max-w-3xl">
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h2 className="text-[13px] font-semibold text-gray-900">Keys</h2>
{keys.filter(k => !k.revokedAt).length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{keys.filter(k => !k.revokedAt).length} active
</span>
)}
</div>
</div>
{keys.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center">
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center mx-auto mb-3">
<Key size={16} className="text-gray-400" />
</div>
<p className="text-[13px] font-medium text-gray-700 mb-1">No API keys</p>
<p className="text-[12px] text-gray-400 mb-4">
Generate a key to authenticate API requests.
</p>
<Button
size="sm"
onClick={() => setGenOpen(true)}
className="h-7 px-3 text-[12px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={11} strokeWidth={2.5} />
Generate key
</Button>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-xl divide-y divide-gray-100 overflow-hidden">
{keys.map((k) => (
<div
key={k.id}
className={cn(
'flex items-center justify-between gap-4 px-4 py-3',
k.revokedAt && 'opacity-50',
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
'w-1.5 h-1.5 rounded-full flex-shrink-0',
k.revokedAt ? 'bg-red-400' : 'bg-emerald-400',
)} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-[12px] font-mono text-gray-700 truncate">
{k.display}…
</code>
{k.label && (
<span className="text-[11px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
{k.label}
</span>
)}
{k.revokedAt && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
Revoked
</Badge>
)}
</div>
<p className="text-[11px] text-gray-400 mt-0.5">
Created{' '}
{new Date(k.createdAt).toLocaleDateString('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
{!k.revokedAt && (
<button
onClick={() => revokeKey(k.id)}
className="text-[12px] text-gray-400 hover:text-red-500 transition-colors flex-shrink-0 px-2 py-1 rounded-md hover:bg-red-50"
>
Revoke
</button>
)}
</div>
))}
</div>
)}
</section>
<Separator className="my-2" />
{/* Danger zone */}
<section>
<h2 className="text-[13px] font-semibold text-red-500 mb-3 flex items-center gap-1.5">
<AlertTriangle size={13} strokeWidth={2} />
Danger zone
</h2>
<div className="bg-white border border-red-200 rounded-xl p-4 flex items-center justify-between gap-4">
<div>
<p className="text-[13px] font-medium text-gray-900">Delete app</p>
<p className="text-[12px] text-gray-500 mt-0.5">
Permanently delete this app and revoke all API keys. This cannot be undone.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
className="h-8 px-3 text-[13px] flex-shrink-0 gap-1.5"
>
<Trash2 size={12} strokeWidth={2} />
Delete
</Button>
</div>
</section>
</div>
{/* Generate Key Dialog */}
<Dialog
open={genOpen}
onOpenChange={(o) => {
setGenOpen(o)
if (!o) { setGenLabel(''); setError('') }
}}
>
<DialogContent className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle className="text-[15px]">Generate API key</DialogTitle>
<DialogDescription className="text-[13px]">
The key will only be shown once. Store it securely.
</DialogDescription>
</DialogHeader>
<form onSubmit={generateKey} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label htmlFor="key-label" className="text-[13px] text-zinc-700">
Label <span className="text-gray-400 font-normal">(optional)</span>
</Label>
<Input
id="key-label"
autoFocus
placeholder="e.g. Production, CI"
value={genLabel}
onChange={e => setGenLabel(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && (
<p className="text-[13px] text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => { setGenOpen(false); setGenLabel(''); setError('') }}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={generating}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
{generating ? 'Generating…' : 'Generate'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete App Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle className="text-[15px] text-red-600">Delete "{app.name}"?</DialogTitle>
<DialogDescription className="text-[13px]">
This will permanently delete the app, revoke all API keys, and cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDeleteOpen(false)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteApp}
disabled={deleting}
className="h-8 px-3 text-[13px]"
>
{deleting ? 'Deleting…' : 'Delete app'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,435 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ChevronLeft, Phone, User, Clock, RefreshCw, QrCode, Smartphone,
Power, Trash2, Key, AlertTriangle, ChevronDown,
} from 'lucide-react'
import { adminSessionDetailQuery, apiKeysQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '#/components/ui/dialog'
import { cn } from '#/lib/utils'
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
}
interface SessionDetail {
id: string
appId: string
status: string
phoneNumber: string | null
displayName: string | null
reconnectCount: number
lastSeen: string | null
createdAt: string
}
interface ApiKey {
id: string
display: string
label: string
revokedAt: string | null
}
export const Route = createFileRoute('/apps/$appId/sessions/$sessionId')({
loader: ({ params }) => ({ sessionId: params.sessionId, appId: params.appId }),
component: AppSessionDetailPage,
})
function AppSessionDetailPage() {
const { sessionId, appId } = Route.useLoaderData()
const router = useRouter()
const queryClient = useQueryClient()
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
adminSessionDetailQuery(sessionId),
)
const { data: allKeys = [] } = useQuery<ApiKey[]>(apiKeysQuery(appId))
const activeKeys = allKeys.filter(k => !k.revokedAt)
const [selectedKeyId, setSelectedKeyId] = useState('')
const [qr, setQr] = useState<string | null>(null)
const [pairingPhone, setPairingPhone] = useState('')
const [pairingCode, setPairingCode] = useState<string | null>(null)
const [error, setError] = useState('')
const [disconnectOpen, setDisconnectOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Auto-select first active key
useEffect(() => {
if (!selectedKeyId && activeKeys.length > 0) {
setSelectedKeyId(activeKeys[0].id)
}
}, [activeKeys, selectedKeyId])
function keyHint() {
return selectedKeyId ? { 'x-key-id': selectedKeyId } : {}
}
async function loadQr() {
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
credentials: 'include',
headers: keyHint(),
})
if (res.status === 200) {
const data = await res.json()
setQr(data.qr ?? null)
} else if (res.status === 409) {
setQr(null)
}
}
useEffect(() => {
if (!session) return
if (pollRef.current) clearInterval(pollRef.current)
if (session.status === 'PENDING_QR') {
loadQr()
pollRef.current = setInterval(() => { refetchSession(); loadQr() }, 3000)
} else if (session.status === 'CONNECTING') {
pollRef.current = setInterval(() => refetchSession(), 2000)
} else {
setQr(null)
}
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [session?.status])
async function requestPairingCode() {
if (!pairingPhone) { setError('Enter phone number'); return }
setError('')
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', ...keyHint() },
body: JSON.stringify({ phone: pairingPhone }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setPairingCode(data.pairingCode)
pollRef.current = setInterval(() => refetchSession(), 3000)
}
async function disconnect() {
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
method: 'POST',
credentials: 'include',
headers: keyHint(),
})
setDisconnectOpen(false)
refetchSession()
}
async function deleteSession() {
setDeleting(true)
try {
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
headers: keyHint(),
})
queryClient.invalidateQueries({ queryKey: ['apps', appId, 'sessions'] })
router.navigate({ to: '/apps/$appId/sessions', params: { appId } })
} finally {
setDeleting(false)
}
}
const style = session ? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!) : null
const canDisconnect = session &&
['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status)
const selectedKey = activeKeys.find(k => k.id === selectedKeyId)
return (
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Link
to="/apps/$appId/sessions"
params={{ appId }}
className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0"
>
<ChevronLeft size={16} strokeWidth={2} />
</Link>
<span className="text-gray-300">/</span>
<code className="text-[13px] text-gray-700 font-mono truncate">{sessionId.slice(0, 12)}…</code>
{style && (
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
<div className={cn('w-1.5 h-1.5 rounded-full', style.dot)} />
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
{style.label}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* API key selector */}
{activeKeys.length > 0 && (
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
<select
value={selectedKeyId}
onChange={e => setSelectedKeyId(e.target.value)}
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
>
{activeKeys.map(k => (
<option key={k.id} value={k.id}>
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
</option>
))}
</select>
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
</div>
)}
<button
onClick={() => refetchSession()}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
title="Refresh"
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
</div>
</div>
{/* Content */}
<div className="px-8 py-6 animate-fade-up">
{error && (
<div className="mb-4 max-w-2xl rounded-md bg-red-50 border border-red-200 px-3 py-2">
<p className="text-[13px] text-red-600">{error}</p>
</div>
)}
{!session ? (
<div className="flex items-center justify-center py-20">
<div className="w-5 h-5 border-2 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
</div>
) : (
<div className="grid gap-4 max-w-3xl lg:grid-cols-[1fr_320px]">
{/* Left column */}
<div className="space-y-4">
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Connection details
</p>
</div>
<div className="divide-y divide-gray-100">
{session.displayName && (
<div className="flex items-center gap-3 px-4 py-3">
<User size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Display name</span>
<span className="text-[13px] text-gray-900 font-medium">{session.displayName}</span>
</div>
)}
{session.phoneNumber && (
<div className="flex items-center gap-3 px-4 py-3">
<Phone size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Phone</span>
<code className="text-[13px] text-gray-900 font-mono">{session.phoneNumber}</code>
</div>
)}
{session.lastSeen && (
<div className="flex items-center gap-3 px-4 py-3">
<Clock size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Last seen</span>
<span className="text-[13px] text-gray-600">{new Date(session.lastSeen).toLocaleString()}</span>
</div>
)}
{session.reconnectCount > 0 && (
<div className="flex items-center gap-3 px-4 py-3">
<RefreshCw size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Reconnects</span>
<span className="text-[13px] text-gray-600">{session.reconnectCount}</span>
</div>
)}
<div className="flex items-center gap-3 px-4 py-3">
<Clock size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Created</span>
<span className="text-[13px] text-gray-600">{new Date(session.createdAt).toLocaleString()}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">Actions</p>
<div className="flex flex-wrap gap-2">
{canDisconnect && (
<Button
variant="outline"
size="sm"
onClick={() => setDisconnectOpen(true)}
className="h-8 px-3 text-[13px] gap-1.5"
>
<Power size={12} strokeWidth={2} />
Disconnect
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
className="h-8 px-3 text-[13px] text-red-500 hover:text-red-700 hover:bg-red-50 border-red-200 gap-1.5"
>
<Trash2 size={12} strokeWidth={2} />
Delete session
</Button>
</div>
</div>
</div>
{/* Right column — QR / pairing / status */}
<div>
{session.status === 'PENDING_QR' && (
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<QrCode size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-gray-900">Scan QR code</p>
</div>
{qr ? (
<>
<div className="flex justify-center">
<img
src={qr}
alt="WhatsApp QR code"
className="w-52 h-52 rounded-xl border border-gray-200 bg-white p-2"
/>
</div>
<p className="text-center text-[11px] text-gray-400">
Open WhatsApp → Settings → Linked Devices → Link a device
</p>
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
Refreshes automatically · expires in 5 min
</div>
</>
) : (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-gray-200 border-t-amber-500 rounded-full animate-spin" />
</div>
)}
</div>
)}
{session.status === 'PENDING_PAIRING' && !pairingCode && (
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<Smartphone size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-gray-900">Get pairing code</p>
</div>
<p className="text-[12px] text-gray-500">
Enter your WhatsApp phone number to receive a pairing code.
</p>
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">Phone number</Label>
<Input
type="tel"
placeholder="+1 234 567 8900"
value={pairingPhone}
onChange={e => setPairingPhone(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && <p className="text-[12px] text-red-600">{error}</p>}
<Button
onClick={requestPairingCode}
className="w-full h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
Get code
</Button>
</div>
)}
{pairingCode && session.status !== 'CONNECTED' && (
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-5 space-y-3">
<div className="flex items-center gap-2">
<Smartphone size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-amber-900">Enter this code</p>
</div>
<div className="text-center py-3">
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">
{pairingCode}
</code>
</div>
<p className="text-[11px] text-amber-700 text-center">
WhatsApp → Linked Devices → Link a Device → Enter phone number
</p>
</div>
)}
{session.status === 'CONNECTED' && (
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5 space-y-2.5">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot flex-shrink-0" />
<p className="text-[13px] font-semibold text-emerald-800">Session connected</p>
</div>
<p className="text-[12px] text-emerald-700 leading-relaxed">
This session is actively connected and ready to send/receive messages via the API.
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Disconnect Dialog */}
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px]">Disconnect session?</DialogTitle>
<DialogDescription className="text-[13px]">
The session will be logged out from WhatsApp. You can reconnect later.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
<Button size="sm" onClick={disconnect} className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Disconnect</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px] flex items-center gap-2">
<AlertTriangle size={15} className="text-red-500" />
Delete session?
</DialogTitle>
<DialogDescription className="text-[13px]">
All credentials will be permanently wiped. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDeleteOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
<Button variant="destructive" size="sm" onClick={deleteSession} disabled={deleting} className="h-8 px-3 text-[13px]">
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,178 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { Zap, Plus, RefreshCw, Key, ChevronDown } from 'lucide-react'
import { appSessionsQuery, apiKeysQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { cn } from '#/lib/utils'
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
}
const FALLBACK_STYLE = STATUS_STYLES.DISCONNECTED!
export const Route = createFileRoute('/apps/$appId/sessions/')({
component: AppSessionsPage,
})
function AppSessionsPage() {
const { appId } = Route.useParams()
const { data: sessions = [], refetch } = useQuery(appSessionsQuery(appId))
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId))
const [selectedKeyId, setSelectedKeyId] = useState<string>('')
const activeKeys = apiKeys.filter((k: { revokedAt: string | null }) => !k.revokedAt)
useEffect(() => {
if (activeKeys.length > 0 && !selectedKeyId) {
setSelectedKeyId(activeKeys[0].id)
}
}, [activeKeys, selectedKeyId])
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length
const pendingCount = sessions.filter(s =>
['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING'].includes(s.status),
).length
return (
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Sessions</h1>
{sessions.length > 0 && (
<div className="flex items-center gap-1.5 ml-1">
<span className="text-[11px] font-medium text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5">
{connectedCount} connected
</span>
{pendingCount > 0 && (
<span className="text-[11px] font-medium text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
{pendingCount} pending
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* API key selector */}
{activeKeys.length > 0 && (
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
<select
value={selectedKeyId}
onChange={e => setSelectedKeyId(e.target.value)}
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
>
{activeKeys.map((k: { id: string; label: string; display: string }) => (
<option key={k.id} value={k.id}>
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
</option>
))}
</select>
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
</div>
)}
<button
onClick={() => refetch()}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
title="Refresh"
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
<Link to="/apps/$appId/sessions/new" params={{ appId }}>
<Button
size="sm"
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New session
</Button>
</Link>
</div>
</div>
{/* Content */}
<div className="px-8 py-6 animate-fade-up">
{sessions.length === 0 ? (
<div className="mt-16 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
<Zap size={20} className="text-gray-400" strokeWidth={1.5} />
</div>
<p className="text-sm font-medium text-gray-900 mb-1">No sessions yet</p>
<p className="text-[13px] text-gray-500 mb-5">
Create a session to connect a WhatsApp number to this app.
</p>
<Link to="/apps/$appId/sessions/new" params={{ appId }}>
<Button
size="sm"
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New session
</Button>
</Link>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
{/* Table header */}
<div className="grid grid-cols-[1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Session</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Last seen</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Reconnects</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-100">
{sessions.map((s) => {
const style = STATUS_STYLES[s.status] ?? FALLBACK_STYLE
return (
<Link
key={s.id}
to="/apps/$appId/sessions/$sessionId"
params={{ appId, sessionId: s.id }}
className="grid grid-cols-[1fr_140px_140px_80px] gap-4 px-4 py-3 hover:bg-gray-50 transition-colors items-center"
>
<code className="text-[12px] font-mono text-gray-600 truncate">
{s.id.slice(0, 12)}…
</code>
<div className="flex items-center gap-1.5">
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', style.dot)} />
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
{style.label}
</span>
</div>
<span className="text-[12px] text-gray-400">
{s.lastSeen
? new Date(s.lastSeen).toLocaleString('en', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: new Date(s.createdAt).toLocaleDateString('en', {
month: 'short',
day: 'numeric',
})}
</span>
<span className="text-[12px] text-gray-400">
{s.reconnectCount > 0 ? s.reconnectCount : '—'}
</span>
</Link>
)
})}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,182 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { ChevronLeft, QrCode, Smartphone, Key } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { apiKeysQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { Label } from '#/components/ui/label'
import { cn } from '#/lib/utils'
export const Route = createFileRoute('/apps/$appId/sessions/new')({
component: AppNewSessionPage,
})
function AppNewSessionPage() {
const { appId } = Route.useParams()
const router = useRouter()
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId))
const activeKeys = apiKeys.filter((k: { revokedAt: string | null }) => !k.revokedAt)
const [selectedKeyId, setSelectedKeyId] = useState<string>('')
useEffect(() => {
if (activeKeys.length > 0 && !selectedKeyId) {
setSelectedKeyId(activeKeys[0].id)
}
}, [activeKeys, selectedKeyId])
const [via, setVia] = useState<'qr' | 'pairing'>('qr')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!selectedKeyId) { setError('Select an API key'); return }
setError('')
setLoading(true)
try {
const res = await fetch('/api/v1/admin/sessions', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', 'x-key-id': selectedKeyId },
body: JSON.stringify({ appId, connect_via: via }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed to create session'); return }
router.navigate({
to: '/apps/$appId/sessions/$sessionId',
params: { appId, sessionId: data.id },
})
} finally {
setLoading(false)
}
}
return (
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center gap-2">
<Link
to="/apps/$appId/sessions"
params={{ appId }}
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<ChevronLeft size={16} strokeWidth={2} />
</Link>
<span className="text-gray-300">/</span>
<h1 className="text-[14px] font-semibold text-gray-900">New session</h1>
</div>
{/* Form */}
<div className="px-8 py-8 animate-fade-up">
<div className="max-w-md">
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<p className="text-[13px] font-medium text-gray-900 mb-0.5">Connect WhatsApp</p>
<p className="text-[12px] text-gray-500">
Select an API key and choose how to authenticate the WhatsApp number.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* API key selector */}
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">API key</Label>
{activeKeys.length === 0 ? (
<div className="rounded-md bg-amber-50 border border-amber-200 px-3 py-2">
<p className="text-[12px] text-amber-700">
No active API keys found. Create one on the API Keys page first.
</p>
</div>
) : (
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-9">
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
<select
value={selectedKeyId}
onChange={e => setSelectedKeyId(e.target.value)}
className="flex-1 text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
>
{activeKeys.map((k: { id: string; label: string; display: string }) => (
<option key={k.id} value={k.id}>
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
</option>
))}
</select>
<svg className="text-gray-400 absolute right-2.5 pointer-events-none w-3 h-3" viewBox="0 0 12 12" fill="none">
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
)}
</div>
{/* Connection method */}
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">Connection method</Label>
<div className="grid grid-cols-2 gap-2">
{([
{ value: 'qr', label: 'QR Code', desc: 'Scan with WhatsApp', icon: QrCode },
{ value: 'pairing', label: 'Pairing Code', desc: 'Enter a code', icon: Smartphone },
] as const).map(({ value, label, desc, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setVia(value)}
className={cn(
'flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all',
via === value
? 'border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900'
: 'border-gray-200 hover:border-gray-300',
)}
>
<div className="flex items-center gap-1.5">
<Icon
size={13}
className={via === value ? 'text-zinc-900' : 'text-gray-400'}
strokeWidth={1.75}
/>
<span
className={cn(
'text-[12px] font-semibold',
via === value ? 'text-zinc-900' : 'text-gray-600',
)}
>
{label}
</span>
</div>
<span className="text-[11px] text-gray-400">{desc}</span>
</button>
))}
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2">
<p className="text-[13px] text-red-600">{error}</p>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="submit"
disabled={loading}
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
>
{loading ? 'Creating…' : 'Create session'}
</Button>
<Link to="/apps/$appId/sessions" params={{ appId }}>
<Button
type="button"
variant="outline"
className="h-9 px-4 text-[13px]"
>
Cancel
</Button>
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,8 +1,19 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Plus, LayoutGrid, ChevronRight, Zap, Calendar, X } from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { appsQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '#/components/ui/dialog'
export const Route = createFileRoute('/apps/')({
beforeLoad: async () => {
@ -22,14 +33,16 @@ interface App {
function AppsPage() {
const { data: apps = [] } = useQuery<App[]>(appsQuery)
const queryClient = useQueryClient()
const router = useRouter()
const [creating, setCreating] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [name, setName] = useState('')
const [error, setError] = useState('')
const [creating, setCreating] = useState(false)
async function createApp(e: React.FormEvent) {
e.preventDefault()
setError('')
setCreating(true)
try {
const res = await fetch('/api/v1/admin/apps', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -41,80 +54,142 @@ function AppsPage() {
setError(d.error ?? 'Failed to create app')
return
}
setCreating(false)
setDialogOpen(false)
setName('')
queryClient.invalidateQueries({ queryKey: ['apps'] })
} finally {
setCreating(false)
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
router.navigate({ to: '/login' })
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between">
<h1 className="font-semibold">Wahoo</h1>
<div className="flex items-center gap-4 text-sm">
<Link to="/audit-log" className="text-zinc-400 hover:text-white">Audit log</Link>
<button onClick={handleLogout} className="text-zinc-400 hover:text-white">Sign out</button>
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<LayoutGrid size={16} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Apps</h1>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Apps</h2>
<button
onClick={() => setCreating(true)}
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
<Button
size="sm"
onClick={() => setDialogOpen(true)}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New app
</button>
</Button>
</div>
{creating && (
<form onSubmit={createApp} className="border border-zinc-800 rounded-lg p-4 space-y-3">
<input
autoFocus
required
placeholder="App name"
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100">
Create
</button>
<button type="button" onClick={() => { setCreating(false); setName('') }} className="text-sm text-zinc-500 hover:text-white px-3 py-1.5">
Cancel
</button>
</div>
</form>
)}
{/* Content */}
<div className="px-8 py-6 animate-fade-up">
{apps.length === 0 ? (
<p className="text-zinc-500 text-sm">No apps yet. Create one to get started.</p>
<div className="mt-16 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
<LayoutGrid size={20} className="text-gray-400" strokeWidth={1.5} />
</div>
<p className="text-sm font-medium text-gray-900 mb-1">No apps yet</p>
<p className="text-[13px] text-gray-500 mb-5">
Create an app to start managing WhatsApp sessions.
</p>
<Button
size="sm"
onClick={() => setDialogOpen(true)}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
Create your first app
</Button>
</div>
) : (
<ul className="space-y-2">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl">
{apps.map((app) => (
<li key={app.id}>
<Link
key={app.id}
to="/apps/$appId"
params={{ appId: app.id }}
className="block border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"
className="group bg-white border border-gray-200 rounded-xl p-5 hover:border-gray-300 hover:shadow-sm transition-all duration-150 block"
>
<p className="font-medium">{app.name}</p>
<p className="text-xs text-zinc-500 mt-1">
Max sessions: {app.maxSessions} · Created {new Date(app.createdAt).toLocaleDateString()}
</p>
<div className="flex items-start justify-between mb-4">
<div className="w-9 h-9 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center">
<LayoutGrid size={15} className="text-emerald-600" strokeWidth={1.75} />
</div>
<ChevronRight
size={15}
className="text-gray-300 group-hover:text-gray-500 transition-colors mt-0.5"
strokeWidth={2}
/>
</div>
<p className="font-semibold text-[14px] text-gray-900 mb-1 truncate">{app.name}</p>
<div className="flex items-center gap-3 mt-3">
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
<Zap size={11} strokeWidth={1.75} />
{app.maxSessions} sessions max
</span>
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
<Calendar size={11} strokeWidth={1.75} />
{new Date(app.createdAt).toLocaleDateString('en', {
month: 'short',
day: 'numeric',
})}
</span>
</div>
</Link>
</li>
))}
</ul>
</div>
)}
</main>
</div>
{/* Create App Dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) { setName(''); setError('') } }}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-[15px]">Create app</DialogTitle>
<DialogDescription className="text-[13px]">
An app is a container for WhatsApp sessions and API keys.
</DialogDescription>
</DialogHeader>
<form onSubmit={createApp} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label htmlFor="app-name" className="text-[13px] text-zinc-700">App name</Label>
<Input
id="app-name"
autoFocus
required
placeholder="My WhatsApp Bot"
value={name}
onChange={e => setName(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && (
<p className="text-[13px] text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => { setDialogOpen(false); setName(''); setError('') }}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={creating}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
{creating ? 'Creating…' : 'Create app'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -1,8 +1,11 @@
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { ScrollText, Search, RefreshCw } from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { auditLogQuery } from '#/lib/query'
import { Input } from '#/components/ui/input'
import { cn } from '#/lib/utils'
export const Route = createFileRoute('/audit-log')({
beforeLoad: async () => {
@ -23,65 +26,149 @@ interface AuditEntry {
createdAt: string
}
const ACTION_COLORS: Record<string, string> = {
login: 'bg-blue-50 text-blue-700 border-blue-200',
logout: 'bg-gray-50 text-gray-600 border-gray-200',
'app.create': 'bg-emerald-50 text-emerald-700 border-emerald-200',
'app.delete': 'bg-red-50 text-red-600 border-red-200',
'session.create': 'bg-emerald-50 text-emerald-700 border-emerald-200',
'session.delete': 'bg-red-50 text-red-600 border-red-200',
'session.disconnect': 'bg-orange-50 text-orange-700 border-orange-200',
'api_key.create': 'bg-violet-50 text-violet-700 border-violet-200',
'api_key.revoke': 'bg-red-50 text-red-600 border-red-200',
}
function ActionBadge({ action }: { action: string }) {
const color = ACTION_COLORS[action] ?? 'bg-gray-50 text-gray-600 border-gray-200'
return (
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border font-mono', color)}>
{action}
</span>
)
}
function AuditLogPage() {
const { data } = useQuery<{ data: AuditEntry[] }>(auditLogQuery)
const { data, refetch } = useQuery<{ data: AuditEntry[] }>(auditLogQuery)
const entries = data?.data ?? []
const [filter, setFilter] = useState('')
const filtered = entries.filter((e: AuditEntry) =>
!filter || e.action.includes(filter) || e.resourceType.includes(filter)
const filtered = entries.filter(e =>
!filter ||
e.action.toLowerCase().includes(filter.toLowerCase()) ||
e.resourceType.toLowerCase().includes(filter.toLowerCase()) ||
e.actorType.toLowerCase().includes(filter.toLowerCase()),
)
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
<Link to="/apps" className="text-zinc-400 hover:text-white text-sm">← Apps</Link>
<span className="text-zinc-700">/</span>
<h1 className="font-semibold">Audit log</h1>
</header>
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<ScrollText size={16} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Audit Log</h1>
{entries.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{entries.length} entries
</span>
)}
</div>
<button
onClick={() => refetch()}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
title="Refresh"
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
</div>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-4">
<input
placeholder="Filter by action or resource…"
{/* Content */}
<div className="px-8 py-6 animate-fade-up">
{/* Filter */}
<div className="relative max-w-xs mb-5">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<Input
placeholder="Filter by action, resource…"
value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full max-w-sm rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm focus:border-zinc-500 focus:outline-none"
className="h-8 pl-8 text-[13px] bg-white"
/>
</div>
{filtered.length === 0 ? (
<p className="text-zinc-500 text-sm">No entries.</p>
<div className="text-center py-16">
<ScrollText size={28} className="text-gray-300 mx-auto mb-3" />
<p className="text-[13px] font-medium text-gray-500">
{filter ? 'No matching entries' : 'No audit log entries yet'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-zinc-500 border-b border-zinc-800">
<th className="pb-2 pr-4 font-normal">Time</th>
<th className="pb-2 pr-4 font-normal">Action</th>
<th className="pb-2 pr-4 font-normal">Resource</th>
<th className="pb-2 pr-4 font-normal">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-900">
{filtered.map((e: AuditEntry) => (
<tr key={e.id} className="text-zinc-300">
<td className="py-2 pr-4 text-zinc-500 whitespace-nowrap">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4">
<code className="text-xs bg-zinc-900 px-1.5 py-0.5 rounded">{e.action}</code>
</td>
<td className="py-2 pr-4">
<span className="text-zinc-400">{e.resourceType}</span>
{e.resourceId && <span className="text-zinc-600 ml-1 text-xs">{e.resourceId.slice(0, 8)}</span>}
</td>
<td className="py-2 pr-4 text-zinc-500 text-xs">{e.ip ?? '—'}</td>
</tr>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
{/* Header */}
<div className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Time</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Action</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Resource</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">IP</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-100">
{filtered.map(e => (
<div
key={e.id}
className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 items-center hover:bg-gray-50 transition-colors"
>
{/* Time */}
<div>
<p className="text-[11px] text-gray-500 font-mono whitespace-nowrap">
{new Date(e.createdAt).toLocaleDateString('en', {
month: 'short',
day: 'numeric',
})}
</p>
<p className="text-[11px] text-gray-400 font-mono">
{new Date(e.createdAt).toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</p>
</div>
{/* Action + actor */}
<div className="flex items-center gap-2 flex-wrap">
<ActionBadge action={e.action} />
<span className="text-[11px] text-gray-400">
by{' '}
<span className={cn(
'font-medium',
e.actorType === 'user' ? 'text-blue-600' : 'text-violet-600',
)}>
{e.actorType}
</span>
</span>
</div>
{/* Resource */}
<div>
<span className="text-[12px] text-gray-700">{e.resourceType}</span>
{e.resourceId && (
<code className="text-[11px] text-gray-400 ml-1.5 font-mono">
{e.resourceId.slice(0, 8)}
</code>
)}
</div>
{/* IP */}
<span className="text-[11px] text-gray-400 font-mono">
{e.ip ?? '—'}
</span>
</div>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
</div>
)
}

View file

@ -1,17 +1,26 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { MessageSquare, Shield, Zap, Globe } from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { cn } from '#/lib/utils'
export const Route = createFileRoute('/login')({
beforeLoad: async () => {
const session = await getSessionFn()
if (session?.userId) {
throw redirect({ to: '/apps' })
}
if (session?.userId) throw redirect({ to: '/apps' })
},
component: LoginPage,
})
const features = [
{ icon: Zap, label: 'Session management', desc: 'QR and pairing code flows' },
{ icon: Shield, label: 'API key auth', desc: 'Per-app bearer tokens' },
{ icon: Globe, label: 'Webhook events', desc: 'Real-time message delivery' },
]
function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
@ -25,7 +34,6 @@ function LoginPage() {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
@ -34,17 +42,8 @@ function LoginPage() {
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Login failed')
return
}
if (data.requireTotp) {
setRequireTotp(true)
return
}
if (!res.ok) { setError(data.error ?? 'Login failed'); return }
if (data.requireTotp) { setRequireTotp(true); return }
router.navigate({ to: '/apps' })
} catch {
setError('Network error')
@ -54,45 +53,131 @@ function LoginPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
<div className="w-full max-w-sm space-y-6 p-8">
<div className="space-y-1">
<h1 className="text-2xl font-semibold text-white">Sign in</h1>
<p className="text-sm text-zinc-400">Wahoo dashboard</p>
<div className="min-h-screen flex">
{/* Left panel — branding */}
<div className="hidden lg:flex lg:w-[52%] bg-zinc-950 flex-col justify-between p-10 relative overflow-hidden">
{/* Subtle grid texture */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}}
/>
{/* Glow accent */}
<div className="absolute top-0 left-0 w-[500px] h-[500px] rounded-full bg-emerald-500/10 blur-[120px] pointer-events-none" />
{/* Logo */}
<div className="relative flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/40">
<MessageSquare size={18} className="text-white fill-white" strokeWidth={1.5} />
</div>
<span
className="text-white text-xl font-semibold tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
>
wahoo
</span>
</div>
{/* Main copy */}
<div className="relative space-y-6">
<h1
className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
>
WhatsApp sessions,{' '}
<span className="text-emerald-400">delivered</span>{' '}
via API.
</h1>
<p className="text-zinc-400 text-[15px] leading-relaxed max-w-sm">
Manage WhatsApp connections at scale. Generate API keys, track sessions, and integrate
messaging into your stack without limits.
</p>
<div className="space-y-3 pt-2">
{features.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0">
<Icon size={14} className="text-emerald-400" strokeWidth={1.75} />
</div>
<div>
<span className="text-zinc-100 text-[13px] font-medium">{label}</span>
<span className="text-zinc-500 text-[13px]"> — {desc}</span>
</div>
</div>
))}
</div>
</div>
{/* Footer note */}
<p className="relative text-zinc-600 text-xs">
Wahoo Gateway · Powered by Baileys
</p>
</div>
{/* Right panel — form */}
<div className="flex-1 flex items-center justify-center bg-white px-6">
<div className="w-full max-w-[360px] space-y-7 animate-fade-up">
{/* Mobile logo */}
<div className="flex lg:hidden items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center">
<MessageSquare size={13} className="text-white fill-white" strokeWidth={1.5} />
</div>
<span
className="text-zinc-900 font-semibold text-base"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
>
wahoo
</span>
</div>
<div className="space-y-1.5">
<h2 className="text-[22px] font-semibold text-zinc-900 tracking-tight">
{requireTotp ? 'Two-factor auth' : 'Sign in'}
</h2>
<p className="text-zinc-500 text-sm">
{requireTotp
? 'Enter the 6-digit code from your authenticator app.'
: 'Enter your credentials to access the dashboard.'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!requireTotp ? (
<>
<div className="space-y-1">
<label className="text-sm text-zinc-300" htmlFor="email">Email</label>
<input
<div className="space-y-1.5">
<Label htmlFor="email" className="text-zinc-700 text-[13px]">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
placeholder="you@company.com"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-zinc-600 focus:outline-none"
className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
/>
</div>
<div className="space-y-1">
<label className="text-sm text-zinc-300" htmlFor="password">Password</label>
<input
<div className="space-y-1.5">
<Label htmlFor="password" className="text-zinc-700 text-[13px]">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-zinc-600 focus:outline-none"
className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
/>
</div>
</>
) : (
<div className="space-y-1">
<label className="text-sm text-zinc-300" htmlFor="totp">Authenticator code</label>
<input
<div className="space-y-1.5">
<Label htmlFor="totp" className="text-zinc-700 text-[13px]">Authenticator code</Label>
<Input
id="totp"
type="text"
inputMode="numeric"
@ -100,35 +185,43 @@ function LoginPage() {
maxLength={6}
autoFocus
required
placeholder="000 000"
value={totpToken}
onChange={e => setTotpToken(e.target.value)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-zinc-600 focus:outline-none tracking-widest"
placeholder="000000"
className="h-9 text-sm tracking-[0.2em] font-mono border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
/>
</div>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
{error && (
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2">
<p className="text-[13px] text-red-600">{error}</p>
</div>
)}
<button
<Button
type="submit"
disabled={loading}
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-100 disabled:opacity-50"
className={cn(
'w-full h-9 text-sm font-medium bg-zinc-900 hover:bg-zinc-800 text-white transition-colors',
loading && 'opacity-60 cursor-not-allowed',
)}
>
{loading ? 'Signing in…' : requireTotp ? 'Verify' : 'Sign in'}
</button>
</Button>
{requireTotp && (
<button
type="button"
onClick={() => { setRequireTotp(false); setTotpToken('') }}
className="w-full text-sm text-zinc-500 hover:text-zinc-300"
className="w-full text-[13px] text-zinc-400 hover:text-zinc-700 transition-colors"
>
Back
← Back to password
</button>
)}
</form>
</div>
</div>
</div>
)
}

View file

@ -1,26 +1,50 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ChevronLeft, Phone, User, Clock, RefreshCw, QrCode, Smartphone,
Power, Trash2, Key, AlertTriangle, ChevronDown,
} from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { adminSessionDetailQuery, apiKeysQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '#/components/ui/dialog'
import { cn } from '#/lib/utils'
const STATUS_COLOR: Record<string, string> = {
CONNECTED: 'text-green-400 bg-green-950/40',
CONNECTING: 'text-blue-400 bg-blue-950/40',
PENDING_QR: 'text-yellow-400 bg-yellow-950/40',
PENDING_PAIRING: 'text-yellow-400 bg-yellow-950/40',
DISCONNECTED: 'text-zinc-400 bg-zinc-800',
LOGGED_OUT: 'text-red-400 bg-red-950/40',
DEAD: 'text-red-500 bg-red-950/60',
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
}
interface SessionDetail {
id: string
appId: string
status: string
phoneNumber: string | null
displayName: string | null
reconnectCount: number
lastSeen: string | null
createdAt: string
appId: string
}
interface ApiKey {
id: string
display: string
label: string
revokedAt: string | null
}
export const Route = createFileRoute('/sessions/$id')({
@ -28,39 +52,51 @@ export const Route = createFileRoute('/sessions/$id')({
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async ({ params }) => {
// Dashboard can't call API-key-gated endpoints directly — return null and let component handle
return { sessionId: params.id }
},
loader: async ({ params }) => ({ sessionId: params.id }),
component: SessionDetailPage,
})
function SessionDetailPage() {
const { sessionId } = Route.useLoaderData()
const router = useRouter()
const queryClient = useQueryClient()
const [apiKey, setApiKey] = useState('')
const [apiKeyInput, setApiKeyInput] = useState('')
const [session, setSession] = useState<SessionDetail | null>(null)
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
adminSessionDetailQuery(sessionId),
)
const appId = session?.appId ?? ''
const { data: allKeys = [] } = useQuery<ApiKey[]>({
...apiKeysQuery(appId),
enabled: !!appId,
})
const activeKeys = allKeys.filter(k => !k.revokedAt)
const [selectedKeyId, setSelectedKeyId] = useState('')
const [qr, setQr] = useState<string | null>(null)
const [pairingPhone, setPairingPhone] = useState('')
const [pairingCode, setPairingCode] = useState<string | null>(null)
const [error, setError] = useState('')
const [disconnectOpen, setDisconnectOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
function authHeader(): Record<string, string> {
return apiKey ? { authorization: `Bearer ${apiKey}` } : {}
useEffect(() => {
if (!selectedKeyId && activeKeys.length > 0) {
setSelectedKeyId(activeKeys[0].id)
}
}, [activeKeys, selectedKeyId])
async function loadSession() {
if (!apiKey) return
const res = await fetch(`/api/v1/sessions/${sessionId}`, { headers: authHeader() })
if (res.ok) setSession(await res.json())
function keyHint() {
return selectedKeyId ? { 'x-key-id': selectedKeyId } : {}
}
async function loadQr() {
if (!apiKey) return
const res = await fetch(`/api/v1/sessions/${sessionId}/qr`, { headers: authHeader() })
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
credentials: 'include',
headers: keyHint(),
})
if (res.status === 200) {
const data = await res.json()
setQr(data.qr ?? null)
@ -69,23 +105,15 @@ function SessionDetailPage() {
}
}
useEffect(() => {
if (!apiKey) return
loadSession()
}, [apiKey])
useEffect(() => {
if (!session) return
if (pollRef.current) clearInterval(pollRef.current)
if (session.status === 'PENDING_QR') {
loadQr()
pollRef.current = setInterval(() => {
loadSession()
loadQr()
}, 3000)
pollRef.current = setInterval(() => { refetchSession(); loadQr() }, 3000)
} else if (session.status === 'CONNECTING') {
pollRef.current = setInterval(loadSession, 2000)
pollRef.current = setInterval(() => refetchSession(), 2000)
} else {
setQr(null)
}
@ -96,156 +124,280 @@ function SessionDetailPage() {
async function requestPairingCode() {
if (!pairingPhone) { setError('Enter phone number'); return }
setError('')
const res = await fetch(`/api/v1/sessions/${sessionId}/pair`, {
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeader() },
credentials: 'include',
headers: { 'content-type': 'application/json', ...keyHint() },
body: JSON.stringify({ phone: pairingPhone }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setPairingCode(data.pairingCode)
pollRef.current = setInterval(loadSession, 3000)
pollRef.current = setInterval(() => refetchSession(), 3000)
}
async function disconnect() {
if (!confirm('Disconnect this session?')) return
await fetch(`/api/v1/sessions/${sessionId}/disconnect`, {
method: 'POST', headers: authHeader(),
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
method: 'POST',
credentials: 'include',
headers: keyHint(),
})
loadSession()
setDisconnectOpen(false)
refetchSession()
}
async function deleteSession() {
if (!confirm('Delete session and wipe all credentials? This cannot be undone.')) return
await fetch(`/api/v1/sessions/${sessionId}`, {
method: 'DELETE', headers: authHeader(),
setDeleting(true)
try {
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
headers: keyHint(),
})
queryClient.invalidateQueries({ queryKey: ['sessions'] })
router.navigate({ to: '/sessions' })
} finally {
setDeleting(false)
}
}
if (!apiKey) {
const style = session ? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!) : null
const canDisconnect = session &&
['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status)
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4">
<Link to="/sessions" className="text-zinc-400 hover:text-white text-sm">← Sessions</Link>
</header>
<main className="max-w-md mx-auto px-6 py-12 space-y-4">
<p className="text-zinc-400 text-sm">Enter the API key for this session's app to continue.</p>
<div className="flex gap-2">
<input
type="password"
placeholder="wak_..."
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
<button
onClick={() => setApiKey(apiKeyInput)}
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Link to="/sessions" className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0">
<ChevronLeft size={16} strokeWidth={2} />
</Link>
<span className="text-gray-300">/</span>
<code className="text-[13px] text-gray-700 font-mono truncate">{sessionId.slice(0, 12)}…</code>
{style && (
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
<div className={cn('w-1.5 h-1.5 rounded-full', style.dot)} />
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
{style.label}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* API key selector */}
{activeKeys.length > 0 && (
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
<select
value={selectedKeyId}
onChange={e => setSelectedKeyId(e.target.value)}
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
>
Load
{activeKeys.map(k => (
<option key={k.id} value={k.id}>
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
</option>
))}
</select>
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
</div>
)}
<button
onClick={() => refetchSession()}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
title="Refresh"
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
<Link to="/sessions" className="text-zinc-400 hover:text-white text-sm">← Sessions</Link>
<span className="text-zinc-700">/</span>
<span className="font-mono text-sm">{sessionId.slice(0, 8)}…</span>
{session && (
<span className={`ml-auto text-xs px-2 py-0.5 rounded font-medium ${STATUS_COLOR[session.status] ?? 'text-zinc-400'}`}>
{session.status}
</span>
{/* Content */}
<div className="px-8 py-6 animate-fade-up">
{error && (
<div className="mb-4 max-w-2xl rounded-md bg-red-50 border border-red-200 px-3 py-2">
<p className="text-[13px] text-red-600">{error}</p>
</div>
)}
</header>
<main className="max-w-2xl mx-auto px-6 py-8 space-y-8">
{error && <p className="text-sm text-red-400">{error}</p>}
{session && (
<section className="space-y-2 text-sm">
{session.phoneNumber && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Phone</span>{session.phoneNumber}</p>
)}
{!session ? (
<div className="flex items-center justify-center py-20">
<div className="w-5 h-5 border-2 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
</div>
) : (
<div className="grid gap-4 max-w-3xl lg:grid-cols-[1fr_320px]">
<div className="space-y-4">
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Connection details
</p>
</div>
<div className="divide-y divide-gray-100">
{session.displayName && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Name</span>{session.displayName}</p>
<div className="flex items-center gap-3 px-4 py-3">
<User size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Display name</span>
<span className="text-[13px] text-gray-900 font-medium">{session.displayName}</span>
</div>
)}
{session.phoneNumber && (
<div className="flex items-center gap-3 px-4 py-3">
<Phone size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Phone</span>
<code className="text-[13px] text-gray-900 font-mono">{session.phoneNumber}</code>
</div>
)}
{session.lastSeen && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Last seen</span>{new Date(session.lastSeen).toLocaleString()}</p>
<div className="flex items-center gap-3 px-4 py-3">
<Clock size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Last seen</span>
<span className="text-[13px] text-gray-600">{new Date(session.lastSeen).toLocaleString()}</span>
</div>
)}
{session.reconnectCount > 0 && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Reconnects</span>{session.reconnectCount}</p>
)}
</section>
)}
{qr && session?.status === 'PENDING_QR' && (
<section className="space-y-3">
<p className="text-sm text-zinc-400">Scan this QR code with WhatsApp to connect.</p>
<img src={qr} alt="QR code" className="w-56 h-56 rounded-lg border border-zinc-700 bg-white p-2" />
<p className="text-xs text-zinc-600">Refreshes automatically. Expires after 5 minutes.</p>
</section>
)}
{session?.status === 'PENDING_QR' && !qr && (
<p className="text-sm text-zinc-500">Waiting for QR code…</p>
)}
{session?.status === 'PENDING_PAIRING' && !pairingCode && (
<section className="space-y-3">
<p className="text-sm text-zinc-400">Enter your WhatsApp phone number to receive a pairing code.</p>
<div className="flex gap-2">
<input
type="tel"
placeholder="+1234567890"
value={pairingPhone}
onChange={(e) => setPairingPhone(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
<button
onClick={requestPairingCode}
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
>
Get code
</button>
<div className="flex items-center gap-3 px-4 py-3">
<RefreshCw size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Reconnects</span>
<span className="text-[13px] text-gray-600">{session.reconnectCount}</span>
</div>
)}
<div className="flex items-center gap-3 px-4 py-3">
<Clock size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Created</span>
<span className="text-[13px] text-gray-600">{new Date(session.createdAt).toLocaleString()}</span>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">Actions</p>
<div className="flex flex-wrap gap-2">
{canDisconnect && (
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(true)} className="h-8 px-3 text-[13px] gap-1.5">
<Power size={12} strokeWidth={2} />
Disconnect
</Button>
)}
<Button
variant="outline" size="sm" onClick={() => setDeleteOpen(true)}
className="h-8 px-3 text-[13px] text-red-500 hover:text-red-700 hover:bg-red-50 border-red-200 gap-1.5"
>
<Trash2 size={12} strokeWidth={2} />
Delete session
</Button>
</div>
</div>
</div>
<div>
{session.status === 'PENDING_QR' && (
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<QrCode size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-gray-900">Scan QR code</p>
</div>
{qr ? (
<>
<div className="flex justify-center">
<img src={qr} alt="WhatsApp QR code" className="w-52 h-52 rounded-xl border border-gray-200 bg-white p-2" />
</div>
<p className="text-center text-[11px] text-gray-400">
Open WhatsApp → Settings → Linked Devices → Link a device
</p>
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
Refreshes automatically · expires in 5 min
</div>
</>
) : (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-gray-200 border-t-amber-500 rounded-full animate-spin" />
</div>
)}
</div>
)}
{session.status === 'PENDING_PAIRING' && !pairingCode && (
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<Smartphone size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-gray-900">Get pairing code</p>
</div>
<p className="text-[12px] text-gray-500">Enter your WhatsApp phone number.</p>
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">Phone number</Label>
<Input type="tel" placeholder="+1 234 567 8900" value={pairingPhone} onChange={e => setPairingPhone(e.target.value)} className="h-9 text-sm" />
</div>
{error && <p className="text-[12px] text-red-600">{error}</p>}
<Button onClick={requestPairingCode} className="w-full h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Get code</Button>
</div>
</section>
)}
{pairingCode && (
<section className="border border-amber-700 rounded-lg p-4 space-y-2 bg-amber-950/20">
<p className="text-sm text-amber-400">Enter this code in WhatsApp → Linked Devices → Link a Device</p>
<code className="text-2xl font-mono tracking-widest text-amber-300">{pairingCode}</code>
</section>
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-5 space-y-3">
<div className="flex items-center gap-2">
<Smartphone size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-amber-900">Enter this code</p>
</div>
<div className="text-center py-3">
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">{pairingCode}</code>
</div>
<p className="text-[11px] text-amber-700 text-center">
WhatsApp → Linked Devices → Link a Device → Enter phone number
</p>
</div>
)}
{session && (
<section className="border-t border-zinc-800 pt-6 space-y-3">
<h3 className="text-sm font-medium text-zinc-400">Actions</h3>
<div className="flex gap-3">
{['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status) && (
<button
onClick={disconnect}
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm hover:border-zinc-500"
>
Disconnect
</button>
)}
<button
onClick={deleteSession}
className="rounded-md border border-red-800 px-3 py-1.5 text-sm text-red-400 hover:bg-red-950/40"
>
Delete session
</button>
{session.status === 'CONNECTED' && (
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot" />
<p className="text-[13px] font-semibold text-emerald-800">Session connected</p>
</div>
<p className="text-[12px] text-emerald-700">
This session is actively connected and ready to use via the API.
</p>
</div>
</section>
)}
</main>
</div>
</div>
)}
</div>
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px]">Disconnect session?</DialogTitle>
<DialogDescription className="text-[13px]">The session will be logged out. You can reconnect later.</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
<Button size="sm" onClick={disconnect} className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Disconnect</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px] flex items-center gap-2">
<AlertTriangle size={15} className="text-red-500" />
Delete session?
</DialogTitle>
<DialogDescription className="text-[13px]">All credentials will be permanently wiped.</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDeleteOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
<Button variant="destructive" size="sm" onClick={deleteSession} disabled={deleting} className="h-8 px-3 text-[13px]">
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -1,16 +1,47 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Zap, Plus, RefreshCw } from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { sessionsQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { cn } from '#/lib/utils'
const STATUS_COLOR: Record<string, string> = {
CONNECTED: 'text-green-400 bg-green-950/40',
CONNECTING: 'text-blue-400 bg-blue-950/40',
PENDING_QR: 'text-yellow-400 bg-yellow-950/40',
PENDING_PAIRING: 'text-yellow-400 bg-yellow-950/40',
DISCONNECTED: 'text-zinc-400 bg-zinc-800',
LOGGED_OUT: 'text-red-400 bg-red-950/40',
DEAD: 'text-red-500 bg-red-950/60',
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
CONNECTED: {
dot: 'bg-emerald-500 animate-pulse-dot',
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
label: 'Connected',
},
CONNECTING: {
dot: 'bg-blue-500 animate-pulse-dot',
badge: 'bg-blue-50 text-blue-700 border-blue-200',
label: 'Connecting',
},
PENDING_QR: {
dot: 'bg-amber-500 animate-pulse-dot',
badge: 'bg-amber-50 text-amber-700 border-amber-200',
label: 'Pending QR',
},
PENDING_PAIRING: {
dot: 'bg-amber-500 animate-pulse-dot',
badge: 'bg-amber-50 text-amber-700 border-amber-200',
label: 'Pending Pairing',
},
DISCONNECTED: {
dot: 'bg-gray-400',
badge: 'bg-gray-50 text-gray-600 border-gray-200',
label: 'Disconnected',
},
LOGGED_OUT: {
dot: 'bg-red-400',
badge: 'bg-red-50 text-red-600 border-red-200',
label: 'Logged Out',
},
DEAD: {
dot: 'bg-red-600',
badge: 'bg-red-50 text-red-700 border-red-200',
label: 'Dead',
},
}
interface SessionRow {
@ -32,68 +63,143 @@ export const Route = createFileRoute('/sessions/')({
})
function SessionsPage() {
const { data: sessions = [] } = useQuery<SessionRow[]>(sessionsQuery)
const router = useRouter()
const { data: sessions = [], refetch } = useQuery<SessionRow[]>(sessionsQuery)
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
router.navigate({ to: '/login' })
}
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length
const pendingCount = sessions.filter(s =>
s.status === 'PENDING_QR' || s.status === 'PENDING_PAIRING' || s.status === 'CONNECTING',
).length
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between">
<h1 className="font-semibold">Wahoo</h1>
<div className="flex items-center gap-4 text-sm">
<Link to="/apps" className="text-zinc-400 hover:text-white">Apps</Link>
<Link to="/audit-log" className="text-zinc-400 hover:text-white">Audit log</Link>
<button onClick={handleLogout} className="text-zinc-400 hover:text-white">Sign out</button>
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap size={16} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Sessions</h1>
{sessions.length > 0 && (
<div className="flex items-center gap-1.5 ml-2">
<span className="text-[11px] font-medium text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5">
{connectedCount} connected
</span>
{pendingCount > 0 && (
<span className="text-[11px] font-medium text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
{pendingCount} pending
</span>
)}
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Sessions</h2>
<Link
to="/sessions/new"
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
title="Refresh"
>
<RefreshCw size={14} strokeWidth={1.75} />
</button>
<Link to="/sessions/new">
<Button
size="sm"
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New session
</Button>
</Link>
</div>
</div>
{/* Content */}
<div className="px-8 py-6 animate-fade-up">
{sessions.length === 0 ? (
<p className="text-zinc-500 text-sm">No sessions yet. Create one to connect a WhatsApp number.</p>
<div className="mt-16 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
<Zap size={20} className="text-gray-400" strokeWidth={1.5} />
</div>
<p className="text-sm font-medium text-gray-900 mb-1">No sessions yet</p>
<p className="text-[13px] text-gray-500 mb-5">
Create a session to connect a WhatsApp number.
</p>
<Link to="/sessions/new">
<Button
size="sm"
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
>
<Plus size={13} strokeWidth={2.5} />
New session
</Button>
</Link>
</div>
) : (
<ul className="space-y-2">
{sessions.map((s) => (
<li key={s.id}>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
{/* Table header */}
<div className="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Session</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">App</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Last seen</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Reconnects</span>
</div>
{/* Table rows */}
<div className="divide-y divide-gray-100">
{sessions.map((s) => {
const style = STATUS_STYLES[s.status] ?? STATUS_STYLES.DISCONNECTED
return (
<Link
key={s.id}
to="/sessions/$id"
params={{ id: s.id }}
className="block border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"
className="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-3 hover:bg-gray-50 transition-colors items-center"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-zinc-500 mb-1">{s.appName}</p>
<p className="font-mono text-sm text-zinc-300">{s.id.slice(0, 8)}…</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${STATUS_COLOR[s.status] ?? 'text-zinc-400'}`}>
{s.status}
{/* Session ID */}
<code className="text-[12px] font-mono text-gray-600 truncate">
{s.id.slice(0, 12)}…
</code>
{/* App name */}
<span className="text-[13px] text-gray-700 truncate">{s.appName}</span>
{/* Status badge */}
<div className="flex items-center gap-1.5">
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', style.dot)} />
<span
className={cn(
'text-[11px] font-medium px-1.5 py-0.5 rounded-md border',
style.badge,
)}
>
{style.label}
</span>
</div>
<p className="text-xs text-zinc-600 mt-2">
{/* Last seen */}
<span className="text-[12px] text-gray-400">
{s.lastSeen
? `Last seen ${new Date(s.lastSeen).toLocaleString()}`
: `Created ${new Date(s.createdAt).toLocaleDateString()}`}
{s.reconnectCount > 0 && ` · ${s.reconnectCount} reconnect${s.reconnectCount !== 1 ? 's' : ''}`}
</p>
? new Date(s.lastSeen).toLocaleString('en', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: new Date(s.createdAt).toLocaleDateString('en', {
month: 'short',
day: 'numeric',
})}
</span>
{/* Reconnect count */}
<span className="text-[12px] text-gray-400">
{s.reconnectCount > 0 ? s.reconnectCount : '—'}
</span>
</Link>
</li>
))}
</ul>
)
})}
</div>
</div>
)}
</main>
</div>
</div>
)
}

View file

@ -1,8 +1,13 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { ChevronLeft, QrCode, Smartphone } from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { appsQuery } from '#/lib/query'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { cn } from '#/lib/utils'
export const Route = createFileRoute('/sessions/new')({
beforeLoad: async () => {
@ -22,9 +27,15 @@ function NewSessionPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const selectedApp = apps.find(a => a.id === (appId || apps[0]?.id))
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!appId || !apiKey) { setError('Select an app and enter an API key'); return }
const resolvedAppId = appId || apps[0]?.id
if (!resolvedAppId || !apiKey) {
setError('Select an app and enter an API key')
return
}
setError('')
setLoading(true)
try {
@ -42,66 +53,131 @@ function NewSessionPage() {
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4">
<button onClick={() => router.history.back()} className="text-zinc-400 hover:text-white text-sm">
← Back
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center gap-2">
<button
onClick={() => router.history.back()}
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
</header>
<span className="text-gray-300">/</span>
<h1 className="text-[14px] font-semibold text-gray-900">New session</h1>
</div>
<main className="max-w-md mx-auto px-6 py-12">
<h2 className="text-lg font-medium mb-6">New session</h2>
{/* Form */}
<div className="px-8 py-8 animate-fade-up">
<div className="max-w-md">
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<p className="text-[13px] font-medium text-gray-900 mb-0.5">Connect WhatsApp</p>
<p className="text-[12px] text-gray-500">
Select an app, provide its API key, and choose how to authenticate.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs text-zinc-400">App</label>
{/* App select */}
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">App</Label>
<select
value={appId || apps[0]?.id || ''}
onChange={(e) => setAppId(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
onChange={e => setAppId(e.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] text-gray-900 focus:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-200 transition-shadow"
>
{apps.map((a) => (
{apps.length === 0 ? (
<option value="">No apps — create one first</option>
) : (
apps.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
))
)}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">API key</label>
<input
{/* API key */}
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">API key</Label>
<Input
type="password"
placeholder="wak_..."
placeholder="wak_live_…"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
onChange={e => setApiKey(e.target.value)}
className="h-9 text-sm font-mono"
/>
<p className="text-[11px] text-gray-400">
Find or generate API keys on the app detail page.
</p>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">Connect via</label>
<div className="flex gap-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" value="qr" checked={via === 'qr'} onChange={() => setVia('qr')} />
QR code
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" value="pairing" checked={via === 'pairing'} onChange={() => setVia('pairing')} />
Pairing code
</label>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
{/* Connection method */}
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">Connection method</Label>
<div className="grid grid-cols-2 gap-2">
{([
{ value: 'qr', label: 'QR Code', desc: 'Scan with WhatsApp', icon: QrCode },
{ value: 'pairing', label: 'Pairing Code', desc: 'Enter a code', icon: Smartphone },
] as const).map(({ value, label, desc, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setVia(value)}
className={cn(
'flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all',
via === value
? 'border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900'
: 'border-gray-200 hover:border-gray-300',
)}
>
<div className="flex items-center gap-1.5">
<Icon
size={13}
className={via === value ? 'text-zinc-900' : 'text-gray-400'}
strokeWidth={1.75}
/>
<span
className={cn(
'text-[12px] font-semibold',
via === value ? 'text-zinc-900' : 'text-gray-600',
)}
>
{label}
</span>
</div>
<span className="text-[11px] text-gray-400">{desc}</span>
</button>
))}
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2">
<p className="text-[13px] text-red-600">{error}</p>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="submit"
disabled={loading}
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-100 disabled:opacity-50"
disabled={loading || apps.length === 0}
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
>
{loading ? 'Creating…' : 'Create session'}
</button>
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.history.back()}
className="h-9 px-4 text-[13px]"
>
Cancel
</Button>
</div>
</form>
</main>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@ -119,6 +119,7 @@
@theme inline {
--font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -160,170 +161,31 @@
html,
body,
#app {
min-height: 100%;
height: 100%;
}
body {
margin: 0;
color: var(--sea-ink);
font-family: var(--font-sans);
background-color: var(--bg-base);
background:
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
background-color: var(--background);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.28;
background:
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
}
a {
color: var(--lagoon-deep);
text-decoration-color: rgba(50, 143, 151, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:hover {
color: #246f76;
}
code {
font-size: 0.9em;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
border-radius: 7px;
padding: 2px 7px;
}
pre code {
border: 0;
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.prose pre {
border: 1px solid var(--line);
border-radius: 12px;
background: #1d2e45;
color: #e8efff;
}
.page-wrap {
width: min(1080px, calc(100% - 2rem));
margin-inline: auto;
code,
.font-mono {
font-family: var(--font-mono);
}
.display-title {
font-family: 'Fraunces', Georgia, serif;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(30, 90, 72, 0.1),
0 6px 18px rgba(23, 58, 64, 0.08);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(30, 90, 72, 0.1),
0 4px 14px rgba(23, 58, 64, 0.06);
}
.feature-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
}
button,
.island-shell,
a {
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
transform 180ms ease;
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
}
.nav-link {
position: relative;
text-decoration: none;
color: var(--sea-ink-soft);
}
.nav-link::after {
content: '';
position: absolute;
left: 0;
bottom: -8px;
width: 100%;
height: 2px;
transform: scaleX(0);
transform-origin: left;
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
transition: transform 170ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sea-ink);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes rise-in {
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(12px);
transform: translateY(8px);
}
to {
opacity: 1;
@ -331,9 +193,17 @@ a {
}
}
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
.animate-fade-up {
animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.animate-pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
@layer base {