feat: session manager
This commit is contained in:
parent
8a84a660b3
commit
0aef5bca86
52 changed files with 4224 additions and 1100 deletions
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -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`.
|
**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.
|
**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
147
bun.lock
|
|
@ -29,6 +29,7 @@
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"rate-limiter-flexible": "^11.1.0",
|
"rate-limiter-flexible": "^11.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|
@ -223,6 +224,14 @@
|
||||||
|
|
||||||
"@faker-js/faker": ["@faker-js/faker@10.4.0", "", {}, "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw=="],
|
"@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/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=="],
|
"@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=="],
|
"@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-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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
@ -775,6 +906,8 @@
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"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=="],
|
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||||
|
|
||||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"rate-limiter-flexible": ["rate-limiter-flexible@11.1.0", "", {}, "sha512-lyyC0SqKz+dE5JoHZ4JMqdrM3LSZKBxzuAFAyKCYAnmHnPz/Rb6iDquxoL4CMipDXoR0G+QRhOzYWL3JKihbNw=="],
|
||||||
|
|
||||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
"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-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=="],
|
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"rate-limiter-flexible": "^11.1.0",
|
"rate-limiter-flexible": "^11.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|
|
||||||
213
src/components/layout/sidebar.tsx
Normal file
213
src/components/layout/sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal 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 }
|
||||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal 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 }
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal 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 }
|
||||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal 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
114
src/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
71
src/lib/actions/api-keys.ts
Normal file
71
src/lib/actions/api-keys.ts
Normal 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
112
src/lib/actions/apps.ts
Normal 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
|
||||||
|
}
|
||||||
32
src/lib/actions/audit-log.ts
Normal file
32
src/lib/actions/audit-log.ts
Normal 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
41
src/lib/actions/auth.ts
Normal 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
117
src/lib/actions/sessions.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
|
@ -161,7 +161,11 @@ export async function startSession(sessionId: string, appId: string): Promise<vo
|
||||||
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
|
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
|
||||||
?.output?.statusCode
|
?.output?.statusCode
|
||||||
|
|
||||||
if (code === DisconnectReason.loggedOut || code === DisconnectReason.forbidden) {
|
if (
|
||||||
|
code === DisconnectReason.loggedOut ||
|
||||||
|
code === DisconnectReason.forbidden ||
|
||||||
|
code === DisconnectReason.connectionReplaced
|
||||||
|
) {
|
||||||
reconnecting.delete(sessionId)
|
reconnecting.delete(sessionId)
|
||||||
await setStatus(sessionId, 'LOGGED_OUT')
|
await setStatus(sessionId, 'LOGGED_OUT')
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'LOGGED_OUT' })
|
emitWebhook(appId, 'session.status', { sessionId, status: 'LOGGED_OUT' })
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,29 @@ export const auditLogQuery = queryOptions({
|
||||||
queryKey: ['audit-log'],
|
queryKey: ['audit-log'],
|
||||||
queryFn: () => apiFetch('/api/v1/admin/audit-log?limit=50'),
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ import { Route as SessionsIdRouteImport } from './routes/sessions/$id'
|
||||||
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
|
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
|
||||||
import { Route as ApiReadyRouteImport } from './routes/api/ready'
|
import { Route as ApiReadyRouteImport } from './routes/api/ready'
|
||||||
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
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 ApiAuthMeRouteImport } from './routes/api/auth/me'
|
||||||
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
|
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
|
||||||
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
|
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 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 ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
|
||||||
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
|
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
|
||||||
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
|
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 ApiV1SessionsIdPairRouteImport } from './routes/api/v1/sessions/$id/pair'
|
||||||
import { Route as ApiV1SessionsIdHealthRouteImport } from './routes/api/v1/sessions/$id/health'
|
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 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 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 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'
|
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',
|
path: '/api/health',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppsAppIdIndexRoute = AppsAppIdIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AppsAppIdRoute,
|
||||||
|
} as any)
|
||||||
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
|
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
|
||||||
id: '/api/auth/me',
|
id: '/api/auth/me',
|
||||||
path: '/api/auth/me',
|
path: '/api/auth/me',
|
||||||
|
|
@ -101,11 +114,27 @@ const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
|
||||||
path: '/api/auth/login',
|
path: '/api/auth/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppsAppIdSessionsIndexRoute = AppsAppIdSessionsIndexRouteImport.update({
|
||||||
|
id: '/sessions/',
|
||||||
|
path: '/sessions/',
|
||||||
|
getParentRoute: () => AppsAppIdRoute,
|
||||||
|
} as any)
|
||||||
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
|
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
|
||||||
id: '/api/v1/sessions/',
|
id: '/api/v1/sessions/',
|
||||||
path: '/api/v1/sessions/',
|
path: '/api/v1/sessions/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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({
|
const ApiV1AdminAuditLogRoute = ApiV1AdminAuditLogRouteImport.update({
|
||||||
id: '/api/v1/admin/audit-log',
|
id: '/api/v1/admin/audit-log',
|
||||||
path: '/api/v1/admin/audit-log',
|
path: '/api/v1/admin/audit-log',
|
||||||
|
|
@ -152,11 +181,34 @@ const ApiV1SessionsIdDisconnectRoute =
|
||||||
path: '/api/v1/sessions/$id/disconnect',
|
path: '/api/v1/sessions/$id/disconnect',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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({
|
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
|
||||||
id: '/api/v1/admin/apps/$id/',
|
id: '/api/v1/admin/apps/$id/',
|
||||||
path: '/api/v1/admin/apps/$id/',
|
path: '/api/v1/admin/apps/$id/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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 =
|
const ApiV1AdminAppsIdApiKeysIndexRoute =
|
||||||
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
|
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
|
||||||
id: '/api/v1/admin/apps/$id/api-keys/',
|
id: '/api/v1/admin/apps/$id/api-keys/',
|
||||||
|
|
@ -176,7 +228,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/api/ready': typeof ApiReadyRoute
|
'/api/ready': typeof ApiReadyRoute
|
||||||
'/apps/$appId': typeof AppsAppIdRoute
|
'/apps/$appId': typeof AppsAppIdRouteWithChildren
|
||||||
'/sessions/$id': typeof SessionsIdRoute
|
'/sessions/$id': typeof SessionsIdRoute
|
||||||
'/sessions/new': typeof SessionsNewRoute
|
'/sessions/new': typeof SessionsNewRoute
|
||||||
'/apps/': typeof AppsIndexRoute
|
'/apps/': typeof AppsIndexRoute
|
||||||
|
|
@ -184,9 +236,13 @@ export interface FileRoutesByFullPath {
|
||||||
'/api/auth/login': typeof ApiAuthLoginRoute
|
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||||
'/api/auth/me': typeof ApiAuthMeRoute
|
'/api/auth/me': typeof ApiAuthMeRoute
|
||||||
|
'/apps/$appId/': typeof AppsAppIdIndexRoute
|
||||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||||
|
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
|
||||||
|
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
|
||||||
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
|
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
|
||||||
|
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
|
||||||
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
||||||
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
||||||
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
||||||
|
|
@ -194,7 +250,11 @@ export interface FileRoutesByFullPath {
|
||||||
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
|
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
|
||||||
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
|
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
|
||||||
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
|
'/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/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/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||||
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +264,6 @@ export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/api/ready': typeof ApiReadyRoute
|
'/api/ready': typeof ApiReadyRoute
|
||||||
'/apps/$appId': typeof AppsAppIdRoute
|
|
||||||
'/sessions/$id': typeof SessionsIdRoute
|
'/sessions/$id': typeof SessionsIdRoute
|
||||||
'/sessions/new': typeof SessionsNewRoute
|
'/sessions/new': typeof SessionsNewRoute
|
||||||
'/apps': typeof AppsIndexRoute
|
'/apps': typeof AppsIndexRoute
|
||||||
|
|
@ -212,9 +271,13 @@ export interface FileRoutesByTo {
|
||||||
'/api/auth/login': typeof ApiAuthLoginRoute
|
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||||
'/api/auth/me': typeof ApiAuthMeRoute
|
'/api/auth/me': typeof ApiAuthMeRoute
|
||||||
|
'/apps/$appId': typeof AppsAppIdIndexRoute
|
||||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||||
|
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
|
||||||
|
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
|
||||||
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
|
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
|
||||||
|
'/apps/$appId/sessions': typeof AppsAppIdSessionsIndexRoute
|
||||||
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
||||||
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
||||||
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
||||||
|
|
@ -222,7 +285,11 @@ export interface FileRoutesByTo {
|
||||||
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
|
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
|
||||||
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
|
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
|
||||||
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
|
'/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/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/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||||
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +300,7 @@ export interface FileRoutesById {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/api/ready': typeof ApiReadyRoute
|
'/api/ready': typeof ApiReadyRoute
|
||||||
'/apps/$appId': typeof AppsAppIdRoute
|
'/apps/$appId': typeof AppsAppIdRouteWithChildren
|
||||||
'/sessions/$id': typeof SessionsIdRoute
|
'/sessions/$id': typeof SessionsIdRoute
|
||||||
'/sessions/new': typeof SessionsNewRoute
|
'/sessions/new': typeof SessionsNewRoute
|
||||||
'/apps/': typeof AppsIndexRoute
|
'/apps/': typeof AppsIndexRoute
|
||||||
|
|
@ -241,9 +308,13 @@ export interface FileRoutesById {
|
||||||
'/api/auth/login': typeof ApiAuthLoginRoute
|
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||||
'/api/auth/me': typeof ApiAuthMeRoute
|
'/api/auth/me': typeof ApiAuthMeRoute
|
||||||
|
'/apps/$appId/': typeof AppsAppIdIndexRoute
|
||||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||||
|
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
|
||||||
|
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
|
||||||
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
|
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
|
||||||
|
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
|
||||||
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
|
||||||
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
|
||||||
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
|
||||||
|
|
@ -251,7 +322,11 @@ export interface FileRoutesById {
|
||||||
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
|
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
|
||||||
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
|
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
|
||||||
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
|
'/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/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/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||||
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -271,9 +346,13 @@ export interface FileRouteTypes {
|
||||||
| '/api/auth/login'
|
| '/api/auth/login'
|
||||||
| '/api/auth/logout'
|
| '/api/auth/logout'
|
||||||
| '/api/auth/me'
|
| '/api/auth/me'
|
||||||
|
| '/apps/$appId/'
|
||||||
| '/api/auth/totp/enroll'
|
| '/api/auth/totp/enroll'
|
||||||
| '/api/v1/admin/audit-log'
|
| '/api/v1/admin/audit-log'
|
||||||
|
| '/apps/$appId/sessions/$sessionId'
|
||||||
|
| '/apps/$appId/sessions/new'
|
||||||
| '/api/v1/sessions/'
|
| '/api/v1/sessions/'
|
||||||
|
| '/apps/$appId/sessions/'
|
||||||
| '/api/v1/sessions/$id/disconnect'
|
| '/api/v1/sessions/$id/disconnect'
|
||||||
| '/api/v1/sessions/$id/health'
|
| '/api/v1/sessions/$id/health'
|
||||||
| '/api/v1/sessions/$id/pair'
|
| '/api/v1/sessions/$id/pair'
|
||||||
|
|
@ -281,7 +360,11 @@ export interface FileRouteTypes {
|
||||||
| '/api/v1/admin/apps/'
|
| '/api/v1/admin/apps/'
|
||||||
| '/api/v1/admin/sessions/'
|
| '/api/v1/admin/sessions/'
|
||||||
| '/api/v1/sessions/$id/'
|
| '/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/apps/$id/'
|
||||||
|
| '/api/v1/admin/sessions/$id/'
|
||||||
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||||
| '/api/v1/admin/apps/$id/api-keys/'
|
| '/api/v1/admin/apps/$id/api-keys/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
|
@ -291,7 +374,6 @@ export interface FileRouteTypes {
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/api/ready'
|
| '/api/ready'
|
||||||
| '/apps/$appId'
|
|
||||||
| '/sessions/$id'
|
| '/sessions/$id'
|
||||||
| '/sessions/new'
|
| '/sessions/new'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
|
|
@ -299,9 +381,13 @@ export interface FileRouteTypes {
|
||||||
| '/api/auth/login'
|
| '/api/auth/login'
|
||||||
| '/api/auth/logout'
|
| '/api/auth/logout'
|
||||||
| '/api/auth/me'
|
| '/api/auth/me'
|
||||||
|
| '/apps/$appId'
|
||||||
| '/api/auth/totp/enroll'
|
| '/api/auth/totp/enroll'
|
||||||
| '/api/v1/admin/audit-log'
|
| '/api/v1/admin/audit-log'
|
||||||
|
| '/apps/$appId/sessions/$sessionId'
|
||||||
|
| '/apps/$appId/sessions/new'
|
||||||
| '/api/v1/sessions'
|
| '/api/v1/sessions'
|
||||||
|
| '/apps/$appId/sessions'
|
||||||
| '/api/v1/sessions/$id/disconnect'
|
| '/api/v1/sessions/$id/disconnect'
|
||||||
| '/api/v1/sessions/$id/health'
|
| '/api/v1/sessions/$id/health'
|
||||||
| '/api/v1/sessions/$id/pair'
|
| '/api/v1/sessions/$id/pair'
|
||||||
|
|
@ -309,7 +395,11 @@ export interface FileRouteTypes {
|
||||||
| '/api/v1/admin/apps'
|
| '/api/v1/admin/apps'
|
||||||
| '/api/v1/admin/sessions'
|
| '/api/v1/admin/sessions'
|
||||||
| '/api/v1/sessions/$id'
|
| '/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/apps/$id'
|
||||||
|
| '/api/v1/admin/sessions/$id'
|
||||||
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||||
| '/api/v1/admin/apps/$id/api-keys'
|
| '/api/v1/admin/apps/$id/api-keys'
|
||||||
id:
|
id:
|
||||||
|
|
@ -327,9 +417,13 @@ export interface FileRouteTypes {
|
||||||
| '/api/auth/login'
|
| '/api/auth/login'
|
||||||
| '/api/auth/logout'
|
| '/api/auth/logout'
|
||||||
| '/api/auth/me'
|
| '/api/auth/me'
|
||||||
|
| '/apps/$appId/'
|
||||||
| '/api/auth/totp/enroll'
|
| '/api/auth/totp/enroll'
|
||||||
| '/api/v1/admin/audit-log'
|
| '/api/v1/admin/audit-log'
|
||||||
|
| '/apps/$appId/sessions/$sessionId'
|
||||||
|
| '/apps/$appId/sessions/new'
|
||||||
| '/api/v1/sessions/'
|
| '/api/v1/sessions/'
|
||||||
|
| '/apps/$appId/sessions/'
|
||||||
| '/api/v1/sessions/$id/disconnect'
|
| '/api/v1/sessions/$id/disconnect'
|
||||||
| '/api/v1/sessions/$id/health'
|
| '/api/v1/sessions/$id/health'
|
||||||
| '/api/v1/sessions/$id/pair'
|
| '/api/v1/sessions/$id/pair'
|
||||||
|
|
@ -337,7 +431,11 @@ export interface FileRouteTypes {
|
||||||
| '/api/v1/admin/apps/'
|
| '/api/v1/admin/apps/'
|
||||||
| '/api/v1/admin/sessions/'
|
| '/api/v1/admin/sessions/'
|
||||||
| '/api/v1/sessions/$id/'
|
| '/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/apps/$id/'
|
||||||
|
| '/api/v1/admin/sessions/$id/'
|
||||||
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
| '/api/v1/admin/apps/$id/api-keys/$keyId'
|
||||||
| '/api/v1/admin/apps/$id/api-keys/'
|
| '/api/v1/admin/apps/$id/api-keys/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
|
|
@ -348,7 +446,7 @@ export interface RootRouteChildren {
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiReadyRoute: typeof ApiReadyRoute
|
ApiReadyRoute: typeof ApiReadyRoute
|
||||||
AppsAppIdRoute: typeof AppsAppIdRoute
|
AppsAppIdRoute: typeof AppsAppIdRouteWithChildren
|
||||||
SessionsIdRoute: typeof SessionsIdRoute
|
SessionsIdRoute: typeof SessionsIdRoute
|
||||||
SessionsNewRoute: typeof SessionsNewRoute
|
SessionsNewRoute: typeof SessionsNewRoute
|
||||||
AppsIndexRoute: typeof AppsIndexRoute
|
AppsIndexRoute: typeof AppsIndexRoute
|
||||||
|
|
@ -366,7 +464,11 @@ export interface RootRouteChildren {
|
||||||
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
|
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
|
||||||
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
|
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
|
||||||
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
|
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
|
||||||
|
ApiV1AdminSessionsIdDisconnectRoute: typeof ApiV1AdminSessionsIdDisconnectRoute
|
||||||
|
ApiV1AdminSessionsIdPairRoute: typeof ApiV1AdminSessionsIdPairRoute
|
||||||
|
ApiV1AdminSessionsIdQrRoute: typeof ApiV1AdminSessionsIdQrRoute
|
||||||
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
|
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
|
||||||
|
ApiV1AdminSessionsIdIndexRoute: typeof ApiV1AdminSessionsIdIndexRoute
|
||||||
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
|
||||||
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||||
}
|
}
|
||||||
|
|
@ -443,6 +545,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ApiHealthRouteImport
|
preLoaderRoute: typeof ApiHealthRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/apps/$appId/': {
|
||||||
|
id: '/apps/$appId/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/apps/$appId/'
|
||||||
|
preLoaderRoute: typeof AppsAppIdIndexRouteImport
|
||||||
|
parentRoute: typeof AppsAppIdRoute
|
||||||
|
}
|
||||||
'/api/auth/me': {
|
'/api/auth/me': {
|
||||||
id: '/api/auth/me'
|
id: '/api/auth/me'
|
||||||
path: '/api/auth/me'
|
path: '/api/auth/me'
|
||||||
|
|
@ -464,6 +573,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ApiAuthLoginRouteImport
|
preLoaderRoute: typeof ApiAuthLoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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/': {
|
'/api/v1/sessions/': {
|
||||||
id: '/api/v1/sessions/'
|
id: '/api/v1/sessions/'
|
||||||
path: '/api/v1/sessions'
|
path: '/api/v1/sessions'
|
||||||
|
|
@ -471,6 +587,20 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ApiV1SessionsIndexRouteImport
|
preLoaderRoute: typeof ApiV1SessionsIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/v1/admin/audit-log': {
|
||||||
id: '/api/v1/admin/audit-log'
|
id: '/api/v1/admin/audit-log'
|
||||||
path: '/api/v1/admin/audit-log'
|
path: '/api/v1/admin/audit-log'
|
||||||
|
|
@ -534,6 +664,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ApiV1SessionsIdDisconnectRouteImport
|
preLoaderRoute: typeof ApiV1SessionsIdDisconnectRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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/': {
|
'/api/v1/admin/apps/$id/': {
|
||||||
id: '/api/v1/admin/apps/$id/'
|
id: '/api/v1/admin/apps/$id/'
|
||||||
path: '/api/v1/admin/apps/$id'
|
path: '/api/v1/admin/apps/$id'
|
||||||
|
|
@ -541,6 +678,27 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ApiV1AdminAppsIdIndexRouteImport
|
preLoaderRoute: typeof ApiV1AdminAppsIdIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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/': {
|
'/api/v1/admin/apps/$id/api-keys/': {
|
||||||
id: '/api/v1/admin/apps/$id/api-keys/'
|
id: '/api/v1/admin/apps/$id/api-keys/'
|
||||||
path: '/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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuditLogRoute: AuditLogRoute,
|
AuditLogRoute: AuditLogRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiReadyRoute: ApiReadyRoute,
|
ApiReadyRoute: ApiReadyRoute,
|
||||||
AppsAppIdRoute: AppsAppIdRoute,
|
AppsAppIdRoute: AppsAppIdRouteWithChildren,
|
||||||
SessionsIdRoute: SessionsIdRoute,
|
SessionsIdRoute: SessionsIdRoute,
|
||||||
SessionsNewRoute: SessionsNewRoute,
|
SessionsNewRoute: SessionsNewRoute,
|
||||||
AppsIndexRoute: AppsIndexRoute,
|
AppsIndexRoute: AppsIndexRoute,
|
||||||
|
|
@ -582,7 +758,11 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
|
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
|
||||||
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
|
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
|
||||||
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
|
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
|
||||||
|
ApiV1AdminSessionsIdDisconnectRoute: ApiV1AdminSessionsIdDisconnectRoute,
|
||||||
|
ApiV1AdminSessionsIdPairRoute: ApiV1AdminSessionsIdPairRoute,
|
||||||
|
ApiV1AdminSessionsIdQrRoute: ApiV1AdminSessionsIdQrRoute,
|
||||||
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
|
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
|
||||||
|
ApiV1AdminSessionsIdIndexRoute: ApiV1AdminSessionsIdIndexRoute,
|
||||||
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
|
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
|
||||||
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
|
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { queryClient } from '#/lib/query'
|
import { queryClient } from '#/lib/query'
|
||||||
|
import { Sidebar } from '#/components/layout/sidebar'
|
||||||
|
|
||||||
import appCss from '../styles.css?url'
|
import appCss from '../styles.css?url'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{ charSet: 'utf-8' },
|
||||||
charSet: 'utf-8',
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
},
|
{ title: 'Wahoo — WhatsApp API Gateway' },
|
||||||
{
|
|
||||||
name: 'viewport',
|
|
||||||
content: 'width=device-width, initial-scale=1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Wahoo — WhatsApp API Gateway',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: appCss,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
links: [{ rel: 'stylesheet', href: appCss }],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
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 }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -40,15 +48,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{ position: 'bottom-right' }}
|
||||||
position: 'bottom-right',
|
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
|
||||||
}}
|
|
||||||
plugins={[
|
|
||||||
{
|
|
||||||
name: 'Tanstack Router',
|
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { verifyPassword } from '#/lib/password'
|
||||||
import { verifyTotp } from '#/lib/totp'
|
import { verifyTotp } from '#/lib/totp'
|
||||||
import { createSessionCookie, json, jsonError } from '#/lib/session-api'
|
import { createSessionCookie, json, jsonError } from '#/lib/session-api'
|
||||||
import { writeAudit } from '#/lib/audit'
|
import { writeAudit } from '#/lib/audit'
|
||||||
|
import { findUserByEmail } from '#/lib/actions/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/auth/login')({
|
export const Route = createFileRoute('/api/auth/login')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -21,7 +19,7 @@ export const Route = createFileRoute('/api/auth/login')({
|
||||||
const { email, password, totpToken } = body
|
const { email, password, totpToken } = body
|
||||||
if (!email || !password) return jsonError('email and password required', 400)
|
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)
|
if (!user) return jsonError('Invalid credentials', 401)
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash)
|
const valid = await verifyPassword(password, user.passwordHash)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { json, jsonError, readSession } from '#/lib/session-api'
|
||||||
|
import { enrollTotpSecret, confirmTotpEnrollment, disableTotp } from '#/lib/actions/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/auth/totp/enroll')({
|
export const Route = createFileRoute('/api/auth/totp/enroll')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -12,13 +9,9 @@ export const Route = createFileRoute('/api/auth/totp/enroll')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) })
|
const result = await enrollTotpSecret(session.userId)
|
||||||
if (!user) return jsonError('User not found', 404)
|
if (!result.ok) return jsonError(result.error, result.status)
|
||||||
if (user.totpEnabled) return jsonError('TOTP already enabled', 409)
|
return json({ secret: result.secret, uri: result.uri })
|
||||||
|
|
||||||
const secret = generateTotpSecret()
|
|
||||||
await db.update(users).set({ totpSecret: secret }).where(eq(users.id, session.userId))
|
|
||||||
return json({ secret, uri: getTotpUri(user.email, secret) })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
PUT: async ({ request }) => {
|
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) }
|
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
||||||
if (!body.token) return jsonError('token required', 400)
|
if (!body.token) return jsonError('token required', 400)
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.userId) })
|
const result = await confirmTotpEnrollment(session.userId, body.token)
|
||||||
if (!user?.totpSecret) return jsonError('Enroll first', 400)
|
if (!result.ok) return jsonError(result.error, result.status)
|
||||||
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))
|
|
||||||
return json({ ok: true })
|
return json({ ok: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -42,7 +31,7 @@ export const Route = createFileRoute('/api/auth/totp/enroll')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
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 })
|
return json({ ok: true })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { 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')({
|
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -12,28 +10,14 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
const app = await db.query.apps.findFirst({
|
const app = await findUserApp(params.id, session.userId)
|
||||||
where: and(eq(apps.id, params.id), eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
|
|
||||||
})
|
|
||||||
if (!app) return jsonError('Not found', 404)
|
if (!app) return jsonError('Not found', 404)
|
||||||
|
|
||||||
const key = await db.query.apiKeys.findFirst({
|
const result = await revokeAppApiKey(params.keyId, params.id, session.userId, {
|
||||||
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,
|
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
||||||
})
|
})
|
||||||
|
if (!result.ok) return jsonError(result.error, result.status)
|
||||||
|
|
||||||
return json({ ok: true })
|
return json({ ok: true })
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { json, jsonError, readSession } from '#/lib/session-api'
|
||||||
import { writeAudit } from '#/lib/audit'
|
import { findUserApp } from '#/lib/actions/apps'
|
||||||
|
import { listAppApiKeys, createAppApiKey } from '#/lib/actions/api-keys'
|
||||||
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)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
|
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -19,49 +10,28 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
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)
|
if (!app) return jsonError('Not found', 404)
|
||||||
|
|
||||||
const keys = await db.query.apiKeys.findMany({
|
return json(await listAppApiKeys(params.id))
|
||||||
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}` })))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
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)
|
if (!app) return jsonError('Not found', 404)
|
||||||
|
|
||||||
let body: { label?: string; env?: 'live' | 'test' } = {}
|
let body: { label?: string; env?: 'live' | 'test' } = {}
|
||||||
try { body = await request.json() } catch {}
|
try { body = await request.json() } catch {}
|
||||||
|
|
||||||
const { key, prefix } = generateApiKey(body.env ?? 'live')
|
const created = await createAppApiKey(params.id, session.userId, body.label, body.env, {
|
||||||
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,
|
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
||||||
diffAfter: { label: created.label, appId: params.id },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return json({ ...created, key }, 201)
|
return json(created, 201)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { json, jsonError, readSession } from '#/lib/session-api'
|
||||||
import { writeAudit } from '#/lib/audit'
|
import { findUserApp, updateUserApp, deleteUserApp } from '#/lib/actions/apps'
|
||||||
|
|
||||||
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)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
|
export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -18,7 +9,7 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
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)
|
if (!app) return jsonError('Not found', 404)
|
||||||
return json({ id: app.id, name: app.name, maxSessions: app.maxSessions, createdAt: app.createdAt })
|
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)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
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 }
|
let body: { name?: string; maxSessions?: number }
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
||||||
|
|
||||||
const updates: Partial<typeof apps.$inferInsert> = {}
|
const updates: { name?: string; maxSessions?: number } = {}
|
||||||
if (body.name !== undefined) updates.name = body.name.trim()
|
if (body.name !== undefined) updates.name = body.name
|
||||||
if (body.maxSessions !== undefined) updates.maxSessions = String(body.maxSessions)
|
if (body.maxSessions !== undefined) updates.maxSessions = body.maxSessions
|
||||||
if (Object.keys(updates).length === 0) return jsonError('Nothing to update', 400)
|
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({
|
const updated = await updateUserApp(params.id, session.userId, updates, {
|
||||||
id: apps.id, name: apps.name, maxSessions: apps.maxSessions,
|
|
||||||
})
|
|
||||||
|
|
||||||
await writeAudit({
|
|
||||||
actorType: 'user',
|
|
||||||
actorId: session.userId,
|
|
||||||
action: 'app.update',
|
|
||||||
resourceType: 'app',
|
|
||||||
resourceId: params.id,
|
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
||||||
userAgent: request.headers.get('user-agent') ?? 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)
|
return json(updated)
|
||||||
},
|
},
|
||||||
|
|
@ -61,27 +39,11 @@ export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
const app = await getOwnedApp(params.id, session.userId)
|
const deleted = await deleteUserApp(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,
|
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
||||||
diffBefore: { name: app.name },
|
|
||||||
})
|
})
|
||||||
|
if (!deleted) return jsonError('Not found', 404)
|
||||||
|
|
||||||
return json({ ok: true })
|
return json({ ok: true })
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { 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/')({
|
export const Route = createFileRoute('/api/v1/admin/apps/')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -13,10 +9,7 @@ export const Route = createFileRoute('/api/v1/admin/apps/')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
const list = await db.query.apps.findMany({
|
const list = await listUserApps(session.userId)
|
||||||
where: and(eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
|
|
||||||
columns: { id: true, name: true, maxSessions: true, createdAt: true },
|
|
||||||
})
|
|
||||||
return json(list)
|
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) }
|
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
||||||
if (!body.name?.trim()) return jsonError('name required', 400)
|
if (!body.name?.trim()) return jsonError('name required', 400)
|
||||||
|
|
||||||
const dek = generateDek()
|
const app = await createUserApp(session.userId, body.name, body.maxSessions, {
|
||||||
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,
|
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
||||||
diffAfter: { name: app.name },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return json(app, 201)
|
return json(app, 201)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { json, jsonError, readSession } from '#/lib/session-api'
|
||||||
|
import { queryAuditLog } from '#/lib/actions/audit-log'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/audit-log')({
|
export const Route = createFileRoute('/api/v1/admin/audit-log')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -12,27 +10,16 @@ export const Route = createFileRoute('/api/v1/admin/audit-log')({
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1))
|
const result = await queryAuditLog(session.userId, {
|
||||||
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50)))
|
page: Number(url.searchParams.get('page') ?? 1),
|
||||||
const action = url.searchParams.get('action')
|
limit: Number(url.searchParams.get('limit') ?? 50),
|
||||||
const resourceType = url.searchParams.get('resourceType')
|
action: url.searchParams.get('action'),
|
||||||
const since = url.searchParams.get('since')
|
resourceType: url.searchParams.get('resourceType'),
|
||||||
const until = url.searchParams.get('until')
|
since: url.searchParams.get('since'),
|
||||||
|
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return json({ data: rows, page, limit })
|
return json(result)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
24
src/routes/api/v1/admin/sessions/$id/disconnect.ts
Normal file
24
src/routes/api/v1/admin/sessions/$id/disconnect.ts
Normal 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' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
40
src/routes/api/v1/admin/sessions/$id/index.ts
Normal file
40
src/routes/api/v1/admin/sessions/$id/index.ts
Normal 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 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
47
src/routes/api/v1/admin/sessions/$id/pair.ts
Normal file
47
src/routes/api/v1/admin/sessions/$id/pair.ts
Normal 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
30
src/routes/api/v1/admin/sessions/$id/qr.ts
Normal file
30
src/routes/api/v1/admin/sessions/$id/qr.ts
Normal 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 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { json, jsonError, readSession } from '#/lib/session-api'
|
||||||
|
import { listUserSessions, createSession } from '#/lib/actions/sessions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/sessions/')({
|
export const Route = createFileRoute('/api/v1/admin/sessions/')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -11,30 +9,24 @@ export const Route = createFileRoute('/api/v1/admin/sessions/')({
|
||||||
const session = await readSession(request)
|
const session = await readSession(request)
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
// All apps owned by this user
|
const rows = await listUserSessions(session.userId)
|
||||||
const userApps = await db.query.apps.findMany({
|
return json(rows)
|
||||||
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)
|
POST: async ({ request }) => {
|
||||||
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]))
|
const sess = await readSession(request)
|
||||||
|
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
||||||
|
|
||||||
const rows = await db.query.sessions.findMany({
|
let body: { appId?: string; connect_via?: 'qr' | 'pairing' } = {}
|
||||||
where: (s, { inArray }) => inArray(s.appId, appIds),
|
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
||||||
columns: {
|
if (!body.appId) return jsonError('appId required', 400)
|
||||||
id: true,
|
|
||||||
appId: true,
|
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
|
||||||
status: true,
|
const result = await createSession(body.appId, via, sess.userId)
|
||||||
reconnectCount: true,
|
|
||||||
lastSeen: true,
|
if (!result.ok) return jsonError(result.error, result.status)
|
||||||
createdAt: true,
|
return json(result.session, 201)
|
||||||
},
|
|
||||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
|
||||||
})
|
|
||||||
|
|
||||||
return json(rows.map((r) => ({ ...r, appName: appNameMap[r.appId] ?? '' })))
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { authenticateApiKey } from '#/lib/api-key-auth'
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
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')({
|
export const Route = createFileRoute('/api/v1/sessions/$id/disconnect')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -13,20 +10,14 @@ export const Route = createFileRoute('/api/v1/sessions/$id/disconnect')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth
|
||||||
|
|
||||||
const session = await db.query.sessions.findFirst({
|
const session = await findSession(params.id, { appId: auth.appId })
|
||||||
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
|
|
||||||
columns: { id: true, status: true },
|
|
||||||
})
|
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError('Not found', 404)
|
||||||
|
|
||||||
if (session.status === 'DISCONNECTED' || session.status === 'LOGGED_OUT' || session.status === 'DEAD') {
|
if (session.status === 'DISCONNECTED' || session.status === 'LOGGED_OUT' || session.status === 'DEAD') {
|
||||||
return json({ ok: true, status: session.status })
|
return json({ ok: true, status: session.status })
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopSession(params.id)
|
await disconnectSession(params.id, auth.appId)
|
||||||
await db.update(sessions).set({ status: 'DISCONNECTED' }).where(eq(sessions.id, params.id))
|
|
||||||
emitWebhook(auth.appId, 'session.status', { sessionId: params.id, status: 'DISCONNECTED' })
|
|
||||||
|
|
||||||
return json({ ok: true, status: 'DISCONNECTED' })
|
return json({ ok: true, status: 'DISCONNECTED' })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { authenticateApiKey } from '#/lib/api-key-auth'
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from '#/lib/session-api'
|
||||||
import { isActive } from '#/lib/baileys'
|
import { isActive } from '#/lib/baileys'
|
||||||
|
import { findSession } from '#/lib/actions/sessions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/health')({
|
export const Route = createFileRoute('/api/v1/sessions/$id/health')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -13,10 +11,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/health')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth
|
||||||
|
|
||||||
const session = await db.query.sessions.findFirst({
|
const session = await findSession(params.id, { appId: auth.appId })
|
||||||
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
|
|
||||||
columns: { id: true, status: true, lastSeen: true, reconnectCount: true },
|
|
||||||
})
|
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError('Not found', 404)
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { authenticateApiKey } from '#/lib/api-key-auth'
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from '#/lib/session-api'
|
||||||
import { stopSession, getDecryptedField, emitWebhook } from '#/lib/baileys'
|
import { getDecryptedField } from '#/lib/baileys'
|
||||||
|
import { findSession, logoutAppSession } from '#/lib/actions/sessions'
|
||||||
async function getSessionForApp(sessionId: string, appId: string) {
|
|
||||||
return db.query.sessions.findFirst({
|
|
||||||
where: and(eq(sessions.id, sessionId), eq(sessions.appId, appId)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/')({
|
export const Route = createFileRoute('/api/v1/sessions/$id/')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -19,7 +11,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
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)
|
if (!session) return jsonError('Not found', 404)
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
|
|
@ -37,22 +29,10 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
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)
|
if (!session) return jsonError('Not found', 404)
|
||||||
|
|
||||||
await stopSession(params.id)
|
await logoutAppSession(params.id, auth.appId)
|
||||||
|
|
||||||
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 })
|
|
||||||
return json({ ok: true })
|
return json({ ok: true })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { authenticateApiKey } from '#/lib/api-key-auth'
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from '#/lib/session-api'
|
||||||
import { startSession, getSocket } from '#/lib/baileys'
|
import { startSession, getSocket } from '#/lib/baileys'
|
||||||
|
import { findSession, setSessionStatus } from '#/lib/actions/sessions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
|
export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -13,10 +11,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth
|
||||||
|
|
||||||
const session = await db.query.sessions.findFirst({
|
const session = await findSession(params.id, { appId: auth.appId })
|
||||||
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
|
|
||||||
columns: { id: true, status: true },
|
|
||||||
})
|
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError('Not found', 404)
|
||||||
|
|
||||||
if (session.status === 'CONNECTED') return jsonError('Already connected', 409)
|
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, '')
|
const phoneDigits = body.phone.replace(/\D/g, '')
|
||||||
if (phoneDigits.length < 7) return jsonError('Invalid phone number', 400)
|
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)) {
|
if (!getSocket(params.id)) {
|
||||||
await startSession(params.id, auth.appId)
|
await startSession(params.id, auth.appId)
|
||||||
// allow socket to initialize
|
|
||||||
await new Promise((r) => setTimeout(r, 2000))
|
await new Promise((r) => setTimeout(r, 2000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { db } from '#/db/index'
|
|
||||||
import { sessions } from '#/db/schema'
|
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from '#/lib/api-key-auth'
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from '#/lib/session-api'
|
||||||
import { getQrCode } from '#/lib/baileys'
|
import { getQrCode } from '#/lib/baileys'
|
||||||
|
import { findSession } from '#/lib/actions/sessions'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
|
export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -14,10 +12,7 @@ export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth
|
||||||
|
|
||||||
const session = await db.query.sessions.findFirst({
|
const session = await findSession(params.id, { appId: auth.appId })
|
||||||
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
|
|
||||||
columns: { status: true },
|
|
||||||
})
|
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError('Not found', 404)
|
||||||
|
|
||||||
if (session.status === 'CONNECTED') {
|
if (session.status === 'CONNECTED') {
|
||||||
|
|
@ -28,12 +23,9 @@ export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrRaw = getQrCode(params.id)
|
const qrRaw = getQrCode(params.id)
|
||||||
if (!qrRaw) {
|
if (!qrRaw) return json({ waiting: true }, 202)
|
||||||
return json({ waiting: true }, 202)
|
|
||||||
}
|
|
||||||
|
|
||||||
const qrPng = await QRCode.toDataURL(qrRaw)
|
const qrPng = await QRCode.toDataURL(qrRaw)
|
||||||
|
|
||||||
return json({ qr: qrPng, qrRaw })
|
return json({ qr: qrPng, qrRaw })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { authenticateApiKey } from '#/lib/api-key-auth'
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
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/')({
|
export const Route = createFileRoute('/api/v1/sessions/')({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -13,17 +10,7 @@ export const Route = createFileRoute('/api/v1/sessions/')({
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request)
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth
|
||||||
|
|
||||||
const list = await db.query.sessions.findMany({
|
return json(await listAppSessions(auth.appId))
|
||||||
where: eq(sessions.appId, auth.appId),
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
lastSeen: true,
|
|
||||||
createdAt: true,
|
|
||||||
reconnectCount: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return json(list)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
|
|
@ -34,37 +21,10 @@ export const Route = createFileRoute('/api/v1/sessions/')({
|
||||||
try { body = await request.json() } catch { /* default */ }
|
try { body = await request.json() } catch { /* default */ }
|
||||||
|
|
||||||
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
|
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({
|
return json(result.session, 201)
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,10 @@
|
||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from '#/server/auth'
|
||||||
import { appQuery, apiKeysQuery } from '#/lib/query'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId')({
|
export const Route = createFileRoute('/apps/$appId')({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn()
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
357
src/routes/apps/$appId/index.tsx
Normal file
357
src/routes/apps/$appId/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
435
src/routes/apps/$appId/sessions/$sessionId.tsx
Normal file
435
src/routes/apps/$appId/sessions/$sessionId.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
src/routes/apps/$appId/sessions/index.tsx
Normal file
178
src/routes/apps/$appId/sessions/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
src/routes/apps/$appId/sessions/new.tsx
Normal file
182
src/routes/apps/$appId/sessions/new.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Plus, LayoutGrid, ChevronRight, Zap, Calendar, X } from 'lucide-react'
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from '#/server/auth'
|
||||||
import { appsQuery } from '#/lib/query'
|
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/')({
|
export const Route = createFileRoute('/apps/')({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
|
|
@ -22,99 +33,163 @@ interface App {
|
||||||
function AppsPage() {
|
function AppsPage() {
|
||||||
const { data: apps = [] } = useQuery<App[]>(appsQuery)
|
const { data: apps = [] } = useQuery<App[]>(appsQuery)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const router = useRouter()
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
async function createApp(e: React.FormEvent) {
|
async function createApp(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
const res = await fetch('/api/v1/admin/apps', {
|
setCreating(true)
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'content-type': 'application/json' },
|
const res = await fetch('/api/v1/admin/apps', {
|
||||||
credentials: 'include',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name }),
|
headers: { 'content-type': 'application/json' },
|
||||||
})
|
credentials: 'include',
|
||||||
if (!res.ok) {
|
body: JSON.stringify({ name }),
|
||||||
const d = await res.json()
|
})
|
||||||
setError(d.error ?? 'Failed to create app')
|
if (!res.ok) {
|
||||||
return
|
const d = await res.json()
|
||||||
|
setError(d.error ?? 'Failed to create app')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
setName('')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['apps'] })
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
}
|
}
|
||||||
setCreating(false)
|
|
||||||
setName('')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['apps'] })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
|
||||||
router.navigate({ to: '/login' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-full">
|
||||||
<header className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between">
|
{/* Page header */}
|
||||||
<h1 className="font-semibold">Wahoo</h1>
|
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<Link to="/audit-log" className="text-zinc-400 hover:text-white">Audit log</Link>
|
<LayoutGrid size={16} className="text-gray-400" strokeWidth={1.75} />
|
||||||
<button onClick={handleLogout} className="text-zinc-400 hover:text-white">Sign out</button>
|
<h1 className="text-[14px] font-semibold text-gray-900">Apps</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8 space-y-6">
|
{/* Content */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-8 py-6 animate-fade-up">
|
||||||
<h2 className="text-lg font-medium">Apps</h2>
|
{apps.length === 0 ? (
|
||||||
<button
|
<div className="mt-16 flex flex-col items-center justify-center text-center">
|
||||||
onClick={() => setCreating(true)}
|
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
||||||
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
|
<LayoutGrid size={20} className="text-gray-400" strokeWidth={1.5} />
|
||||||
>
|
</div>
|
||||||
New app
|
<p className="text-sm font-medium text-gray-900 mb-1">No apps yet</p>
|
||||||
</button>
|
<p className="text-[13px] text-gray-500 mb-5">
|
||||||
</div>
|
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>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<Link
|
||||||
|
key={app.id}
|
||||||
|
to="/apps/$appId"
|
||||||
|
params={{ appId: app.id }}
|
||||||
|
className="group bg-white border border-gray-200 rounded-xl p-5 hover:border-gray-300 hover:shadow-sm transition-all duration-150 block"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
{creating && (
|
<p className="font-semibold text-[14px] text-gray-900 mb-1 truncate">{app.name}</p>
|
||||||
<form onSubmit={createApp} className="border border-zinc-800 rounded-lg p-4 space-y-3">
|
|
||||||
<input
|
<div className="flex items-center gap-3 mt-3">
|
||||||
autoFocus
|
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
|
||||||
required
|
<Zap size={11} strokeWidth={1.75} />
|
||||||
placeholder="App name"
|
{app.maxSessions} sessions max
|
||||||
value={name}
|
</span>
|
||||||
onChange={e => setName(e.target.value)}
|
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
|
||||||
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"
|
<Calendar size={11} strokeWidth={1.75} />
|
||||||
/>
|
{new Date(app.createdAt).toLocaleDateString('en', {
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
month: 'short',
|
||||||
<div className="flex gap-2">
|
day: 'numeric',
|
||||||
<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
|
</span>
|
||||||
</button>
|
</div>
|
||||||
<button type="button" onClick={() => { setCreating(false); setName('') }} className="text-sm text-zinc-500 hover:text-white px-3 py-1.5">
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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
|
Cancel
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
{apps.length === 0 ? (
|
|
||||||
<p className="text-zinc-500 text-sm">No apps yet. Create one to get started.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{apps.map((app) => (
|
|
||||||
<li key={app.id}>
|
|
||||||
<Link
|
|
||||||
to="/apps/$appId"
|
|
||||||
params={{ appId: app.id }}
|
|
||||||
className="block border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { ScrollText, Search, RefreshCw } from 'lucide-react'
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from '#/server/auth'
|
||||||
import { auditLogQuery } from '#/lib/query'
|
import { auditLogQuery } from '#/lib/query'
|
||||||
|
import { Input } from '#/components/ui/input'
|
||||||
|
import { cn } from '#/lib/utils'
|
||||||
|
|
||||||
export const Route = createFileRoute('/audit-log')({
|
export const Route = createFileRoute('/audit-log')({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
|
|
@ -23,65 +26,149 @@ interface AuditEntry {
|
||||||
createdAt: string
|
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() {
|
function AuditLogPage() {
|
||||||
const { data } = useQuery<{ data: AuditEntry[] }>(auditLogQuery)
|
const { data, refetch } = useQuery<{ data: AuditEntry[] }>(auditLogQuery)
|
||||||
const entries = data?.data ?? []
|
const entries = data?.data ?? []
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
const filtered = entries.filter((e: AuditEntry) =>
|
const filtered = entries.filter(e =>
|
||||||
!filter || e.action.includes(filter) || e.resourceType.includes(filter)
|
!filter ||
|
||||||
|
e.action.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
e.resourceType.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
e.actorType.toLowerCase().includes(filter.toLowerCase()),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-full">
|
||||||
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
|
{/* Page header */}
|
||||||
<Link to="/apps" className="text-zinc-400 hover:text-white text-sm">← Apps</Link>
|
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||||
<span className="text-zinc-700">/</span>
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="font-semibold">Audit log</h1>
|
<ScrollText size={16} className="text-gray-400" strokeWidth={1.75} />
|
||||||
</header>
|
<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">
|
{/* Content */}
|
||||||
<input
|
<div className="px-8 py-6 animate-fade-up">
|
||||||
placeholder="Filter by action or resource…"
|
{/* Filter */}
|
||||||
value={filter}
|
<div className="relative max-w-xs mb-5">
|
||||||
onChange={e => setFilter(e.target.value)}
|
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
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"
|
<Input
|
||||||
/>
|
placeholder="Filter by action, resource…"
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
className="h-8 pl-8 text-[13px] bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{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">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
||||||
<table className="w-full text-sm">
|
{/* Header */}
|
||||||
<thead>
|
<div className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
||||||
<tr className="text-left text-zinc-500 border-b border-zinc-800">
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Time</span>
|
||||||
<th className="pb-2 pr-4 font-normal">Time</th>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Action</span>
|
||||||
<th className="pb-2 pr-4 font-normal">Action</th>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Resource</span>
|
||||||
<th className="pb-2 pr-4 font-normal">Resource</th>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">IP</span>
|
||||||
<th className="pb-2 pr-4 font-normal">IP</th>
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
{/* Rows */}
|
||||||
<tbody className="divide-y divide-zinc-900">
|
<div className="divide-y divide-gray-100">
|
||||||
{filtered.map((e: AuditEntry) => (
|
{filtered.map(e => (
|
||||||
<tr key={e.id} className="text-zinc-300">
|
<div
|
||||||
<td className="py-2 pr-4 text-zinc-500 whitespace-nowrap">
|
key={e.id}
|
||||||
{new Date(e.createdAt).toLocaleString()}
|
className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 items-center hover:bg-gray-50 transition-colors"
|
||||||
</td>
|
>
|
||||||
<td className="py-2 pr-4">
|
{/* Time */}
|
||||||
<code className="text-xs bg-zinc-900 px-1.5 py-0.5 rounded">{e.action}</code>
|
<div>
|
||||||
</td>
|
<p className="text-[11px] text-gray-500 font-mono whitespace-nowrap">
|
||||||
<td className="py-2 pr-4">
|
{new Date(e.createdAt).toLocaleDateString('en', {
|
||||||
<span className="text-zinc-400">{e.resourceType}</span>
|
month: 'short',
|
||||||
{e.resourceId && <span className="text-zinc-600 ml-1 text-xs">{e.resourceId.slice(0, 8)}</span>}
|
day: 'numeric',
|
||||||
</td>
|
})}
|
||||||
<td className="py-2 pr-4 text-zinc-500 text-xs">{e.ip ?? '—'}</td>
|
</p>
|
||||||
</tr>
|
<p className="text-[11px] text-gray-400 font-mono">
|
||||||
))}
|
{new Date(e.createdAt).toLocaleTimeString('en', {
|
||||||
</tbody>
|
hour: '2-digit',
|
||||||
</table>
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { MessageSquare, Shield, Zap, Globe } from 'lucide-react'
|
||||||
import { getSessionFn } from '#/server/auth'
|
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')({
|
export const Route = createFileRoute('/login')({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn()
|
||||||
if (session?.userId) {
|
if (session?.userId) throw redirect({ to: '/apps' })
|
||||||
throw redirect({ to: '/apps' })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
component: LoginPage,
|
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() {
|
function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
|
|
@ -25,7 +34,6 @@ function LoginPage() {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -34,17 +42,8 @@ function LoginPage() {
|
||||||
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }),
|
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
if (!res.ok) { setError(data.error ?? 'Login failed'); return }
|
||||||
if (!res.ok) {
|
if (data.requireTotp) { setRequireTotp(true); return }
|
||||||
setError(data.error ?? 'Login failed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.requireTotp) {
|
|
||||||
setRequireTotp(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
router.navigate({ to: '/apps' })
|
router.navigate({ to: '/apps' })
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError('Network error')
|
||||||
|
|
@ -54,80 +53,174 @@ function LoginPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
|
<div className="min-h-screen flex">
|
||||||
<div className="w-full max-w-sm space-y-6 p-8">
|
{/* Left panel — branding */}
|
||||||
<div className="space-y-1">
|
<div className="hidden lg:flex lg:w-[52%] bg-zinc-950 flex-col justify-between p-10 relative overflow-hidden">
|
||||||
<h1 className="text-2xl font-semibold text-white">Sign in</h1>
|
{/* Subtle grid texture */}
|
||||||
<p className="text-sm text-zinc-400">Wahoo dashboard</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{/* Main copy */}
|
||||||
{!requireTotp ? (
|
<div className="relative space-y-6">
|
||||||
<>
|
<h1
|
||||||
<div className="space-y-1">
|
className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight"
|
||||||
<label className="text-sm text-zinc-300" htmlFor="email">Email</label>
|
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm text-zinc-300" htmlFor="password">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm text-zinc-300" htmlFor="totp">Authenticator code</label>
|
|
||||||
<input
|
|
||||||
id="totp"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
maxLength={6}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
{loading ? 'Signing in…' : requireTotp ? 'Verify' : 'Sign in'}
|
WhatsApp sessions,{' '}
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
{requireTotp && (
|
<div className="space-y-3 pt-2">
|
||||||
<button
|
{features.map(({ icon: Icon, label, desc }) => (
|
||||||
type="button"
|
<div key={label} className="flex items-center gap-3">
|
||||||
onClick={() => { setRequireTotp(false); setTotpToken('') }}
|
<div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0">
|
||||||
className="w-full text-sm text-zinc-500 hover:text-zinc-300"
|
<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' }}
|
||||||
>
|
>
|
||||||
Back
|
wahoo
|
||||||
</button>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</form>
|
|
||||||
|
<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.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="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
placeholder="000 000"
|
||||||
|
value={totpToken}
|
||||||
|
onChange={e => setTotpToken(e.target.value)}
|
||||||
|
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 && (
|
||||||
|
<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
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{requireTotp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setRequireTotp(false); setTotpToken('') }}
|
||||||
|
className="w-full text-[13px] text-zinc-400 hover:text-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to password
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,50 @@
|
||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { 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> = {
|
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
|
||||||
CONNECTED: 'text-green-400 bg-green-950/40',
|
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
|
||||||
CONNECTING: 'text-blue-400 bg-blue-950/40',
|
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
|
||||||
PENDING_QR: 'text-yellow-400 bg-yellow-950/40',
|
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
|
||||||
PENDING_PAIRING: 'text-yellow-400 bg-yellow-950/40',
|
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
|
||||||
DISCONNECTED: 'text-zinc-400 bg-zinc-800',
|
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
|
||||||
LOGGED_OUT: 'text-red-400 bg-red-950/40',
|
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
|
||||||
DEAD: 'text-red-500 bg-red-950/60',
|
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
id: string
|
id: string
|
||||||
|
appId: string
|
||||||
status: string
|
status: string
|
||||||
phoneNumber: string | null
|
phoneNumber: string | null
|
||||||
displayName: string | null
|
displayName: string | null
|
||||||
reconnectCount: number
|
reconnectCount: number
|
||||||
lastSeen: string | null
|
lastSeen: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
appId: string
|
}
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string
|
||||||
|
display: string
|
||||||
|
label: string
|
||||||
|
revokedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/sessions/$id')({
|
export const Route = createFileRoute('/sessions/$id')({
|
||||||
|
|
@ -28,39 +52,51 @@ export const Route = createFileRoute('/sessions/$id')({
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn()
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: '/login' })
|
||||||
},
|
},
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => ({ sessionId: params.id }),
|
||||||
// Dashboard can't call API-key-gated endpoints directly — return null and let component handle
|
|
||||||
return { sessionId: params.id }
|
|
||||||
},
|
|
||||||
component: SessionDetailPage,
|
component: SessionDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function SessionDetailPage() {
|
function SessionDetailPage() {
|
||||||
const { sessionId } = Route.useLoaderData()
|
const { sessionId } = Route.useLoaderData()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [apiKey, setApiKey] = useState('')
|
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
|
||||||
const [apiKeyInput, setApiKeyInput] = useState('')
|
adminSessionDetailQuery(sessionId),
|
||||||
const [session, setSession] = useState<SessionDetail | null>(null)
|
)
|
||||||
|
|
||||||
|
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 [qr, setQr] = useState<string | null>(null)
|
||||||
const [pairingPhone, setPairingPhone] = useState('')
|
const [pairingPhone, setPairingPhone] = useState('')
|
||||||
const [pairingCode, setPairingCode] = useState<string | null>(null)
|
const [pairingCode, setPairingCode] = useState<string | null>(null)
|
||||||
const [error, setError] = useState('')
|
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)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
function authHeader(): Record<string, string> {
|
useEffect(() => {
|
||||||
return apiKey ? { authorization: `Bearer ${apiKey}` } : {}
|
if (!selectedKeyId && activeKeys.length > 0) {
|
||||||
}
|
setSelectedKeyId(activeKeys[0].id)
|
||||||
|
}
|
||||||
|
}, [activeKeys, selectedKeyId])
|
||||||
|
|
||||||
async function loadSession() {
|
function keyHint() {
|
||||||
if (!apiKey) return
|
return selectedKeyId ? { 'x-key-id': selectedKeyId } : {}
|
||||||
const res = await fetch(`/api/v1/sessions/${sessionId}`, { headers: authHeader() })
|
|
||||||
if (res.ok) setSession(await res.json())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadQr() {
|
async function loadQr() {
|
||||||
if (!apiKey) return
|
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
|
||||||
const res = await fetch(`/api/v1/sessions/${sessionId}/qr`, { headers: authHeader() })
|
credentials: 'include',
|
||||||
|
headers: keyHint(),
|
||||||
|
})
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setQr(data.qr ?? null)
|
setQr(data.qr ?? null)
|
||||||
|
|
@ -69,23 +105,15 @@ function SessionDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!apiKey) return
|
|
||||||
loadSession()
|
|
||||||
}, [apiKey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
|
||||||
if (session.status === 'PENDING_QR') {
|
if (session.status === 'PENDING_QR') {
|
||||||
loadQr()
|
loadQr()
|
||||||
pollRef.current = setInterval(() => {
|
pollRef.current = setInterval(() => { refetchSession(); loadQr() }, 3000)
|
||||||
loadSession()
|
|
||||||
loadQr()
|
|
||||||
}, 3000)
|
|
||||||
} else if (session.status === 'CONNECTING') {
|
} else if (session.status === 'CONNECTING') {
|
||||||
pollRef.current = setInterval(loadSession, 2000)
|
pollRef.current = setInterval(() => refetchSession(), 2000)
|
||||||
} else {
|
} else {
|
||||||
setQr(null)
|
setQr(null)
|
||||||
}
|
}
|
||||||
|
|
@ -96,156 +124,280 @@ function SessionDetailPage() {
|
||||||
async function requestPairingCode() {
|
async function requestPairingCode() {
|
||||||
if (!pairingPhone) { setError('Enter phone number'); return }
|
if (!pairingPhone) { setError('Enter phone number'); return }
|
||||||
setError('')
|
setError('')
|
||||||
const res = await fetch(`/api/v1/sessions/${sessionId}/pair`, {
|
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json', ...authHeader() },
|
credentials: 'include',
|
||||||
|
headers: { 'content-type': 'application/json', ...keyHint() },
|
||||||
body: JSON.stringify({ phone: pairingPhone }),
|
body: JSON.stringify({ phone: pairingPhone }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||||
setPairingCode(data.pairingCode)
|
setPairingCode(data.pairingCode)
|
||||||
pollRef.current = setInterval(loadSession, 3000)
|
pollRef.current = setInterval(() => refetchSession(), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function disconnect() {
|
||||||
if (!confirm('Disconnect this session?')) return
|
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
|
||||||
await fetch(`/api/v1/sessions/${sessionId}/disconnect`, {
|
method: 'POST',
|
||||||
method: 'POST', headers: authHeader(),
|
credentials: 'include',
|
||||||
|
headers: keyHint(),
|
||||||
})
|
})
|
||||||
loadSession()
|
setDisconnectOpen(false)
|
||||||
|
refetchSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSession() {
|
async function deleteSession() {
|
||||||
if (!confirm('Delete session and wipe all credentials? This cannot be undone.')) return
|
setDeleting(true)
|
||||||
await fetch(`/api/v1/sessions/${sessionId}`, {
|
try {
|
||||||
method: 'DELETE', headers: authHeader(),
|
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
|
||||||
})
|
method: 'DELETE',
|
||||||
router.navigate({ to: '/sessions' })
|
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
|
||||||
return (
|
const canDisconnect = session &&
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status)
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Load
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-full">
|
||||||
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
|
{/* Page header */}
|
||||||
<Link to="/sessions" className="text-zinc-400 hover:text-white text-sm">← Sessions</Link>
|
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||||
<span className="text-zinc-700">/</span>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="font-mono text-sm">{sessionId.slice(0, 8)}…</span>
|
<Link to="/sessions" className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0">
|
||||||
{session && (
|
<ChevronLeft size={16} strokeWidth={2} />
|
||||||
<span className={`ml-auto text-xs px-2 py-0.5 rounded font-medium ${STATUS_COLOR[session.status] ?? 'text-zinc-400'}`}>
|
</Link>
|
||||||
{session.status}
|
<span className="text-gray-300">/</span>
|
||||||
</span>
|
<code className="text-[13px] text-gray-700 font-mono truncate">{sessionId.slice(0, 12)}…</code>
|
||||||
)}
|
{style && (
|
||||||
</header>
|
<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)} />
|
||||||
<main className="max-w-2xl mx-auto px-6 py-8 space-y-8">
|
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{style.label}
|
||||||
|
</span>
|
||||||
{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.displayName && (
|
|
||||||
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Name</span>{session.displayName}</p>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{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>
|
</div>
|
||||||
</section>
|
)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pairingCode && (
|
{!session ? (
|
||||||
<section className="border border-amber-700 rounded-lg p-4 space-y-2 bg-amber-950/20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<p className="text-sm text-amber-400">Enter this code in WhatsApp → Linked Devices → Link a Device</p>
|
<div className="w-5 h-5 border-2 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
|
||||||
<code className="text-2xl font-mono tracking-widest text-amber-300">{pairingCode}</code>
|
</div>
|
||||||
</section>
|
) : (
|
||||||
)}
|
<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 && (
|
||||||
|
<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>
|
||||||
|
|
||||||
{session && (
|
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
<section className="border-t border-zinc-800 pt-6 space-y-3">
|
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">Actions</p>
|
||||||
<h3 className="text-sm font-medium text-zinc-400">Actions</h3>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex gap-3">
|
{canDisconnect && (
|
||||||
{['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status) && (
|
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(true)} className="h-8 px-3 text-[13px] gap-1.5">
|
||||||
<button
|
<Power size={12} strokeWidth={2} />
|
||||||
onClick={disconnect}
|
Disconnect
|
||||||
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm hover:border-zinc-500"
|
</Button>
|
||||||
>
|
)}
|
||||||
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pairingCode && (
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Zap, Plus, RefreshCw } from 'lucide-react'
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from '#/server/auth'
|
||||||
import { sessionsQuery } from '#/lib/query'
|
import { sessionsQuery } from '#/lib/query'
|
||||||
|
import { Button } from '#/components/ui/button'
|
||||||
|
import { cn } from '#/lib/utils'
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
|
||||||
CONNECTED: 'text-green-400 bg-green-950/40',
|
CONNECTED: {
|
||||||
CONNECTING: 'text-blue-400 bg-blue-950/40',
|
dot: 'bg-emerald-500 animate-pulse-dot',
|
||||||
PENDING_QR: 'text-yellow-400 bg-yellow-950/40',
|
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
PENDING_PAIRING: 'text-yellow-400 bg-yellow-950/40',
|
label: 'Connected',
|
||||||
DISCONNECTED: 'text-zinc-400 bg-zinc-800',
|
},
|
||||||
LOGGED_OUT: 'text-red-400 bg-red-950/40',
|
CONNECTING: {
|
||||||
DEAD: 'text-red-500 bg-red-950/60',
|
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 {
|
interface SessionRow {
|
||||||
|
|
@ -32,68 +63,143 @@ export const Route = createFileRoute('/sessions/')({
|
||||||
})
|
})
|
||||||
|
|
||||||
function SessionsPage() {
|
function SessionsPage() {
|
||||||
const { data: sessions = [] } = useQuery<SessionRow[]>(sessionsQuery)
|
const { data: sessions = [], refetch } = useQuery<SessionRow[]>(sessionsQuery)
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
async function handleLogout() {
|
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length
|
||||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
const pendingCount = sessions.filter(s =>
|
||||||
router.navigate({ to: '/login' })
|
s.status === 'PENDING_QR' || s.status === 'PENDING_PAIRING' || s.status === 'CONNECTING',
|
||||||
}
|
).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-full">
|
||||||
<header className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between">
|
{/* Page header */}
|
||||||
<h1 className="font-semibold">Wahoo</h1>
|
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<Link to="/apps" className="text-zinc-400 hover:text-white">Apps</Link>
|
<Zap size={16} className="text-gray-400" strokeWidth={1.75} />
|
||||||
<Link to="/audit-log" className="text-zinc-400 hover:text-white">Audit log</Link>
|
<h1 className="text-[14px] font-semibold text-gray-900">Sessions</h1>
|
||||||
<button onClick={handleLogout} className="text-zinc-400 hover:text-white">Sign out</button>
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
|
onClick={() => refetch()}
|
||||||
<div className="flex items-center justify-between">
|
className="p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
<h2 className="text-lg font-medium">Sessions</h2>
|
title="Refresh"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
New session
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-8 py-6 animate-fade-up">
|
||||||
{sessions.length === 0 ? (
|
{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">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
||||||
{sessions.map((s) => (
|
{/* Table header */}
|
||||||
<li key={s.id}>
|
<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">
|
||||||
<Link
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Session</span>
|
||||||
to="/sessions/$id"
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">App</span>
|
||||||
params={{ id: s.id }}
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span>
|
||||||
className="block border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"
|
<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 className="flex items-center justify-between">
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">{s.appName}</p>
|
{/* Table rows */}
|
||||||
<p className="font-mono text-sm text-zinc-300">{s.id.slice(0, 8)}…</p>
|
<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="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-3 hover:bg-gray-50 transition-colors items-center"
|
||||||
|
>
|
||||||
|
{/* 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>
|
</div>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${STATUS_COLOR[s.status] ?? 'text-zinc-400'}`}>
|
|
||||||
{s.status}
|
{/* Last seen */}
|
||||||
|
<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>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-zinc-600 mt-2">
|
{/* Reconnect count */}
|
||||||
{s.lastSeen
|
<span className="text-[12px] text-gray-400">
|
||||||
? `Last seen ${new Date(s.lastSeen).toLocaleString()}`
|
{s.reconnectCount > 0 ? s.reconnectCount : '—'}
|
||||||
: `Created ${new Date(s.createdAt).toLocaleDateString()}`}
|
</span>
|
||||||
{s.reconnectCount > 0 && ` · ${s.reconnectCount} reconnect${s.reconnectCount !== 1 ? 's' : ''}`}
|
</Link>
|
||||||
</p>
|
)
|
||||||
</Link>
|
})}
|
||||||
</li>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { ChevronLeft, QrCode, Smartphone } from 'lucide-react'
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from '#/server/auth'
|
||||||
import { appsQuery } from '#/lib/query'
|
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')({
|
export const Route = createFileRoute('/sessions/new')({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
|
|
@ -22,9 +27,15 @@ function NewSessionPage() {
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const selectedApp = apps.find(a => a.id === (appId || apps[0]?.id))
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
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('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -42,66 +53,131 @@ function NewSessionPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-full">
|
||||||
<header className="border-b border-zinc-800 px-6 py-4">
|
{/* Page header */}
|
||||||
<button onClick={() => router.history.back()} className="text-zinc-400 hover:text-white text-sm">
|
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center gap-2">
|
||||||
← Back
|
<button
|
||||||
|
onClick={() => router.history.back()}
|
||||||
|
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} strokeWidth={2} />
|
||||||
</button>
|
</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">
|
{/* Form */}
|
||||||
<h2 className="text-lg font-medium mb-6">New session</h2>
|
<div className="px-8 py-8 animate-fade-up">
|
||||||
|
<div className="max-w-md">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<label className="text-xs text-zinc-400">App</label>
|
<p className="text-[13px] font-medium text-gray-900 mb-0.5">Connect WhatsApp</p>
|
||||||
<select
|
<p className="text-[12px] text-gray-500">
|
||||||
value={appId || apps[0]?.id || ''}
|
Select an app, provide its API key, and choose how to authenticate.
|
||||||
onChange={(e) => setAppId(e.target.value)}
|
</p>
|
||||||
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"
|
|
||||||
>
|
|
||||||
{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
|
|
||||||
type="password"
|
|
||||||
placeholder="wak_..."
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 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-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.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>
|
||||||
|
|
||||||
|
{/* API key */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[13px] text-zinc-700">API key</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="wak_live_…"
|
||||||
|
value={apiKey}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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 || 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
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.history.back()}
|
||||||
|
className="h-9 px-4 text-[13px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
</div>
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{loading ? 'Creating…' : 'Create session'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
src/styles.css
170
src/styles.css
|
|
@ -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';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
|
@ -119,6 +119,7 @@
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
|
@ -160,170 +161,31 @@
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--sea-ink);
|
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background-color: var(--bg-base);
|
background-color: var(--background);
|
||||||
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%);
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
code,
|
||||||
content: '';
|
.font-mono {
|
||||||
position: fixed;
|
font-family: var(--font-mono);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-title {
|
.display-title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-shell {
|
@keyframes fade-up {
|
||||||
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 {
|
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -331,9 +193,17 @@ a {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer {
|
.animate-fade-up {
|
||||||
border-top: 1px solid var(--line);
|
animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-dot {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue