style: format prettier eslint

This commit is contained in:
Edqe14 2026-05-15 04:06:45 +07:00
parent 0aef5bca86
commit c37f8f77c2
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
92 changed files with 3782 additions and 2662 deletions

View file

@ -16,11 +16,5 @@
"routerOnly": false, "routerOnly": false,
"version": 1, "version": 1,
"framework": "react", "framework": "react",
"chosenAddOns": [ "chosenAddOns": ["eslint", "nitro", "shadcn", "drizzle", "table"]
"eslint",
"nitro",
"shadcn",
"drizzle",
"table"
]
} }

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false
}

View file

@ -27,12 +27,14 @@ Add shadcn components: `pnpm dlx shadcn@latest add <component>` (uses pnpm, not
Milestone-by-milestone plan lives in `.playground/plan/`. Milestone-by-milestone plan lives in `.playground/plan/`.
**Before starting any session:** **Before starting any session:**
1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns. 1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns.
2. Open the relevant milestone file (e.g. `M1-foundations.md`). 2. Open the relevant milestone file (e.g. `M1-foundations.md`).
3. Work tasks top-to-bottom; check boxes as you complete them. 3. Work tasks top-to-bottom; check boxes as you complete them.
4. After each task or at session end, update `STATUS.md`: progress, new blockers, decisions made. 4. After each task or at session end, update `STATUS.md`: progress, new blockers, decisions made.
**Status conventions in milestone files:** **Status conventions in milestone files:**
- `⬜` ready to start - `⬜` ready to start
- `🟡` concern / needs decision - `🟡` concern / needs decision
- `🔴` blocked - `🔴` blocked
@ -44,13 +46,13 @@ Milestone-by-milestone plan lives in `.playground/plan/`.
Project docs live in `docs/`: Project docs live in `docs/`:
| File | Contents | | File | Contents |
|------|----------| | ---------------------- | ----------------------------------------------- |
| `docs/architecture.md` | System overview, component map, key files | | `docs/architecture.md` | System overview, component map, key files |
| `docs/api.md` | REST endpoint reference | | `docs/api.md` | REST endpoint reference |
| `docs/webhooks.md` | Event catalog, HMAC verification snippets | | `docs/webhooks.md` | Event catalog, HMAC verification snippets |
| `docs/security.md` | Encryption model, key rotation, audit log | | `docs/security.md` | Encryption model, key rotation, audit log |
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade | | `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
Update docs when adding endpoints, changing the schema, or changing security behavior. Update docs when adding endpoints, changing the schema, or changing security behavior.
@ -74,25 +76,32 @@ Update docs when adding endpoints, changing the schema, or changing security beh
All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`). All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`).
| File | Domain | | File | Domain |
|------|--------| | ---------------------- | ----------------- |
| `actions/apps.ts` | App CRUD | | `actions/apps.ts` | App CRUD |
| `actions/api-keys.ts` | API key lifecycle | | `actions/api-keys.ts` | API key lifecycle |
| `actions/sessions.ts` | Session lifecycle | | `actions/sessions.ts` | Session lifecycle |
| `actions/auth.ts` | User lookup, TOTP | | `actions/auth.ts` | User lookup, TOTP |
| `actions/audit-log.ts` | Audit log queries | | `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: **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 ```ts
type Scope = { appId: string } | { userId: string } type Scope = { appId: string } | { userId: string };
export async function findSession(sessionId: string, scope: Scope): Promise<Session | null> 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: **Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
```ts ```ts
export async function createSession(appId: string, via: 'qr' | 'pairing', ownerUserId?: string) export async function createSession(
appId: string,
via: "qr" | "pairing",
ownerUserId?: string,
);
// admin: createSession(appId, via, sess.userId) // admin: createSession(appId, via, sess.userId)
// api key: createSession(appId, via) // api key: createSession(appId, via)
``` ```
@ -100,8 +109,8 @@ export async function createSession(appId: string, via: 'qr' | 'pairing', ownerU
**Result type** — actions that can fail return a discriminated union, not thrown errors: **Result type** — actions that can fail return a discriminated union, not thrown errors:
```ts ```ts
return { ok: false, error: 'App not found', status: 404 } return { ok: false, error: "App not found", status: 404 };
return { ok: true, session } return { ok: true, session };
// route: if (!result.ok) return jsonError(result.error, result.status) // route: if (!result.ok) return jsonError(result.error, result.status)
``` ```

View file

@ -40,7 +40,6 @@ If you prefer not to use Tailwind CSS:
## Linting & Formatting ## Linting & Formatting
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available: This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
```bash ```bash
@ -49,7 +48,6 @@ bun --bun run format
bun --bun run check bun --bun run check
``` ```
## Deploy with Nitro ## Deploy with Nitro
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host. This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
@ -63,7 +61,6 @@ The build output is a self-contained Node server. To deploy, push the `dist/` di
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy. For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
## Shadcn ## Shadcn
Add components using the latest version of [Shadcn](https://ui.shadcn.com/). Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
@ -72,8 +69,6 @@ Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
pnpm dlx shadcn@latest add button pnpm dlx shadcn@latest add button
``` ```
## Routing ## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`. This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
@ -111,14 +106,14 @@ In the File Based Routing setup the layout is located in `src/routes/__root.tsx`
Here is an example layout that includes a header: Here is an example layout that includes a header:
```tsx ```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
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' }, { name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: 'My App' }, { title: "My App" },
], ],
}), }),
shellComponent: ({ children }) => ( shellComponent: ({ children }) => (
@ -138,7 +133,7 @@ export const Route = createRootRoute({
</body> </body>
</html> </html>
), ),
}) });
``` ```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
@ -148,23 +143,23 @@ More information on layouts can be found in the [Layouts documentation](https://
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components. TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx ```tsx
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from "@tanstack/react-start";
const getServerTime = createServerFn({ const getServerTime = createServerFn({
method: 'GET', method: "GET",
}).handler(async () => { }).handler(async () => {
return new Date().toISOString() return new Date().toISOString();
}) });
// Use in a component // Use in a component
function MyComponent() { function MyComponent() {
const [time, setTime] = useState('') const [time, setTime] = useState("");
useEffect(() => { useEffect(() => {
getServerTime().then(setTime) getServerTime().then(setTime);
}, []) }, []);
return <div>Server time: {time}</div> return <div>Server time: {time}</div>;
} }
``` ```
@ -173,16 +168,16 @@ function MyComponent() {
You can create API routes by using the `server` property in your route definitions: You can create API routes by using the `server` property in your route definitions:
```tsx ```tsx
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json } from '@tanstack/react-start' import { json } from "@tanstack/react-start";
export const Route = createFileRoute('/api/hello')({ export const Route = createFileRoute("/api/hello")({
server: { server: {
handlers: { handlers: {
GET: () => json({ message: 'Hello, World!' }), GET: () => json({ message: "Hello, World!" }),
}, },
}, },
}) });
``` ```
## Data Fetching ## Data Fetching
@ -192,25 +187,25 @@ There are multiple ways to fetch data in your application. You can use TanStack
For example: For example:
```tsx ```tsx
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/people')({ export const Route = createFileRoute("/people")({
loader: async () => { loader: async () => {
const response = await fetch('https://swapi.dev/api/people') const response = await fetch("https://swapi.dev/api/people");
return response.json() return response.json();
}, },
component: PeopleComponent, component: PeopleComponent,
}) });
function PeopleComponent() { function PeopleComponent() {
const data = Route.useLoaderData() const data = Route.useLoaderData();
return ( return (
<ul> <ul>
{data.results.map((person) => ( {data.results.map((person) => (
<li key={person.name}>{person.name}</li> <li key={person.name}>{person.name}</li>
))} ))}
</ul> </ul>
) );
} }
``` ```

View file

@ -18,7 +18,13 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))"] test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View file

@ -37,13 +37,13 @@ Header: `X-Signature: sha256=<hex>`.
Verify (Node.js): Verify (Node.js):
```js ```js
const crypto = require('crypto') const crypto = require("crypto");
function verify(secret, body, timestamp, sig) { function verify(secret, body, timestamp, sig) {
const expected = crypto const expected = crypto
.createHmac('sha256', secret) .createHmac("sha256", secret)
.update(body + '.' + timestamp) .update(body + "." + timestamp)
.digest('hex') .digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} }
``` ```

View file

@ -73,18 +73,18 @@ Signature covers: `raw_body + "." + X-Timestamp`.
**Node.js** **Node.js**
```js ```js
const crypto = require('crypto') const crypto = require("crypto");
function verifyWebhook(secret, rawBody, timestamp, signature) { function verifyWebhook(secret, rawBody, timestamp, signature) {
const expected = crypto const expected = crypto
.createHmac('sha256', secret) .createHmac("sha256", secret)
.update(rawBody + '.' + timestamp) .update(rawBody + "." + timestamp)
.digest('hex') .digest("hex");
const received = signature.replace('sha256=', '') const received = signature.replace("sha256=", "");
return crypto.timingSafeEqual( return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'), Buffer.from(expected, "hex"),
Buffer.from(received, 'hex'), Buffer.from(received, "hex"),
) );
} }
``` ```

View file

@ -1,20 +1,26 @@
import { config } from 'dotenv' import { config } from "dotenv";
import { defineConfig } from 'drizzle-kit' import { defineConfig } from "drizzle-kit";
config({ path: ['.env.local', '.env'] }) config({ path: [".env.local", ".env"] });
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env const {
POSTGRES_USER,
POSTGRES_PASSWORD,
POSTGRES_HOST,
POSTGRES_PORT,
POSTGRES_DB,
} = process.env;
export default defineConfig({ export default defineConfig({
out: './drizzle', out: "./drizzle",
schema: './src/db/schema.ts', schema: "./src/db/schema.ts",
dialect: 'postgresql', dialect: "postgresql",
dbCredentials: { dbCredentials: {
host: POSTGRES_HOST ?? 'localhost', host: POSTGRES_HOST ?? "localhost",
port: Number(POSTGRES_PORT ?? 5432), port: Number(POSTGRES_PORT ?? 5432),
user: POSTGRES_USER ?? 'wahoo', user: POSTGRES_USER ?? "wahoo",
password: POSTGRES_PASSWORD ?? 'wahoo', password: POSTGRES_PASSWORD ?? "wahoo",
database: POSTGRES_DB ?? 'wahoo', database: POSTGRES_DB ?? "wahoo",
ssl: false, ssl: false,
}, },
}) });

View file

@ -76,12 +76,8 @@
"name": "api_keys_app_id_apps_id_fk", "name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys", "tableFrom": "api_keys",
"tableTo": "apps", "tableTo": "apps",
"columnsFrom": [ "columnsFrom": ["app_id"],
"app_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -148,12 +144,8 @@
"name": "apps_owner_user_id_users_id_fk", "name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps", "tableFrom": "apps",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["owner_user_id"],
"owner_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -349,9 +341,7 @@
"users_email_unique": { "users_email_unique": {
"name": "users_email_unique", "name": "users_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@ -363,10 +353,7 @@
"public.actor_type": { "public.actor_type": {
"name": "actor_type", "name": "actor_type",
"schema": "public", "schema": "public",
"values": [ "values": ["user", "api_key"]
"user",
"api_key"
]
} }
}, },
"schemas": {}, "schemas": {},

View file

@ -76,12 +76,8 @@
"name": "api_keys_app_id_apps_id_fk", "name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys", "tableFrom": "api_keys",
"tableTo": "apps", "tableTo": "apps",
"columnsFrom": [ "columnsFrom": ["app_id"],
"app_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -148,12 +144,8 @@
"name": "apps_owner_user_id_users_id_fk", "name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps", "tableFrom": "apps",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["owner_user_id"],
"owner_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -347,12 +339,8 @@
"name": "session_keys_session_id_sessions_id_fk", "name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys", "tableFrom": "session_keys",
"tableTo": "sessions", "tableTo": "sessions",
"columnsFrom": [ "columnsFrom": ["session_id"],
"session_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -362,11 +350,7 @@
"session_keys_unique": { "session_keys_unique": {
"name": "session_keys_unique", "name": "session_keys_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["session_id", "key_type", "key_id"]
"session_id",
"key_type",
"key_id"
]
} }
}, },
"policies": {}, "policies": {},
@ -474,12 +458,8 @@
"name": "sessions_app_id_apps_id_fk", "name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions", "tableFrom": "sessions",
"tableTo": "apps", "tableTo": "apps",
"columnsFrom": [ "columnsFrom": ["app_id"],
"app_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -541,9 +521,7 @@
"users_email_unique": { "users_email_unique": {
"name": "users_email_unique", "name": "users_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@ -555,10 +533,7 @@
"public.actor_type": { "public.actor_type": {
"name": "actor_type", "name": "actor_type",
"schema": "public", "schema": "public",
"values": [ "values": ["user", "api_key"]
"user",
"api_key"
]
}, },
"public.session_status": { "public.session_status": {
"name": "session_status", "name": "session_status",

View file

@ -1,20 +1,20 @@
// @ts-check // @ts-check
import { tanstackConfig } from '@tanstack/eslint-config' import { tanstackConfig } from "@tanstack/eslint-config";
export default [ export default [
...tanstackConfig, ...tanstackConfig,
{ {
rules: { rules: {
'import/no-cycle': 'off', "import/no-cycle": "off",
'import/order': 'off', "import/order": "off",
'sort-imports': 'off', "sort-imports": "off",
'@typescript-eslint/array-type': 'off', "@typescript-eslint/array-type": "off",
'@typescript-eslint/require-await': 'off', "@typescript-eslint/require-await": "off",
'pnpm/json-enforce-catalog': 'off', "pnpm/json-enforce-catalog": "off",
}, },
}, },
{ {
ignores: ['eslint.config.js', 'prettier.config.js'], ignores: ["eslint.config.js", "prettier.config.js"],
}, },
] ];

View file

@ -1,19 +1,19 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: "./tests",
timeout: 30000, timeout: 30000,
retries: 0, retries: 0,
reporter: 'list', reporter: "list",
use: { use: {
baseURL: 'http://localhost:3000', baseURL: "http://localhost:3000",
headless: true, headless: true,
channel: 'chrome', channel: "chrome",
}, },
projects: [ projects: [
{ {
name: 'chrome', name: "chrome",
use: { ...devices['Desktop Chrome'], channel: 'chrome' }, use: { ...devices["Desktop Chrome"], channel: "chrome" },
}, },
], ],
}) });

View file

@ -1,24 +1,21 @@
import { drizzle } from 'drizzle-orm/node-postgres' import { drizzle } from "drizzle-orm/node-postgres";
import { users } from '../src/db/schema.ts' import { users } from "../src/db/schema.ts";
import argon2 from 'argon2' import argon2 from "argon2";
import { config } from 'dotenv' import { config } from "dotenv";
config({ path: ['.env.local', '.env'] }) config({ path: [".env.local", ".env"] });
const db = drizzle( const db = drizzle(
`postgresql://${process.env.POSTGRES_USER ?? 'postgres'}:${process.env.POSTGRES_PASSWORD ?? 'postgres'}@${process.env.POSTGRES_HOST ?? 'localhost'}:${process.env.POSTGRES_PORT ?? '5432'}/${process.env.POSTGRES_DB ?? 'wahoo'}`, `postgresql://${process.env.POSTGRES_USER ?? "postgres"}:${process.env.POSTGRES_PASSWORD ?? "postgres"}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB ?? "wahoo"}`,
{ schema: { users } }, { schema: { users } },
) );
const email = 'test@wahoo.local' const email = "test@wahoo.local";
const password = 'TestPassword123!' const password = "TestPassword123!";
const passwordHash = await argon2.hash(password, { type: argon2.argon2id }) const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
await db await db.insert(users).values({ email, passwordHash }).onConflictDoNothing();
.insert(users)
.values({ email, passwordHash })
.onConflictDoNothing()
console.log(`Seeded user: ${email} / ${password}`) console.log(`Seeded user: ${email} / ${password}`);
process.exit(0) process.exit(0);

View file

@ -1,30 +1,35 @@
import { Link, useLocation, useRouter } from '@tanstack/react-router' import { Link, useLocation, useRouter } from "@tanstack/react-router";
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { import {
LayoutGrid, Zap, ScrollText, LogOut, MessageSquare, LayoutGrid,
ChevronLeft, Key, Zap,
} from 'lucide-react' ScrollText,
import { appQuery } from '#/lib/query' LogOut,
import { cn } from '#/lib/utils' MessageSquare,
ChevronLeft,
Key,
} from "lucide-react";
import { appQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
export function Sidebar() { export function Sidebar() {
const location = useLocation() const location = useLocation();
const router = useRouter() const router = useRouter();
// Detect app context: /apps/<uuid>[/*] // Detect app context: /apps/<uuid>[/*]
const appContextMatch = location.pathname.match( 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, /^\/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 currentAppId = appContextMatch?.[1] ?? null;
const { data: currentApp } = useQuery({ const { data: currentApp } = useQuery({
...appQuery(currentAppId ?? ''), ...appQuery(currentAppId ?? ""),
enabled: !!currentAppId, enabled: !!currentAppId,
}) });
async function handleLogout() { async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
router.navigate({ to: '/login' }) router.navigate({ to: "/login" });
} }
return ( return (
@ -32,11 +37,15 @@ export function Sidebar() {
{/* Logo */} {/* Logo */}
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-zinc-900"> <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"> <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} /> <MessageSquare
size={14}
className="text-white fill-white"
strokeWidth={1.5}
/>
</div> </div>
<span <span
className="text-white font-semibold text-[15px] tracking-tight" className="text-white font-semibold text-[15px] tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }} style={{ fontFamily: "Fraunces, Georgia, serif" }}
> >
wahoo wahoo
</span> </span>
@ -66,47 +75,47 @@ export function Sidebar() {
</button> </button>
</div> </div>
</aside> </aside>
) );
} }
function GlobalNav({ pathname }: { pathname: string }) { function GlobalNav({ pathname }: { pathname: string }) {
const items = [ const items = [
{ to: '/apps' as const, label: 'Apps', icon: LayoutGrid }, { to: "/apps" as const, label: "Apps", icon: LayoutGrid },
{ to: '/audit-log' as const, label: 'Audit Log', icon: ScrollText }, { to: "/audit-log" as const, label: "Audit Log", icon: ScrollText },
] ];
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
{items.map(({ to, label, icon: Icon }) => { {items.map(({ to, label, icon: Icon }) => {
const isActive = const isActive =
to === '/apps' to === "/apps"
? pathname === '/apps' || pathname === '/apps/' ? pathname === "/apps" || pathname === "/apps/"
: to === '/sessions' : to === "/sessions"
? pathname.startsWith('/sessions') ? pathname.startsWith("/sessions")
: pathname === to : pathname === to;
return ( return (
<Link <Link
key={to} key={to}
to={to} to={to}
className={cn( className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full', "flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full",
isActive isActive
? 'bg-zinc-800 text-white' ? "bg-zinc-800 text-white"
: 'text-zinc-400 hover:text-white hover:bg-zinc-900', : "text-zinc-400 hover:text-white hover:bg-zinc-900",
)} )}
> >
<Icon <Icon
size={14} size={14}
strokeWidth={isActive ? 2 : 1.75} strokeWidth={isActive ? 2 : 1.75}
className={isActive ? 'text-emerald-400' : ''} className={isActive ? "text-emerald-400" : ""}
/> />
{label} {label}
</Link> </Link>
) );
})} })}
</div> </div>
) );
} }
function AppContextNav({ function AppContextNav({
@ -114,19 +123,20 @@ function AppContextNav({
appName, appName,
pathname, pathname,
}: { }: {
appId: string appId: string;
appName: string | undefined appName: string | undefined;
pathname: string pathname: string;
}) { }) {
const keysPath = `/apps/${appId}/` const keysPath = `/apps/${appId}/`;
const sessionsPath = `/apps/${appId}/sessions` const sessionsPath = `/apps/${appId}/sessions`;
const isKeys = const isKeys =
pathname === `/apps/${appId}` || pathname === `/apps/${appId}` ||
pathname === `/apps/${appId}/` || pathname === `/apps/${appId}/` ||
(pathname.startsWith(`/apps/${appId}/`) && !pathname.startsWith(sessionsPath)) (pathname.startsWith(`/apps/${appId}/`) &&
const isSessions = pathname.startsWith(sessionsPath) !pathname.startsWith(sessionsPath));
const isAuditLog = pathname === '/audit-log' const isSessions = pathname.startsWith(sessionsPath);
const isAuditLog = pathname === "/audit-log";
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@ -143,10 +153,14 @@ function AppContextNav({
<div className="px-3 py-1.5 mb-0.5"> <div className="px-3 py-1.5 mb-0.5">
<div className="flex items-center gap-2"> <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"> <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} /> <LayoutGrid
size={10}
className="text-emerald-400"
strokeWidth={2}
/>
</div> </div>
<span className="text-[12px] font-semibold text-zinc-300 truncate"> <span className="text-[12px] font-semibold text-zinc-300 truncate">
{appName ?? '…'} {appName ?? "…"}
</span> </span>
</div> </div>
</div> </div>
@ -156,16 +170,16 @@ function AppContextNav({
to="/apps/$appId/" to="/apps/$appId/"
params={{ appId }} params={{ appId }}
className={cn( className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1', "flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
isKeys isKeys
? 'bg-zinc-800 text-white' ? "bg-zinc-800 text-white"
: 'text-zinc-400 hover:text-white hover:bg-zinc-900', : "text-zinc-400 hover:text-white hover:bg-zinc-900",
)} )}
> >
<Key <Key
size={13} size={13}
strokeWidth={isKeys ? 2 : 1.75} strokeWidth={isKeys ? 2 : 1.75}
className={isKeys ? 'text-emerald-400' : ''} className={isKeys ? "text-emerald-400" : ""}
/> />
API Keys API Keys
</Link> </Link>
@ -174,16 +188,16 @@ function AppContextNav({
to="/apps/$appId/sessions/" to="/apps/$appId/sessions/"
params={{ appId }} params={{ appId }}
className={cn( className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1', "flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
isSessions isSessions
? 'bg-zinc-800 text-white' ? "bg-zinc-800 text-white"
: 'text-zinc-400 hover:text-white hover:bg-zinc-900', : "text-zinc-400 hover:text-white hover:bg-zinc-900",
)} )}
> >
<Zap <Zap
size={13} size={13}
strokeWidth={isSessions ? 2 : 1.75} strokeWidth={isSessions ? 2 : 1.75}
className={isSessions ? 'text-emerald-400' : ''} className={isSessions ? "text-emerald-400" : ""}
/> />
Sessions Sessions
</Link> </Link>
@ -195,19 +209,19 @@ function AppContextNav({
<Link <Link
to="/audit-log" to="/audit-log"
className={cn( className={cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150', "flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150",
isAuditLog isAuditLog
? 'bg-zinc-800 text-white' ? "bg-zinc-800 text-white"
: 'text-zinc-400 hover:text-white hover:bg-zinc-900', : "text-zinc-400 hover:text-white hover:bg-zinc-900",
)} )}
> >
<ScrollText <ScrollText
size={13} size={13}
strokeWidth={isAuditLog ? 2 : 1.75} strokeWidth={isAuditLog ? 2 : 1.75}
className={isAuditLog ? 'text-emerald-400' : ''} className={isAuditLog ? "text-emerald-400" : ""}
/> />
Audit Log Audit Log
</Link> </Link>
</div> </div>
) );
} }

View file

@ -1,8 +1,9 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority";
import { Slot } from "radix-ui" import type {VariantProps} from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
const badgeVariants = cva( 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", "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",
@ -23,8 +24,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@ -33,7 +34,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span" const Comp = asChild ? Slot.Root : "span";
return ( return (
<Comp <Comp
@ -42,7 +43,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View file

@ -1,8 +1,9 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority";
import { Slot } from "radix-ui" import type {VariantProps} from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
const buttonVariants = cva( 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", "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",
@ -35,8 +36,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
@ -46,9 +47,9 @@ function Button({
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -58,7 +59,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( 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", "@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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View file

@ -1,34 +1,34 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
import { Button } from "#/components/ui/button.tsx" import { Button } from "#/components/ui/button.tsx";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@ -40,11 +40,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@ -53,7 +53,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@ -62,7 +62,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
@ -78,7 +78,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -88,7 +88,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@ -97,14 +97,14 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
@ -115,7 +115,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({
@ -128,7 +128,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@ -141,7 +141,7 @@ function DialogDescription({
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -155,4 +155,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"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", "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", "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", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View file

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
function Label({ function Label({
className, className,
@ -12,11 +12,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View file

@ -1,27 +1,27 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui" import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@ -30,7 +30,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@ -38,7 +38,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@ -65,7 +65,7 @@ function SelectContent({
"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", "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" && 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", "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 className,
)} )}
position={position} position={position}
align={align} align={align}
@ -76,7 +76,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@ -84,7 +84,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@ -97,7 +97,7 @@ function SelectLabel({
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)} className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@ -110,7 +110,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
@ -124,7 +124,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@ -137,7 +137,7 @@ function SelectSeparator({
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@ -149,13 +149,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@ -167,13 +167,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@ -187,4 +187,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View file

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
function Separator({ function Separator({
className, className,
@ -16,11 +16,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "#/lib/utils.ts" import { cn } from "#/lib/utils.ts";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( 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]", "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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@ -99,7 +99,7 @@ function TableCaption({
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -111,4 +111,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View file

@ -1,15 +1,21 @@
import { drizzle } from 'drizzle-orm/node-postgres' import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from './schema.ts' import * as schema from "./schema.ts";
function getDbUrl(): string { function getDbUrl(): string {
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env const {
const user = POSTGRES_USER ?? 'wahoo' POSTGRES_USER,
const pass = POSTGRES_PASSWORD ?? 'wahoo' POSTGRES_PASSWORD,
const host = POSTGRES_HOST ?? 'localhost' POSTGRES_HOST,
const port = POSTGRES_PORT ?? '5432' POSTGRES_PORT,
const db = POSTGRES_DB ?? 'wahoo' POSTGRES_DB,
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}` } = process.env;
const user = POSTGRES_USER ?? "wahoo";
const pass = POSTGRES_PASSWORD ?? "wahoo";
const host = POSTGRES_HOST ?? "localhost";
const port = POSTGRES_PORT ?? "5432";
const db = POSTGRES_DB ?? "wahoo";
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}`;
} }
export const db = drizzle(getDbUrl(), { schema }) export const db = drizzle(getDbUrl(), { schema });

View file

@ -9,132 +9,138 @@ import {
timestamp, timestamp,
unique, unique,
uuid, uuid,
} from 'drizzle-orm/pg-core' } from "drizzle-orm/pg-core";
import { relations } from 'drizzle-orm' import { relations } from "drizzle-orm";
export const actorTypeEnum = pgEnum('actor_type', ['user', 'api_key']) export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
export const users = pgTable('users', { export const users = pgTable("users", {
id: uuid('id').primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
email: text('email').notNull().unique(), email: text("email").notNull().unique(),
passwordHash: text('password_hash').notNull(), passwordHash: text("password_hash").notNull(),
totpSecret: text('totp_secret'), totpSecret: text("totp_secret"),
totpEnabled: boolean('totp_enabled').notNull().default(false), totpEnabled: boolean("totp_enabled").notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}) });
export const apps = pgTable('apps', { export const apps = pgTable("apps", {
id: uuid('id').primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
name: text('name').notNull(), name: text("name").notNull(),
ownerUserId: uuid('owner_user_id') ownerUserId: uuid("owner_user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
dekEncrypted: text('dek_encrypted').notNull(), dekEncrypted: text("dek_encrypted").notNull(),
maxSessions: text('max_sessions').notNull().default('10'), maxSessions: text("max_sessions").notNull().default("10"),
deletedAt: timestamp('deleted_at'), deletedAt: timestamp("deleted_at"),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}) });
export const apiKeys = pgTable( export const apiKeys = pgTable(
'api_keys', "api_keys",
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
appId: uuid('app_id') appId: uuid("app_id")
.notNull() .notNull()
.references(() => apps.id), .references(() => apps.id),
keyHash: text('key_hash').notNull(), keyHash: text("key_hash").notNull(),
keyPrefix: text('key_prefix').notNull(), keyPrefix: text("key_prefix").notNull(),
label: text('label').notNull().default(''), label: text("label").notNull().default(""),
revokedAt: timestamp('revoked_at'), revokedAt: timestamp("revoked_at"),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}, },
(t) => [index('api_keys_app_id_idx').on(t.appId)], (t) => [index("api_keys_app_id_idx").on(t.appId)],
) );
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
apps: many(apps), apps: many(apps),
})) }));
export const appsRelations = relations(apps, ({ one, many }) => ({ export const appsRelations = relations(apps, ({ one, many }) => ({
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }), owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
apiKeys: many(apiKeys), apiKeys: many(apiKeys),
sessions: many(sessions), sessions: many(sessions),
})) }));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }), app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
})) }));
export const sessionStatusEnum = pgEnum('session_status', [ export const sessionStatusEnum = pgEnum("session_status", [
'PENDING_QR', "PENDING_QR",
'PENDING_PAIRING', "PENDING_PAIRING",
'CONNECTING', "CONNECTING",
'CONNECTED', "CONNECTED",
'DISCONNECTED', "DISCONNECTED",
'LOGGED_OUT', "LOGGED_OUT",
'DEAD', "DEAD",
]) ]);
export const sessions = pgTable( export const sessions = pgTable(
'sessions', "sessions",
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
appId: uuid('app_id') appId: uuid("app_id")
.notNull() .notNull()
.references(() => apps.id), .references(() => apps.id),
status: sessionStatusEnum('status').notNull().default('PENDING_QR'), status: sessionStatusEnum("status").notNull().default("PENDING_QR"),
phoneNumber: text('phone_number'), phoneNumber: text("phone_number"),
displayName: text('display_name'), displayName: text("display_name"),
credentialsEncrypted: text('credentials_encrypted'), credentialsEncrypted: text("credentials_encrypted"),
reconnectCount: integer('reconnect_count').notNull().default(0), reconnectCount: integer("reconnect_count").notNull().default(0),
lastSeen: timestamp('last_seen'), lastSeen: timestamp("last_seen"),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}, },
(t) => [index('sessions_app_id_idx').on(t.appId), index('sessions_status_idx').on(t.status)], (t) => [
) index("sessions_app_id_idx").on(t.appId),
index("sessions_status_idx").on(t.status),
],
);
export const sessionKeys = pgTable( export const sessionKeys = pgTable(
'session_keys', "session_keys",
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid('session_id') sessionId: uuid("session_id")
.notNull() .notNull()
.references(() => sessions.id, { onDelete: 'cascade' }), .references(() => sessions.id, { onDelete: "cascade" }),
keyType: text('key_type').notNull(), keyType: text("key_type").notNull(),
keyId: text('key_id').notNull(), keyId: text("key_id").notNull(),
valueEncrypted: text('value_encrypted').notNull(), valueEncrypted: text("value_encrypted").notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, },
(t) => [unique('session_keys_unique').on(t.sessionId, t.keyType, t.keyId)], (t) => [unique("session_keys_unique").on(t.sessionId, t.keyType, t.keyId)],
) );
export const sessionsRelations = relations(sessions, ({ one, many }) => ({ export const sessionsRelations = relations(sessions, ({ one, many }) => ({
app: one(apps, { fields: [sessions.appId], references: [apps.id] }), app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
keys: many(sessionKeys), keys: many(sessionKeys),
})) }));
export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({ export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({
session: one(sessions, { fields: [sessionKeys.sessionId], references: [sessions.id] }), session: one(sessions, {
})) fields: [sessionKeys.sessionId],
references: [sessions.id],
}),
}));
export const auditLog = pgTable( export const auditLog = pgTable(
'audit_log', "audit_log",
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
actorType: actorTypeEnum('actor_type').notNull(), actorType: actorTypeEnum("actor_type").notNull(),
actorId: uuid('actor_id').notNull(), actorId: uuid("actor_id").notNull(),
action: text('action').notNull(), action: text("action").notNull(),
resourceType: text('resource_type').notNull(), resourceType: text("resource_type").notNull(),
resourceId: text('resource_id'), resourceId: text("resource_id"),
ip: text('ip'), ip: text("ip"),
userAgent: text('user_agent'), userAgent: text("user_agent"),
diffBefore: jsonb('diff_before'), diffBefore: jsonb("diff_before"),
diffAfter: jsonb('diff_after'), diffAfter: jsonb("diff_after"),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}, },
(t) => [ (t) => [
index('audit_log_actor_idx').on(t.actorType, t.actorId), index("audit_log_actor_idx").on(t.actorType, t.actorId),
index('audit_log_action_idx').on(t.action), index("audit_log_action_idx").on(t.action),
index('audit_log_created_at_idx').on(t.createdAt), index("audit_log_created_at_idx").on(t.createdAt),
], ],
) );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,33 +1,36 @@
import { and, eq, isNull } from 'drizzle-orm' import { and, eq, isNull } from "drizzle-orm";
import { db } from '#/db/index' import { db } from "#/db/index";
import { apiKeys } from '#/db/schema' import { apiKeys } from "#/db/schema";
import { verifyApiKey } from './api-keys' import { verifyApiKey } from "./api-keys";
import { jsonError } from './session-api' import { jsonError } from "./session-api";
export interface AuthedRequest { export interface AuthedRequest {
appId: string appId: string;
keyId: string keyId: string;
} }
export async function authenticateApiKey(request: Request): Promise<AuthedRequest | Response> { export async function authenticateApiKey(
const auth = request.headers.get('authorization') request: Request,
if (!auth?.startsWith('Bearer ')) return jsonError('Missing Authorization header', 401) ): Promise<AuthedRequest | Response> {
const auth = request.headers.get("authorization");
if (!auth?.startsWith("Bearer "))
return jsonError("Missing Authorization header", 401);
const key = auth.slice(7).trim() const key = auth.slice(7).trim();
if (!key.startsWith('wak_')) return jsonError('Invalid API key format', 401) if (!key.startsWith("wak_")) return jsonError("Invalid API key format", 401);
const prefix = key.slice(-4) const prefix = key.slice(-4);
const candidates = await db.query.apiKeys.findMany({ const candidates = await db.query.apiKeys.findMany({
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)), where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
with: { app: { columns: { id: true, deletedAt: true } } }, with: { app: { columns: { id: true, deletedAt: true } } },
}) });
for (const candidate of candidates) { for (const candidate of candidates) {
if (candidate.app?.deletedAt) continue if (candidate.app?.deletedAt) continue;
const valid = await verifyApiKey(key, candidate.keyHash) const valid = await verifyApiKey(key, candidate.keyHash);
if (valid) return { appId: candidate.appId, keyId: candidate.id } if (valid) return { appId: candidate.appId, keyId: candidate.id };
} }
return jsonError('Invalid or revoked API key', 401) return jsonError("Invalid or revoked API key", 401);
} }

View file

@ -1,23 +1,29 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from "node:crypto";
import argon2 from 'argon2' import argon2 from "argon2";
export type ApiKeyEnv = 'live' | 'test' export type ApiKeyEnv = "live" | "test";
export function generateApiKey(env: ApiKeyEnv = 'live'): { key: string; prefix: string } { export function generateApiKey(env: ApiKeyEnv = "live"): {
const rand = randomBytes(24).toString('base64url') key: string;
const key = `wak_${env}_${rand}` prefix: string;
const prefix = key.slice(-4) } {
return { key, prefix } const rand = randomBytes(24).toString("base64url");
const key = `wak_${env}_${rand}`;
const prefix = key.slice(-4);
return { key, prefix };
} }
export async function hashApiKey(key: string): Promise<string> { export async function hashApiKey(key: string): Promise<string> {
return argon2.hash(key, { type: argon2.argon2id }) return argon2.hash(key, { type: argon2.argon2id });
} }
export async function verifyApiKey(key: string, hash: string): Promise<boolean> { export async function verifyApiKey(
key: string,
hash: string,
): Promise<boolean> {
try { try {
return await argon2.verify(hash, key) return await argon2.verify(hash, key);
} catch { } catch {
return false return false;
} }
} }

View file

@ -1,16 +1,16 @@
import { db } from '#/db/index' import { db } from "#/db/index";
import { auditLog } from '#/db/schema' import { auditLog } from "#/db/schema";
interface AuditEntry { interface AuditEntry {
actorType: 'user' | 'api_key' actorType: "user" | "api_key";
actorId: string actorId: string;
action: string action: string;
resourceType: string resourceType: string;
resourceId?: string resourceId?: string;
ip?: string ip?: string;
userAgent?: string userAgent?: string;
diffBefore?: unknown diffBefore?: unknown;
diffAfter?: unknown diffAfter?: unknown;
} }
export async function writeAudit(entry: AuditEntry): Promise<void> { export async function writeAudit(entry: AuditEntry): Promise<void> {
@ -24,5 +24,5 @@ export async function writeAudit(entry: AuditEntry): Promise<void> {
userAgent: entry.userAgent, userAgent: entry.userAgent,
diffBefore: entry.diffBefore ?? null, diffBefore: entry.diffBefore ?? null,
diffAfter: entry.diffAfter ?? null, diffAfter: entry.diffAfter ?? null,
}) });
} }

View file

@ -1,9 +1,18 @@
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray } from "drizzle-orm";
import { BufferJSON, initAuthCreds, makeCacheableSignalKeyStore } from '@whiskeysockets/baileys' import {
import type { AuthenticationState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '@whiskeysockets/baileys' BufferJSON,
import { db } from '#/db/index' initAuthCreds,
import { sessionKeys, sessions } from '#/db/schema' makeCacheableSignalKeyStore,
import { decryptColumn, encryptColumn } from '#/lib/crypto' } from "@whiskeysockets/baileys";
import type {
AuthenticationState,
SignalDataSet,
SignalDataTypeMap,
SignalKeyStore,
} from "@whiskeysockets/baileys";
import { db } from "#/db/index";
import { sessionKeys, sessions } from "#/db/schema";
import { decryptColumn, encryptColumn } from "#/lib/crypto";
export async function usePostgresAuthState( export async function usePostgresAuthState(
sessionId: string, sessionId: string,
@ -12,43 +21,43 @@ export async function usePostgresAuthState(
const row = await db.query.sessions.findFirst({ const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId), where: eq(sessions.id, sessionId),
columns: { credentialsEncrypted: true }, columns: { credentialsEncrypted: true },
}) });
let creds = initAuthCreds() let creds = initAuthCreds();
if (row?.credentialsEncrypted) { if (row?.credentialsEncrypted) {
try { try {
const json = decryptColumn(row.credentialsEncrypted, appDek) const json = decryptColumn(row.credentialsEncrypted, appDek);
creds = JSON.parse(json, BufferJSON.reviver) creds = JSON.parse(json, BufferJSON.reviver);
} catch { } catch {
creds = initAuthCreds() creds = initAuthCreds();
} }
} }
const store: SignalKeyStore = { const store: SignalKeyStore = {
async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) { async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) {
if (ids.length === 0) return {} as { [id: string]: SignalDataTypeMap[T] } if (ids.length === 0) return {};
const rows = await db.query.sessionKeys.findMany({ const rows = await db.query.sessionKeys.findMany({
where: and( where: and(
eq(sessionKeys.sessionId, sessionId), eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type), eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids), inArray(sessionKeys.keyId, ids),
), ),
}) });
const result: { [id: string]: SignalDataTypeMap[T] } = {} const result: { [id: string]: SignalDataTypeMap[T] } = {};
for (const r of rows) { for (const r of rows) {
try { try {
const json = decryptColumn(r.valueEncrypted, appDek) const json = decryptColumn(r.valueEncrypted, appDek);
result[r.keyId] = JSON.parse(json, BufferJSON.reviver) result[r.keyId] = JSON.parse(json, BufferJSON.reviver);
} catch { } catch {
// skip corrupt key // skip corrupt key
} }
} }
return result return result;
}, },
async set(data: SignalDataSet) { async set(data: SignalDataSet) {
for (const [type, typeData] of Object.entries(data)) { for (const [type, typeData] of Object.entries(data)) {
if (!typeData) continue if (!typeData) continue;
for (const [id, value] of Object.entries(typeData)) { for (const [id, value] of Object.entries(typeData)) {
if (value == null) { if (value == null) {
await db await db
@ -59,34 +68,46 @@ export async function usePostgresAuthState(
eq(sessionKeys.keyType, type), eq(sessionKeys.keyType, type),
eq(sessionKeys.keyId, id), eq(sessionKeys.keyId, id),
), ),
) );
} else { } else {
const json = JSON.stringify(value, BufferJSON.replacer) const json = JSON.stringify(value, BufferJSON.replacer);
const encrypted = encryptColumn(json, appDek) const encrypted = encryptColumn(json, appDek);
await db await db
.insert(sessionKeys) .insert(sessionKeys)
.values({ sessionId, keyType: type, keyId: id, valueEncrypted: encrypted }) .values({
.onConflictDoUpdate({ sessionId,
target: [sessionKeys.sessionId, sessionKeys.keyType, sessionKeys.keyId], keyType: type,
set: { valueEncrypted: encrypted, updatedAt: new Date() }, keyId: id,
valueEncrypted: encrypted,
}) })
.onConflictDoUpdate({
target: [
sessionKeys.sessionId,
sessionKeys.keyType,
sessionKeys.keyId,
],
set: { valueEncrypted: encrypted, updatedAt: new Date() },
});
} }
} }
} }
}, },
async clear() { async clear() {
await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId)) await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
}, },
} };
const keys = makeCacheableSignalKeyStore(store) const keys = makeCacheableSignalKeyStore(store);
async function saveCreds() { async function saveCreds() {
const json = JSON.stringify(creds, BufferJSON.replacer) const json = JSON.stringify(creds, BufferJSON.replacer);
const encrypted = encryptColumn(json, appDek) const encrypted = encryptColumn(json, appDek);
await db.update(sessions).set({ credentialsEncrypted: encrypted }).where(eq(sessions.id, sessionId)) await db
.update(sessions)
.set({ credentialsEncrypted: encrypted })
.where(eq(sessions.id, sessionId));
} }
return { state: { creds, keys }, saveCreds } return { state: { creds, keys }, saveCreds };
} }

View file

@ -1,4 +1,4 @@
export { usePostgresAuthState } from './auth-state' export { usePostgresAuthState } from "./auth-state";
export { export {
startSession, startSession,
stopSession, stopSession,
@ -7,5 +7,5 @@ export {
isActive, isActive,
initialize, initialize,
getDecryptedField, getDecryptedField,
} from './session-manager' } from "./session-manager";
export { emitWebhook } from './webhook' export { emitWebhook } from "./webhook";

View file

@ -1,222 +1,274 @@
import { eq, inArray } from 'drizzle-orm' import { eq, inArray } from "drizzle-orm";
import { import {
Browsers, Browsers,
DisconnectReason, DisconnectReason,
jidDecode, jidDecode,
makeWASocket, makeWASocket,
} from '@whiskeysockets/baileys' } from "@whiskeysockets/baileys";
import type { WASocket } from '@whiskeysockets/baileys' import type { WASocket } from "@whiskeysockets/baileys";
import { db } from '#/db/index' import { db } from "#/db/index";
import { apps, sessions } from '#/db/schema' import { apps, sessions } from "#/db/schema";
import { decryptColumn, encryptColumn, unwrapDek } from '#/lib/crypto' import { decryptColumn, encryptColumn, unwrapDek } from "#/lib/crypto";
import { usePostgresAuthState } from './auth-state' import { usePostgresAuthState } from "./auth-state";
import { emitWebhook } from './webhook' import { emitWebhook } from "./webhook";
type SessionStatus = (typeof sessions.$inferSelect)['status'] type SessionStatus = (typeof sessions.$inferSelect)["status"];
interface SessionEntry { interface SessionEntry {
socket: WASocket socket: WASocket;
qrCode: string | null qrCode: string | null;
appId: string appId: string;
dek: Buffer dek: Buffer;
} }
interface ReconnectEntry { interface ReconnectEntry {
start: Date start: Date;
count: number count: number;
timer: ReturnType<typeof setTimeout> | null timer: ReturnType<typeof setTimeout> | null;
} }
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000 // 1h const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
const QR_TIMEOUT_MS = 5 * 60 * 1000 // 5 min const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
const active = new Map<string, SessionEntry>() const active = new Map<string, SessionEntry>();
const reconnecting = new Map<string, ReconnectEntry>() const reconnecting = new Map<string, ReconnectEntry>();
const qrTimers = new Map<string, ReturnType<typeof setTimeout>>() const qrTimers = new Map<string, ReturnType<typeof setTimeout>>();
async function getAppDek(appId: string): Promise<Buffer> { async function getAppDek(appId: string): Promise<Buffer> {
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
columns: { dekEncrypted: true }, columns: { dekEncrypted: true },
}) });
if (!app) throw new Error(`App ${appId} not found`) if (!app) throw new Error(`App ${appId} not found`);
return unwrapDek(app.dekEncrypted) return unwrapDek(app.dekEncrypted);
} }
async function setStatus(sessionId: string, status: SessionStatus): Promise<void> { async function setStatus(
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId)) sessionId: string,
status: SessionStatus,
): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
} }
async function scheduleReconnect(sessionId: string, appId: string): Promise<void> { async function scheduleReconnect(
let rec = reconnecting.get(sessionId) sessionId: string,
appId: string,
): Promise<void> {
let rec = reconnecting.get(sessionId);
if (rec) { if (rec) {
const elapsed = Date.now() - rec.start.getTime() const elapsed = Date.now() - rec.start.getTime();
if (elapsed > MAX_RECONNECT_WINDOW_MS) { if (elapsed > MAX_RECONNECT_WINDOW_MS) {
reconnecting.delete(sessionId) reconnecting.delete(sessionId);
await setStatus(sessionId, 'DEAD') await setStatus(sessionId, "DEAD");
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' }) emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
return return;
} }
} else { } else {
rec = { start: new Date(), count: 0, timer: null } rec = { start: new Date(), count: 0, timer: null };
reconnecting.set(sessionId, rec) reconnecting.set(sessionId, rec);
} }
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000) const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000);
rec.count++ rec.count++;
await db.update(sessions).set({ reconnectCount: rec.count }).where(eq(sessions.id, sessionId)) await db
.update(sessions)
.set({ reconnectCount: rec.count })
.where(eq(sessions.id, sessionId));
rec.timer = setTimeout(() => { rec.timer = setTimeout(() => {
reconnecting.get(sessionId)!.timer = null reconnecting.get(sessionId)!.timer = null;
startSession(sessionId, appId).catch(console.error) startSession(sessionId, appId).catch(console.error);
}, delay) }, delay);
} }
export async function startSession(sessionId: string, appId: string): Promise<void> { export async function startSession(
if (active.has(sessionId)) return sessionId: string,
appId: string,
): Promise<void> {
if (active.has(sessionId)) return;
const dek = await getAppDek(appId) const dek = await getAppDek(appId);
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek) const { state, saveCreds } = await usePostgresAuthState(sessionId, dek);
const sock = makeWASocket({ const sock = makeWASocket({
auth: state, auth: state,
browser: Browsers.ubuntu('Chrome'), browser: Browsers.ubuntu("Chrome"),
printQRInTerminal: false, printQRInTerminal: false,
syncFullHistory: false, syncFullHistory: false,
}) });
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek } const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek };
active.set(sessionId, entry) active.set(sessionId, entry);
sock.ev.on('creds.update', async (patch) => { sock.ev.on("creds.update", async (patch) => {
Object.assign(state.creds, patch) Object.assign(state.creds, patch);
await saveCreds().catch(console.error) await saveCreds().catch(console.error);
}) });
sock.ev.on('connection.update', async (update) => { sock.ev.on("connection.update", async (update) => {
const { connection, lastDisconnect, qr } = update const { connection, lastDisconnect, qr } = update;
if (qr) { if (qr) {
entry.qrCode = qr entry.qrCode = qr;
// Reset the 5-min expiry timer on each new QR rotation // Reset the 5-min expiry timer on each new QR rotation
const existing = qrTimers.get(sessionId) const existing = qrTimers.get(sessionId);
if (existing) clearTimeout(existing) if (existing) clearTimeout(existing);
qrTimers.set( qrTimers.set(
sessionId, sessionId,
setTimeout(async () => { setTimeout(async () => {
qrTimers.delete(sessionId) qrTimers.delete(sessionId);
const e = active.get(sessionId) const e = active.get(sessionId);
if (!e) return if (!e) return;
// Only expire if still waiting for QR // Only expire if still waiting for QR
const row = await db.query.sessions.findFirst({ const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId), where: eq(sessions.id, sessionId),
columns: { status: true }, columns: { status: true },
}) });
if (row?.status === 'PENDING_QR') { if (row?.status === "PENDING_QR") {
active.delete(sessionId) active.delete(sessionId);
try { e.socket.end(undefined) } catch { /* ignore */ } try {
await setStatus(sessionId, 'DEAD') e.socket.end(undefined);
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' }) } catch {
/* ignore */
}
await setStatus(sessionId, "DEAD");
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
} }
}, QR_TIMEOUT_MS), }, QR_TIMEOUT_MS),
) );
await setStatus(sessionId, 'PENDING_QR') await setStatus(sessionId, "PENDING_QR");
emitWebhook(appId, 'session.status', { sessionId, status: 'PENDING_QR' }) emitWebhook(appId, "session.status", { sessionId, status: "PENDING_QR" });
} }
if (connection === 'connecting') { if (connection === "connecting") {
await setStatus(sessionId, 'CONNECTING') await setStatus(sessionId, "CONNECTING");
} }
if (connection === 'open') { if (connection === "open") {
entry.qrCode = null entry.qrCode = null;
reconnecting.delete(sessionId) reconnecting.delete(sessionId);
const qt = qrTimers.get(sessionId) const qt = qrTimers.get(sessionId);
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) } if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const meId = state.creds.me?.id const meId = state.creds.me?.id;
const mePhone = meId ? jidDecode(meId)?.user : undefined const mePhone = meId ? jidDecode(meId)?.user : undefined;
const meName = state.creds.me?.name const meName = state.creds.me?.name;
await db.update(sessions).set({ await db
status: 'CONNECTED', .update(sessions)
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined, .set({
displayName: meName ? encryptColumn(meName, dek) : undefined, status: "CONNECTED",
lastSeen: new Date(), phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
reconnectCount: 0, displayName: meName ? encryptColumn(meName, dek) : undefined,
}).where(eq(sessions.id, sessionId)) lastSeen: new Date(),
reconnectCount: 0,
})
.where(eq(sessions.id, sessionId));
emitWebhook(appId, 'session.status', { sessionId, status: 'CONNECTED' }) emitWebhook(appId, "session.status", { sessionId, status: "CONNECTED" });
} }
if (connection === 'close') { if (connection === "close") {
active.delete(sessionId) active.delete(sessionId);
const qt = qrTimers.get(sessionId) const qt = qrTimers.get(sessionId);
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) } if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined) const code = (
?.output?.statusCode lastDisconnect?.error as
| { output?: { statusCode?: number } }
| undefined
)?.output?.statusCode;
if ( if (
code === DisconnectReason.loggedOut || code === DisconnectReason.loggedOut ||
code === DisconnectReason.forbidden || code === DisconnectReason.forbidden ||
code === DisconnectReason.connectionReplaced 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", {
return sessionId,
status: "LOGGED_OUT",
});
return;
} }
await setStatus(sessionId, 'DISCONNECTED') await setStatus(sessionId, "DISCONNECTED");
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' }) emitWebhook(appId, "session.status", {
await scheduleReconnect(sessionId, appId) sessionId,
status: "DISCONNECTED",
});
await scheduleReconnect(sessionId, appId);
} }
}) });
} }
export async function stopSession(sessionId: string): Promise<void> { export async function stopSession(sessionId: string): Promise<void> {
const rec = reconnecting.get(sessionId) const rec = reconnecting.get(sessionId);
if (rec?.timer) clearTimeout(rec.timer) if (rec?.timer) clearTimeout(rec.timer);
reconnecting.delete(sessionId) reconnecting.delete(sessionId);
const qt = qrTimers.get(sessionId) const qt = qrTimers.get(sessionId);
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) } if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const entry = active.get(sessionId) const entry = active.get(sessionId);
if (entry) { if (entry) {
try { entry.socket.end(undefined) } catch { /* ignore */ } try {
active.delete(sessionId) entry.socket.end(undefined);
} catch {
/* ignore */
}
active.delete(sessionId);
} }
} }
export function getQrCode(sessionId: string): string | null { export function getQrCode(sessionId: string): string | null {
return active.get(sessionId)?.qrCode ?? null return active.get(sessionId)?.qrCode ?? null;
} }
export function getSocket(sessionId: string): WASocket | null { export function getSocket(sessionId: string): WASocket | null {
return active.get(sessionId)?.socket ?? null return active.get(sessionId)?.socket ?? null;
} }
export function isActive(sessionId: string): boolean { export function isActive(sessionId: string): boolean {
return active.has(sessionId) return active.has(sessionId);
} }
export async function initialize(): Promise<void> { export async function initialize(): Promise<void> {
const rows = await db.query.sessions.findMany({ const rows = await db.query.sessions.findMany({
where: inArray(sessions.status, ['CONNECTED', 'DISCONNECTED', 'CONNECTING']), where: inArray(sessions.status, [
"CONNECTED",
"DISCONNECTED",
"CONNECTING",
]),
columns: { id: true, appId: true }, columns: { id: true, appId: true },
}) });
await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId))) await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId)));
} }
export function getDecryptedField(sessionId: string, encryptedValue: string | null): string | null { export function getDecryptedField(
if (!encryptedValue) return null sessionId: string,
const entry = active.get(sessionId) encryptedValue: string | null,
if (!entry) return null ): string | null {
try { return decryptColumn(encryptedValue, entry.dek) } catch { return null } if (!encryptedValue) return null;
const entry = active.get(sessionId);
if (!entry) return null;
try {
return decryptColumn(encryptedValue, entry.dek);
} catch {
return null;
}
} }

View file

@ -1,4 +1,8 @@
// Webhook stub — replaced by full dispatcher in M5 // Webhook stub — replaced by full dispatcher in M5
export function emitWebhook(appId: string, event: string, payload: Record<string, unknown>): void { export function emitWebhook(
console.log('[webhook]', appId, event, JSON.stringify(payload)) appId: string,
event: string,
payload: Record<string, unknown>,
): void {
console.log("[webhook]", appId, event, JSON.stringify(payload));
} }

View file

@ -1,64 +1,77 @@
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto' import {
createCipheriv,
createDecipheriv,
createHmac,
randomBytes,
} from "node:crypto";
const ALG = 'aes-256-gcm' const ALG = "aes-256-gcm";
const IV_BYTES = 12 const IV_BYTES = 12;
const TAG_BYTES = 16 const TAG_BYTES = 16;
function getMasterKey(): Buffer { function getMasterKey(): Buffer {
const raw = process.env.MASTER_ENCRYPTION_KEY const raw = process.env.MASTER_ENCRYPTION_KEY;
if (!raw) throw new Error('MASTER_ENCRYPTION_KEY is not set — server cannot start') if (!raw)
const buf = Buffer.from(raw, 'base64') throw new Error("MASTER_ENCRYPTION_KEY is not set — server cannot start");
const buf = Buffer.from(raw, "base64");
if (buf.byteLength !== 32) if (buf.byteLength !== 32)
throw new Error('MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)') throw new Error(
return buf "MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)",
);
return buf;
} }
export function generateDek(): Buffer { export function generateDek(): Buffer {
return randomBytes(32) return randomBytes(32);
} }
export function wrapDek(dek: Buffer): string { export function wrapDek(dek: Buffer): string {
const kek = getMasterKey() const kek = getMasterKey();
const iv = randomBytes(IV_BYTES) const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALG, kek, iv) const cipher = createCipheriv(ALG, kek, iv);
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]) const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]);
const tag = cipher.getAuthTag() const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64') return Buffer.concat([iv, tag, encrypted]).toString("base64");
} }
export function unwrapDek(wrapped: string): Buffer { export function unwrapDek(wrapped: string): Buffer {
const kek = getMasterKey() const kek = getMasterKey();
const buf = Buffer.from(wrapped, 'base64') const buf = Buffer.from(wrapped, "base64");
const iv = buf.subarray(0, IV_BYTES) const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES) const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const data = buf.subarray(IV_BYTES + TAG_BYTES) const data = buf.subarray(IV_BYTES + TAG_BYTES);
const decipher = createDecipheriv(ALG, kek, iv) const decipher = createDecipheriv(ALG, kek, iv);
decipher.setAuthTag(tag) decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]) return Buffer.concat([decipher.update(data), decipher.final()]);
} }
export function encryptColumn(plaintext: string, dek: Buffer): string { export function encryptColumn(plaintext: string, dek: Buffer): string {
const iv = randomBytes(IV_BYTES) const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALG, dek, iv) const cipher = createCipheriv(ALG, dek, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) const encrypted = Buffer.concat([
const tag = cipher.getAuthTag() cipher.update(plaintext, "utf8"),
return Buffer.concat([iv, tag, encrypted]).toString('base64') cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
} }
export function decryptColumn(ciphertext: string, dek: Buffer): string { export function decryptColumn(ciphertext: string, dek: Buffer): string {
const buf = Buffer.from(ciphertext, 'base64') const buf = Buffer.from(ciphertext, "base64");
const iv = buf.subarray(0, IV_BYTES) const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES) const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const data = buf.subarray(IV_BYTES + TAG_BYTES) const data = buf.subarray(IV_BYTES + TAG_BYTES);
const decipher = createDecipheriv(ALG, dek, iv) const decipher = createDecipheriv(ALG, dek, iv);
decipher.setAuthTag(tag) decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8') return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
} }
export function blindIndex(value: string, dek: Buffer): string { export function blindIndex(value: string, dek: Buffer): string {
return createHmac('sha256', dek).update(value, 'utf8').digest('hex') return createHmac("sha256", dek).update(value, "utf8").digest("hex");
} }
export function validateMasterKey(): void { export function validateMasterKey(): void {
getMasterKey() getMasterKey();
} }

View file

@ -1,13 +1,16 @@
import argon2 from 'argon2' import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id }) return argon2.hash(password, { type: argon2.argon2id });
} }
export async function verifyPassword(password: string, hash: string): Promise<boolean> { export async function verifyPassword(
password: string,
hash: string,
): Promise<boolean> {
try { try {
return await argon2.verify(hash, password) return await argon2.verify(hash, password);
} catch { } catch {
return false return false;
} }
} }

View file

@ -1,4 +1,4 @@
import { QueryClient, queryOptions } from '@tanstack/react-query' import { QueryClient, queryOptions } from "@tanstack/react-query";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -7,63 +7,63 @@ export const queryClient = new QueryClient({
retry: 1, retry: 1,
}, },
}, },
}) });
async function apiFetch(url: string, init?: RequestInit) { async function apiFetch(url: string, init?: RequestInit) {
const res = await fetch(url, { credentials: 'include', ...init }) const res = await fetch(url, { credentials: "include", ...init });
if (!res.ok) throw new Error(await res.text()) if (!res.ok) throw new Error(await res.text());
return res.json() return res.json();
} }
export const appsQuery = queryOptions({ export const appsQuery = queryOptions({
queryKey: ['apps'], queryKey: ["apps"],
queryFn: () => apiFetch('/api/v1/admin/apps'), queryFn: () => apiFetch("/api/v1/admin/apps"),
}) });
export const appQuery = (appId: string) => export const appQuery = (appId: string) =>
queryOptions({ queryOptions({
queryKey: ['apps', appId], queryKey: ["apps", appId],
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`), queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`),
}) });
export const apiKeysQuery = (appId: string) => export const apiKeysQuery = (appId: string) =>
queryOptions({ queryOptions({
queryKey: ['apps', appId, 'api-keys'], queryKey: ["apps", appId, "api-keys"],
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`), queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`),
}) });
export const sessionsQuery = queryOptions({ export const sessionsQuery = queryOptions({
queryKey: ['sessions'], queryKey: ["sessions"],
queryFn: () => apiFetch('/api/v1/admin/sessions'), queryFn: () => apiFetch("/api/v1/admin/sessions"),
}) });
export const auditLogQuery = queryOptions({ 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 { interface SessionRow {
id: string id: string;
appId: string appId: string;
appName: string appName: string;
status: string status: string;
lastSeen: string | null lastSeen: string | null;
createdAt: string createdAt: string;
reconnectCount: number reconnectCount: number;
} }
export const adminSessionDetailQuery = (sessionId: string) => export const adminSessionDetailQuery = (sessionId: string) =>
queryOptions({ queryOptions({
queryKey: ['sessions', sessionId, 'admin-detail'], queryKey: ["sessions", sessionId, "admin-detail"],
queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`), queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`),
staleTime: 5_000, staleTime: 5_000,
}) });
export const appSessionsQuery = (appId: string) => export const appSessionsQuery = (appId: string) =>
queryOptions({ queryOptions({
queryKey: ['apps', appId, 'sessions'], queryKey: ["apps", appId, "sessions"],
queryFn: async (): Promise<SessionRow[]> => { queryFn: async (): Promise<SessionRow[]> => {
const data: SessionRow[] = await apiFetch('/api/v1/admin/sessions') const data: SessionRow[] = await apiFetch("/api/v1/admin/sessions");
return data.filter(s => s.appId === appId) return data.filter((s) => s.appId === appId);
}, },
}) });

View file

@ -1,49 +1,60 @@
import { sealData, unsealData } from 'iron-session' import { sealData, unsealData } from "iron-session";
import type { SessionData } from './session' import type { SessionData } from "./session";
const COOKIE_NAME = 'wahoo_session' const COOKIE_NAME = "wahoo_session";
const TTL = 60 * 60 * 24 * 7 const TTL = 60 * 60 * 24 * 7;
function getPassword(): string { function getPassword(): string {
const s = process.env.SESSION_SECRET const s = process.env.SESSION_SECRET;
if (!s) throw new Error('SESSION_SECRET not set') if (!s) throw new Error("SESSION_SECRET not set");
return s return s;
} }
export async function readSession(req: Request): Promise<SessionData | null> { export async function readSession(req: Request): Promise<SessionData | null> {
const cookieHeader = req.headers.get('cookie') ?? '' const cookieHeader = req.headers.get("cookie") ?? "";
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`)) const match = cookieHeader.match(
if (!match) return null new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`),
);
if (!match) return null;
try { try {
return await unsealData<SessionData>(match[1], { password: getPassword(), ttl: TTL }) return await unsealData<SessionData>(match[1], {
password: getPassword(),
ttl: TTL,
});
} catch { } catch {
return null return null;
} }
} }
export async function createSessionCookie(data: SessionData): Promise<string> { export async function createSessionCookie(data: SessionData): Promise<string> {
const sealed = await sealData(data, { password: getPassword(), ttl: TTL }) const sealed = await sealData(data, { password: getPassword(), ttl: TTL });
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '' const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}` return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}`;
} }
export function clearSessionCookie(): string { export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0` return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
} }
export function json(data: unknown, status = 200, extraHeaders?: Record<string, string>): Response { export function json(
data: unknown,
status = 200,
extraHeaders?: Record<string, string>,
): Response {
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
status, status,
headers: { 'content-type': 'application/json', ...extraHeaders }, headers: { "content-type": "application/json", ...extraHeaders },
}) });
} }
export function jsonError(message: string, status: number): Response { export function jsonError(message: string, status: number): Response {
return json({ error: message }, status) return json({ error: message }, status);
} }
export async function requireAuth(req: Request): Promise<SessionData | Response> { export async function requireAuth(
const session = await readSession(req) req: Request,
if (!session?.userId) return jsonError('Unauthorized', 401) ): Promise<SessionData | Response> {
return session const session = await readSession(req);
if (!session?.userId) return jsonError("Unauthorized", 401);
return session;
} }

View file

@ -1,5 +1,5 @@
export interface SessionData { export interface SessionData {
userId: string userId: string;
email: string email: string;
totpVerified: boolean totpVerified: boolean;
} }

View file

@ -1,19 +1,34 @@
import * as OTPAuth from 'otpauth' import * as OTPAuth from "otpauth";
import { randomBytes } from 'node:crypto' import { randomBytes } from "node:crypto";
const ISSUER = 'Wahoo' const ISSUER = "Wahoo";
export function generateTotpSecret(): string { export function generateTotpSecret(): string {
return randomBytes(20).toString('base64').replace(/[^A-Z2-7]/gi, 'A').slice(0, 32).toUpperCase() return randomBytes(20)
.toString("base64")
.replace(/[^A-Z2-7]/gi, "A")
.slice(0, 32)
.toUpperCase();
} }
export function getTotpUri(email: string, secret: string): string { export function getTotpUri(email: string, secret: string): string {
const totp = new OTPAuth.TOTP({ issuer: ISSUER, label: email, secret, digits: 6, period: 30 }) const totp = new OTPAuth.TOTP({
return totp.toString() issuer: ISSUER,
label: email,
secret,
digits: 6,
period: 30,
});
return totp.toString();
} }
export function verifyTotp(secret: string, token: string): boolean { export function verifyTotp(secret: string, token: string): boolean {
const totp = new OTPAuth.TOTP({ issuer: ISSUER, secret, digits: 6, period: 30 }) const totp = new OTPAuth.TOTP({
const delta = totp.validate({ token, window: 1 }) issuer: ISSUER,
return delta !== null secret,
digits: 6,
period: 30,
});
const delta = totp.validate({ token, window: 1 });
return delta !== null;
} }

View file

@ -1,7 +1,7 @@
import type { ClassValue } from 'clsx' import type { ClassValue } from "clsx";
import { clsx } from 'clsx' import { clsx } from "clsx";
import { twMerge } from 'tailwind-merge' import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View file

@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from './routeTree.gen' import { routeTree } from "./routeTree.gen";
export function getRouter() { export function getRouter() {
const router = createTanStackRouter({ const router = createTanStackRouter({
routeTree, routeTree,
scrollRestoration: true, scrollRestoration: true,
defaultPreload: 'intent', defaultPreload: "intent",
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
}) });
return router return router;
} }
declare module '@tanstack/react-router' { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: ReturnType<typeof getRouter> router: ReturnType<typeof getRouter>;
} }
} }

View file

@ -1,31 +1,37 @@
import { HeadContent, Outlet, Scripts, createRootRoute, useLocation } from '@tanstack/react-router' import {
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' HeadContent,
import { TanStackDevtools } from '@tanstack/react-devtools' Outlet,
import { QueryClientProvider } from '@tanstack/react-query' Scripts,
import { queryClient } from '#/lib/query' createRootRoute,
import { Sidebar } from '#/components/layout/sidebar' useLocation,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "#/lib/query";
import { Sidebar } from "#/components/layout/sidebar";
import appCss from '../styles.css?url' 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' }, { name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: 'Wahoo — WhatsApp API Gateway' }, { title: "Wahoo — WhatsApp API Gateway" },
], ],
links: [{ rel: 'stylesheet', href: appCss }], links: [{ rel: "stylesheet", href: appCss }],
}), }),
shellComponent: RootDocument, shellComponent: RootDocument,
component: RootLayout, component: RootLayout,
}) });
function RootLayout() { function RootLayout() {
const location = useLocation() const location = useLocation();
const isLogin = location.pathname === '/login' const isLogin = location.pathname === "/login";
if (isLogin) { if (isLogin) {
return <Outlet /> return <Outlet />;
} }
return ( return (
@ -35,7 +41,7 @@ function RootLayout() {
<Outlet /> <Outlet />
</div> </div>
</div> </div>
) );
} }
function RootDocument({ children }: { children: React.ReactNode }) { function RootDocument({ children }: { children: React.ReactNode }) {
@ -48,12 +54,17 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<TanStackDevtools <TanStackDevtools
config={{ position: 'bottom-right' }} config={{ position: "bottom-right" }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]} plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/> />
<Scripts /> <Scripts />
</QueryClientProvider> </QueryClientProvider>
</body> </body>
</html> </html>
) );
} }

View file

@ -1,54 +1,63 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
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' import { findUserByEmail } from "#/lib/actions/auth";
export const Route = createFileRoute('/api/auth/login')({ export const Route = createFileRoute("/api/auth/login")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }) => { POST: async ({ request }) => {
let body: { email: string; password: string; totpToken?: string } let body: { email: string; password: string; totpToken?: string };
try { try {
body = await request.json() body = await request.json();
} catch { } catch {
return jsonError('Invalid JSON', 400) return jsonError("Invalid JSON", 400);
} }
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 findUserByEmail(email) 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);
if (!valid) return jsonError('Invalid credentials', 401) if (!valid) return jsonError("Invalid credentials", 401);
if (user.totpEnabled) { if (user.totpEnabled) {
if (!totpToken) return json({ requireTotp: true }, 200) if (!totpToken) return json({ requireTotp: true }, 200);
if (!verifyTotp(user.totpSecret!, totpToken)) return jsonError('Invalid TOTP token', 401) if (!verifyTotp(user.totpSecret!, totpToken))
return jsonError("Invalid TOTP token", 401);
} }
const ip = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? undefined const ip =
request.headers.get("x-forwarded-for") ??
request.headers.get("x-real-ip") ??
undefined;
await writeAudit({ await writeAudit({
actorType: 'user', actorType: "user",
actorId: user.id, actorId: user.id,
action: 'login', action: "login",
resourceType: 'user', resourceType: "user",
resourceId: user.id, resourceId: user.id,
ip, ip,
userAgent: request.headers.get('user-agent') ?? undefined, userAgent: request.headers.get("user-agent") ?? undefined,
}) });
const cookie = await createSessionCookie({ const cookie = await createSessionCookie({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
totpVerified: user.totpEnabled, totpVerified: user.totpEnabled,
}) });
return json({ ok: true, user: { id: user.id, email: user.email } }, 200, { 'set-cookie': cookie }) return json(
{ ok: true, user: { id: user.id, email: user.email } },
200,
{ "set-cookie": cookie },
);
}, },
}, },
}, },
}) });

View file

@ -1,10 +1,11 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { clearSessionCookie, json } from '#/lib/session-api' import { clearSessionCookie, json } from "#/lib/session-api";
export const Route = createFileRoute('/api/auth/logout')({ export const Route = createFileRoute("/api/auth/logout")({
server: { server: {
handlers: { handlers: {
POST: () => json({ ok: true }, 200, { 'set-cookie': clearSessionCookie() }), POST: () =>
json({ ok: true }, 200, { "set-cookie": clearSessionCookie() }),
}, },
}, },
}) });

View file

@ -1,14 +1,14 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
export const Route = createFileRoute('/api/auth/me')({ export const Route = createFileRoute("/api/auth/me")({
server: { server: {
handlers: { handlers: {
GET: async ({ request }) => { GET: async ({ request }) => {
const session = await readSession(request) const session = await readSession(request);
if (!session?.userId) return jsonError('Unauthorized', 401) if (!session?.userId) return jsonError("Unauthorized", 401);
return json({ user: { id: session.userId, email: session.email } }) return json({ user: { id: session.userId, email: session.email } });
}, },
}, },
}, },
}) });

View file

@ -1,39 +1,47 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { enrollTotpSecret, confirmTotpEnrollment, disableTotp } from '#/lib/actions/auth' 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: {
handlers: { handlers: {
POST: async ({ request }) => { POST: async ({ request }) => {
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 result = await enrollTotpSecret(session.userId) const result = await enrollTotpSecret(session.userId);
if (!result.ok) return jsonError(result.error, result.status) if (!result.ok) return jsonError(result.error, result.status);
return json({ secret: result.secret, uri: result.uri }) return json({ secret: result.secret, uri: result.uri });
}, },
PUT: async ({ request }) => { PUT: async ({ request }) => {
const session = await readSession(request) const session = await readSession(request);
if (!session?.userId) return jsonError('Unauthorized', 401) if (!session?.userId) return jsonError("Unauthorized", 401);
let body: { token: string } let body: { token: string };
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) } try {
if (!body.token) return jsonError('token required', 400) body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.token) return jsonError("token required", 400);
const result = await confirmTotpEnrollment(session.userId, body.token) const result = await confirmTotpEnrollment(session.userId, body.token);
if (!result.ok) return jsonError(result.error, result.status) if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true }) return json({ ok: true });
}, },
DELETE: async ({ request }) => { DELETE: async ({ request }) => {
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 disableTotp(session.userId) await disableTotp(session.userId);
return json({ ok: true }) return json({ ok: true });
}, },
}, },
}, },
}) });

View file

@ -1,9 +1,13 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/api/health')({ export const Route = createFileRoute("/api/health")({
server: { server: {
handlers: { handlers: {
GET: () => new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'content-type': 'application/json' } }), GET: () =>
new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
}, },
}, },
}) });

View file

@ -1,32 +1,38 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { db } from '#/db/index' import { db } from "#/db/index";
import { sql } from 'drizzle-orm' import { sql } from "drizzle-orm";
import Redis from 'ioredis' import Redis from "ioredis";
export const Route = createFileRoute('/api/ready')({ export const Route = createFileRoute("/api/ready")({
server: { server: {
handlers: { handlers: {
GET: async () => { GET: async () => {
const checks: Record<string, boolean> = { db: false, redis: false } const checks: Record<string, boolean> = { db: false, redis: false };
try { try {
await db.execute(sql`SELECT 1`) await db.execute(sql`SELECT 1`);
checks.db = true checks.db = true;
} catch {} } catch {}
try { try {
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', { lazyConnect: true, connectTimeout: 2000 }) const redis = new Redis(
await redis.ping() process.env.REDIS_URL ?? "redis://localhost:6379",
await redis.quit() { lazyConnect: true, connectTimeout: 2000 },
checks.redis = true );
await redis.ping();
await redis.quit();
checks.redis = true;
} catch {} } catch {}
const ok = Object.values(checks).every(Boolean) const ok = Object.values(checks).every(Boolean);
return new Response(JSON.stringify({ status: ok ? 'ok' : 'degraded', checks }), { return new Response(
status: ok ? 200 : 503, JSON.stringify({ status: ok ? "ok" : "degraded", checks }),
headers: { 'content-type': 'application/json' }, {
}) status: ok ? 200 : 503,
headers: { "content-type": "application/json" },
},
);
}, },
}, },
}, },
}) });

View file

@ -1,26 +1,31 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from '#/lib/actions/apps' import { findUserApp } from "#/lib/actions/apps";
import { revokeAppApiKey } from '#/lib/actions/api-keys' 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: {
handlers: { handlers: {
DELETE: async ({ request, params }) => { DELETE: 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 findUserApp(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 result = await revokeAppApiKey(params.keyId, params.id, session.userId, { const result = await revokeAppApiKey(
ip: request.headers.get('x-forwarded-for') ?? undefined, params.keyId,
userAgent: request.headers.get('user-agent') ?? undefined, params.id,
}) session.userId,
if (!result.ok) return jsonError(result.error, result.status) {
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true }) return json({ ok: true });
}, },
}, },
}, },
}) });

View file

@ -1,38 +1,46 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from '#/lib/actions/apps' import { findUserApp } from "#/lib/actions/apps";
import { listAppApiKeys, createAppApiKey } from '#/lib/actions/api-keys' import { listAppApiKeys, createAppApiKey } from "#/lib/actions/api-keys";
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({ export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/")({
server: { server: {
handlers: { handlers: {
GET: async ({ request, params }) => { GET: 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 findUserApp(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(await listAppApiKeys(params.id)) return json(await listAppApiKeys(params.id));
}, },
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 findUserApp(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 created = await createAppApiKey(params.id, session.userId, body.label, body.env, { const created = await createAppApiKey(
ip: request.headers.get('x-forwarded-for') ?? undefined, params.id,
userAgent: request.headers.get('user-agent') ?? undefined, session.userId,
}) body.label,
body.env,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
return json(created, 201) return json(created, 201);
}, },
}, },
}, },
}) });

View file

@ -1,52 +1,68 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp, updateUserApp, deleteUserApp } from '#/lib/actions/apps' import { findUserApp, updateUserApp, deleteUserApp } from "#/lib/actions/apps";
export const Route = createFileRoute('/api/v1/admin/apps/$id/')({ export const Route = createFileRoute("/api/v1/admin/apps/$id/")({
server: { server: {
handlers: { handlers: {
GET: async ({ request, params }) => { GET: 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 findUserApp(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,
});
}, },
PATCH: async ({ request, params }) => { PATCH: 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);
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: { name?: string; maxSessions?: number } = {} const updates: { name?: string; maxSessions?: number } = {};
if (body.name !== undefined) updates.name = body.name if (body.name !== undefined) updates.name = body.name;
if (body.maxSessions !== undefined) updates.maxSessions = body.maxSessions if (body.maxSessions !== undefined)
if (Object.keys(updates).length === 0) return jsonError('Nothing to update', 400) updates.maxSessions = body.maxSessions;
if (Object.keys(updates).length === 0)
return jsonError("Nothing to update", 400);
const updated = await updateUserApp(params.id, session.userId, updates, { const updated = await updateUserApp(
ip: request.headers.get('x-forwarded-for') ?? undefined, params.id,
userAgent: request.headers.get('user-agent') ?? undefined, session.userId,
}) updates,
if (!updated) return jsonError('Not found', 404) {
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
if (!updated) return jsonError("Not found", 404);
return json(updated) return json(updated);
}, },
DELETE: async ({ request, params }) => { DELETE: 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 deleted = await deleteUserApp(params.id, session.userId, { const deleted = await deleteUserApp(params.id, session.userId, {
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 (!deleted) return jsonError('Not found', 404) if (!deleted) return jsonError("Not found", 404);
return json({ ok: true }) return json({ ok: true });
}, },
}, },
}, },
}) });

View file

@ -1,33 +1,42 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { listUserApps, createUserApp } from '#/lib/actions/apps' 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: {
handlers: { handlers: {
GET: async ({ request }) => { GET: async ({ request }) => {
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 listUserApps(session.userId) const list = await listUserApps(session.userId);
return json(list) return json(list);
}, },
POST: async ({ request }) => { POST: async ({ request }) => {
const session = await readSession(request) const session = await readSession(request);
if (!session?.userId) return jsonError('Unauthorized', 401) if (!session?.userId) return jsonError("Unauthorized", 401);
let body: { name: string; maxSessions?: number } let body: { name: string; maxSessions?: number };
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) } try {
if (!body.name?.trim()) return jsonError('name required', 400) body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.name?.trim()) return jsonError("name required", 400);
const app = await createUserApp(session.userId, body.name, body.maxSessions, { const app = await createUserApp(
ip: request.headers.get('x-forwarded-for') ?? undefined, session.userId,
userAgent: request.headers.get('user-agent') ?? undefined, body.name,
}) body.maxSessions,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
return json(app, 201) return json(app, 201);
}, },
}, },
}, },
}) });

View file

@ -1,26 +1,26 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { queryAuditLog } from '#/lib/actions/audit-log' 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: {
handlers: { handlers: {
GET: async ({ request }) => { GET: async ({ request }) => {
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 url = new URL(request.url) const url = new URL(request.url);
const result = await queryAuditLog(session.userId, { const result = await queryAuditLog(session.userId, {
page: Number(url.searchParams.get('page') ?? 1), page: Number(url.searchParams.get("page") ?? 1),
limit: Number(url.searchParams.get('limit') ?? 50), limit: Number(url.searchParams.get("limit") ?? 50),
action: url.searchParams.get('action'), action: url.searchParams.get("action"),
resourceType: url.searchParams.get('resourceType'), resourceType: url.searchParams.get("resourceType"),
since: url.searchParams.get('since'), since: url.searchParams.get("since"),
until: url.searchParams.get('until'), until: url.searchParams.get("until"),
}) });
return json(result) return json(result);
}, },
}, },
}, },
}) });

View file

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

View file

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

View file

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

View file

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

View file

@ -1,33 +1,36 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from '#/lib/session-api' import { json, jsonError, readSession } from "#/lib/session-api";
import { listUserSessions, createSession } from '#/lib/actions/sessions' 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: {
handlers: { handlers: {
GET: async ({ request }) => { GET: async ({ request }) => {
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 rows = await listUserSessions(session.userId) const rows = await listUserSessions(session.userId);
return json(rows) return json(rows);
}, },
POST: async ({ request }) => { POST: async ({ request }) => {
const sess = await readSession(request) const sess = await readSession(request);
if (!sess?.userId) return jsonError('Unauthorized', 401) if (!sess?.userId) return jsonError("Unauthorized", 401);
let body: { appId?: string; connect_via?: 'qr' | 'pairing' } = {} let body: { appId?: string; connect_via?: "qr" | "pairing" } = {};
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) } try {
if (!body.appId) return jsonError('appId required', 400) body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.appId) return jsonError("appId required", 400);
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr' const via = body.connect_via === "pairing" ? "pairing" : "qr";
const result = await createSession(body.appId, via, sess.userId) const result = await createSession(body.appId, via, sess.userId);
if (!result.ok) return jsonError(result.error, result.status)
return json(result.session, 201)
if (!result.ok) return jsonError(result.error, result.status);
return json(result.session, 201);
}, },
}, },
}, },
}) });

View file

@ -1,25 +1,29 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
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 { findSession, disconnectSession } from '#/lib/actions/sessions' 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: {
handlers: { handlers: {
POST: async ({ request, params }) => { POST: async ({ request, params }) => {
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 findSession(params.id, { appId: 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);
if (session.status === 'DISCONNECTED' || session.status === 'LOGGED_OUT' || session.status === 'DEAD') { if (
return json({ ok: true, status: session.status }) session.status === "DISCONNECTED" ||
session.status === "LOGGED_OUT" ||
session.status === "DEAD"
) {
return json({ ok: true, status: session.status });
} }
await disconnectSession(params.id, auth.appId) await disconnectSession(params.id, auth.appId);
return json({ ok: true, status: 'DISCONNECTED' }) return json({ ok: true, status: "DISCONNECTED" });
}, },
}, },
}, },
}) });

View file

@ -1,18 +1,18 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
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' 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: {
handlers: { handlers: {
GET: async ({ request, params }) => { GET: async ({ request, params }) => {
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 findSession(params.id, { appId: 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({
id: session.id, id: session.id,
@ -20,8 +20,8 @@ export const Route = createFileRoute('/api/v1/sessions/$id/health')({
lastSeen: session.lastSeen, lastSeen: session.lastSeen,
reconnectCount: session.reconnectCount, reconnectCount: session.reconnectCount,
socketAlive: isActive(session.id), socketAlive: isActive(session.id),
}) });
}, },
}, },
}, },
}) });

View file

@ -1,18 +1,18 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
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 { getDecryptedField } from '#/lib/baileys' import { getDecryptedField } from "#/lib/baileys";
import { findSession, logoutAppSession } from '#/lib/actions/sessions' import { findSession, logoutAppSession } from "#/lib/actions/sessions";
export const Route = createFileRoute('/api/v1/sessions/$id/')({ export const Route = createFileRoute("/api/v1/sessions/$id/")({
server: { server: {
handlers: { handlers: {
GET: async ({ request, params }) => { GET: async ({ request, params }) => {
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 findSession(params.id, { appId: 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({
id: session.id, id: session.id,
@ -22,19 +22,19 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
reconnectCount: session.reconnectCount, reconnectCount: session.reconnectCount,
lastSeen: session.lastSeen, lastSeen: session.lastSeen,
createdAt: session.createdAt, createdAt: session.createdAt,
}) });
}, },
DELETE: async ({ request, params }) => { DELETE: async ({ request, params }) => {
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 findSession(params.id, { appId: 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 logoutAppSession(params.id, auth.appId) await logoutAppSession(params.id, auth.appId);
return json({ ok: true }) return json({ ok: true });
}, },
}, },
}, },
}) });

View file

@ -1,49 +1,56 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
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' 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: {
handlers: { handlers: {
POST: async ({ request, params }) => { POST: async ({ request, params }) => {
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 findSession(params.id, { appId: 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);
if (session.status === 'CONNECTED') return jsonError('Already connected', 409) if (session.status === "CONNECTED")
if (session.status === 'DEAD' || session.status === 'LOGGED_OUT') { return jsonError("Already connected", 409);
return jsonError('Session is no longer active', 410) if (session.status === "DEAD" || session.status === "LOGGED_OUT") {
return jsonError("Session is no longer active", 410);
} }
let body: { phone?: string } let body: { phone?: string };
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) } try {
if (!body.phone) return jsonError('phone required', 400) 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, '') 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 setSessionStatus(params.id, 'PENDING_PAIRING') 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);
await new Promise((r) => setTimeout(r, 2000)) await new Promise((r) => setTimeout(r, 2000));
} }
const sock = getSocket(params.id) const sock = getSocket(params.id);
if (!sock) return jsonError('Session not ready — retry in a moment', 503) if (!sock)
return jsonError("Session not ready — retry in a moment", 503);
try { try {
const pairingCode = await sock.requestPairingCode(phoneDigits) const pairingCode = await sock.requestPairingCode(phoneDigits);
return json({ pairingCode }) return json({ pairingCode });
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err);
return jsonError(`Pairing code request failed: ${msg}`, 502) return jsonError(`Pairing code request failed: ${msg}`, 502);
} }
}, },
}, },
}, },
}) });

View file

@ -1,33 +1,33 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
import QRCode from 'qrcode' import QRCode from "qrcode";
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' 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: {
handlers: { handlers: {
GET: async ({ request, params }) => { GET: async ({ request, params }) => {
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 findSession(params.id, { appId: 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);
if (session.status === 'CONNECTED') { if (session.status === "CONNECTED") {
return json({ error: 'Session already connected' }, 409) return json({ error: "Session already connected" }, 409);
} }
if (session.status === 'DEAD' || session.status === 'LOGGED_OUT') { if (session.status === "DEAD" || session.status === "LOGGED_OUT") {
return json({ error: 'Session is no longer active' }, 410) return json({ error: "Session is no longer active" }, 410);
} }
const qrRaw = getQrCode(params.id) const qrRaw = getQrCode(params.id);
if (!qrRaw) return json({ waiting: true }, 202) if (!qrRaw) return json({ waiting: true }, 202);
const qrPng = await QRCode.toDataURL(qrRaw) const qrPng = await QRCode.toDataURL(qrRaw);
return json({ qr: qrPng, qrRaw }) return json({ qr: qrPng, qrRaw });
}, },
}, },
}, },
}) });

View file

@ -1,31 +1,35 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from "@tanstack/react-router";
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 { listAppSessions, createSession } from '#/lib/actions/sessions' import { listAppSessions, createSession } from "#/lib/actions/sessions";
export const Route = createFileRoute('/api/v1/sessions/')({ export const Route = createFileRoute("/api/v1/sessions/")({
server: { server: {
handlers: { handlers: {
GET: async ({ request }) => { GET: async ({ request }) => {
const auth = await authenticateApiKey(request) const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth if (auth instanceof Response) return auth;
return json(await listAppSessions(auth.appId)) return json(await listAppSessions(auth.appId));
}, },
POST: async ({ request }) => { POST: async ({ request }) => {
const auth = await authenticateApiKey(request) const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth if (auth instanceof Response) return auth;
let body: { connect_via?: 'qr' | 'pairing' } = {} let body: { connect_via?: "qr" | "pairing" } = {};
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 result = await createSession(auth.appId, via) const result = await createSession(auth.appId, via);
if (!result.ok) return jsonError(result.error, result.status) if (!result.ok) return jsonError(result.error, result.status);
return json(result.session, 201) return json(result.session, 201);
}, },
}, },
}, },
}) });

View file

@ -1,10 +1,10 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { getSessionFn } from '#/server/auth' import { getSessionFn } from "#/server/auth";
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: () => <Outlet />, component: () => <Outlet />,
}) });

View file

@ -1,99 +1,107 @@
import { createFileRoute, useRouter } from '@tanstack/react-router' import { createFileRoute, useRouter } 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 { Key, Plus, Copy, Check, Trash2, AlertTriangle } from 'lucide-react' import { Key, Plus, Copy, Check, Trash2, AlertTriangle } from "lucide-react";
import { appQuery, apiKeysQuery } from '#/lib/query' import { appQuery, apiKeysQuery } from "#/lib/query";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { Badge } from '#/components/ui/badge' import { Badge } from "#/components/ui/badge";
import { Separator } from '#/components/ui/separator' import { Separator } from "#/components/ui/separator";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from '#/components/ui/dialog' } from "#/components/ui/dialog";
import { cn } from '#/lib/utils' import { cn } from "#/lib/utils";
export const Route = createFileRoute('/apps/$appId/')({ export const Route = createFileRoute("/apps/$appId/")({
component: AppKeysPage, component: AppKeysPage,
}) });
interface ApiKey { interface ApiKey {
id: string id: string;
display: string display: string;
label: string label: string;
revokedAt: string | null revokedAt: string | null;
createdAt: string createdAt: string;
} }
function AppKeysPage() { function AppKeysPage() {
const { appId } = Route.useParams() const { appId } = Route.useParams();
const { data: app } = useQuery(appQuery(appId)) const { data: app } = useQuery(appQuery(appId));
const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(apiKeysQuery(appId)) const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(
const queryClient = useQueryClient() apiKeysQuery(appId),
const router = useRouter() );
const queryClient = useQueryClient();
const router = useRouter();
const [genLabel, setGenLabel] = useState('') const [genLabel, setGenLabel] = useState("");
const [showingKey, setShowingKey] = useState<string | null>(null) const [showingKey, setShowingKey] = useState<string | null>(null);
const [genOpen, setGenOpen] = useState(false) const [genOpen, setGenOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false);
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false);
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false);
async function generateKey(e: React.FormEvent) { async function generateKey(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
setError('') setError("");
setGenerating(true) setGenerating(true);
try { try {
const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, { const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ label: genLabel || undefined }), body: JSON.stringify({ label: genLabel || undefined }),
}) });
const data = await res.json() const data = await res.json();
if (!res.ok) { setError(data.error ?? 'Failed'); return } if (!res.ok) {
setShowingKey(data.key) setError(data.error ?? "Failed");
setGenLabel('') return;
setGenOpen(false) }
refetchKeys() setShowingKey(data.key);
setGenLabel("");
setGenOpen(false);
refetchKeys();
} finally { } finally {
setGenerating(false) setGenerating(false);
} }
} }
async function revokeKey(keyId: string) { async function revokeKey(keyId: string) {
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, { await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, {
method: 'DELETE', method: "DELETE",
credentials: 'include', credentials: "include",
}) });
refetchKeys() refetchKeys();
} }
async function deleteApp() { async function deleteApp() {
setDeleting(true) setDeleting(true);
try { try {
await fetch(`/api/v1/admin/apps/${appId}`, { method: 'DELETE', credentials: 'include' }) await fetch(`/api/v1/admin/apps/${appId}`, {
queryClient.invalidateQueries({ queryKey: ['apps'] }) method: "DELETE",
router.navigate({ to: '/apps' }) credentials: "include",
});
queryClient.invalidateQueries({ queryKey: ["apps"] });
router.navigate({ to: "/apps" });
} finally { } finally {
setDeleting(false) setDeleting(false);
} }
} }
async function copyKey() { async function copyKey() {
if (!showingKey) return if (!showingKey) return;
await navigator.clipboard.writeText(showingKey) await navigator.clipboard.writeText(showingKey);
setCopied(true) setCopied(true);
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
} }
if (!app) return <div className="min-h-full" /> if (!app) return <div className="min-h-full" />;
return ( return (
<div className="min-h-full"> <div className="min-h-full">
@ -135,7 +143,7 @@ function AppKeysPage() {
className="h-7 px-2.5 text-[12px] border-amber-300 text-amber-700 hover:bg-amber-100 gap-1.5" 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 ? <Check size={11} /> : <Copy size={11} />}
{copied ? 'Copied!' : 'Copy'} {copied ? "Copied!" : "Copy"}
</Button> </Button>
<button <button
onClick={() => setShowingKey(null)} onClick={() => setShowingKey(null)}
@ -155,9 +163,9 @@ function AppKeysPage() {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-[13px] font-semibold text-gray-900">Keys</h2> <h2 className="text-[13px] font-semibold text-gray-900">Keys</h2>
{keys.filter(k => !k.revokedAt).length > 0 && ( {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"> <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 {keys.filter((k) => !k.revokedAt).length} active
</span> </span>
)} )}
</div> </div>
@ -168,7 +176,9 @@ function AppKeysPage() {
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center mx-auto mb-3"> <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" /> <Key size={16} className="text-gray-400" />
</div> </div>
<p className="text-[13px] font-medium text-gray-700 mb-1">No API keys</p> <p className="text-[13px] font-medium text-gray-700 mb-1">
No API keys
</p>
<p className="text-[12px] text-gray-400 mb-4"> <p className="text-[12px] text-gray-400 mb-4">
Generate a key to authenticate API requests. Generate a key to authenticate API requests.
</p> </p>
@ -187,15 +197,17 @@ function AppKeysPage() {
<div <div
key={k.id} key={k.id}
className={cn( className={cn(
'flex items-center justify-between gap-4 px-4 py-3', "flex items-center justify-between gap-4 px-4 py-3",
k.revokedAt && 'opacity-50', k.revokedAt && "opacity-50",
)} )}
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className={cn( <div
'w-1.5 h-1.5 rounded-full flex-shrink-0', className={cn(
k.revokedAt ? 'bg-red-400' : 'bg-emerald-400', "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="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<code className="text-[12px] font-mono text-gray-700 truncate"> <code className="text-[12px] font-mono text-gray-700 truncate">
@ -207,17 +219,20 @@ function AppKeysPage() {
</span> </span>
)} )}
{k.revokedAt && ( {k.revokedAt && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4"> <Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
Revoked Revoked
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-[11px] text-gray-400 mt-0.5"> <p className="text-[11px] text-gray-400 mt-0.5">
Created{' '} Created{" "}
{new Date(k.createdAt).toLocaleDateString('en', { {new Date(k.createdAt).toLocaleDateString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric', year: "numeric",
})} })}
</p> </p>
</div> </div>
@ -246,9 +261,12 @@ function AppKeysPage() {
</h2> </h2>
<div className="bg-white border border-red-200 rounded-xl p-4 flex items-center justify-between gap-4"> <div className="bg-white border border-red-200 rounded-xl p-4 flex items-center justify-between gap-4">
<div> <div>
<p className="text-[13px] font-medium text-gray-900">Delete app</p> <p className="text-[13px] font-medium text-gray-900">
Delete app
</p>
<p className="text-[12px] text-gray-500 mt-0.5"> <p className="text-[12px] text-gray-500 mt-0.5">
Permanently delete this app and revoke all API keys. This cannot be undone. Permanently delete this app and revoke all API keys. This cannot
be undone.
</p> </p>
</div> </div>
<Button <Button
@ -268,8 +286,11 @@ function AppKeysPage() {
<Dialog <Dialog
open={genOpen} open={genOpen}
onOpenChange={(o) => { onOpenChange={(o) => {
setGenOpen(o) setGenOpen(o);
if (!o) { setGenLabel(''); setError('') } if (!o) {
setGenLabel("");
setError("");
}
}} }}
> >
<DialogContent className="sm:max-w-[380px]"> <DialogContent className="sm:max-w-[380px]">
@ -282,14 +303,15 @@ function AppKeysPage() {
<form onSubmit={generateKey} className="space-y-4 pt-1"> <form onSubmit={generateKey} className="space-y-4 pt-1">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="key-label" className="text-[13px] text-zinc-700"> <Label htmlFor="key-label" className="text-[13px] text-zinc-700">
Label <span className="text-gray-400 font-normal">(optional)</span> Label{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</Label> </Label>
<Input <Input
id="key-label" id="key-label"
autoFocus autoFocus
placeholder="e.g. Production, CI" placeholder="e.g. Production, CI"
value={genLabel} value={genLabel}
onChange={e => setGenLabel(e.target.value)} onChange={(e) => setGenLabel(e.target.value)}
className="h-9 text-sm" className="h-9 text-sm"
/> />
</div> </div>
@ -303,7 +325,11 @@ function AppKeysPage() {
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { setGenOpen(false); setGenLabel(''); setError('') }} onClick={() => {
setGenOpen(false);
setGenLabel("");
setError("");
}}
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
> >
Cancel Cancel
@ -314,7 +340,7 @@ function AppKeysPage() {
disabled={generating} disabled={generating}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white" className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
> >
{generating ? 'Generating…' : 'Generate'} {generating ? "Generating…" : "Generate"}
</Button> </Button>
</div> </div>
</form> </form>
@ -325,9 +351,12 @@ function AppKeysPage() {
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[380px]"> <DialogContent className="sm:max-w-[380px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[15px] text-red-600">Delete "{app.name}"?</DialogTitle> <DialogTitle className="text-[15px] text-red-600">
Delete "{app.name}"?
</DialogTitle>
<DialogDescription className="text-[13px]"> <DialogDescription className="text-[13px]">
This will permanently delete the app, revoke all API keys, and cannot be undone. This will permanently delete the app, revoke all API keys, and
cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2 pt-3"> <div className="flex justify-end gap-2 pt-3">
@ -347,11 +376,11 @@ function AppKeysPage() {
disabled={deleting} disabled={deleting}
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
> >
{deleting ? 'Deleting…' : 'Delete app'} {deleting ? "Deleting…" : "Delete app"}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View file

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

View file

@ -1,45 +1,80 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { Zap, Plus, RefreshCw, Key, ChevronDown } from 'lucide-react' import { Zap, Plus, RefreshCw, Key, ChevronDown } from "lucide-react";
import { appSessionsQuery, apiKeysQuery } from '#/lib/query' import { appSessionsQuery, apiKeysQuery } from "#/lib/query";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { cn } from '#/lib/utils' import { cn } from "#/lib/utils";
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = { const STATUS_STYLES: Record<
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' }, string,
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' }, { dot: string; badge: string; label: string } | undefined
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' }, CONNECTED: {
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' }, dot: "bg-emerald-500 animate-pulse-dot",
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' }, badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' }, 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! const FALLBACK_STYLE = STATUS_STYLES.DISCONNECTED!;
export const Route = createFileRoute('/apps/$appId/sessions/')({ export const Route = createFileRoute("/apps/$appId/sessions/")({
component: AppSessionsPage, component: AppSessionsPage,
}) });
function AppSessionsPage() { function AppSessionsPage() {
const { appId } = Route.useParams() const { appId } = Route.useParams();
const { data: sessions = [], refetch } = useQuery(appSessionsQuery(appId)) const { data: sessions = [], refetch } = useQuery(appSessionsQuery(appId));
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId)) const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId));
const [selectedKeyId, setSelectedKeyId] = useState<string>('') const [selectedKeyId, setSelectedKeyId] = useState<string>("");
const activeKeys = apiKeys.filter((k: { revokedAt: string | null }) => !k.revokedAt) const activeKeys = apiKeys.filter(
(k: { revokedAt: string | null }) => !k.revokedAt,
);
useEffect(() => { useEffect(() => {
if (activeKeys.length > 0 && !selectedKeyId) { if (activeKeys.length > 0 && !selectedKeyId) {
setSelectedKeyId(activeKeys[0].id) setSelectedKeyId(activeKeys[0].id);
} }
}, [activeKeys, selectedKeyId]) }, [activeKeys, selectedKeyId]);
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length const connectedCount = sessions.filter(
const pendingCount = sessions.filter(s => (s) => s.status === "CONNECTED",
['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING'].includes(s.status), ).length;
).length const pendingCount = sessions.filter((s) =>
["PENDING_QR", "PENDING_PAIRING", "CONNECTING"].includes(s.status),
).length;
return ( return (
<div className="min-h-full"> <div className="min-h-full">
@ -65,19 +100,28 @@ function AppSessionsPage() {
{/* API key selector */} {/* API key selector */}
{activeKeys.length > 0 && ( {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"> <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} /> <Key
size={11}
className="text-gray-400 flex-shrink-0"
strokeWidth={2}
/>
<select <select
value={selectedKeyId} value={selectedKeyId}
onChange={e => setSelectedKeyId(e.target.value)} 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" 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 }) => ( {activeKeys.map(
<option key={k.id} value={k.id}> (k: { id: string; label: string; display: string }) => (
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`} <option key={k.id} value={k.id}>
</option> {k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
))} </option>
),
)}
</select> </select>
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" /> <ChevronDown
size={10}
className="text-gray-400 absolute right-2 pointer-events-none"
/>
</div> </div>
)} )}
<button <button
@ -106,7 +150,9 @@ function AppSessionsPage() {
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4"> <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} /> <Zap size={20} className="text-gray-400" strokeWidth={1.5} />
</div> </div>
<p className="text-sm font-medium text-gray-900 mb-1">No sessions yet</p> <p className="text-sm font-medium text-gray-900 mb-1">
No sessions yet
</p>
<p className="text-[13px] text-gray-500 mb-5"> <p className="text-[13px] text-gray-500 mb-5">
Create a session to connect a WhatsApp number to this app. Create a session to connect a WhatsApp number to this app.
</p> </p>
@ -124,16 +170,24 @@ function AppSessionsPage() {
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
{/* Table header */} {/* 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"> <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">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span> Session
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Last seen</span> </span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Reconnects</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> </div>
{/* Rows */} {/* Rows */}
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{sessions.map((s) => { {sessions.map((s) => {
const style = STATUS_STYLES[s.status] ?? FALLBACK_STYLE const style = STATUS_STYLES[s.status] ?? FALLBACK_STYLE;
return ( return (
<Link <Link
key={s.id} key={s.id}
@ -145,34 +199,44 @@ function AppSessionsPage() {
{s.id.slice(0, 12)}… {s.id.slice(0, 12)}…
</code> </code>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', style.dot)} /> <div
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}> 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} {style.label}
</span> </span>
</div> </div>
<span className="text-[12px] text-gray-400"> <span className="text-[12px] text-gray-400">
{s.lastSeen {s.lastSeen
? new Date(s.lastSeen).toLocaleString('en', { ? new Date(s.lastSeen).toLocaleString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
}) })
: new Date(s.createdAt).toLocaleDateString('en', { : new Date(s.createdAt).toLocaleDateString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
})} })}
</span> </span>
<span className="text-[12px] text-gray-400"> <span className="text-[12px] text-gray-400">
{s.reconnectCount > 0 ? s.reconnectCount : '—'} {s.reconnectCount > 0 ? s.reconnectCount : "—"}
</span> </span>
</Link> </Link>
) );
})} })}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
) );
} }

View file

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

View file

@ -1,64 +1,64 @@
import { createFileRoute, Link, redirect } 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 { 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 { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from '#/components/ui/dialog' } from "#/components/ui/dialog";
export const Route = createFileRoute('/apps/')({ export const Route = createFileRoute("/apps/")({
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: AppsPage, component: AppsPage,
}) });
interface App { interface App {
id: string id: string;
name: string name: string;
maxSessions: string maxSessions: string;
createdAt: string createdAt: string;
} }
function AppsPage() { function AppsPage() {
const { data: apps = [] } = useQuery<App[]>(appsQuery) const { data: apps = [] } = useQuery<App[]>(appsQuery);
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState('') const [name, setName] = useState("");
const [error, setError] = useState('') const [error, setError] = useState("");
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false);
async function createApp(e: React.FormEvent) { async function createApp(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
setError('') setError("");
setCreating(true) setCreating(true);
try { try {
const res = await fetch('/api/v1/admin/apps', { const res = await fetch("/api/v1/admin/apps", {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}) });
if (!res.ok) { if (!res.ok) {
const d = await res.json() const d = await res.json();
setError(d.error ?? 'Failed to create app') setError(d.error ?? "Failed to create app");
return return;
} }
setDialogOpen(false) setDialogOpen(false);
setName('') setName("");
queryClient.invalidateQueries({ queryKey: ['apps'] }) queryClient.invalidateQueries({ queryKey: ["apps"] });
} finally { } finally {
setCreating(false) setCreating(false);
} }
} }
@ -85,9 +85,15 @@ function AppsPage() {
{apps.length === 0 ? ( {apps.length === 0 ? (
<div className="mt-16 flex flex-col items-center justify-center text-center"> <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"> <div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
<LayoutGrid size={20} className="text-gray-400" strokeWidth={1.5} /> <LayoutGrid
size={20}
className="text-gray-400"
strokeWidth={1.5}
/>
</div> </div>
<p className="text-sm font-medium text-gray-900 mb-1">No apps yet</p> <p className="text-sm font-medium text-gray-900 mb-1">
No apps yet
</p>
<p className="text-[13px] text-gray-500 mb-5"> <p className="text-[13px] text-gray-500 mb-5">
Create an app to start managing WhatsApp sessions. Create an app to start managing WhatsApp sessions.
</p> </p>
@ -111,7 +117,11 @@ function AppsPage() {
> >
<div className="flex items-start justify-between mb-4"> <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"> <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} /> <LayoutGrid
size={15}
className="text-emerald-600"
strokeWidth={1.75}
/>
</div> </div>
<ChevronRight <ChevronRight
size={15} size={15}
@ -120,7 +130,9 @@ function AppsPage() {
/> />
</div> </div>
<p className="font-semibold text-[14px] text-gray-900 mb-1 truncate">{app.name}</p> <p className="font-semibold text-[14px] text-gray-900 mb-1 truncate">
{app.name}
</p>
<div className="flex items-center gap-3 mt-3"> <div className="flex items-center gap-3 mt-3">
<span className="flex items-center gap-1.5 text-[12px] text-gray-400"> <span className="flex items-center gap-1.5 text-[12px] text-gray-400">
@ -129,9 +141,9 @@ function AppsPage() {
</span> </span>
<span className="flex items-center gap-1.5 text-[12px] text-gray-400"> <span className="flex items-center gap-1.5 text-[12px] text-gray-400">
<Calendar size={11} strokeWidth={1.75} /> <Calendar size={11} strokeWidth={1.75} />
{new Date(app.createdAt).toLocaleDateString('en', { {new Date(app.createdAt).toLocaleDateString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
})} })}
</span> </span>
</div> </div>
@ -142,7 +154,16 @@ function AppsPage() {
</div> </div>
{/* Create App Dialog */} {/* Create App Dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) { setName(''); setError('') } }}> <Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setName("");
setError("");
}
}}
>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[15px]">Create app</DialogTitle> <DialogTitle className="text-[15px]">Create app</DialogTitle>
@ -152,14 +173,16 @@ function AppsPage() {
</DialogHeader> </DialogHeader>
<form onSubmit={createApp} className="space-y-4 pt-1"> <form onSubmit={createApp} className="space-y-4 pt-1">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="app-name" className="text-[13px] text-zinc-700">App name</Label> <Label htmlFor="app-name" className="text-[13px] text-zinc-700">
App name
</Label>
<Input <Input
id="app-name" id="app-name"
autoFocus autoFocus
required required
placeholder="My WhatsApp Bot" placeholder="My WhatsApp Bot"
value={name} value={name}
onChange={e => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="h-9 text-sm" className="h-9 text-sm"
/> />
</div> </div>
@ -173,7 +196,11 @@ function AppsPage() {
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { setDialogOpen(false); setName(''); setError('') }} onClick={() => {
setDialogOpen(false);
setName("");
setError("");
}}
className="h-8 px-3 text-[13px]" className="h-8 px-3 text-[13px]"
> >
Cancel Cancel
@ -184,12 +211,12 @@ function AppsPage() {
disabled={creating} disabled={creating}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white" className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
> >
{creating ? 'Creating…' : 'Create app'} {creating ? "Creating…" : "Create app"}
</Button> </Button>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View file

@ -1,63 +1,70 @@
import { createFileRoute, 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 { 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 { Input } from "#/components/ui/input";
import { cn } from '#/lib/utils' import { cn } from "#/lib/utils";
export const Route = createFileRoute('/audit-log')({ export const Route = createFileRoute("/audit-log")({
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: AuditLogPage, component: AuditLogPage,
}) });
interface AuditEntry { interface AuditEntry {
id: string id: string;
actorType: string actorType: string;
actorId: string actorId: string;
action: string action: string;
resourceType: string resourceType: string;
resourceId: string | null resourceId: string | null;
ip: string | null ip: string | null;
createdAt: string createdAt: string;
} }
const ACTION_COLORS: Record<string, string> = { const ACTION_COLORS: Record<string, string> = {
login: 'bg-blue-50 text-blue-700 border-blue-200', login: "bg-blue-50 text-blue-700 border-blue-200",
logout: 'bg-gray-50 text-gray-600 border-gray-200', logout: "bg-gray-50 text-gray-600 border-gray-200",
'app.create': 'bg-emerald-50 text-emerald-700 border-emerald-200', "app.create": "bg-emerald-50 text-emerald-700 border-emerald-200",
'app.delete': 'bg-red-50 text-red-600 border-red-200', "app.delete": "bg-red-50 text-red-600 border-red-200",
'session.create': 'bg-emerald-50 text-emerald-700 border-emerald-200', "session.create": "bg-emerald-50 text-emerald-700 border-emerald-200",
'session.delete': 'bg-red-50 text-red-600 border-red-200', "session.delete": "bg-red-50 text-red-600 border-red-200",
'session.disconnect': 'bg-orange-50 text-orange-700 border-orange-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.create": "bg-violet-50 text-violet-700 border-violet-200",
'api_key.revoke': 'bg-red-50 text-red-600 border-red-200', "api_key.revoke": "bg-red-50 text-red-600 border-red-200",
} };
function ActionBadge({ action }: { action: string }) { function ActionBadge({ action }: { action: string }) {
const color = ACTION_COLORS[action] ?? 'bg-gray-50 text-gray-600 border-gray-200' const color =
ACTION_COLORS[action] ?? "bg-gray-50 text-gray-600 border-gray-200";
return ( return (
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border font-mono', color)}> <span
className={cn(
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border font-mono",
color,
)}
>
{action} {action}
</span> </span>
) );
} }
function AuditLogPage() { function AuditLogPage() {
const { data, refetch } = 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 => const filtered = entries.filter(
!filter || (e) =>
e.action.toLowerCase().includes(filter.toLowerCase()) || !filter ||
e.resourceType.toLowerCase().includes(filter.toLowerCase()) || e.action.toLowerCase().includes(filter.toLowerCase()) ||
e.actorType.toLowerCase().includes(filter.toLowerCase()), e.resourceType.toLowerCase().includes(filter.toLowerCase()) ||
) e.actorType.toLowerCase().includes(filter.toLowerCase()),
);
return ( return (
<div className="min-h-full"> <div className="min-h-full">
@ -85,11 +92,14 @@ function AuditLogPage() {
<div className="px-8 py-6 animate-fade-up"> <div className="px-8 py-6 animate-fade-up">
{/* Filter */} {/* Filter */}
<div className="relative max-w-xs mb-5"> <div className="relative max-w-xs mb-5">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> <Search
size={13}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<Input <Input
placeholder="Filter by action, resource…" placeholder="Filter by action, resource…"
value={filter} value={filter}
onChange={e => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
className="h-8 pl-8 text-[13px] bg-white" className="h-8 pl-8 text-[13px] bg-white"
/> />
</div> </div>
@ -98,22 +108,30 @@ function AuditLogPage() {
<div className="text-center py-16"> <div className="text-center py-16">
<ScrollText size={28} className="text-gray-300 mx-auto mb-3" /> <ScrollText size={28} className="text-gray-300 mx-auto mb-3" />
<p className="text-[13px] font-medium text-gray-500"> <p className="text-[13px] font-medium text-gray-500">
{filter ? 'No matching entries' : 'No audit log entries yet'} {filter ? "No matching entries" : "No audit log entries yet"}
</p> </p>
</div> </div>
) : ( ) : (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
{/* Header */} {/* Header */}
<div className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50"> <div className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Time</span> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Action</span> Time
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Resource</span> </span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">IP</span> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Action
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Resource
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
IP
</span>
</div> </div>
{/* Rows */} {/* Rows */}
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{filtered.map(e => ( {filtered.map((e) => (
<div <div
key={e.id} key={e.id}
className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 items-center hover:bg-gray-50 transition-colors" className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 items-center hover:bg-gray-50 transition-colors"
@ -121,16 +139,16 @@ function AuditLogPage() {
{/* Time */} {/* Time */}
<div> <div>
<p className="text-[11px] text-gray-500 font-mono whitespace-nowrap"> <p className="text-[11px] text-gray-500 font-mono whitespace-nowrap">
{new Date(e.createdAt).toLocaleDateString('en', { {new Date(e.createdAt).toLocaleDateString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
})} })}
</p> </p>
<p className="text-[11px] text-gray-400 font-mono"> <p className="text-[11px] text-gray-400 font-mono">
{new Date(e.createdAt).toLocaleTimeString('en', { {new Date(e.createdAt).toLocaleTimeString("en", {
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
second: '2-digit', second: "2-digit",
})} })}
</p> </p>
</div> </div>
@ -139,11 +157,15 @@ function AuditLogPage() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<ActionBadge action={e.action} /> <ActionBadge action={e.action} />
<span className="text-[11px] text-gray-400"> <span className="text-[11px] text-gray-400">
by{' '} by{" "}
<span className={cn( <span
'font-medium', className={cn(
e.actorType === 'user' ? 'text-blue-600' : 'text-violet-600', "font-medium",
)}> e.actorType === "user"
? "text-blue-600"
: "text-violet-600",
)}
>
{e.actorType} {e.actorType}
</span> </span>
</span> </span>
@ -151,7 +173,9 @@ function AuditLogPage() {
{/* Resource */} {/* Resource */}
<div> <div>
<span className="text-[12px] text-gray-700">{e.resourceType}</span> <span className="text-[12px] text-gray-700">
{e.resourceType}
</span>
{e.resourceId && ( {e.resourceId && (
<code className="text-[11px] text-gray-400 ml-1.5 font-mono"> <code className="text-[11px] text-gray-400 ml-1.5 font-mono">
{e.resourceId.slice(0, 8)} {e.resourceId.slice(0, 8)}
@ -161,7 +185,7 @@ function AuditLogPage() {
{/* IP */} {/* IP */}
<span className="text-[11px] text-gray-400 font-mono"> <span className="text-[11px] text-gray-400 font-mono">
{e.ip ?? '—'} {e.ip ?? "—"}
</span> </span>
</div> </div>
))} ))}
@ -170,5 +194,5 @@ function AuditLogPage() {
)} )}
</div> </div>
</div> </div>
) );
} }

View file

@ -1,9 +1,9 @@
import { createFileRoute, redirect } from '@tanstack/react-router' import { createFileRoute, redirect } from "@tanstack/react-router";
import { getSessionFn } from '#/server/auth' import { getSessionFn } from "#/server/auth";
export const Route = createFileRoute('/')({ export const Route = createFileRoute("/")({
beforeLoad: async () => { beforeLoad: async () => {
const session = await getSessionFn() const session = await getSessionFn();
throw redirect({ to: session?.userId ? '/apps' : '/login' }) throw redirect({ to: session?.userId ? "/apps" : "/login" });
}, },
}) });

View file

@ -1,54 +1,64 @@
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 { MessageSquare, Shield, Zap, Globe } from "lucide-react";
import { getSessionFn } from '#/server/auth' import { getSessionFn } from "#/server/auth";
import { Button } from '#/components/ui/button' import { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { cn } from '#/lib/utils' 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) throw redirect({ to: '/apps' }) if (session?.userId) throw redirect({ to: "/apps" });
}, },
component: LoginPage, component: LoginPage,
}) });
const features = [ const features = [
{ icon: Zap, label: 'Session management', desc: 'QR and pairing code flows' }, { icon: Zap, label: "Session management", desc: "QR and pairing code flows" },
{ icon: Shield, label: 'API key auth', desc: 'Per-app bearer tokens' }, { icon: Shield, label: "API key auth", desc: "Per-app bearer tokens" },
{ icon: Globe, label: 'Webhook events', desc: 'Real-time message delivery' }, { 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("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [totpToken, setTotpToken] = useState('') const [totpToken, setTotpToken] = useState("");
const [requireTotp, setRequireTotp] = useState(false) const [requireTotp, setRequireTotp] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
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",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }), body: JSON.stringify({
}) email,
const data = await res.json() password,
if (!res.ok) { setError(data.error ?? 'Login failed'); return } totpToken: totpToken || undefined,
if (data.requireTotp) { setRequireTotp(true); return } }),
router.navigate({ to: '/apps' }) });
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Login failed");
return;
}
if (data.requireTotp) {
setRequireTotp(true);
return;
}
router.navigate({ to: "/apps" });
} catch { } catch {
setError('Network error') setError("Network error");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} }
@ -61,8 +71,8 @@ function LoginPage() {
className="absolute inset-0 opacity-[0.04]" className="absolute inset-0 opacity-[0.04]"
style={{ style={{
backgroundImage: backgroundImage:
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)', "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', backgroundSize: "32px 32px",
}} }}
/> />
{/* Glow accent */} {/* Glow accent */}
@ -71,11 +81,15 @@ function LoginPage() {
{/* Logo */} {/* Logo */}
<div className="relative flex items-center gap-3"> <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"> <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} /> <MessageSquare
size={18}
className="text-white fill-white"
strokeWidth={1.5}
/>
</div> </div>
<span <span
className="text-white text-xl font-semibold tracking-tight" className="text-white text-xl font-semibold tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }} style={{ fontFamily: "Fraunces, Georgia, serif" }}
> >
wahoo wahoo
</span> </span>
@ -85,25 +99,30 @@ function LoginPage() {
<div className="relative space-y-6"> <div className="relative space-y-6">
<h1 <h1
className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight" className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }} style={{ fontFamily: "Fraunces, Georgia, serif" }}
> >
WhatsApp sessions,{' '} WhatsApp sessions,{" "}
<span className="text-emerald-400">delivered</span>{' '} <span className="text-emerald-400">delivered</span> via API.
via API.
</h1> </h1>
<p className="text-zinc-400 text-[15px] leading-relaxed max-w-sm"> <p className="text-zinc-400 text-[15px] leading-relaxed max-w-sm">
Manage WhatsApp connections at scale. Generate API keys, track sessions, and integrate Manage WhatsApp connections at scale. Generate API keys, track
messaging into your stack without limits. sessions, and integrate messaging into your stack without limits.
</p> </p>
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{features.map(({ icon: Icon, label, desc }) => ( {features.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-3"> <div key={label} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0">
<Icon size={14} className="text-emerald-400" strokeWidth={1.75} /> <Icon
size={14}
className="text-emerald-400"
strokeWidth={1.75}
/>
</div> </div>
<div> <div>
<span className="text-zinc-100 text-[13px] font-medium">{label}</span> <span className="text-zinc-100 text-[13px] font-medium">
{label}
</span>
<span className="text-zinc-500 text-[13px]"> — {desc}</span> <span className="text-zinc-500 text-[13px]"> — {desc}</span>
</div> </div>
</div> </div>
@ -123,11 +142,15 @@ function LoginPage() {
{/* Mobile logo */} {/* Mobile logo */}
<div className="flex lg:hidden items-center gap-2 mb-2"> <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"> <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} /> <MessageSquare
size={13}
className="text-white fill-white"
strokeWidth={1.5}
/>
</div> </div>
<span <span
className="text-zinc-900 font-semibold text-base" className="text-zinc-900 font-semibold text-base"
style={{ fontFamily: 'Fraunces, Georgia, serif' }} style={{ fontFamily: "Fraunces, Georgia, serif" }}
> >
wahoo wahoo
</span> </span>
@ -135,12 +158,12 @@ function LoginPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-[22px] font-semibold text-zinc-900 tracking-tight"> <h2 className="text-[22px] font-semibold text-zinc-900 tracking-tight">
{requireTotp ? 'Two-factor auth' : 'Sign in'} {requireTotp ? "Two-factor auth" : "Sign in"}
</h2> </h2>
<p className="text-zinc-500 text-sm"> <p className="text-zinc-500 text-sm">
{requireTotp {requireTotp
? 'Enter the 6-digit code from your authenticator app.' ? "Enter the 6-digit code from your authenticator app."
: 'Enter your credentials to access the dashboard.'} : "Enter your credentials to access the dashboard."}
</p> </p>
</div> </div>
@ -148,7 +171,9 @@ function LoginPage() {
{!requireTotp ? ( {!requireTotp ? (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email" className="text-zinc-700 text-[13px]">Email</Label> <Label htmlFor="email" className="text-zinc-700 text-[13px]">
Email
</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
@ -156,12 +181,17 @@ function LoginPage() {
required required
placeholder="you@company.com" placeholder="you@company.com"
value={email} value={email}
onChange={e => setEmail(e.target.value)} 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" className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="password" className="text-zinc-700 text-[13px]">Password</Label> <Label
htmlFor="password"
className="text-zinc-700 text-[13px]"
>
Password
</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@ -169,14 +199,16 @@ function LoginPage() {
required required
placeholder="••••••••" placeholder="••••••••"
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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" className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
/> />
</div> </div>
</> </>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="totp" className="text-zinc-700 text-[13px]">Authenticator code</Label> <Label htmlFor="totp" className="text-zinc-700 text-[13px]">
Authenticator code
</Label>
<Input <Input
id="totp" id="totp"
type="text" type="text"
@ -187,7 +219,7 @@ function LoginPage() {
required required
placeholder="000 000" placeholder="000 000"
value={totpToken} value={totpToken}
onChange={e => setTotpToken(e.target.value)} 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" 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> </div>
@ -203,17 +235,20 @@ function LoginPage() {
type="submit" type="submit"
disabled={loading} disabled={loading}
className={cn( className={cn(
'w-full h-9 text-sm font-medium bg-zinc-900 hover:bg-zinc-800 text-white transition-colors', "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 && "opacity-60 cursor-not-allowed",
)} )}
> >
{loading ? 'Signing in…' : requireTotp ? 'Verify' : 'Sign in'} {loading ? "Signing in…" : requireTotp ? "Verify" : "Sign in"}
</Button> </Button>
{requireTotp && ( {requireTotp && (
<button <button
type="button" type="button"
onClick={() => { setRequireTotp(false); setTotpToken('') }} onClick={() => {
setRequireTotp(false);
setTotpToken("");
}}
className="w-full text-[13px] text-zinc-400 hover:text-zinc-700 transition-colors" className="w-full text-[13px] text-zinc-400 hover:text-zinc-700 transition-colors"
> >
← Back to password ← Back to password
@ -223,5 +258,5 @@ function LoginPage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View file

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

View file

@ -1,74 +1,83 @@
import { createFileRoute, Link, redirect } 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 { 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 { Button } from "#/components/ui/button";
import { cn } from '#/lib/utils' import { cn } from "#/lib/utils";
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = { const STATUS_STYLES: Record<
string,
{ dot: string; badge: string; label: string } | undefined
> = {
CONNECTED: { CONNECTED: {
dot: 'bg-emerald-500 animate-pulse-dot', dot: "bg-emerald-500 animate-pulse-dot",
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
label: 'Connected', label: "Connected",
}, },
CONNECTING: { CONNECTING: {
dot: 'bg-blue-500 animate-pulse-dot', dot: "bg-blue-500 animate-pulse-dot",
badge: 'bg-blue-50 text-blue-700 border-blue-200', badge: "bg-blue-50 text-blue-700 border-blue-200",
label: 'Connecting', label: "Connecting",
}, },
PENDING_QR: { PENDING_QR: {
dot: 'bg-amber-500 animate-pulse-dot', dot: "bg-amber-500 animate-pulse-dot",
badge: 'bg-amber-50 text-amber-700 border-amber-200', badge: "bg-amber-50 text-amber-700 border-amber-200",
label: 'Pending QR', label: "Pending QR",
}, },
PENDING_PAIRING: { PENDING_PAIRING: {
dot: 'bg-amber-500 animate-pulse-dot', dot: "bg-amber-500 animate-pulse-dot",
badge: 'bg-amber-50 text-amber-700 border-amber-200', badge: "bg-amber-50 text-amber-700 border-amber-200",
label: 'Pending Pairing', label: "Pending Pairing",
}, },
DISCONNECTED: { DISCONNECTED: {
dot: 'bg-gray-400', dot: "bg-gray-400",
badge: 'bg-gray-50 text-gray-600 border-gray-200', badge: "bg-gray-50 text-gray-600 border-gray-200",
label: 'Disconnected', label: "Disconnected",
}, },
LOGGED_OUT: { LOGGED_OUT: {
dot: 'bg-red-400', dot: "bg-red-400",
badge: 'bg-red-50 text-red-600 border-red-200', badge: "bg-red-50 text-red-600 border-red-200",
label: 'Logged Out', label: "Logged Out",
}, },
DEAD: { DEAD: {
dot: 'bg-red-600', dot: "bg-red-600",
badge: 'bg-red-50 text-red-700 border-red-200', badge: "bg-red-50 text-red-700 border-red-200",
label: 'Dead', label: "Dead",
}, },
} };
interface SessionRow { interface SessionRow {
id: string id: string;
appId: string appId: string;
appName: string appName: string;
status: string status: string;
lastSeen: string | null lastSeen: string | null;
createdAt: string createdAt: string;
reconnectCount: number reconnectCount: number;
} }
export const Route = createFileRoute('/sessions/')({ export const Route = createFileRoute("/sessions/")({
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: SessionsPage, component: SessionsPage,
}) });
function SessionsPage() { function SessionsPage() {
const { data: sessions = [], refetch } = useQuery<SessionRow[]>(sessionsQuery) const { data: sessions = [], refetch } =
useQuery<SessionRow[]>(sessionsQuery);
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length const connectedCount = sessions.filter(
const pendingCount = sessions.filter(s => (s) => s.status === "CONNECTED",
s.status === 'PENDING_QR' || s.status === 'PENDING_PAIRING' || s.status === 'CONNECTING', ).length;
).length const pendingCount = sessions.filter(
(s) =>
s.status === "PENDING_QR" ||
s.status === "PENDING_PAIRING" ||
s.status === "CONNECTING",
).length;
return ( return (
<div className="min-h-full"> <div className="min-h-full">
@ -117,7 +126,9 @@ function SessionsPage() {
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4"> <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} /> <Zap size={20} className="text-gray-400" strokeWidth={1.5} />
</div> </div>
<p className="text-sm font-medium text-gray-900 mb-1">No sessions yet</p> <p className="text-sm font-medium text-gray-900 mb-1">
No sessions yet
</p>
<p className="text-[13px] text-gray-500 mb-5"> <p className="text-[13px] text-gray-500 mb-5">
Create a session to connect a WhatsApp number. Create a session to connect a WhatsApp number.
</p> </p>
@ -135,17 +146,28 @@ function SessionsPage() {
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
{/* Table header */} {/* Table header */}
<div className="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50"> <div className="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Session</span> <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">App</span> Session
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span> </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">
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Reconnects</span> App
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Status
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Last seen
</span>
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
Reconnects
</span>
</div> </div>
{/* Table rows */} {/* Table rows */}
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{sessions.map((s) => { {sessions.map((s) => {
const style = STATUS_STYLES[s.status] ?? STATUS_STYLES.DISCONNECTED const style =
STATUS_STYLES[s.status] ?? STATUS_STYLES.DISCONNECTED;
return ( return (
<Link <Link
key={s.id} key={s.id}
@ -159,14 +181,21 @@ function SessionsPage() {
</code> </code>
{/* App name */} {/* App name */}
<span className="text-[13px] text-gray-700 truncate">{s.appName}</span> <span className="text-[13px] text-gray-700 truncate">
{s.appName}
</span>
{/* Status badge */} {/* Status badge */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', style.dot)} /> <div
className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0",
style.dot,
)}
/>
<span <span
className={cn( className={cn(
'text-[11px] font-medium px-1.5 py-0.5 rounded-md border', "text-[11px] font-medium px-1.5 py-0.5 rounded-md border",
style.badge, style.badge,
)} )}
> >
@ -177,29 +206,29 @@ function SessionsPage() {
{/* Last seen */} {/* Last seen */}
<span className="text-[12px] text-gray-400"> <span className="text-[12px] text-gray-400">
{s.lastSeen {s.lastSeen
? new Date(s.lastSeen).toLocaleString('en', { ? new Date(s.lastSeen).toLocaleString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
}) })
: new Date(s.createdAt).toLocaleDateString('en', { : new Date(s.createdAt).toLocaleDateString("en", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
})} })}
</span> </span>
{/* Reconnect count */} {/* Reconnect count */}
<span className="text-[12px] text-gray-400"> <span className="text-[12px] text-gray-400">
{s.reconnectCount > 0 ? s.reconnectCount : '—'} {s.reconnectCount > 0 ? s.reconnectCount : "—"}
</span> </span>
</Link> </Link>
) );
})} })}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
) );
} }

View file

@ -1,54 +1,61 @@
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 { 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 { Button } from "#/components/ui/button";
import { Input } from '#/components/ui/input' import { Input } from "#/components/ui/input";
import { Label } from '#/components/ui/label' import { Label } from "#/components/ui/label";
import { cn } from '#/lib/utils' import { cn } from "#/lib/utils";
export const Route = createFileRoute('/sessions/new')({ export const Route = createFileRoute("/sessions/new")({
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: NewSessionPage, component: NewSessionPage,
}) });
function NewSessionPage() { function NewSessionPage() {
const { data: apps = [] } = useQuery<{ id: string; name: string }[]>(appsQuery) const { data: apps = [] } =
const router = useRouter() useQuery<{ id: string; name: string }[]>(appsQuery);
const router = useRouter();
const [appId, setAppId] = useState('') const [appId, setAppId] = useState("");
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState("");
const [via, setVia] = useState<'qr' | 'pairing'>('qr') const [via, setVia] = useState<"qr" | "pairing">("qr");
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)) 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();
const resolvedAppId = appId || apps[0]?.id const resolvedAppId = appId || apps[0]?.id;
if (!resolvedAppId || !apiKey) { if (!resolvedAppId || !apiKey) {
setError('Select an app and enter an API key') setError("Select an app and enter an API key");
return return;
} }
setError('') setError("");
setLoading(true) setLoading(true);
try { try {
const res = await fetch('/api/v1/sessions', { const res = await fetch("/api/v1/sessions", {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }, headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ connect_via: via }), body: JSON.stringify({ connect_via: via }),
}) });
const data = await res.json() const data = await res.json();
if (!res.ok) { setError(data.error ?? 'Failed to create session'); return } if (!res.ok) {
router.navigate({ to: '/sessions/$id', params: { id: data.id } }) setError(data.error ?? "Failed to create session");
return;
}
router.navigate({ to: "/sessions/$id", params: { id: data.id } });
} finally { } finally {
setLoading(false) setLoading(false);
} }
} }
@ -71,9 +78,12 @@ function NewSessionPage() {
<div className="max-w-md"> <div className="max-w-md">
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5"> <div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div> <div>
<p className="text-[13px] font-medium text-gray-900 mb-0.5">Connect WhatsApp</p> <p className="text-[13px] font-medium text-gray-900 mb-0.5">
Connect WhatsApp
</p>
<p className="text-[12px] text-gray-500"> <p className="text-[12px] text-gray-500">
Select an app, provide its API key, and choose how to authenticate. Select an app, provide its API key, and choose how to
authenticate.
</p> </p>
</div> </div>
@ -82,15 +92,17 @@ function NewSessionPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">App</Label> <Label className="text-[13px] text-zinc-700">App</Label>
<select <select
value={appId || apps[0]?.id || ''} value={appId || apps[0]?.id || ""}
onChange={e => setAppId(e.target.value)} 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" 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 ? ( {apps.length === 0 ? (
<option value="">No apps — create one first</option> <option value="">No apps — create one first</option>
) : ( ) : (
apps.map(a => ( apps.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option> <option key={a.id} value={a.id}>
{a.name}
</option>
)) ))
)} )}
</select> </select>
@ -103,7 +115,7 @@ function NewSessionPage() {
type="password" type="password"
placeholder="wak_live_…" placeholder="wak_live_…"
value={apiKey} value={apiKey}
onChange={e => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
className="h-9 text-sm font-mono" className="h-9 text-sm font-mono"
/> />
<p className="text-[11px] text-gray-400"> <p className="text-[11px] text-gray-400">
@ -113,33 +125,49 @@ function NewSessionPage() {
{/* Connection method */} {/* Connection method */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">Connection method</Label> <Label className="text-[13px] text-zinc-700">
Connection method
</Label>
<div className="grid grid-cols-2 gap-2"> <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 }) => ( 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 <button
key={value} key={value}
type="button" type="button"
onClick={() => setVia(value)} onClick={() => setVia(value)}
className={cn( className={cn(
'flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all', "flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all",
via === value via === value
? 'border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900' ? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
: 'border-gray-200 hover:border-gray-300', : "border-gray-200 hover:border-gray-300",
)} )}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Icon <Icon
size={13} size={13}
className={via === value ? 'text-zinc-900' : 'text-gray-400'} className={
via === value ? "text-zinc-900" : "text-gray-400"
}
strokeWidth={1.75} strokeWidth={1.75}
/> />
<span <span
className={cn( className={cn(
'text-[12px] font-semibold', "text-[12px] font-semibold",
via === value ? 'text-zinc-900' : 'text-gray-600', via === value ? "text-zinc-900" : "text-gray-600",
)} )}
> >
{label} {label}
@ -163,7 +191,7 @@ function NewSessionPage() {
disabled={loading || apps.length === 0} disabled={loading || apps.length === 0}
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50" className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
> >
{loading ? 'Creating…' : 'Create session'} {loading ? "Creating…" : "Create session"}
</Button> </Button>
<Button <Button
type="button" type="button"
@ -179,5 +207,5 @@ function NewSessionPage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View file

@ -1,8 +1,10 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from "@tanstack/react-start";
import { getRequest } from '@tanstack/react-start/server' import { getRequest } from "@tanstack/react-start/server";
import { readSession } from '#/lib/session-api' import { readSession } from "#/lib/session-api";
export const getSessionFn = createServerFn({ method: 'GET' }).handler(async () => { export const getSessionFn = createServerFn({ method: "GET" }).handler(
const request = getRequest() async () => {
return readSession(request) const request = getRequest();
}) return readSession(request);
},
);

View file

@ -1,11 +1,11 @@
import { initialize } from '#/lib/baileys' import { initialize } from "#/lib/baileys";
// Nitro plugins export a default function; defineNitroPlugin is an identity wrapper. // Nitro plugins export a default function; defineNitroPlugin is an identity wrapper.
export default async function () { export default async function () {
try { try {
await initialize() await initialize();
console.log('[session-startup] sessions initialized') console.log("[session-startup] sessions initialized");
} catch (err) { } catch (err) {
console.error('[session-startup] failed to initialize sessions:', err) console.error("[session-startup] failed to initialize sessions:", err);
} }
} }

View file

@ -1,8 +1,8 @@
@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 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';
@import 'tw-animate-css'; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@ -118,8 +118,8 @@
} }
@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; --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);
@ -179,7 +179,7 @@ code,
} }
.display-title { .display-title {
font-family: 'Fraunces', Georgia, serif; font-family: "Fraunces", Georgia, serif;
} }
@keyframes fade-up { @keyframes fade-up {
@ -198,8 +198,13 @@ code,
} }
@keyframes pulse-dot { @keyframes pulse-dot {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.4; } 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
} }
.animate-pulse-dot { .animate-pulse-dot {

View file

@ -1,36 +1,40 @@
import { test, expect } from '@playwright/test' import { test, expect } from "@playwright/test";
const BASE = 'http://localhost:3000' const BASE = "http://localhost:3000";
async function login(page: any) { async function login(page: any) {
await page.goto(`${BASE}/login`) await page.goto(`${BASE}/login`);
await page.getByLabel('Email').fill('test@wahoo.local') await page.getByLabel("Email").fill("test@wahoo.local");
await page.getByLabel('Password').fill('TestPassword123!') await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole('button', { name: 'Sign in' }).click() await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/apps/) await page.waitForURL(/\/apps/);
} }
test('re-navigate to /apps shows New app button', async ({ page }) => { test("re-navigate to /apps shows New app button", async ({ page }) => {
await login(page) await login(page);
// This is what createAppAndKey does // This is what createAppAndKey does
await page.goto(`${BASE}/apps`) await page.goto(`${BASE}/apps`);
// wait for dom ready, not network idle // wait for dom ready, not network idle
await page.waitForLoadState('domcontentloaded') await page.waitForLoadState("domcontentloaded");
const content = (await page.content()).slice(0, 2000) const content = (await page.content()).slice(0, 2000);
const hasBtn = content.includes('New app') const hasBtn = content.includes("New app");
const hasErr = content.includes('fetch() URL is invalid') const hasErr = content.includes("fetch() URL is invalid");
console.log('Has New app:', hasBtn, '| Has error:', hasErr) console.log("Has New app:", hasBtn, "| Has error:", hasErr);
// If button found, click and proceed // If button found, click and proceed
await expect(page.getByRole('button', { name: 'New app' })).toBeVisible({ timeout: 10000 }) await expect(page.getByRole("button", { name: "New app" })).toBeVisible({
}) timeout: 10000,
});
});
test('sessions page after login', async ({ page }) => { test("sessions page after login", async ({ page }) => {
await login(page) await login(page);
await page.goto(`${BASE}/sessions`) await page.goto(`${BASE}/sessions`);
await page.waitForLoadState('domcontentloaded') await page.waitForLoadState("domcontentloaded");
const content = (await page.content()).slice(0, 1000) const content = (await page.content()).slice(0, 1000);
const hasHeading = content.includes('Sessions') const hasHeading = content.includes("Sessions");
const hasErr = content.includes('fetch() URL is invalid') const hasErr = content.includes("fetch() URL is invalid");
console.log('Has Sessions:', hasHeading, '| Has error:', hasErr) console.log("Has Sessions:", hasHeading, "| Has error:", hasErr);
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible({ timeout: 10000 }) await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible({
}) timeout: 10000,
});
});

View file

@ -1,80 +1,85 @@
import { test, expect, type Page } from '@playwright/test' import { test, expect } from "@playwright/test";
import type {Page} from "@playwright/test";
const BASE = 'http://localhost:3000' const BASE = "http://localhost:3000";
const EMAIL = 'test@wahoo.local' const EMAIL = "test@wahoo.local";
const PASSWORD = 'TestPassword123!' const PASSWORD = "TestPassword123!";
async function login(page: Page) { async function login(page: Page) {
await page.goto(`${BASE}/login`) await page.goto(`${BASE}/login`);
await page.getByLabel('Email').fill(EMAIL) await page.getByLabel("Email").fill(EMAIL);
await page.getByLabel('Password').fill(PASSWORD) await page.getByLabel("Password").fill(PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click() await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/apps/) await page.waitForURL(/\/apps/);
} }
test('redirect / → /login when unauthenticated', async ({ page }) => { test("redirect / → /login when unauthenticated", async ({ page }) => {
await page.goto(BASE) await page.goto(BASE);
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
test('login with email + password', async ({ page }) => { test("login with email + password", async ({ page }) => {
await login(page) await login(page);
await expect(page).toHaveURL(/\/apps/) await expect(page).toHaveURL(/\/apps/);
await expect(page.getByRole('heading', { name: 'Apps' })).toBeVisible() await expect(page.getByRole("heading", { name: "Apps" })).toBeVisible();
}) });
test('create app, generate API key (shown once), revoke key', async ({ page }) => { test("create app, generate API key (shown once), revoke key", async ({
await login(page) page,
}) => {
await login(page);
// Create app // Create app
await page.getByRole('button', { name: 'New app' }).click() await page.getByRole("button", { name: "New app" }).click();
await page.getByPlaceholder('App name').fill('Test App') await page.getByPlaceholder("App name").fill("Test App");
await page.getByRole('button', { name: 'Create' }).click() await page.getByRole("button", { name: "Create" }).click();
// Navigate to app detail // Navigate to app detail
await page.getByText('Test App').click() await page.getByText("Test App").click();
await expect(page.getByRole('heading', { name: 'Test App' })).toBeVisible() await expect(page.getByRole("heading", { name: "Test App" })).toBeVisible();
// Generate API key // Generate API key
await page.getByRole('button', { name: 'Generate key' }).click() await page.getByRole("button", { name: "Generate key" }).click();
// Key shown once banner // Key shown once banner
const banner = page.locator('text=Copy this key') const banner = page.locator("text=Copy this key");
await expect(banner).toBeVisible() await expect(banner).toBeVisible();
const keyText = await page.locator('code').first().innerText() const keyText = await page.locator("code").first().innerText();
expect(keyText).toMatch(/^wak_live_/) expect(keyText).toMatch(/^wak_live_/);
// Authenticate an API request using the key // Authenticate an API request using the key
const authRes = await page.evaluate(async (key) => { const authRes = await page.evaluate(async (key) => {
const r = await fetch('/api/auth/me', { const r = await fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${key}` }, headers: { Authorization: `Bearer ${key}` },
}) });
return r.status return r.status;
}, keyText) }, keyText);
// /api/auth/me uses session not API key — just verify key is structurally valid // /api/auth/me uses session not API key — just verify key is structurally valid
expect(keyText.startsWith('wak_live_')).toBe(true) expect(keyText.startsWith("wak_live_")).toBe(true);
// Revoke key // Revoke key
page.once('dialog', (d) => d.accept()) page.once("dialog", (d) => d.accept());
await page.getByRole('button', { name: 'Revoke' }).first().click() await page.getByRole("button", { name: "Revoke" }).first().click();
await expect(page.locator('text=Revoked')).toBeVisible() await expect(page.locator("text=Revoked")).toBeVisible();
}) });
test('audit log records events', async ({ page }) => { test("audit log records events", async ({ page }) => {
await login(page) await login(page);
await page.getByRole('link', { name: 'Audit log' }).click() await page.getByRole("link", { name: "Audit log" }).click();
await expect(page).toHaveURL(`${BASE}/audit-log`) await expect(page).toHaveURL(`${BASE}/audit-log`);
// At minimum login event should exist // At minimum login event should exist
await expect(page.locator('code', { hasText: 'login' }).first()).toBeVisible() await expect(
}) page.locator("code", { hasText: "login" }).first(),
).toBeVisible();
});
test('logout clears session', async ({ page }) => { test("logout clears session", async ({ page }) => {
await login(page) await login(page);
await page.getByRole('button', { name: 'Sign out' }).click() await page.getByRole("button", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
// Navigating to protected route redirects back // Navigating to protected route redirects back
await page.goto(`${BASE}/apps/`) await page.goto(`${BASE}/apps/`);
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });

View file

@ -9,44 +9,45 @@
* and pairing code entry are not automatable; those paths are tested up to * and pairing code entry are not automatable; those paths are tested up to
* the point of waiting-for-device. * the point of waiting-for-device.
*/ */
import { test, expect, type Page } from '@playwright/test' import { test, expect } from "@playwright/test";
import type {Page} from "@playwright/test";
const BASE = 'http://localhost:3000' const BASE = "http://localhost:3000";
const EMAIL = 'test@wahoo.local' const EMAIL = "test@wahoo.local";
const PASSWORD = 'TestPassword123!' const PASSWORD = "TestPassword123!";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
async function login(page: Page) { async function login(page: Page) {
await page.goto(`${BASE}/login`) await page.goto(`${BASE}/login`);
await page.getByLabel('Email').fill(EMAIL) await page.getByLabel("Email").fill(EMAIL);
await page.getByLabel('Password').fill(PASSWORD) await page.getByLabel("Password").fill(PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click() await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/apps/) await page.waitForURL(/\/apps/);
} }
async function createAppAndKey( async function createAppAndKey(
page: Page, page: Page,
): Promise<{ appId: string; apiKey: string }> { ): Promise<{ appId: string; apiKey: string }> {
const appName = `M2TestApp-${Date.now()}` const appName = `M2TestApp-${Date.now()}`;
await page.goto(`${BASE}/apps`) await page.goto(`${BASE}/apps`);
await page.getByRole('button', { name: 'New app' }).click() await page.getByRole("button", { name: "New app" }).click();
await page.getByPlaceholder('App name').fill(appName) await page.getByPlaceholder("App name").fill(appName);
await page.getByRole('button', { name: 'Create' }).click() await page.getByRole("button", { name: "Create" }).click();
await page.getByText(appName).click() await page.getByText(appName).click();
await page.getByRole('button', { name: 'Generate key' }).click() await page.getByRole("button", { name: "Generate key" }).click();
await expect(page.locator('text=Copy this key')).toBeVisible() await expect(page.locator("text=Copy this key")).toBeVisible();
const apiKey = await page.locator('code').first().innerText() const apiKey = await page.locator("code").first().innerText();
// extract appId from URL // extract appId from URL
const appId = page.url().split('/apps/')[1].replace(/\/$/, '') const appId = page.url().split("/apps/")[1].replace(/\/$/, "");
return { appId, apiKey } return { appId, apiKey };
} }
type ApiResult = { status: number; body: Record<string, unknown> } type ApiResult = { status: number; body: Record<string, unknown> };
async function api( async function api(
page: Page, page: Page,
@ -56,297 +57,398 @@ async function api(
): Promise<ApiResult> { ): Promise<ApiResult> {
return page.evaluate( return page.evaluate(
async ({ method, url, apiKey, body }) => { async ({ method, url, apiKey, body }) => {
const headers: Record<string, string> = {} const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
if (body !== undefined) headers['Content-Type'] = 'application/json' if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers, headers,
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
}) });
return { status: res.status, body: await res.json() } return { status: res.status, body: await res.json() };
}, },
{ method, url: `${BASE}${path}`, apiKey: opts.apiKey, body: opts.body }, { method, url: `${BASE}${path}`, apiKey: opts.apiKey, body: opts.body },
) );
} }
// ─── Tests ──────────────────────────────────────────────────────────────────── // ─── Tests ────────────────────────────────────────────────────────────────────
test.describe('M2 Session Core', () => { test.describe("M2 Session Core", () => {
test('sessions page renders after login', async ({ page }) => { test("sessions page renders after login", async ({ page }) => {
await login(page) await login(page);
await page.goto(`${BASE}/sessions`) await page.goto(`${BASE}/sessions`);
await expect(page).toHaveURL(/\/sessions/) await expect(page).toHaveURL(/\/sessions/);
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible() await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible();
await expect(page.getByRole('link', { name: 'New session' })).toBeVisible() await expect(page.getByRole("link", { name: "New session" })).toBeVisible();
}) });
test('new session page renders with connect method selector', async ({ page }) => { test("new session page renders with connect method selector", async ({
await login(page) page,
await page.goto(`${BASE}/sessions/new`) }) => {
await expect(page.getByText('Connect via')).toBeVisible() await login(page);
await expect(page.getByRole('radio', { name: 'QR code' })).toBeVisible() await page.goto(`${BASE}/sessions/new`);
await expect(page.getByRole('radio', { name: 'Pairing code' })).toBeVisible() await expect(page.getByText("Connect via")).toBeVisible();
}) await expect(page.getByRole("radio", { name: "QR code" })).toBeVisible();
await expect(
page.getByRole("radio", { name: "Pairing code" }),
).toBeVisible();
});
test('POST /v1/sessions — creates session with PENDING_QR status', async ({ page }) => { test("POST /v1/sessions — creates session with PENDING_QR status", async ({
await login(page) page,
const { apiKey } = await createAppAndKey(page) }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
const { status, body } = await api(page, 'POST', '/api/v1/sessions', { const { status, body } = await api(page, "POST", "/api/v1/sessions", {
apiKey, apiKey,
body: { connect_via: 'qr' }, body: { connect_via: "qr" },
}) });
expect(status).toBe(201) expect(status).toBe(201);
expect(body.id).toBeTruthy() expect(body.id).toBeTruthy();
expect(body.status).toBe('PENDING_QR') expect(body.status).toBe("PENDING_QR");
expect(body.createdAt).toBeTruthy() expect(body.createdAt).toBeTruthy();
}) });
test('POST /v1/sessions — creates session with PENDING_PAIRING status', async ({ page }) => { test("POST /v1/sessions — creates session with PENDING_PAIRING status", async ({
await login(page) page,
const { apiKey } = await createAppAndKey(page) }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
const { status, body } = await api(page, 'POST', '/api/v1/sessions', { const { status, body } = await api(page, "POST", "/api/v1/sessions", {
apiKey, apiKey,
body: { connect_via: 'pairing' }, body: { connect_via: "pairing" },
}) });
expect(status).toBe(201) expect(status).toBe(201);
expect(body.status).toBe('PENDING_PAIRING') expect(body.status).toBe("PENDING_PAIRING");
}) });
test('GET /v1/sessions — lists sessions for authenticated app', async ({ page }) => { test("GET /v1/sessions — lists sessions for authenticated app", async ({
await login(page) page,
const { apiKey } = await createAppAndKey(page) }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
// Create two sessions // Create two sessions
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} }) await api(page, "POST", "/api/v1/sessions", { apiKey, body: {} });
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} }) await api(page, "POST", "/api/v1/sessions", { apiKey, body: {} });
const { status, body } = await api(page, 'GET', '/api/v1/sessions', { apiKey }) const { status, body } = await api(page, "GET", "/api/v1/sessions", {
expect(status).toBe(200)
expect(Array.isArray(body)).toBe(true)
expect((body as unknown as unknown[]).length).toBeGreaterThanOrEqual(2)
})
test('GET /v1/sessions/:id — returns session detail', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(status).toBe(200)
expect(body.id).toBe(sessionId)
expect(body.status).toBe('PENDING_QR')
expect(body.reconnectCount).toBe(0)
})
test('GET /v1/sessions/:id/health — returns health info', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/health`, { apiKey })
expect(status).toBe(200)
expect(body.id).toBe(sessionId)
expect(typeof body.socketAlive).toBe('boolean')
expect(typeof body.reconnectCount).toBe('number')
})
// AC1 partial — QR endpoint responds while waiting for scan (full scan→CONNECTED requires live device)
test('GET /v1/sessions/:id/qr — QR session returns 200 or 202', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey, apiKey,
body: { connect_via: 'qr' }, });
}) expect(status).toBe(200);
const sessionId = (created as { id: string }).id expect(Array.isArray(body)).toBe(true);
expect((body as unknown as unknown[]).length).toBeGreaterThanOrEqual(2);
});
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey }) test("GET /v1/sessions/:id — returns session detail", async ({ page }) => {
// 200 = QR ready (qr + qrRaw in body); 202 = waiting (waiting: true) await login(page);
expect([200, 202]).toContain(status) const { apiKey } = await createAppAndKey(page);
if (status === 200) {
expect(body.qr).toBeTruthy()
expect(body.qrRaw).toBeTruthy()
} else {
expect(body.waiting).toBe(true)
}
})
test('GET /v1/sessions/:id/qr — pairing session returns 202 while waiting', async ({ page }) => { const { body: created } = await api(page, "POST", "/api/v1/sessions", {
await login(page)
const { apiKey } = await createAppAndKey(page)
// pairing session → no QR generated
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
expect([200, 202]).toContain(status)
if (status === 202) expect(body.waiting).toBe(true)
})
// AC1 partial — DEAD/LOGGED_OUT sessions return 410 from /qr
test('GET /v1/sessions/:id/qr — 410 for LOGGED_OUT session', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
// Delete session → sets status to LOGGED_OUT
await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey })
const { status } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
expect(status).toBe(410)
})
// AC2 partial — pairing flow validation (full flow requires live device)
test('POST /v1/sessions/:id/pair — rejects missing phone', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'POST', `/api/v1/sessions/${sessionId}/pair`, {
apiKey, apiKey,
body: {}, body: {},
}) });
expect(status).toBe(400) const sessionId = (created as { id: string }).id;
expect(body.error).toMatch(/phone/)
})
test('POST /v1/sessions/:id/pair — rejects too-short phone', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'POST', `/api/v1/sessions/${sessionId}/pair`, {
apiKey,
body: { phone: '123' },
})
expect(status).toBe(400)
expect(body.error).toMatch(/phone/i)
})
test('POST /v1/sessions/:id/disconnect — disconnects active session', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api( const { status, body } = await api(
page, page,
'POST', "GET",
`/api/v1/sessions/${sessionId}/disconnect`, `/api/v1/sessions/${sessionId}`,
{ apiKey }, { apiKey },
) );
expect(status).toBe(200) expect(status).toBe(200);
expect(body.ok).toBe(true) expect(body.id).toBe(sessionId);
expect(body.status).toBe("PENDING_QR");
expect(body.reconnectCount).toBe(0);
});
// Verify status is now DISCONNECTED test("GET /v1/sessions/:id/health — returns health info", async ({
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey }) page,
expect(detail.status).toBe('DISCONNECTED') }) => {
}) await login(page);
const { apiKey } = await createAppAndKey(page);
// AC6 — session delete wipes encrypted credentials from DB const { body: created } = await api(page, "POST", "/api/v1/sessions", {
test('DELETE /v1/sessions/:id — wipes credentials and returns ok', async ({ page }) => { apiKey,
await login(page) body: {},
const { apiKey } = await createAppAndKey(page) });
const sessionId = (created as { id: string }).id;
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} }) const { status, body } = await api(
const sessionId = (created as { id: string }).id page,
"GET",
`/api/v1/sessions/${sessionId}/health`,
{ apiKey },
);
expect(status).toBe(200);
expect(body.id).toBe(sessionId);
expect(typeof body.socketAlive).toBe("boolean");
expect(typeof body.reconnectCount).toBe("number");
});
const { status, body } = await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey }) // AC1 partial — QR endpoint responds while waiting for scan (full scan→CONNECTED requires live device)
expect(status).toBe(200) test("GET /v1/sessions/:id/qr — QR session returns 200 or 202", async ({
expect(body.ok).toBe(true) page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
// Session still exists but status = LOGGED_OUT; credentials wiped const { body: created } = await api(page, "POST", "/api/v1/sessions", {
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey }) apiKey,
expect(detail.status).toBe('LOGGED_OUT') body: { connect_via: "qr" },
expect(detail.phoneNumber).toBeNull() });
expect(detail.displayName).toBeNull() const sessionId = (created as { id: string }).id;
})
test('session limit enforced — rejects beyond maxSessions', async ({ page }) => { const { status, body } = await api(
await login(page) page,
"GET",
`/api/v1/sessions/${sessionId}/qr`,
{ apiKey },
);
// 200 = QR ready (qr + qrRaw in body); 202 = waiting (waiting: true)
expect([200, 202]).toContain(status);
if (status === 200) {
expect(body.qr).toBeTruthy();
expect(body.qrRaw).toBeTruthy();
} else {
expect(body.waiting).toBe(true);
}
});
// create app with maxSessions=1 via admin API test("GET /v1/sessions/:id/qr — pairing session returns 202 while waiting", async ({
const { apiKey, appId: _appId } = await createAppAndKey(page) page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
// PATCH app to maxSessions=1 // pairing session → no QR generated
await api(page, 'PATCH', `/api/v1/admin/apps/${_appId}`, { const { body: created } = await api(page, "POST", "/api/v1/sessions", {
body: { maxSessions: 1 }, apiKey,
}) body: { connect_via: "pairing" },
});
const sessionId = (created as { id: string }).id;
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} }) const { status, body } = await api(
page,
"GET",
`/api/v1/sessions/${sessionId}/qr`,
{ apiKey },
);
expect([200, 202]).toContain(status);
if (status === 202) expect(body.waiting).toBe(true);
});
const { status, body } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} }) // AC1 partial — DEAD/LOGGED_OUT sessions return 410 from /qr
expect(status).toBe(429) test("GET /v1/sessions/:id/qr — 410 for LOGGED_OUT session", async ({
expect((body.error as string).toLowerCase()).toMatch(/limit/) page,
}) }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
test('unknown session id returns 404', async ({ page }) => { const { body: created } = await api(page, "POST", "/api/v1/sessions", {
await login(page) apiKey,
const { apiKey } = await createAppAndKey(page) body: {},
});
const sessionId = (created as { id: string }).id;
// Delete session → sets status to LOGGED_OUT
await api(page, "DELETE", `/api/v1/sessions/${sessionId}`, { apiKey });
const { status } = await api( const { status } = await api(
page, page,
'GET', "GET",
'/api/v1/sessions/00000000-0000-0000-0000-000000000000', `/api/v1/sessions/${sessionId}/qr`,
{ apiKey }, { apiKey },
) );
expect(status).toBe(404) expect(status).toBe(410);
}) });
test('missing Authorization header returns 401', async ({ page }) => { // AC2 partial — pairing flow validation (full flow requires live device)
const { status } = await api(page, 'GET', '/api/v1/sessions') test("POST /v1/sessions/:id/pair — rejects missing phone", async ({
expect(status).toBe(401) page,
}) }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
test('invalid API key returns 401', async ({ page }) => { const { body: created } = await api(page, "POST", "/api/v1/sessions", {
const { status } = await api(page, 'GET', '/api/v1/sessions', { apiKey,
apiKey: 'wak_live_invalid_key_xxxx', body: { connect_via: "pairing" },
}) });
expect(status).toBe(401) const sessionId = (created as { id: string }).id;
})
test('session detail page loads for created session', async ({ page }) => { const { status, body } = await api(
await login(page) page,
const { apiKey } = await createAppAndKey(page) "POST",
`/api/v1/sessions/${sessionId}/pair`,
{
apiKey,
body: {},
},
);
expect(status).toBe(400);
expect(body.error).toMatch(/phone/);
});
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} }) test("POST /v1/sessions/:id/pair — rejects too-short phone", async ({
const sessionId = (created as { id: string }).id page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
await page.goto(`${BASE}/sessions/${sessionId}`) const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: { connect_via: "pairing" },
});
const sessionId = (created as { id: string }).id;
const { status, body } = await api(
page,
"POST",
`/api/v1/sessions/${sessionId}/pair`,
{
apiKey,
body: { phone: "123" },
},
);
expect(status).toBe(400);
expect(body.error).toMatch(/phone/i);
});
test("POST /v1/sessions/:id/disconnect — disconnects active session", async ({
page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: {},
});
const sessionId = (created as { id: string }).id;
const { status, body } = await api(
page,
"POST",
`/api/v1/sessions/${sessionId}/disconnect`,
{ apiKey },
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
// Verify status is now DISCONNECTED
const { body: detail } = await api(
page,
"GET",
`/api/v1/sessions/${sessionId}`,
{ apiKey },
);
expect(detail.status).toBe("DISCONNECTED");
});
// AC6 — session delete wipes encrypted credentials from DB
test("DELETE /v1/sessions/:id — wipes credentials and returns ok", async ({
page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: {},
});
const sessionId = (created as { id: string }).id;
const { status, body } = await api(
page,
"DELETE",
`/api/v1/sessions/${sessionId}`,
{ apiKey },
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
// Session still exists but status = LOGGED_OUT; credentials wiped
const { body: detail } = await api(
page,
"GET",
`/api/v1/sessions/${sessionId}`,
{ apiKey },
);
expect(detail.status).toBe("LOGGED_OUT");
expect(detail.phoneNumber).toBeNull();
expect(detail.displayName).toBeNull();
});
test("session limit enforced — rejects beyond maxSessions", async ({
page,
}) => {
await login(page);
// create app with maxSessions=1 via admin API
const { apiKey, appId: _appId } = await createAppAndKey(page);
// PATCH app to maxSessions=1
await api(page, "PATCH", `/api/v1/admin/apps/${_appId}`, {
body: { maxSessions: 1 },
});
await api(page, "POST", "/api/v1/sessions", { apiKey, body: {} });
const { status, body } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: {},
});
expect(status).toBe(429);
expect((body.error as string).toLowerCase()).toMatch(/limit/);
});
test("unknown session id returns 404", async ({ page }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
const { status } = await api(
page,
"GET",
"/api/v1/sessions/00000000-0000-0000-0000-000000000000",
{ apiKey },
);
expect(status).toBe(404);
});
test("missing Authorization header returns 401", async ({ page }) => {
const { status } = await api(page, "GET", "/api/v1/sessions");
expect(status).toBe(401);
});
test("invalid API key returns 401", async ({ page }) => {
const { status } = await api(page, "GET", "/api/v1/sessions", {
apiKey: "wak_live_invalid_key_xxxx",
});
expect(status).toBe(401);
});
test("session detail page loads for created session", async ({ page }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: {},
});
const sessionId = (created as { id: string }).id;
await page.goto(`${BASE}/sessions/${sessionId}`);
// Needs API key input // Needs API key input
await expect(page.getByPlaceholder('wak_...')).toBeVisible() await expect(page.getByPlaceholder("wak_...")).toBeVisible();
await page.getByPlaceholder('wak_...').fill(apiKey) await page.getByPlaceholder("wak_...").fill(apiKey);
await page.getByRole('button', { name: 'Load' }).click() await page.getByRole("button", { name: "Load" }).click();
// Status badge should appear // Status badge should appear
await expect(page.locator('header')).toContainText('PENDING_QR') await expect(page.locator("header")).toContainText("PENDING_QR");
}) });
}) });

View file

@ -8,213 +8,238 @@
* AC4 partial — reconnect timing within 30s window * AC4 partial — reconnect timing within 30s window
* AC5 — 1h reconnect window → DEAD * AC5 — 1h reconnect window → DEAD
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ─── Reconnect backoff ──────────────────────────────────────────────────────── // ─── Reconnect backoff ────────────────────────────────────────────────────────
describe('reconnect backoff calculation', () => { describe("reconnect backoff calculation", () => {
function backoffDelay(count: number): number { function backoffDelay(count: number): number {
return Math.min(1000 * Math.pow(2, count), 60_000) return Math.min(1000 * Math.pow(2, count), 60_000);
} }
it('starts at 1s', () => expect(backoffDelay(0)).toBe(1000)) it("starts at 1s", () => expect(backoffDelay(0)).toBe(1000));
it('doubles each attempt', () => { it("doubles each attempt", () => {
expect(backoffDelay(1)).toBe(2000) expect(backoffDelay(1)).toBe(2000);
expect(backoffDelay(2)).toBe(4000) expect(backoffDelay(2)).toBe(4000);
expect(backoffDelay(3)).toBe(8000) expect(backoffDelay(3)).toBe(8000);
expect(backoffDelay(4)).toBe(16_000) expect(backoffDelay(4)).toBe(16_000);
expect(backoffDelay(5)).toBe(32_000) expect(backoffDelay(5)).toBe(32_000);
}) });
it('caps at 60s', () => { it("caps at 60s", () => {
expect(backoffDelay(6)).toBe(60_000) expect(backoffDelay(6)).toBe(60_000);
expect(backoffDelay(10)).toBe(60_000) expect(backoffDelay(10)).toBe(60_000);
expect(backoffDelay(100)).toBe(60_000) expect(backoffDelay(100)).toBe(60_000);
}) });
}) });
// ─── QR timeout ────────────────────────────────────────────────────────────── // ─── QR timeout ──────────────────────────────────────────────────────────────
describe('QR timeout window', () => { describe("QR timeout window", () => {
beforeEach(() => vi.useFakeTimers()) beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers()) afterEach(() => vi.useRealTimers());
it('fires after 5 minutes', () => { it("fires after 5 minutes", () => {
const cb = vi.fn() const cb = vi.fn();
setTimeout(cb, 5 * 60 * 1000) setTimeout(cb, 5 * 60 * 1000);
vi.advanceTimersByTime(4 * 60 * 1000 + 59_999) vi.advanceTimersByTime(4 * 60 * 1000 + 59_999);
expect(cb).not.toHaveBeenCalled() expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(1) vi.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledOnce() expect(cb).toHaveBeenCalledOnce();
}) });
it('resets timer on new QR rotation', () => { it("resets timer on new QR rotation", () => {
const cb = vi.fn() const cb = vi.fn();
let timer = setTimeout(cb, 5 * 60 * 1000) let timer = setTimeout(cb, 5 * 60 * 1000);
// At 4:00 a new QR arrives → reset timer // At 4:00 a new QR arrives → reset timer
vi.advanceTimersByTime(4 * 60 * 1000) vi.advanceTimersByTime(4 * 60 * 1000);
clearTimeout(timer) clearTimeout(timer);
timer = setTimeout(cb, 5 * 60 * 1000) timer = setTimeout(cb, 5 * 60 * 1000);
// Old deadline (t=5m) has passed but cb should not have fired // Old deadline (t=5m) has passed but cb should not have fired
vi.advanceTimersByTime(60_001) vi.advanceTimersByTime(60_001);
expect(cb).not.toHaveBeenCalled() expect(cb).not.toHaveBeenCalled();
// New deadline (t=9m) fires // New deadline (t=9m) fires
vi.advanceTimersByTime(4 * 60 * 1000) vi.advanceTimersByTime(4 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce() expect(cb).toHaveBeenCalledOnce();
}) });
}) });
// ─── AC5: 1h reconnect window → DEAD ───────────────────────────────────────── // ─── AC5: 1h reconnect window → DEAD ─────────────────────────────────────────
// Mirrors the decision in scheduleReconnect(): // Mirrors the decision in scheduleReconnect():
// if (elapsed > MAX_RECONNECT_WINDOW_MS) → setStatus('DEAD') // if (elapsed > MAX_RECONNECT_WINDOW_MS) → setStatus('DEAD')
describe('AC5: 1h reconnect window → DEAD', () => { describe("AC5: 1h reconnect window → DEAD", () => {
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000 const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000;
function shouldMarkDead(startTime: Date, now: Date): boolean { function shouldMarkDead(startTime: Date, now: Date): boolean {
return now.getTime() - startTime.getTime() > MAX_RECONNECT_WINDOW_MS return now.getTime() - startTime.getTime() > MAX_RECONNECT_WINDOW_MS;
} }
it('does not mark DEAD at 59m 59s', () => { it("does not mark DEAD at 59m 59s", () => {
const start = new Date(0) const start = new Date(0);
const now = new Date(MAX_RECONNECT_WINDOW_MS - 1) const now = new Date(MAX_RECONNECT_WINDOW_MS - 1);
expect(shouldMarkDead(start, now)).toBe(false) expect(shouldMarkDead(start, now)).toBe(false);
}) });
it('marks DEAD when elapsed exceeds 1h', () => { it("marks DEAD when elapsed exceeds 1h", () => {
const start = new Date(0) const start = new Date(0);
const now = new Date(MAX_RECONNECT_WINDOW_MS + 1) const now = new Date(MAX_RECONNECT_WINDOW_MS + 1);
expect(shouldMarkDead(start, now)).toBe(true) expect(shouldMarkDead(start, now)).toBe(true);
}) });
it('marks DEAD after multiple hours', () => { it("marks DEAD after multiple hours", () => {
const start = new Date(0) const start = new Date(0);
const now = new Date(3 * MAX_RECONNECT_WINDOW_MS) const now = new Date(3 * MAX_RECONNECT_WINDOW_MS);
expect(shouldMarkDead(start, now)).toBe(true) expect(shouldMarkDead(start, now)).toBe(true);
}) });
it('marks DEAD using timer with fake timers', async () => { it("marks DEAD using timer with fake timers", async () => {
vi.useFakeTimers() vi.useFakeTimers();
const cb = vi.fn() const cb = vi.fn();
// Simulate the 1h window expiring on the next reconnect attempt // Simulate the 1h window expiring on the next reconnect attempt
setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1) setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1);
vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS) vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS);
expect(cb).not.toHaveBeenCalled() expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(2) vi.advanceTimersByTime(2);
expect(cb).toHaveBeenCalledOnce() expect(cb).toHaveBeenCalledOnce();
vi.useRealTimers() vi.useRealTimers();
}) });
}) });
// ─── AC3: initialize() — resumable status set ──────────────────────────────── // ─── AC3: initialize() — resumable status set ────────────────────────────────
// initialize() queries sessions with status in ['CONNECTED', 'DISCONNECTED', 'CONNECTING'] // initialize() queries sessions with status in ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
// and calls startSession() for each. Tests verify which statuses are resumed. // and calls startSession() for each. Tests verify which statuses are resumed.
describe('AC3: session resume on restart', () => { describe("AC3: session resume on restart", () => {
const RESUMABLE = ['CONNECTED', 'DISCONNECTED', 'CONNECTING'] const RESUMABLE = ["CONNECTED", "DISCONNECTED", "CONNECTING"];
const TERMINAL = ['LOGGED_OUT', 'DEAD'] const TERMINAL = ["LOGGED_OUT", "DEAD"];
const PENDING = ['PENDING_QR', 'PENDING_PAIRING'] const PENDING = ["PENDING_QR", "PENDING_PAIRING"];
it('resumes CONNECTED sessions', () => expect(RESUMABLE).toContain('CONNECTED')) it("resumes CONNECTED sessions", () =>
it('resumes DISCONNECTED sessions', () => expect(RESUMABLE).toContain('DISCONNECTED')) expect(RESUMABLE).toContain("CONNECTED"));
it('resumes CONNECTING sessions', () => expect(RESUMABLE).toContain('CONNECTING')) it("resumes DISCONNECTED sessions", () =>
it('does not resume DEAD sessions', () => expect(RESUMABLE).not.toContain('DEAD')) expect(RESUMABLE).toContain("DISCONNECTED"));
it('does not resume LOGGED_OUT sessions', () => expect(RESUMABLE).not.toContain('LOGGED_OUT')) it("resumes CONNECTING sessions", () =>
it('does not resume PENDING_QR sessions (re-pair required)', () => expect(RESUMABLE).not.toContain('PENDING_QR')) expect(RESUMABLE).toContain("CONNECTING"));
it('does not resume PENDING_PAIRING sessions', () => expect(RESUMABLE).not.toContain('PENDING_PAIRING')) it("does not resume DEAD sessions", () =>
expect(RESUMABLE).not.toContain("DEAD"));
it("does not resume LOGGED_OUT sessions", () =>
expect(RESUMABLE).not.toContain("LOGGED_OUT"));
it("does not resume PENDING_QR sessions (re-pair required)", () =>
expect(RESUMABLE).not.toContain("PENDING_QR"));
it("does not resume PENDING_PAIRING sessions", () =>
expect(RESUMABLE).not.toContain("PENDING_PAIRING"));
it('terminal + pending statuses are not in resumable set', () => { it("terminal + pending statuses are not in resumable set", () => {
for (const s of [...TERMINAL, ...PENDING]) { for (const s of [...TERMINAL, ...PENDING]) {
expect(RESUMABLE).not.toContain(s) expect(RESUMABLE).not.toContain(s);
} }
}) });
}) });
// ─── AC4: auto-reconnect timing ────────────────────────────────────────────── // ─── AC4: auto-reconnect timing ──────────────────────────────────────────────
// Kill network for 30s → should reconnect. Backoff starts at 1s, so first // Kill network for 30s → should reconnect. Backoff starts at 1s, so first
// several attempts all fall within a 30s window before 30s has elapsed. // several attempts all fall within a 30s window before 30s has elapsed.
describe('AC4: auto-reconnect within 30s window', () => { describe("AC4: auto-reconnect within 30s window", () => {
function backoffDelay(count: number): number { function backoffDelay(count: number): number {
return Math.min(1000 * Math.pow(2, count), 60_000) return Math.min(1000 * Math.pow(2, count), 60_000);
} }
it('first reconnect attempt at 1s (well within 30s)', () => { it("first reconnect attempt at 1s (well within 30s)", () => {
expect(backoffDelay(0)).toBe(1_000) expect(backoffDelay(0)).toBe(1_000);
expect(backoffDelay(0)).toBeLessThan(30_000) expect(backoffDelay(0)).toBeLessThan(30_000);
}) });
it('cumulative delay after 4 attempts still < 30s', () => { it("cumulative delay after 4 attempts still < 30s", () => {
// 1s + 2s + 4s + 8s = 15s — all within 30s window // 1s + 2s + 4s + 8s = 15s — all within 30s window
const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0) const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0);
expect(total).toBeLessThan(30_000) expect(total).toBeLessThan(30_000);
}) });
it('5th attempt (16s) also within 30s', () => { it("5th attempt (16s) also within 30s", () => {
expect(backoffDelay(4)).toBeLessThanOrEqual(30_000) expect(backoffDelay(4)).toBeLessThanOrEqual(30_000);
}) });
}) });
// ─── AC1 partial: QR session initial state ─────────────────────────────────── // ─── AC1 partial: QR session initial state ───────────────────────────────────
// Full AC1 (scan → CONNECTED) requires live WhatsApp device. // Full AC1 (scan → CONNECTED) requires live WhatsApp device.
// We test the QR code memory store returns null for unknown sessions. // We test the QR code memory store returns null for unknown sessions.
describe('AC1 partial: QR code in-memory store', () => { describe("AC1 partial: QR code in-memory store", () => {
it('getQrCode returns null for session not in active map', () => { it("getQrCode returns null for session not in active map", () => {
// Mirrors active.get(sessionId)?.qrCode ?? null // Mirrors active.get(sessionId)?.qrCode ?? null
const active = new Map<string, { qrCode: string | null }>() const active = new Map<string, { qrCode: string | null }>();
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
expect(getQrCode('nonexistent')).toBeNull() expect(getQrCode("nonexistent")).toBeNull();
}) });
it('getQrCode returns stored QR string', () => { it("getQrCode returns stored QR string", () => {
const active = new Map<string, { qrCode: string | null }>() const active = new Map<string, { qrCode: string | null }>();
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
active.set('sess-1', { qrCode: '2@abc123,xyz,def' }) active.set("sess-1", { qrCode: "2@abc123,xyz,def" });
expect(getQrCode('sess-1')).toBe('2@abc123,xyz,def') expect(getQrCode("sess-1")).toBe("2@abc123,xyz,def");
}) });
it('QR string clears to null after CONNECTED', () => { it("QR string clears to null after CONNECTED", () => {
const active = new Map<string, { qrCode: string | null }>() const active = new Map<string, { qrCode: string | null }>();
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
active.set('sess-1', { qrCode: '2@abc123' }) active.set("sess-1", { qrCode: "2@abc123" });
expect(getQrCode('sess-1')).not.toBeNull() expect(getQrCode("sess-1")).not.toBeNull();
active.get('sess-1')!.qrCode = null // mirrors connection='open' handler active.get("sess-1")!.qrCode = null; // mirrors connection='open' handler
expect(getQrCode('sess-1')).toBeNull() expect(getQrCode("sess-1")).toBeNull();
}) });
}) });
// ─── Status enum ───────────────────────────────────────────────────────────── // ─── Status enum ─────────────────────────────────────────────────────────────
describe('session status values', () => { describe("session status values", () => {
const VALID = ['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD'] const VALID = [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD",
];
it('contains all 7 expected statuses', () => { it("contains all 7 expected statuses", () => {
expect(VALID).toHaveLength(7) expect(VALID).toHaveLength(7);
}) });
it('terminal statuses are LOGGED_OUT and DEAD', () => { it("terminal statuses are LOGGED_OUT and DEAD", () => {
const terminal = VALID.filter((s) => s === 'LOGGED_OUT' || s === 'DEAD') const terminal = VALID.filter((s) => s === "LOGGED_OUT" || s === "DEAD");
expect(terminal).toEqual(['LOGGED_OUT', 'DEAD']) expect(terminal).toEqual(["LOGGED_OUT", "DEAD"]);
}) });
it('active statuses exclude terminal', () => { it("active statuses exclude terminal", () => {
const active = VALID.filter((s) => !['LOGGED_OUT', 'DEAD'].includes(s)) const active = VALID.filter((s) => !["LOGGED_OUT", "DEAD"].includes(s));
expect(active).toEqual(['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED']) expect(active).toEqual([
}) "PENDING_QR",
}) "PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
]);
});
});
// ─── Phone number sanitization ──────────────────────────────────────────────── // ─── Phone number sanitization ────────────────────────────────────────────────
describe('phone number digit extraction', () => { describe("phone number digit extraction", () => {
function digitsOnly(phone: string): string { function digitsOnly(phone: string): string {
return phone.replace(/\D/g, '') return phone.replace(/\D/g, "");
} }
it('strips + and spaces', () => expect(digitsOnly('+1 800 555 1234')).toBe('18005551234')) it("strips + and spaces", () =>
it('strips dashes', () => expect(digitsOnly('+44-20-7946-0958')).toBe('442079460958')) expect(digitsOnly("+1 800 555 1234")).toBe("18005551234"));
it('leaves plain digits untouched', () => expect(digitsOnly('12345678901')).toBe('12345678901')) it("strips dashes", () =>
it('rejects too-short numbers', () => expect(digitsOnly('123').length).toBeLessThan(7)) expect(digitsOnly("+44-20-7946-0958")).toBe("442079460958"));
}) it("leaves plain digits untouched", () =>
expect(digitsOnly("12345678901")).toBe("12345678901"));
it("rejects too-short numbers", () =>
expect(digitsOnly("123").length).toBeLessThan(7));
});

View file

@ -1,5 +1,11 @@
{ {
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"], "include": [
"**/*.ts",
"**/*.tsx",
"eslint.config.js",
"prettier.config.js",
"vite.config.js"
],
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",

View file

@ -1,25 +1,25 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import { devtools } from '@tanstack/devtools-vite' import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from '@vitejs/plugin-react' import viteReact from "@vitejs/plugin-react";
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite";
import { nitro } from 'nitro/vite' import { nitro } from "nitro/vite";
const config = defineConfig({ const config = defineConfig({
resolve: { tsconfigPaths: true }, resolve: { tsconfigPaths: true },
plugins: [ plugins: [
devtools(), devtools(),
nitro({ nitro({
preset: 'node', preset: "node",
rollupConfig: { external: [/^@sentry\//] }, rollupConfig: { external: [/^@sentry\//] },
plugins: ['./src/server/plugins/session-startup.ts'], plugins: ["./src/server/plugins/session-startup.ts"],
}), }),
tailwindcss(), tailwindcss(),
tanstackStart(), tanstackStart(),
viteReact(), viteReact(),
], ],
}) });
export default config export default config;