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,
"version": 1,
"framework": "react",
"chosenAddOns": [
"eslint",
"nitro",
"shadcn",
"drizzle",
"table"
]
"chosenAddOns": ["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/`.
**Before starting any session:**
1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns.
2. Open the relevant milestone file (e.g. `M1-foundations.md`).
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.
**Status conventions in milestone files:**
- `⬜` ready to start
- `🟡` concern / needs decision
- `🔴` blocked
@ -44,13 +46,13 @@ Milestone-by-milestone plan lives in `.playground/plan/`.
Project docs live in `docs/`:
| File | Contents |
|------|----------|
| `docs/architecture.md` | System overview, component map, key files |
| `docs/api.md` | REST endpoint reference |
| `docs/webhooks.md` | Event catalog, HMAC verification snippets |
| `docs/security.md` | Encryption model, key rotation, audit log |
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
| File | Contents |
| ---------------------- | ----------------------------------------------- |
| `docs/architecture.md` | System overview, component map, key files |
| `docs/api.md` | REST endpoint reference |
| `docs/webhooks.md` | Event catalog, HMAC verification snippets |
| `docs/security.md` | Encryption model, key rotation, audit log |
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
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`).
| File | Domain |
|------|--------|
| `actions/apps.ts` | App CRUD |
| `actions/api-keys.ts` | API key lifecycle |
| `actions/sessions.ts` | Session lifecycle |
| `actions/auth.ts` | User lookup, TOTP |
| File | Domain |
| ---------------------- | ----------------- |
| `actions/apps.ts` | App CRUD |
| `actions/api-keys.ts` | API key lifecycle |
| `actions/sessions.ts` | Session lifecycle |
| `actions/auth.ts` | User lookup, TOTP |
| `actions/audit-log.ts` | Audit log queries |
**Scope pattern** — when a function needs different authorization checks (user-owned vs app-scoped), use a discriminated union scope parameter instead of separate functions:
```ts
type Scope = { appId: string } | { userId: string }
export async function findSession(sessionId: string, scope: Scope): Promise<Session | null>
type Scope = { appId: string } | { userId: string };
export async function findSession(
sessionId: string,
scope: Scope,
): Promise<Session | null>;
```
**Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
```ts
export async function createSession(appId: string, via: 'qr' | 'pairing', ownerUserId?: string)
export async function createSession(
appId: string,
via: "qr" | "pairing",
ownerUserId?: string,
);
// admin: createSession(appId, via, sess.userId)
// 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:
```ts
return { ok: false, error: 'App not found', status: 404 }
return { ok: true, session }
return { ok: false, error: "App not found", status: 404 };
return { ok: true, session };
// 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
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
@ -49,7 +48,6 @@ bun --bun run format
bun --bun run check
```
## Deploy with Nitro
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.
## Shadcn
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
```
## Routing
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:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "My App" },
],
}),
shellComponent: ({ children }) => (
@ -138,7 +133,7 @@ export const Route = createRootRoute({
</body>
</html>
),
})
});
```
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.
```tsx
import { createServerFn } from '@tanstack/react-start'
import { createServerFn } from "@tanstack/react-start";
const getServerTime = createServerFn({
method: 'GET',
method: "GET",
}).handler(async () => {
return new Date().toISOString()
})
return new Date().toISOString();
});
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
const [time, setTime] = useState("");
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:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { createFileRoute } from "@tanstack/react-router";
import { json } from "@tanstack/react-start";
export const Route = createFileRoute('/api/hello')({
export const Route = createFileRoute("/api/hello")({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
GET: () => json({ message: "Hello, World!" }),
},
},
})
});
```
## Data Fetching
@ -192,25 +187,25 @@ There are multiple ways to fetch data in your application. You can use TanStack
For example:
```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 () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
const response = await fetch("https://swapi.dev/api/people");
return response.json();
},
component: PeopleComponent,
})
});
function PeopleComponent() {
const data = Route.useLoaderData()
const data = Route.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
);
}
```

View file

@ -18,7 +18,13 @@ services:
redis:
condition: service_healthy
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
timeout: 5s
retries: 5

View file

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

View file

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

View file

@ -1,20 +1,26 @@
import { config } from 'dotenv'
import { defineConfig } from 'drizzle-kit'
import { config } from "dotenv";
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({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
host: POSTGRES_HOST ?? 'localhost',
host: POSTGRES_HOST ?? "localhost",
port: Number(POSTGRES_PORT ?? 5432),
user: POSTGRES_USER ?? 'wahoo',
password: POSTGRES_PASSWORD ?? 'wahoo',
database: POSTGRES_DB ?? 'wahoo',
user: POSTGRES_USER ?? "wahoo",
password: POSTGRES_PASSWORD ?? "wahoo",
database: POSTGRES_DB ?? "wahoo",
ssl: false,
},
})
});

View file

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

View file

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

View file

@ -1,20 +1,20 @@
// @ts-check
import { tanstackConfig } from '@tanstack/eslint-config'
import { tanstackConfig } from "@tanstack/eslint-config";
export default [
...tanstackConfig,
{
rules: {
'import/no-cycle': 'off',
'import/order': 'off',
'sort-imports': 'off',
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/require-await': 'off',
'pnpm/json-enforce-catalog': 'off',
"import/no-cycle": "off",
"import/order": "off",
"sort-imports": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/require-await": "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({
testDir: './tests',
testDir: "./tests",
timeout: 30000,
retries: 0,
reporter: 'list',
reporter: "list",
use: {
baseURL: 'http://localhost:3000',
baseURL: "http://localhost:3000",
headless: true,
channel: 'chrome',
channel: "chrome",
},
projects: [
{
name: 'chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
name: "chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" },
},
],
})
});

View file

@ -1,24 +1,21 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { users } from '../src/db/schema.ts'
import argon2 from 'argon2'
import { config } from 'dotenv'
import { drizzle } from "drizzle-orm/node-postgres";
import { users } from "../src/db/schema.ts";
import argon2 from "argon2";
import { config } from "dotenv";
config({ path: ['.env.local', '.env'] })
config({ path: [".env.local", ".env"] });
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 } },
)
);
const email = 'test@wahoo.local'
const password = 'TestPassword123!'
const email = "test@wahoo.local";
const password = "TestPassword123!";
const passwordHash = await argon2.hash(password, { type: argon2.argon2id })
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
await db
.insert(users)
.values({ email, passwordHash })
.onConflictDoNothing()
await db.insert(users).values({ email, passwordHash }).onConflictDoNothing();
console.log(`Seeded user: ${email} / ${password}`)
process.exit(0)
console.log(`Seeded user: ${email} / ${password}`);
process.exit(0);

View file

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

View file

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

View file

@ -1,8 +1,9 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva } from "class-variance-authority";
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(
"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",
size: "default",
},
}
)
},
);
function Button({
className,
@ -46,9 +47,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@ -58,7 +59,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...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">) {
return (
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
export {
@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View file

@ -1,34 +1,34 @@
"use client"
"use client";
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts"
import { Button } from "#/components/ui/button.tsx"
import { cn } from "#/lib/utils.ts";
import { Button } from "#/components/ui/button.tsx";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@ -40,11 +40,11 @@ function DialogOverlay({
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@ -53,7 +53,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@ -62,7 +62,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
className,
)}
{...props}
>
@ -78,7 +78,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
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)}
{...props}
/>
)
);
}
function DialogFooter({
@ -97,14 +97,14 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
>
@ -115,7 +115,7 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({
@ -128,7 +128,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@ -141,7 +141,7 @@ function DialogDescription({
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
@ -155,4 +155,4 @@ export {
DialogPortal,
DialogTitle,
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">) {
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",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };

View file

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

View file

@ -1,27 +1,27 @@
"use client"
"use client";
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
@ -30,7 +30,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@ -38,7 +38,7 @@ function SelectTrigger({
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
className,
)}
{...props}
>
@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
align={align}
@ -76,7 +76,7 @@ function SelectContent({
className={cn(
"p-1",
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}
@ -84,7 +84,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@ -97,7 +97,7 @@ function SelectLabel({
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
);
}
function SelectItem({
@ -110,7 +110,7 @@ function SelectItem({
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
className,
)}
{...props}
>
@ -124,7 +124,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@ -137,7 +137,7 @@ function SelectSeparator({
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@ -149,13 +149,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@ -167,13 +167,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@ -187,4 +187,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};

View file

@ -1,7 +1,7 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Separator({
className,
@ -16,11 +16,11 @@ function Separator({
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
className,
)}
{...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">) {
return (
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
className,
)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
className,
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
className,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
className,
)}
{...props}
/>
)
);
}
function TableCaption({
@ -99,7 +99,7 @@ function TableCaption({
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
@ -111,4 +111,4 @@ export {
TableRow,
TableCell,
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 {
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_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}`
const {
POSTGRES_USER,
POSTGRES_PASSWORD,
POSTGRES_HOST,
POSTGRES_PORT,
POSTGRES_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,
unique,
uuid,
} from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
} from "drizzle-orm/pg-core";
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', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
totpSecret: text('totp_secret'),
totpEnabled: boolean('totp_enabled').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
totpSecret: text("totp_secret"),
totpEnabled: boolean("totp_enabled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const apps = pgTable('apps', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
ownerUserId: uuid('owner_user_id')
export const apps = pgTable("apps", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
ownerUserId: uuid("owner_user_id")
.notNull()
.references(() => users.id),
dekEncrypted: text('dek_encrypted').notNull(),
maxSessions: text('max_sessions').notNull().default('10'),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
dekEncrypted: text("dek_encrypted").notNull(),
maxSessions: text("max_sessions").notNull().default("10"),
deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const apiKeys = pgTable(
'api_keys',
"api_keys",
{
id: uuid('id').primaryKey().defaultRandom(),
appId: uuid('app_id')
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
keyHash: text('key_hash').notNull(),
keyPrefix: text('key_prefix').notNull(),
label: text('label').notNull().default(''),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(),
label: text("label").notNull().default(""),
revokedAt: timestamp("revoked_at"),
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 }) => ({
apps: many(apps),
}))
}));
export const appsRelations = relations(apps, ({ one, many }) => ({
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
apiKeys: many(apiKeys),
sessions: many(sessions),
}))
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
}))
}));
export const sessionStatusEnum = pgEnum('session_status', [
'PENDING_QR',
'PENDING_PAIRING',
'CONNECTING',
'CONNECTED',
'DISCONNECTED',
'LOGGED_OUT',
'DEAD',
])
export const sessionStatusEnum = pgEnum("session_status", [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD",
]);
export const sessions = pgTable(
'sessions',
"sessions",
{
id: uuid('id').primaryKey().defaultRandom(),
appId: uuid('app_id')
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
status: sessionStatusEnum('status').notNull().default('PENDING_QR'),
phoneNumber: text('phone_number'),
displayName: text('display_name'),
credentialsEncrypted: text('credentials_encrypted'),
reconnectCount: integer('reconnect_count').notNull().default(0),
lastSeen: timestamp('last_seen'),
createdAt: timestamp('created_at').defaultNow().notNull(),
status: sessionStatusEnum("status").notNull().default("PENDING_QR"),
phoneNumber: text("phone_number"),
displayName: text("display_name"),
credentialsEncrypted: text("credentials_encrypted"),
reconnectCount: integer("reconnect_count").notNull().default(0),
lastSeen: timestamp("last_seen"),
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(
'session_keys',
"session_keys",
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: uuid('session_id')
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id")
.notNull()
.references(() => sessions.id, { onDelete: 'cascade' }),
keyType: text('key_type').notNull(),
keyId: text('key_id').notNull(),
valueEncrypted: text('value_encrypted').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
.references(() => sessions.id, { onDelete: "cascade" }),
keyType: text("key_type").notNull(),
keyId: text("key_id").notNull(),
valueEncrypted: text("value_encrypted").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 }) => ({
app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
keys: many(sessionKeys),
}))
}));
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(
'audit_log',
"audit_log",
{
id: uuid('id').primaryKey().defaultRandom(),
actorType: actorTypeEnum('actor_type').notNull(),
actorId: uuid('actor_id').notNull(),
action: text('action').notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
ip: text('ip'),
userAgent: text('user_agent'),
diffBefore: jsonb('diff_before'),
diffAfter: jsonb('diff_after'),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: uuid("id").primaryKey().defaultRandom(),
actorType: actorTypeEnum("actor_type").notNull(),
actorId: uuid("actor_id").notNull(),
action: text("action").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id"),
ip: text("ip"),
userAgent: text("user_agent"),
diffBefore: jsonb("diff_before"),
diffAfter: jsonb("diff_after"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [
index('audit_log_actor_idx').on(t.actorType, t.actorId),
index('audit_log_action_idx').on(t.action),
index('audit_log_created_at_idx').on(t.createdAt),
index("audit_log_actor_idx").on(t.actorType, t.actorId),
index("audit_log_action_idx").on(t.action),
index("audit_log_created_at_idx").on(t.createdAt),
],
)
);

View file

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

View file

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

View file

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

View file

@ -1,41 +1,60 @@
import { eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { users } from '#/db/schema'
import { generateTotpSecret, getTotpUri, verifyTotp } from '#/lib/totp'
import { eq } from "drizzle-orm";
import { db } from "#/db/index";
import { users } from "#/db/schema";
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
export async function findUserByEmail(email: string) {
return (await db.query.users.findFirst({ where: eq(users.email, email.toLowerCase()) })) ?? null
return (
(await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
})) ?? null
);
}
export async function findUserById(userId: string) {
return (await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
return (
(await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
);
}
export async function enrollTotpSecret(
userId: string,
): Promise<{ ok: true; secret: string; uri: string } | { ok: false; error: string; status: number }> {
const user = await findUserById(userId)
if (!user) return { ok: false, error: 'User not found', status: 404 }
if (user.totpEnabled) return { ok: false, error: 'TOTP already enabled', status: 409 }
): Promise<
| { ok: true; secret: string; uri: string }
| { ok: false; error: string; status: number }
> {
const user = await findUserById(userId);
if (!user) return { ok: false, error: "User not found", status: 404 };
if (user.totpEnabled)
return { ok: false, error: "TOTP already enabled", status: 409 };
const secret = generateTotpSecret()
await db.update(users).set({ totpSecret: secret }).where(eq(users.id, userId))
return { ok: true, secret, uri: getTotpUri(user.email, secret) }
const secret = generateTotpSecret();
await db
.update(users)
.set({ totpSecret: secret })
.where(eq(users.id, userId));
return { ok: true, secret, uri: getTotpUri(user.email, secret) };
}
export async function confirmTotpEnrollment(
userId: string,
token: string,
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const user = await findUserById(userId)
if (!user?.totpSecret) return { ok: false, error: 'Enroll first', status: 400 }
if (user.totpEnabled) return { ok: false, error: 'TOTP already enabled', status: 409 }
if (!verifyTotp(user.totpSecret, token)) return { ok: false, error: 'Invalid token', status: 401 }
const user = await findUserById(userId);
if (!user?.totpSecret)
return { ok: false, error: "Enroll first", status: 400 };
if (user.totpEnabled)
return { ok: false, error: "TOTP already enabled", status: 409 };
if (!verifyTotp(user.totpSecret, token))
return { ok: false, error: "Invalid token", status: 401 };
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, userId))
return { ok: true }
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, userId));
return { ok: true };
}
export async function disableTotp(userId: string): Promise<void> {
await db.update(users).set({ totpEnabled: false, totpSecret: null }).where(eq(users.id, userId))
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 { db } from '#/db/index'
import { apps, sessions, sessionKeys } from '#/db/schema'
import { startSession, stopSession, emitWebhook } from '#/lib/baileys'
import { and, eq, inArray, isNull } from "drizzle-orm";
import { db } from "#/db/index";
import { apps, sessions, sessionKeys } from "#/db/schema";
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
type Session = typeof sessions.$inferSelect
type SessionStatus = Session['status']
type Session = typeof sessions.$inferSelect;
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> {
if ('appId' in scope) {
return (await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
})) ?? null
export async function findSession(
sessionId: string,
scope: SessionScope,
): Promise<Session | null> {
if ("appId" in scope) {
return (
(await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
})) ?? null
);
}
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, scope.userId), isNull(apps.deletedAt)),
columns: { id: true },
})
if (userApps.length === 0) return null
const appIds = userApps.map((a) => a.id)
return (await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
})) ?? null
});
if (userApps.length === 0) return null;
const appIds = userApps.map((a) => a.id);
return (
(await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
})) ?? null
);
}
export async function listUserSessions(userId: string) {
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
columns: { id: true, name: true },
})
if (userApps.length === 0) return []
});
if (userApps.length === 0) return [];
const appIds = userApps.map((a) => a.id)
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]))
const appIds = userApps.map((a) => a.id);
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]));
const rows = await db.query.sessions.findMany({
where: (s, { inArray: _inArray }) => _inArray(s.appId, appIds),
columns: { id: true, appId: true, status: true, reconnectCount: true, lastSeen: true, createdAt: true },
columns: {
id: true,
appId: true,
status: true,
reconnectCount: true,
lastSeen: true,
createdAt: true,
},
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
});
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || '' }))
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || "" }));
}
export async function listAppSessions(appId: string) {
return db.query.sessions.findMany({
where: eq(sessions.appId, appId),
columns: { id: true, status: true, lastSeen: true, createdAt: true, reconnectCount: true },
})
columns: {
id: true,
status: true,
lastSeen: true,
createdAt: true,
reconnectCount: true,
},
});
}
export async function createSession(
appId: string,
via: 'qr' | 'pairing',
via: "qr" | "pairing",
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
? and(eq(apps.id, appId), eq(apps.ownerUserId, ownerUserId), isNull(apps.deletedAt))
: and(eq(apps.id, appId), isNull(apps.deletedAt))
? and(
eq(apps.id, appId),
eq(apps.ownerUserId, ownerUserId),
isNull(apps.deletedAt),
)
: and(eq(apps.id, appId), isNull(apps.deletedAt));
const app = await db.query.apps.findFirst({ where: appWhere, columns: { id: true, maxSessions: true } })
if (!app) return { ok: false, error: 'App not found', status: 404 }
const app = await db.query.apps.findFirst({
where: appWhere,
columns: { id: true, maxSessions: true },
});
if (!app) return { ok: false, error: "App not found", status: 404 };
const existing = await db.query.sessions.findMany({
where: eq(sessions.appId, appId),
columns: { status: true },
})
const activeCount = existing.filter((s) => (ACTIVE_STATUSES as readonly string[]).includes(s.status)).length
});
const activeCount = existing.filter((s) =>
(ACTIVE_STATUSES as readonly string[]).includes(s.status),
).length;
if (activeCount >= Number(app.maxSessions)) {
return { ok: false, error: `Session limit reached (max ${app.maxSessions})`, status: 429 }
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
.insert(sessions)
.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> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId))
export async function setSessionStatus(
sessionId: string,
status: SessionStatus,
): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
}
export async function disconnectSession(sessionId: string, appId: string): Promise<void> {
await stopSession(sessionId)
await setSessionStatus(sessionId, 'DISCONNECTED')
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' })
export async function disconnectSession(
sessionId: string,
appId: string,
): Promise<void> {
await stopSession(sessionId);
await setSessionStatus(sessionId, "DISCONNECTED");
emitWebhook(appId, "session.status", { sessionId, status: "DISCONNECTED" });
}
export async function deleteSession(sessionId: string, appId: string): Promise<void> {
await stopSession(sessionId)
await db.delete(sessions).where(eq(sessions.id, sessionId))
emitWebhook(appId, 'session.deleted', { sessionId })
export async function deleteSession(
sessionId: string,
appId: string,
): Promise<void> {
await stopSession(sessionId);
await db.delete(sessions).where(eq(sessions.id, sessionId));
emitWebhook(appId, "session.deleted", { sessionId });
}
export async function logoutAppSession(sessionId: string, appId: string): Promise<void> {
await stopSession(sessionId)
export async function logoutAppSession(
sessionId: string,
appId: string,
): Promise<void> {
await stopSession(sessionId);
await db.transaction(async (tx) => {
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId))
await tx.update(sessions).set({
status: 'LOGGED_OUT',
credentialsEncrypted: null,
phoneNumber: null,
displayName: null,
}).where(eq(sessions.id, sessionId))
})
emitWebhook(appId, 'session.deleted', { sessionId })
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
await tx
.update(sessions)
.set({
status: "LOGGED_OUT",
credentialsEncrypted: null,
phoneNumber: null,
displayName: null,
})
.where(eq(sessions.id, sessionId));
});
emitWebhook(appId, "session.deleted", { sessionId });
}

View file

@ -1,33 +1,36 @@
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apiKeys } from '#/db/schema'
import { verifyApiKey } from './api-keys'
import { jsonError } from './session-api'
import { and, eq, isNull } from "drizzle-orm";
import { db } from "#/db/index";
import { apiKeys } from "#/db/schema";
import { verifyApiKey } from "./api-keys";
import { jsonError } from "./session-api";
export interface AuthedRequest {
appId: string
keyId: string
appId: string;
keyId: string;
}
export async function authenticateApiKey(request: Request): Promise<AuthedRequest | Response> {
const auth = request.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return jsonError('Missing Authorization header', 401)
export async function authenticateApiKey(
request: Request,
): 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()
if (!key.startsWith('wak_')) return jsonError('Invalid API key format', 401)
const key = auth.slice(7).trim();
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({
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
with: { app: { columns: { id: true, deletedAt: true } } },
})
});
for (const candidate of candidates) {
if (candidate.app?.deletedAt) continue
const valid = await verifyApiKey(key, candidate.keyHash)
if (valid) return { appId: candidate.appId, keyId: candidate.id }
if (candidate.app?.deletedAt) continue;
const valid = await verifyApiKey(key, candidate.keyHash);
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 argon2 from 'argon2'
import { randomBytes } from "node:crypto";
import argon2 from "argon2";
export type ApiKeyEnv = 'live' | 'test'
export type ApiKeyEnv = "live" | "test";
export function generateApiKey(env: ApiKeyEnv = 'live'): { key: string; prefix: string } {
const rand = randomBytes(24).toString('base64url')
const key = `wak_${env}_${rand}`
const prefix = key.slice(-4)
return { key, prefix }
export function generateApiKey(env: ApiKeyEnv = "live"): {
key: string;
prefix: string;
} {
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> {
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 {
return await argon2.verify(hash, key)
return await argon2.verify(hash, key);
} catch {
return false
return false;
}
}

View file

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

View file

@ -1,9 +1,18 @@
import { and, eq, inArray } from 'drizzle-orm'
import { BufferJSON, initAuthCreds, makeCacheableSignalKeyStore } 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'
import { and, eq, inArray } from "drizzle-orm";
import {
BufferJSON,
initAuthCreds,
makeCacheableSignalKeyStore,
} 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(
sessionId: string,
@ -12,43 +21,43 @@ export async function usePostgresAuthState(
const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { credentialsEncrypted: true },
})
});
let creds = initAuthCreds()
let creds = initAuthCreds();
if (row?.credentialsEncrypted) {
try {
const json = decryptColumn(row.credentialsEncrypted, appDek)
creds = JSON.parse(json, BufferJSON.reviver)
const json = decryptColumn(row.credentialsEncrypted, appDek);
creds = JSON.parse(json, BufferJSON.reviver);
} catch {
creds = initAuthCreds()
creds = initAuthCreds();
}
}
const store: SignalKeyStore = {
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({
where: and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids),
),
})
const result: { [id: string]: SignalDataTypeMap[T] } = {}
});
const result: { [id: string]: SignalDataTypeMap[T] } = {};
for (const r of rows) {
try {
const json = decryptColumn(r.valueEncrypted, appDek)
result[r.keyId] = JSON.parse(json, BufferJSON.reviver)
const json = decryptColumn(r.valueEncrypted, appDek);
result[r.keyId] = JSON.parse(json, BufferJSON.reviver);
} catch {
// skip corrupt key
}
}
return result
return result;
},
async set(data: SignalDataSet) {
for (const [type, typeData] of Object.entries(data)) {
if (!typeData) continue
if (!typeData) continue;
for (const [id, value] of Object.entries(typeData)) {
if (value == null) {
await db
@ -59,34 +68,46 @@ export async function usePostgresAuthState(
eq(sessionKeys.keyType, type),
eq(sessionKeys.keyId, id),
),
)
);
} else {
const json = JSON.stringify(value, BufferJSON.replacer)
const encrypted = encryptColumn(json, appDek)
const json = JSON.stringify(value, BufferJSON.replacer);
const encrypted = encryptColumn(json, appDek);
await db
.insert(sessionKeys)
.values({ sessionId, keyType: type, keyId: id, valueEncrypted: encrypted })
.onConflictDoUpdate({
target: [sessionKeys.sessionId, sessionKeys.keyType, sessionKeys.keyId],
set: { valueEncrypted: encrypted, updatedAt: new Date() },
.values({
sessionId,
keyType: type,
keyId: id,
valueEncrypted: encrypted,
})
.onConflictDoUpdate({
target: [
sessionKeys.sessionId,
sessionKeys.keyType,
sessionKeys.keyId,
],
set: { valueEncrypted: encrypted, updatedAt: new Date() },
});
}
}
}
},
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() {
const json = JSON.stringify(creds, BufferJSON.replacer)
const encrypted = encryptColumn(json, appDek)
await db.update(sessions).set({ credentialsEncrypted: encrypted }).where(eq(sessions.id, sessionId))
const json = JSON.stringify(creds, BufferJSON.replacer);
const encrypted = encryptColumn(json, appDek);
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 {
startSession,
stopSession,
@ -7,5 +7,5 @@ export {
isActive,
initialize,
getDecryptedField,
} from './session-manager'
export { emitWebhook } from './webhook'
} from "./session-manager";
export { emitWebhook } from "./webhook";

View file

@ -1,222 +1,274 @@
import { eq, inArray } from 'drizzle-orm'
import { eq, inArray } from "drizzle-orm";
import {
Browsers,
DisconnectReason,
jidDecode,
makeWASocket,
} from '@whiskeysockets/baileys'
import type { WASocket } from '@whiskeysockets/baileys'
import { db } from '#/db/index'
import { apps, sessions } from '#/db/schema'
import { decryptColumn, encryptColumn, unwrapDek } from '#/lib/crypto'
import { usePostgresAuthState } from './auth-state'
import { emitWebhook } from './webhook'
} from "@whiskeysockets/baileys";
import type { WASocket } from "@whiskeysockets/baileys";
import { db } from "#/db/index";
import { apps, sessions } from "#/db/schema";
import { decryptColumn, encryptColumn, unwrapDek } from "#/lib/crypto";
import { usePostgresAuthState } from "./auth-state";
import { emitWebhook } from "./webhook";
type SessionStatus = (typeof sessions.$inferSelect)['status']
type SessionStatus = (typeof sessions.$inferSelect)["status"];
interface SessionEntry {
socket: WASocket
qrCode: string | null
appId: string
dek: Buffer
socket: WASocket;
qrCode: string | null;
appId: string;
dek: Buffer;
}
interface ReconnectEntry {
start: Date
count: number
timer: ReturnType<typeof setTimeout> | null
start: Date;
count: number;
timer: ReturnType<typeof setTimeout> | null;
}
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000 // 1h
const QR_TIMEOUT_MS = 5 * 60 * 1000 // 5 min
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
const active = new Map<string, SessionEntry>()
const reconnecting = new Map<string, ReconnectEntry>()
const qrTimers = new Map<string, ReturnType<typeof setTimeout>>()
const active = new Map<string, SessionEntry>();
const reconnecting = new Map<string, ReconnectEntry>();
const qrTimers = new Map<string, ReturnType<typeof setTimeout>>();
async function getAppDek(appId: string): Promise<Buffer> {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true },
})
if (!app) throw new Error(`App ${appId} not found`)
return unwrapDek(app.dekEncrypted)
});
if (!app) throw new Error(`App ${appId} not found`);
return unwrapDek(app.dekEncrypted);
}
async function setStatus(sessionId: string, status: SessionStatus): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId))
async function setStatus(
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> {
let rec = reconnecting.get(sessionId)
async function scheduleReconnect(
sessionId: string,
appId: string,
): Promise<void> {
let rec = reconnecting.get(sessionId);
if (rec) {
const elapsed = Date.now() - rec.start.getTime()
const elapsed = Date.now() - rec.start.getTime();
if (elapsed > MAX_RECONNECT_WINDOW_MS) {
reconnecting.delete(sessionId)
await setStatus(sessionId, 'DEAD')
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' })
return
reconnecting.delete(sessionId);
await setStatus(sessionId, "DEAD");
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
return;
}
} else {
rec = { start: new Date(), count: 0, timer: null }
reconnecting.set(sessionId, rec)
rec = { start: new Date(), count: 0, timer: null };
reconnecting.set(sessionId, rec);
}
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000)
rec.count++
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000);
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(() => {
reconnecting.get(sessionId)!.timer = null
startSession(sessionId, appId).catch(console.error)
}, delay)
reconnecting.get(sessionId)!.timer = null;
startSession(sessionId, appId).catch(console.error);
}, delay);
}
export async function startSession(sessionId: string, appId: string): Promise<void> {
if (active.has(sessionId)) return
export async function startSession(
sessionId: string,
appId: string,
): Promise<void> {
if (active.has(sessionId)) return;
const dek = await getAppDek(appId)
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek)
const dek = await getAppDek(appId);
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek);
const sock = makeWASocket({
auth: state,
browser: Browsers.ubuntu('Chrome'),
browser: Browsers.ubuntu("Chrome"),
printQRInTerminal: false,
syncFullHistory: false,
})
});
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek }
active.set(sessionId, entry)
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek };
active.set(sessionId, entry);
sock.ev.on('creds.update', async (patch) => {
Object.assign(state.creds, patch)
await saveCreds().catch(console.error)
})
sock.ev.on("creds.update", async (patch) => {
Object.assign(state.creds, patch);
await saveCreds().catch(console.error);
});
sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update
sock.ev.on("connection.update", async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
entry.qrCode = qr
entry.qrCode = qr;
// Reset the 5-min expiry timer on each new QR rotation
const existing = qrTimers.get(sessionId)
if (existing) clearTimeout(existing)
const existing = qrTimers.get(sessionId);
if (existing) clearTimeout(existing);
qrTimers.set(
sessionId,
setTimeout(async () => {
qrTimers.delete(sessionId)
const e = active.get(sessionId)
if (!e) return
qrTimers.delete(sessionId);
const e = active.get(sessionId);
if (!e) return;
// Only expire if still waiting for QR
const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { status: true },
})
if (row?.status === 'PENDING_QR') {
active.delete(sessionId)
try { e.socket.end(undefined) } catch { /* ignore */ }
await setStatus(sessionId, 'DEAD')
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' })
});
if (row?.status === "PENDING_QR") {
active.delete(sessionId);
try {
e.socket.end(undefined);
} catch {
/* ignore */
}
await setStatus(sessionId, "DEAD");
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
}
}, QR_TIMEOUT_MS),
)
);
await setStatus(sessionId, 'PENDING_QR')
emitWebhook(appId, 'session.status', { sessionId, status: 'PENDING_QR' })
await setStatus(sessionId, "PENDING_QR");
emitWebhook(appId, "session.status", { sessionId, status: "PENDING_QR" });
}
if (connection === 'connecting') {
await setStatus(sessionId, 'CONNECTING')
if (connection === "connecting") {
await setStatus(sessionId, "CONNECTING");
}
if (connection === 'open') {
entry.qrCode = null
reconnecting.delete(sessionId)
const qt = qrTimers.get(sessionId)
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
if (connection === "open") {
entry.qrCode = null;
reconnecting.delete(sessionId);
const qt = qrTimers.get(sessionId);
if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const meId = state.creds.me?.id
const mePhone = meId ? jidDecode(meId)?.user : undefined
const meName = state.creds.me?.name
const meId = state.creds.me?.id;
const mePhone = meId ? jidDecode(meId)?.user : undefined;
const meName = state.creds.me?.name;
await db.update(sessions).set({
status: 'CONNECTED',
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
displayName: meName ? encryptColumn(meName, dek) : undefined,
lastSeen: new Date(),
reconnectCount: 0,
}).where(eq(sessions.id, sessionId))
await db
.update(sessions)
.set({
status: "CONNECTED",
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
displayName: meName ? encryptColumn(meName, dek) : undefined,
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') {
active.delete(sessionId)
const qt = qrTimers.get(sessionId)
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
if (connection === "close") {
active.delete(sessionId);
const qt = qrTimers.get(sessionId);
if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
?.output?.statusCode
const code = (
lastDisconnect?.error as
| { output?: { statusCode?: number } }
| undefined
)?.output?.statusCode;
if (
code === DisconnectReason.loggedOut ||
code === DisconnectReason.forbidden ||
code === DisconnectReason.connectionReplaced
) {
reconnecting.delete(sessionId)
await setStatus(sessionId, 'LOGGED_OUT')
emitWebhook(appId, 'session.status', { sessionId, status: 'LOGGED_OUT' })
return
reconnecting.delete(sessionId);
await setStatus(sessionId, "LOGGED_OUT");
emitWebhook(appId, "session.status", {
sessionId,
status: "LOGGED_OUT",
});
return;
}
await setStatus(sessionId, 'DISCONNECTED')
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' })
await scheduleReconnect(sessionId, appId)
await setStatus(sessionId, "DISCONNECTED");
emitWebhook(appId, "session.status", {
sessionId,
status: "DISCONNECTED",
});
await scheduleReconnect(sessionId, appId);
}
})
});
}
export async function stopSession(sessionId: string): Promise<void> {
const rec = reconnecting.get(sessionId)
if (rec?.timer) clearTimeout(rec.timer)
reconnecting.delete(sessionId)
const qt = qrTimers.get(sessionId)
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
const rec = reconnecting.get(sessionId);
if (rec?.timer) clearTimeout(rec.timer);
reconnecting.delete(sessionId);
const qt = qrTimers.get(sessionId);
if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const entry = active.get(sessionId)
const entry = active.get(sessionId);
if (entry) {
try { entry.socket.end(undefined) } catch { /* ignore */ }
active.delete(sessionId)
try {
entry.socket.end(undefined);
} catch {
/* ignore */
}
active.delete(sessionId);
}
}
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 {
return active.get(sessionId)?.socket ?? null
return active.get(sessionId)?.socket ?? null;
}
export function isActive(sessionId: string): boolean {
return active.has(sessionId)
return active.has(sessionId);
}
export async function initialize(): Promise<void> {
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 },
})
});
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 {
if (!encryptedValue) return null
const entry = active.get(sessionId)
if (!entry) return null
try { return decryptColumn(encryptedValue, entry.dek) } catch { return null }
export function getDecryptedField(
sessionId: string,
encryptedValue: string | null,
): string | 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
export function emitWebhook(appId: string, event: string, payload: Record<string, unknown>): void {
console.log('[webhook]', appId, event, JSON.stringify(payload))
export function emitWebhook(
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 IV_BYTES = 12
const TAG_BYTES = 16
const ALG = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
function getMasterKey(): Buffer {
const raw = process.env.MASTER_ENCRYPTION_KEY
if (!raw) throw new Error('MASTER_ENCRYPTION_KEY is not set — server cannot start')
const buf = Buffer.from(raw, 'base64')
const raw = process.env.MASTER_ENCRYPTION_KEY;
if (!raw)
throw new Error("MASTER_ENCRYPTION_KEY is not set — server cannot start");
const buf = Buffer.from(raw, "base64");
if (buf.byteLength !== 32)
throw new Error('MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)')
return buf
throw new Error(
"MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)",
);
return buf;
}
export function generateDek(): Buffer {
return randomBytes(32)
return randomBytes(32);
}
export function wrapDek(dek: Buffer): string {
const kek = getMasterKey()
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALG, kek, iv)
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64')
const kek = getMasterKey();
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALG, kek, iv);
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
export function unwrapDek(wrapped: string): Buffer {
const kek = getMasterKey()
const buf = Buffer.from(wrapped, 'base64')
const iv = buf.subarray(0, IV_BYTES)
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)
const data = buf.subarray(IV_BYTES + TAG_BYTES)
const decipher = createDecipheriv(ALG, kek, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()])
const kek = getMasterKey();
const buf = Buffer.from(wrapped, "base64");
const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const data = buf.subarray(IV_BYTES + TAG_BYTES);
const decipher = createDecipheriv(ALG, kek, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]);
}
export function encryptColumn(plaintext: string, dek: Buffer): string {
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALG, dek, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64')
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALG, dek, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
export function decryptColumn(ciphertext: string, dek: Buffer): string {
const buf = Buffer.from(ciphertext, 'base64')
const iv = buf.subarray(0, IV_BYTES)
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)
const data = buf.subarray(IV_BYTES + TAG_BYTES)
const decipher = createDecipheriv(ALG, dek, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
const buf = Buffer.from(ciphertext, "base64");
const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const data = buf.subarray(IV_BYTES + TAG_BYTES);
const decipher = createDecipheriv(ALG, dek, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
}
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 {
getMasterKey()
getMasterKey();
}

View file

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

View file

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

View file

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

View file

@ -1,19 +1,34 @@
import * as OTPAuth from 'otpauth'
import { randomBytes } from 'node:crypto'
import * as OTPAuth from "otpauth";
import { randomBytes } from "node:crypto";
const ISSUER = 'Wahoo'
const ISSUER = "Wahoo";
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 {
const totp = new OTPAuth.TOTP({ issuer: ISSUER, label: email, secret, digits: 6, period: 30 })
return totp.toString()
const totp = new OTPAuth.TOTP({
issuer: ISSUER,
label: email,
secret,
digits: 6,
period: 30,
});
return totp.toString();
}
export function verifyTotp(secret: string, token: string): boolean {
const totp = new OTPAuth.TOTP({ issuer: ISSUER, secret, digits: 6, period: 30 })
const delta = totp.validate({ token, window: 1 })
return delta !== null
const totp = new OTPAuth.TOTP({
issuer: ISSUER,
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 { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
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 { routeTree } from './routeTree.gen'
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
})
});
return router
return router;
}
declare module '@tanstack/react-router' {
declare module "@tanstack/react-router" {
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 { 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 {
HeadContent,
Outlet,
Scripts,
createRootRoute,
useLocation,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "#/lib/query";
import { Sidebar } from "#/components/layout/sidebar";
import appCss from '../styles.css?url'
import appCss from "../styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Wahoo — WhatsApp API Gateway' },
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "Wahoo — WhatsApp API Gateway" },
],
links: [{ rel: 'stylesheet', href: appCss }],
links: [{ rel: "stylesheet", href: appCss }],
}),
shellComponent: RootDocument,
component: RootLayout,
})
});
function RootLayout() {
const location = useLocation()
const isLogin = location.pathname === '/login'
const location = useLocation();
const isLogin = location.pathname === "/login";
if (isLogin) {
return <Outlet />
return <Outlet />;
}
return (
@ -35,7 +41,7 @@ function RootLayout() {
<Outlet />
</div>
</div>
)
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
@ -48,12 +54,17 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
config={{ position: "bottom-right" }}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</QueryClientProvider>
</body>
</html>
)
);
}

View file

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

View file

@ -1,39 +1,47 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
import { enrollTotpSecret, confirmTotpEnrollment, disableTotp } from '#/lib/actions/auth'
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import {
enrollTotpSecret,
confirmTotpEnrollment,
disableTotp,
} from "#/lib/actions/auth";
export const Route = createFileRoute('/api/auth/totp/enroll')({
export const Route = createFileRoute("/api/auth/totp/enroll")({
server: {
handlers: {
POST: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const result = await enrollTotpSecret(session.userId)
if (!result.ok) return jsonError(result.error, result.status)
return json({ secret: result.secret, uri: result.uri })
const result = await enrollTotpSecret(session.userId);
if (!result.ok) return jsonError(result.error, result.status);
return json({ secret: result.secret, uri: result.uri });
},
PUT: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
let body: { token: string }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.token) return jsonError('token required', 400)
let body: { token: string };
try {
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)
if (!result.ok) return jsonError(result.error, result.status)
return json({ ok: true })
const result = await confirmTotpEnrollment(session.userId, body.token);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true });
},
DELETE: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
await disableTotp(session.userId)
return json({ ok: true })
await disableTotp(session.userId);
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: {
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 { db } from '#/db/index'
import { sql } from 'drizzle-orm'
import Redis from 'ioredis'
import { createFileRoute } from "@tanstack/react-router";
import { db } from "#/db/index";
import { sql } from "drizzle-orm";
import Redis from "ioredis";
export const Route = createFileRoute('/api/ready')({
export const Route = createFileRoute("/api/ready")({
server: {
handlers: {
GET: async () => {
const checks: Record<string, boolean> = { db: false, redis: false }
const checks: Record<string, boolean> = { db: false, redis: false };
try {
await db.execute(sql`SELECT 1`)
checks.db = true
await db.execute(sql`SELECT 1`);
checks.db = true;
} catch {}
try {
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', { lazyConnect: true, connectTimeout: 2000 })
await redis.ping()
await redis.quit()
checks.redis = true
const redis = new Redis(
process.env.REDIS_URL ?? "redis://localhost:6379",
{ lazyConnect: true, connectTimeout: 2000 },
);
await redis.ping();
await redis.quit();
checks.redis = true;
} catch {}
const ok = Object.values(checks).every(Boolean)
return new Response(JSON.stringify({ status: ok ? 'ok' : 'degraded', checks }), {
status: ok ? 200 : 503,
headers: { 'content-type': 'application/json' },
})
const ok = Object.values(checks).every(Boolean);
return new Response(
JSON.stringify({ status: ok ? "ok" : "degraded", checks }),
{
status: ok ? 200 : 503,
headers: { "content-type": "application/json" },
},
);
},
},
},
})
});

View file

@ -1,26 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
import { findUserApp } from '#/lib/actions/apps'
import { revokeAppApiKey } from '#/lib/actions/api-keys'
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import { revokeAppApiKey } from "#/lib/actions/api-keys";
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/$keyId")({
server: {
handlers: {
DELETE: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
const result = await revokeAppApiKey(params.keyId, params.id, session.userId, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
})
if (!result.ok) return jsonError(result.error, result.status)
const result = await revokeAppApiKey(
params.keyId,
params.id,
session.userId,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true })
return json({ ok: true });
},
},
},
})
});

View file

@ -1,38 +1,46 @@
import { createFileRoute } from '@tanstack/react-router'
import { json, jsonError, readSession } from '#/lib/session-api'
import { findUserApp } from '#/lib/actions/apps'
import { listAppApiKeys, createAppApiKey } from '#/lib/actions/api-keys'
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import { listAppApiKeys, createAppApiKey } from "#/lib/actions/api-keys";
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
return json(await listAppApiKeys(params.id))
return json(await listAppApiKeys(params.id));
},
POST: async ({ request, params }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId)
if (!app) return jsonError('Not found', 404)
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
let body: { label?: string; env?: 'live' | 'test' } = {}
try { body = await request.json() } catch {}
let body: { label?: string; env?: "live" | "test" } = {};
try {
body = await request.json();
} catch {}
const created = await createAppApiKey(params.id, session.userId, body.label, body.env, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
})
const created = await createAppApiKey(
params.id,
session.userId,
body.label,
body.env,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
return json(created, 201)
return json(created, 201);
},
},
},
})
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +1,18 @@
import { createFileRoute } from '@tanstack/react-router'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { isActive } from '#/lib/baileys'
import { findSession } from '#/lib/actions/sessions'
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { isActive } from "#/lib/baileys";
import { findSession } from "#/lib/actions/sessions";
export const Route = createFileRoute('/api/v1/sessions/$id/health')({
export const Route = createFileRoute("/api/v1/sessions/$id/health")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
return json({
id: session.id,
@ -20,8 +20,8 @@ export const Route = createFileRoute('/api/v1/sessions/$id/health')({
lastSeen: session.lastSeen,
reconnectCount: session.reconnectCount,
socketAlive: isActive(session.id),
})
});
},
},
},
})
});

View file

@ -1,18 +1,18 @@
import { createFileRoute } from '@tanstack/react-router'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { getDecryptedField } from '#/lib/baileys'
import { findSession, logoutAppSession } from '#/lib/actions/sessions'
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { getDecryptedField } from "#/lib/baileys";
import { findSession, logoutAppSession } from "#/lib/actions/sessions";
export const Route = createFileRoute('/api/v1/sessions/$id/')({
export const Route = createFileRoute("/api/v1/sessions/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
return json({
id: session.id,
@ -22,19 +22,19 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
reconnectCount: session.reconnectCount,
lastSeen: session.lastSeen,
createdAt: session.createdAt,
})
});
},
DELETE: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId })
if (!session) return jsonError('Not found', 404)
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
await logoutAppSession(params.id, auth.appId)
return json({ ok: true })
await logoutAppSession(params.id, auth.appId);
return json({ ok: true });
},
},
},
})
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,54 +1,64 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { MessageSquare, Shield, Zap, Globe } from 'lucide-react'
import { getSessionFn } from '#/server/auth'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { cn } from '#/lib/utils'
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { MessageSquare, Shield, Zap, Globe } from "lucide-react";
import { getSessionFn } from "#/server/auth";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { cn } from "#/lib/utils";
export const Route = createFileRoute('/login')({
export const Route = createFileRoute("/login")({
beforeLoad: async () => {
const session = await getSessionFn()
if (session?.userId) throw redirect({ to: '/apps' })
const session = await getSessionFn();
if (session?.userId) throw redirect({ to: "/apps" });
},
component: LoginPage,
})
});
const features = [
{ icon: Zap, label: 'Session management', desc: 'QR and pairing code flows' },
{ icon: Shield, label: 'API key auth', desc: 'Per-app bearer tokens' },
{ icon: Globe, label: 'Webhook events', desc: 'Real-time message delivery' },
]
{ icon: Zap, label: "Session management", desc: "QR and pairing code flows" },
{ icon: Shield, label: "API key auth", desc: "Per-app bearer tokens" },
{ icon: Globe, label: "Webhook events", desc: "Real-time message delivery" },
];
function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpToken, setTotpToken] = useState('')
const [requireTotp, setRequireTotp] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpToken, setTotpToken] = useState("");
const [requireTotp, setRequireTotp] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Login failed'); return }
if (data.requireTotp) { setRequireTotp(true); return }
router.navigate({ to: '/apps' })
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
email,
password,
totpToken: totpToken || undefined,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Login failed");
return;
}
if (data.requireTotp) {
setRequireTotp(true);
return;
}
router.navigate({ to: "/apps" });
} catch {
setError('Network error')
setError("Network error");
} finally {
setLoading(false)
setLoading(false);
}
}
@ -61,8 +71,8 @@ function LoginPage() {
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
backgroundSize: '32px 32px',
"linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
{/* Glow accent */}
@ -71,11 +81,15 @@ function LoginPage() {
{/* Logo */}
<div className="relative flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/40">
<MessageSquare size={18} className="text-white fill-white" strokeWidth={1.5} />
<MessageSquare
size={18}
className="text-white fill-white"
strokeWidth={1.5}
/>
</div>
<span
className="text-white text-xl font-semibold tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
style={{ fontFamily: "Fraunces, Georgia, serif" }}
>
wahoo
</span>
@ -85,25 +99,30 @@ function LoginPage() {
<div className="relative space-y-6">
<h1
className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
style={{ fontFamily: "Fraunces, Georgia, serif" }}
>
WhatsApp sessions,{' '}
<span className="text-emerald-400">delivered</span>{' '}
via API.
WhatsApp sessions,{" "}
<span className="text-emerald-400">delivered</span> via API.
</h1>
<p className="text-zinc-400 text-[15px] leading-relaxed max-w-sm">
Manage WhatsApp connections at scale. Generate API keys, track sessions, and integrate
messaging into your stack without limits.
Manage WhatsApp connections at scale. Generate API keys, track
sessions, and integrate messaging into your stack without limits.
</p>
<div className="space-y-3 pt-2">
{features.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0">
<Icon size={14} className="text-emerald-400" strokeWidth={1.75} />
<Icon
size={14}
className="text-emerald-400"
strokeWidth={1.75}
/>
</div>
<div>
<span className="text-zinc-100 text-[13px] font-medium">{label}</span>
<span className="text-zinc-100 text-[13px] font-medium">
{label}
</span>
<span className="text-zinc-500 text-[13px]"> — {desc}</span>
</div>
</div>
@ -123,11 +142,15 @@ function LoginPage() {
{/* Mobile logo */}
<div className="flex lg:hidden items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center">
<MessageSquare size={13} className="text-white fill-white" strokeWidth={1.5} />
<MessageSquare
size={13}
className="text-white fill-white"
strokeWidth={1.5}
/>
</div>
<span
className="text-zinc-900 font-semibold text-base"
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
style={{ fontFamily: "Fraunces, Georgia, serif" }}
>
wahoo
</span>
@ -135,12 +158,12 @@ function LoginPage() {
<div className="space-y-1.5">
<h2 className="text-[22px] font-semibold text-zinc-900 tracking-tight">
{requireTotp ? 'Two-factor auth' : 'Sign in'}
{requireTotp ? "Two-factor auth" : "Sign in"}
</h2>
<p className="text-zinc-500 text-sm">
{requireTotp
? 'Enter the 6-digit code from your authenticator app.'
: 'Enter your credentials to access the dashboard.'}
? "Enter the 6-digit code from your authenticator app."
: "Enter your credentials to access the dashboard."}
</p>
</div>
@ -148,7 +171,9 @@ function LoginPage() {
{!requireTotp ? (
<>
<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
id="email"
type="email"
@ -156,12 +181,17 @@ function LoginPage() {
required
placeholder="you@company.com"
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"
/>
</div>
<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
id="password"
type="password"
@ -169,14 +199,16 @@ function LoginPage() {
required
placeholder="••••••••"
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"
/>
</div>
</>
) : (
<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
id="totp"
type="text"
@ -187,7 +219,7 @@ function LoginPage() {
required
placeholder="000 000"
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"
/>
</div>
@ -203,17 +235,20 @@ function LoginPage() {
type="submit"
disabled={loading}
className={cn(
'w-full h-9 text-sm font-medium bg-zinc-900 hover:bg-zinc-800 text-white transition-colors',
loading && 'opacity-60 cursor-not-allowed',
"w-full h-9 text-sm font-medium bg-zinc-900 hover:bg-zinc-800 text-white transition-colors",
loading && "opacity-60 cursor-not-allowed",
)}
>
{loading ? 'Signing in…' : requireTotp ? 'Verify' : 'Sign in'}
{loading ? "Signing in…" : requireTotp ? "Verify" : "Sign in"}
</Button>
{requireTotp && (
<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"
>
← Back to password
@ -223,5 +258,5 @@ function LoginPage() {
</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 {
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'
createFileRoute,
Link,
redirect,
useRouter,
} from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
Phone,
User,
Clock,
RefreshCw,
QrCode,
Smartphone,
Power,
Trash2,
Key,
AlertTriangle,
ChevronDown,
} from "lucide-react";
import { getSessionFn } from "#/server/auth";
import { adminSessionDetailQuery, apiKeysQuery } from "#/lib/query";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '#/components/ui/dialog'
import { cn } from '#/lib/utils'
} from "#/components/ui/dialog";
import { cn } from "#/lib/utils";
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
}
const STATUS_STYLES: Record<
string,
{ dot: string; badge: string; label: string } | undefined
> = {
CONNECTED: {
dot: "bg-emerald-500 animate-pulse-dot",
badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
label: "Connected",
},
CONNECTING: {
dot: "bg-blue-500 animate-pulse-dot",
badge: "bg-blue-50 text-blue-700 border-blue-200",
label: "Connecting",
},
PENDING_QR: {
dot: "bg-amber-500 animate-pulse-dot",
badge: "bg-amber-50 text-amber-700 border-amber-200",
label: "Pending QR",
},
PENDING_PAIRING: {
dot: "bg-amber-500 animate-pulse-dot",
badge: "bg-amber-50 text-amber-700 border-amber-200",
label: "Pending Pairing",
},
DISCONNECTED: {
dot: "bg-gray-400",
badge: "bg-gray-50 text-gray-600 border-gray-200",
label: "Disconnected",
},
LOGGED_OUT: {
dot: "bg-red-400",
badge: "bg-red-50 text-red-600 border-red-200",
label: "Logged Out",
},
DEAD: {
dot: "bg-red-600",
badge: "bg-red-50 text-red-700 border-red-200",
label: "Dead",
},
};
interface SessionDetail {
id: string
appId: string
status: string
phoneNumber: string | null
displayName: string | null
reconnectCount: number
lastSeen: string | null
createdAt: string
id: string;
appId: string;
status: string;
phoneNumber: string | null;
displayName: string | null;
reconnectCount: number;
lastSeen: string | null;
createdAt: string;
}
interface ApiKey {
id: string
display: string
label: string
revokedAt: string | null
id: string;
display: string;
label: string;
revokedAt: string | null;
}
export const Route = createFileRoute('/sessions/$id')({
export const Route = createFileRoute("/sessions/$id")({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
const session = await getSessionFn();
if (!session?.userId) throw redirect({ to: "/login" });
},
loader: async ({ params }) => ({ sessionId: params.id }),
component: SessionDetailPage,
})
});
function SessionDetailPage() {
const { sessionId } = Route.useLoaderData()
const router = useRouter()
const queryClient = useQueryClient()
const { sessionId } = Route.useLoaderData();
const router = useRouter();
const queryClient = useQueryClient();
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
adminSessionDetailQuery(sessionId),
)
);
const appId = session?.appId ?? ''
const appId = session?.appId ?? "";
const { data: allKeys = [] } = useQuery<ApiKey[]>({
...apiKeysQuery(appId),
enabled: !!appId,
})
const activeKeys = allKeys.filter(k => !k.revokedAt)
});
const activeKeys = allKeys.filter((k) => !k.revokedAt);
const [selectedKeyId, setSelectedKeyId] = useState('')
const [qr, setQr] = useState<string | null>(null)
const [pairingPhone, setPairingPhone] = useState('')
const [pairingCode, setPairingCode] = useState<string | null>(null)
const [error, setError] = useState('')
const [disconnectOpen, setDisconnectOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [selectedKeyId, setSelectedKeyId] = useState("");
const [qr, setQr] = useState<string | null>(null);
const [pairingPhone, setPairingPhone] = useState("");
const [pairingCode, setPairingCode] = useState<string | null>(null);
const [error, setError] = useState("");
const [disconnectOpen, setDisconnectOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!selectedKeyId && activeKeys.length > 0) {
setSelectedKeyId(activeKeys[0].id)
setSelectedKeyId(activeKeys[0].id);
}
}, [activeKeys, selectedKeyId])
}, [activeKeys, selectedKeyId]);
function keyHint() {
return selectedKeyId ? { 'x-key-id': selectedKeyId } : {}
return selectedKeyId ? { "x-key-id": selectedKeyId } : {};
}
async function loadQr() {
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
credentials: 'include',
credentials: "include",
headers: keyHint(),
})
});
if (res.status === 200) {
const data = await res.json()
setQr(data.qr ?? null)
const data = await res.json();
setQr(data.qr ?? null);
} else if (res.status === 409) {
setQr(null)
setQr(null);
}
}
useEffect(() => {
if (!session) return
if (pollRef.current) clearInterval(pollRef.current)
if (!session) return;
if (pollRef.current) clearInterval(pollRef.current);
if (session.status === 'PENDING_QR') {
loadQr()
pollRef.current = setInterval(() => { refetchSession(); loadQr() }, 3000)
} else if (session.status === 'CONNECTING') {
pollRef.current = setInterval(() => refetchSession(), 2000)
if (session.status === "PENDING_QR") {
loadQr();
pollRef.current = setInterval(() => {
refetchSession();
loadQr();
}, 3000);
} else if (session.status === "CONNECTING") {
pollRef.current = setInterval(() => refetchSession(), 2000);
} else {
setQr(null)
setQr(null);
}
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [session?.status])
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [session?.status]);
async function requestPairingCode() {
if (!pairingPhone) { setError('Enter phone number'); return }
setError('')
if (!pairingPhone) {
setError("Enter phone number");
return;
}
setError("");
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', ...keyHint() },
method: "POST",
credentials: "include",
headers: { "content-type": "application/json", ...keyHint() },
body: JSON.stringify({ phone: pairingPhone }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setPairingCode(data.pairingCode)
pollRef.current = setInterval(() => refetchSession(), 3000)
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Failed");
return;
}
setPairingCode(data.pairingCode);
pollRef.current = setInterval(() => refetchSession(), 3000);
}
async function disconnect() {
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
method: 'POST',
credentials: 'include',
method: "POST",
credentials: "include",
headers: keyHint(),
})
setDisconnectOpen(false)
refetchSession()
});
setDisconnectOpen(false);
refetchSession();
}
async function deleteSession() {
setDeleting(true)
setDeleting(true);
try {
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
method: "DELETE",
credentials: "include",
headers: keyHint(),
})
queryClient.invalidateQueries({ queryKey: ['sessions'] })
router.navigate({ to: '/sessions' })
});
queryClient.invalidateQueries({ queryKey: ["sessions"] });
router.navigate({ to: "/sessions" });
} finally {
setDeleting(false)
setDeleting(false);
}
}
const style = session ? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!) : null
const canDisconnect = session &&
['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status)
const style = session
? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!)
: null;
const canDisconnect =
session &&
["CONNECTED", "CONNECTING", "PENDING_QR", "PENDING_PAIRING"].includes(
session.status,
);
return (
<div className="min-h-full">
{/* Page header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Link to="/sessions" className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0">
<Link
to="/sessions"
className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0"
>
<ChevronLeft size={16} strokeWidth={2} />
</Link>
<span className="text-gray-300">/</span>
<code className="text-[13px] text-gray-700 font-mono truncate">{sessionId.slice(0, 12)}…</code>
<code className="text-[13px] text-gray-700 font-mono truncate">
{sessionId.slice(0, 12)}…
</code>
{style && (
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
<div className={cn('w-1.5 h-1.5 rounded-full', style.dot)} />
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
<div className={cn("w-1.5 h-1.5 rounded-full", style.dot)} />
<span
className={cn(
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border",
style.badge,
)}
>
{style.label}
</span>
</div>
@ -189,19 +261,26 @@ function SessionDetailPage() {
{/* API key selector */}
{activeKeys.length > 0 && (
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
<Key
size={11}
className="text-gray-400 flex-shrink-0"
strokeWidth={2}
/>
<select
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"
>
{activeKeys.map(k => (
{activeKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
</option>
))}
</select>
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
<ChevronDown
size={10}
className="text-gray-400 absolute right-2 pointer-events-none"
/>
</div>
)}
@ -240,50 +319,88 @@ function SessionDetailPage() {
{session.displayName && (
<div className="flex items-center gap-3 px-4 py-3">
<User size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Display name</span>
<span className="text-[13px] text-gray-900 font-medium">{session.displayName}</span>
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
Display name
</span>
<span className="text-[13px] text-gray-900 font-medium">
{session.displayName}
</span>
</div>
)}
{session.phoneNumber && (
<div className="flex items-center gap-3 px-4 py-3">
<Phone size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Phone</span>
<code className="text-[13px] text-gray-900 font-mono">{session.phoneNumber}</code>
<Phone
size={13}
className="text-gray-400 flex-shrink-0"
/>
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
Phone
</span>
<code className="text-[13px] text-gray-900 font-mono">
{session.phoneNumber}
</code>
</div>
)}
{session.lastSeen && (
<div className="flex items-center gap-3 px-4 py-3">
<Clock size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Last seen</span>
<span className="text-[13px] text-gray-600">{new Date(session.lastSeen).toLocaleString()}</span>
<Clock
size={13}
className="text-gray-400 flex-shrink-0"
/>
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
Last seen
</span>
<span className="text-[13px] text-gray-600">
{new Date(session.lastSeen).toLocaleString()}
</span>
</div>
)}
{session.reconnectCount > 0 && (
<div className="flex items-center gap-3 px-4 py-3">
<RefreshCw size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Reconnects</span>
<span className="text-[13px] text-gray-600">{session.reconnectCount}</span>
<RefreshCw
size={13}
className="text-gray-400 flex-shrink-0"
/>
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
Reconnects
</span>
<span className="text-[13px] text-gray-600">
{session.reconnectCount}
</span>
</div>
)}
<div className="flex items-center gap-3 px-4 py-3">
<Clock size={13} className="text-gray-400 flex-shrink-0" />
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Created</span>
<span className="text-[13px] text-gray-600">{new Date(session.createdAt).toLocaleString()}</span>
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
Created
</span>
<span className="text-[13px] text-gray-600">
{new Date(session.createdAt).toLocaleString()}
</span>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">Actions</p>
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">
Actions
</p>
<div className="flex flex-wrap gap-2">
{canDisconnect && (
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(true)} className="h-8 px-3 text-[13px] gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setDisconnectOpen(true)}
className="h-8 px-3 text-[13px] gap-1.5"
>
<Power size={12} strokeWidth={2} />
Disconnect
</Button>
)}
<Button
variant="outline" size="sm" onClick={() => setDeleteOpen(true)}
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
className="h-8 px-3 text-[13px] text-red-500 hover:text-red-700 hover:bg-red-50 border-red-200 gap-1.5"
>
<Trash2 size={12} strokeWidth={2} />
@ -294,19 +411,26 @@ function SessionDetailPage() {
</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="flex items-center gap-2">
<QrCode size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-gray-900">Scan QR code</p>
<p className="text-[13px] font-semibold text-gray-900">
Scan QR code
</p>
</div>
{qr ? (
<>
<div className="flex justify-center">
<img src={qr} alt="WhatsApp QR code" className="w-52 h-52 rounded-xl border border-gray-200 bg-white p-2" />
<img
src={qr}
alt="WhatsApp QR code"
className="w-52 h-52 rounded-xl border border-gray-200 bg-white p-2"
/>
</div>
<p className="text-center text-[11px] text-gray-400">
Open WhatsApp → Settings → Linked Devices → Link a device
Open WhatsApp → Settings → Linked Devices → Link a
device
</p>
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
@ -321,19 +445,36 @@ function SessionDetailPage() {
</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="flex items-center gap-2">
<Smartphone size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-gray-900">Get pairing code</p>
<p className="text-[13px] font-semibold text-gray-900">
Get pairing code
</p>
</div>
<p className="text-[12px] text-gray-500">Enter your WhatsApp phone number.</p>
<p className="text-[12px] text-gray-500">
Enter your WhatsApp phone number.
</p>
<div className="space-y-1.5">
<Label className="text-[13px] text-zinc-700">Phone number</Label>
<Input type="tel" placeholder="+1 234 567 8900" value={pairingPhone} onChange={e => setPairingPhone(e.target.value)} className="h-9 text-sm" />
<Label className="text-[13px] text-zinc-700">
Phone number
</Label>
<Input
type="tel"
placeholder="+1 234 567 8900"
value={pairingPhone}
onChange={(e) => setPairingPhone(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && <p className="text-[12px] text-red-600">{error}</p>}
<Button onClick={requestPairingCode} className="w-full h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Get code</Button>
<Button
onClick={requestPairingCode}
className="w-full h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
Get code
</Button>
</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="flex items-center gap-2">
<Smartphone size={14} className="text-amber-600" />
<p className="text-[13px] font-semibold text-amber-900">Enter this code</p>
<p className="text-[13px] font-semibold text-amber-900">
Enter this code
</p>
</div>
<div className="text-center py-3">
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">{pairingCode}</code>
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">
{pairingCode}
</code>
</div>
<p className="text-[11px] text-amber-700 text-center">
WhatsApp → Linked Devices → Link a Device → Enter phone number
WhatsApp → Linked Devices → Link a Device → Enter phone
number
</p>
</div>
)}
{session.status === 'CONNECTED' && (
{session.status === "CONNECTED" && (
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot" />
<p className="text-[13px] font-semibold text-emerald-800">Session connected</p>
<p className="text-[13px] font-semibold text-emerald-800">
Session connected
</p>
</div>
<p className="text-[12px] text-emerald-700">
This session is actively connected and ready to use via the API.
This session is actively connected and ready to use via the
API.
</p>
</div>
)}
@ -371,12 +520,29 @@ function SessionDetailPage() {
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-[15px]">Disconnect session?</DialogTitle>
<DialogDescription className="text-[13px]">The session will be logged out. You can reconnect later.</DialogDescription>
<DialogTitle className="text-[15px]">
Disconnect session?
</DialogTitle>
<DialogDescription className="text-[13px]">
The session will be logged out. You can reconnect later.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
<Button size="sm" onClick={disconnect} className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Disconnect</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDisconnectOpen(false)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
size="sm"
onClick={disconnect}
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
>
Disconnect
</Button>
</div>
</DialogContent>
</Dialog>
@ -388,16 +554,31 @@ function SessionDetailPage() {
<AlertTriangle size={15} className="text-red-500" />
Delete session?
</DialogTitle>
<DialogDescription className="text-[13px]">All credentials will be permanently wiped.</DialogDescription>
<DialogDescription className="text-[13px]">
All credentials will be permanently wiped.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-3">
<Button variant="outline" size="sm" onClick={() => setDeleteOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
<Button variant="destructive" size="sm" onClick={deleteSession} disabled={deleting} className="h-8 px-3 text-[13px]">
{deleting ? 'Deleting…' : 'Delete'}
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(false)}
className="h-8 px-3 text-[13px]"
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteSession}
disabled={deleting}
className="h-8 px-3 text-[13px]"
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
);
}

View file

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

View file

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

View file

@ -1,8 +1,10 @@
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { readSession } from '#/lib/session-api'
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { readSession } from "#/lib/session-api";
export const getSessionFn = createServerFn({ method: 'GET' }).handler(async () => {
const request = getRequest()
return readSession(request)
})
export const getSessionFn = createServerFn({ method: "GET" }).handler(
async () => {
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.
export default async function () {
try {
await initialize()
console.log('[session-startup] sessions initialized')
await initialize();
console.log("[session-startup] sessions initialized");
} 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 'tailwindcss';
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap");
@import "tailwindcss";
@plugin '@tailwindcss/typography';
@import 'tw-animate-css';
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@ -118,8 +118,8 @@
}
@theme inline {
--font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -179,7 +179,7 @@ code,
}
.display-title {
font-family: 'Fraunces', Georgia, serif;
font-family: "Fraunces", Georgia, serif;
}
@keyframes fade-up {
@ -198,8 +198,13 @@ code,
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.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) {
await page.goto(`${BASE}/login`)
await page.getByLabel('Email').fill('test@wahoo.local')
await page.getByLabel('Password').fill('TestPassword123!')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/apps/)
await page.goto(`${BASE}/login`);
await page.getByLabel("Email").fill("test@wahoo.local");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/apps/);
}
test('re-navigate to /apps shows New app button', async ({ page }) => {
await login(page)
test("re-navigate to /apps shows New app button", async ({ page }) => {
await login(page);
// This is what createAppAndKey does
await page.goto(`${BASE}/apps`)
await page.goto(`${BASE}/apps`);
// wait for dom ready, not network idle
await page.waitForLoadState('domcontentloaded')
const content = (await page.content()).slice(0, 2000)
const hasBtn = content.includes('New app')
const hasErr = content.includes('fetch() URL is invalid')
console.log('Has New app:', hasBtn, '| Has error:', hasErr)
await page.waitForLoadState("domcontentloaded");
const content = (await page.content()).slice(0, 2000);
const hasBtn = content.includes("New app");
const hasErr = content.includes("fetch() URL is invalid");
console.log("Has New app:", hasBtn, "| Has error:", hasErr);
// 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 }) => {
await login(page)
await page.goto(`${BASE}/sessions`)
await page.waitForLoadState('domcontentloaded')
const content = (await page.content()).slice(0, 1000)
const hasHeading = content.includes('Sessions')
const hasErr = content.includes('fetch() URL is invalid')
console.log('Has Sessions:', hasHeading, '| Has error:', hasErr)
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible({ timeout: 10000 })
})
test("sessions page after login", async ({ page }) => {
await login(page);
await page.goto(`${BASE}/sessions`);
await page.waitForLoadState("domcontentloaded");
const content = (await page.content()).slice(0, 1000);
const hasHeading = content.includes("Sessions");
const hasErr = content.includes("fetch() URL is invalid");
console.log("Has Sessions:", hasHeading, "| Has error:", hasErr);
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 EMAIL = 'test@wahoo.local'
const PASSWORD = 'TestPassword123!'
const BASE = "http://localhost:3000";
const EMAIL = "test@wahoo.local";
const PASSWORD = "TestPassword123!";
async function login(page: Page) {
await page.goto(`${BASE}/login`)
await page.getByLabel('Email').fill(EMAIL)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/apps/)
await page.goto(`${BASE}/login`);
await page.getByLabel("Email").fill(EMAIL);
await page.getByLabel("Password").fill(PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/apps/);
}
test('redirect / → /login when unauthenticated', async ({ page }) => {
await page.goto(BASE)
await expect(page).toHaveURL(/\/login/)
})
test("redirect / → /login when unauthenticated", async ({ page }) => {
await page.goto(BASE);
await expect(page).toHaveURL(/\/login/);
});
test('login with email + password', async ({ page }) => {
await login(page)
await expect(page).toHaveURL(/\/apps/)
await expect(page.getByRole('heading', { name: 'Apps' })).toBeVisible()
})
test("login with email + password", async ({ page }) => {
await login(page);
await expect(page).toHaveURL(/\/apps/);
await expect(page.getByRole("heading", { name: "Apps" })).toBeVisible();
});
test('create app, generate API key (shown once), revoke key', async ({ page }) => {
await login(page)
test("create app, generate API key (shown once), revoke key", async ({
page,
}) => {
await login(page);
// Create app
await page.getByRole('button', { name: 'New app' }).click()
await page.getByPlaceholder('App name').fill('Test App')
await page.getByRole('button', { name: 'Create' }).click()
await page.getByRole("button", { name: "New app" }).click();
await page.getByPlaceholder("App name").fill("Test App");
await page.getByRole("button", { name: "Create" }).click();
// Navigate to app detail
await page.getByText('Test App').click()
await expect(page.getByRole('heading', { name: 'Test App' })).toBeVisible()
await page.getByText("Test App").click();
await expect(page.getByRole("heading", { name: "Test App" })).toBeVisible();
// Generate API key
await page.getByRole('button', { name: 'Generate key' }).click()
await page.getByRole("button", { name: "Generate key" }).click();
// Key shown once banner
const banner = page.locator('text=Copy this key')
await expect(banner).toBeVisible()
const keyText = await page.locator('code').first().innerText()
expect(keyText).toMatch(/^wak_live_/)
const banner = page.locator("text=Copy this key");
await expect(banner).toBeVisible();
const keyText = await page.locator("code").first().innerText();
expect(keyText).toMatch(/^wak_live_/);
// Authenticate an API request using the 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}` },
})
return r.status
}, keyText)
});
return r.status;
}, keyText);
// /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
page.once('dialog', (d) => d.accept())
await page.getByRole('button', { name: 'Revoke' }).first().click()
await expect(page.locator('text=Revoked')).toBeVisible()
})
page.once("dialog", (d) => d.accept());
await page.getByRole("button", { name: "Revoke" }).first().click();
await expect(page.locator("text=Revoked")).toBeVisible();
});
test('audit log records events', async ({ page }) => {
await login(page)
await page.getByRole('link', { name: 'Audit log' }).click()
await expect(page).toHaveURL(`${BASE}/audit-log`)
test("audit log records events", async ({ page }) => {
await login(page);
await page.getByRole("link", { name: "Audit log" }).click();
await expect(page).toHaveURL(`${BASE}/audit-log`);
// 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 }) => {
await login(page)
await page.getByRole('button', { name: 'Sign out' }).click()
await expect(page).toHaveURL(/\/login/)
test("logout clears session", async ({ page }) => {
await login(page);
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/login/);
// Navigating to protected route redirects back
await page.goto(`${BASE}/apps/`)
await expect(page).toHaveURL(/\/login/)
})
await page.goto(`${BASE}/apps/`);
await expect(page).toHaveURL(/\/login/);
});

View file

@ -9,44 +9,45 @@
* and pairing code entry are not automatable; those paths are tested up to
* 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 EMAIL = 'test@wahoo.local'
const PASSWORD = 'TestPassword123!'
const BASE = "http://localhost:3000";
const EMAIL = "test@wahoo.local";
const PASSWORD = "TestPassword123!";
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function login(page: Page) {
await page.goto(`${BASE}/login`)
await page.getByLabel('Email').fill(EMAIL)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/apps/)
await page.goto(`${BASE}/login`);
await page.getByLabel("Email").fill(EMAIL);
await page.getByLabel("Password").fill(PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/apps/);
}
async function createAppAndKey(
page: Page,
): Promise<{ appId: string; apiKey: string }> {
const appName = `M2TestApp-${Date.now()}`
const appName = `M2TestApp-${Date.now()}`;
await page.goto(`${BASE}/apps`)
await page.getByRole('button', { name: 'New app' }).click()
await page.getByPlaceholder('App name').fill(appName)
await page.getByRole('button', { name: 'Create' }).click()
await page.getByText(appName).click()
await page.goto(`${BASE}/apps`);
await page.getByRole("button", { name: "New app" }).click();
await page.getByPlaceholder("App name").fill(appName);
await page.getByRole("button", { name: "Create" }).click();
await page.getByText(appName).click();
await page.getByRole('button', { name: 'Generate key' }).click()
await expect(page.locator('text=Copy this key')).toBeVisible()
const apiKey = await page.locator('code').first().innerText()
await page.getByRole("button", { name: "Generate key" }).click();
await expect(page.locator("text=Copy this key")).toBeVisible();
const apiKey = await page.locator("code").first().innerText();
// 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(
page: Page,
@ -56,297 +57,398 @@ async function api(
): Promise<ApiResult> {
return page.evaluate(
async ({ method, url, apiKey, body }) => {
const headers: Record<string, string> = {}
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
if (body !== undefined) headers['Content-Type'] = 'application/json'
const headers: Record<string, string> = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(url, {
method,
headers,
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 },
)
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test.describe('M2 Session Core', () => {
test('sessions page renders after login', async ({ page }) => {
await login(page)
await page.goto(`${BASE}/sessions`)
await expect(page).toHaveURL(/\/sessions/)
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible()
await expect(page.getByRole('link', { name: 'New session' })).toBeVisible()
})
test.describe("M2 Session Core", () => {
test("sessions page renders after login", async ({ page }) => {
await login(page);
await page.goto(`${BASE}/sessions`);
await expect(page).toHaveURL(/\/sessions/);
await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible();
await expect(page.getByRole("link", { name: "New session" })).toBeVisible();
});
test('new session page renders with connect method selector', async ({ page }) => {
await login(page)
await page.goto(`${BASE}/sessions/new`)
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("new session page renders with connect method selector", async ({
page,
}) => {
await login(page);
await page.goto(`${BASE}/sessions/new`);
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 }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
test("POST /v1/sessions — creates session with PENDING_QR status", async ({
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,
body: { connect_via: 'qr' },
})
body: { connect_via: "qr" },
});
expect(status).toBe(201)
expect(body.id).toBeTruthy()
expect(body.status).toBe('PENDING_QR')
expect(body.createdAt).toBeTruthy()
})
expect(status).toBe(201);
expect(body.id).toBeTruthy();
expect(body.status).toBe("PENDING_QR");
expect(body.createdAt).toBeTruthy();
});
test('POST /v1/sessions — creates session with PENDING_PAIRING status', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
test("POST /v1/sessions — creates session with PENDING_PAIRING status", async ({
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,
body: { connect_via: 'pairing' },
})
body: { connect_via: "pairing" },
});
expect(status).toBe(201)
expect(body.status).toBe('PENDING_PAIRING')
})
expect(status).toBe(201);
expect(body.status).toBe("PENDING_PAIRING");
});
test('GET /v1/sessions — lists sessions for authenticated app', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
test("GET /v1/sessions — lists sessions for authenticated app", async ({
page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
// 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 })
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', {
const { status, body } = await api(page, "GET", "/api/v1/sessions", {
apiKey,
body: { connect_via: 'qr' },
})
const sessionId = (created as { id: string }).id
});
expect(status).toBe(200);
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 })
// 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)
}
})
test("GET /v1/sessions/:id — returns session detail", async ({ page }) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
test('GET /v1/sessions/:id/qr — pairing session returns 202 while waiting', async ({ page }) => {
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`, {
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: {},
})
expect(status).toBe(400)
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 sessionId = (created as { id: string }).id;
const { status, body } = await api(
page,
'POST',
`/api/v1/sessions/${sessionId}/disconnect`,
"GET",
`/api/v1/sessions/${sessionId}`,
{ apiKey },
)
expect(status).toBe(200)
expect(body.ok).toBe(true)
);
expect(status).toBe(200);
expect(body.id).toBe(sessionId);
expect(body.status).toBe("PENDING_QR");
expect(body.reconnectCount).toBe(0);
});
// Verify status is now DISCONNECTED
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(detail.status).toBe('DISCONNECTED')
})
test("GET /v1/sessions/:id/health — returns health info", async ({
page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
// 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 { 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");
});
const { status, body } = await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(status).toBe(200)
expect(body.ok).toBe(true)
// 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);
// 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()
})
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: { connect_via: "qr" },
});
const sessionId = (created as { id: string }).id;
test('session limit enforced — rejects beyond maxSessions', async ({ page }) => {
await login(page)
const { status, body } = await api(
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
const { apiKey, appId: _appId } = await createAppAndKey(page)
test("GET /v1/sessions/:id/qr — pairing session returns 202 while waiting", async ({
page,
}) => {
await login(page);
const { apiKey } = await createAppAndKey(page);
// PATCH app to maxSessions=1
await api(page, 'PATCH', `/api/v1/admin/apps/${_appId}`, {
body: { maxSessions: 1 },
})
// 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;
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: {} })
expect(status).toBe(429)
expect((body.error as string).toLowerCase()).toMatch(/limit/)
})
// 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);
test('unknown session id returns 404', 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/00000000-0000-0000-0000-000000000000',
"GET",
`/api/v1/sessions/${sessionId}/qr`,
{ apiKey },
)
expect(status).toBe(404)
})
);
expect(status).toBe(410);
});
test('missing Authorization header returns 401', async ({ page }) => {
const { status } = await api(page, 'GET', '/api/v1/sessions')
expect(status).toBe(401)
})
// 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);
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)
})
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
apiKey,
body: { connect_via: "pairing" },
});
const sessionId = (created as { id: string }).id;
test('session detail page loads for created session', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { status, body } = await api(
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: {} })
const sessionId = (created as { id: string }).id
test("POST /v1/sessions/:id/pair — rejects too-short phone", async ({
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
await expect(page.getByPlaceholder('wak_...')).toBeVisible()
await expect(page.getByPlaceholder("wak_...")).toBeVisible();
await page.getByPlaceholder('wak_...').fill(apiKey)
await page.getByRole('button', { name: 'Load' }).click()
await page.getByPlaceholder("wak_...").fill(apiKey);
await page.getByRole("button", { name: "Load" }).click();
// 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
* 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 ────────────────────────────────────────────────────────
describe('reconnect backoff calculation', () => {
describe("reconnect backoff calculation", () => {
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('doubles each attempt', () => {
expect(backoffDelay(1)).toBe(2000)
expect(backoffDelay(2)).toBe(4000)
expect(backoffDelay(3)).toBe(8000)
expect(backoffDelay(4)).toBe(16_000)
expect(backoffDelay(5)).toBe(32_000)
})
it('caps at 60s', () => {
expect(backoffDelay(6)).toBe(60_000)
expect(backoffDelay(10)).toBe(60_000)
expect(backoffDelay(100)).toBe(60_000)
})
})
it("starts at 1s", () => expect(backoffDelay(0)).toBe(1000));
it("doubles each attempt", () => {
expect(backoffDelay(1)).toBe(2000);
expect(backoffDelay(2)).toBe(4000);
expect(backoffDelay(3)).toBe(8000);
expect(backoffDelay(4)).toBe(16_000);
expect(backoffDelay(5)).toBe(32_000);
});
it("caps at 60s", () => {
expect(backoffDelay(6)).toBe(60_000);
expect(backoffDelay(10)).toBe(60_000);
expect(backoffDelay(100)).toBe(60_000);
});
});
// ─── QR timeout ──────────────────────────────────────────────────────────────
describe('QR timeout window', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe("QR timeout window", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires after 5 minutes', () => {
const cb = vi.fn()
setTimeout(cb, 5 * 60 * 1000)
vi.advanceTimersByTime(4 * 60 * 1000 + 59_999)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(cb).toHaveBeenCalledOnce()
})
it("fires after 5 minutes", () => {
const cb = vi.fn();
setTimeout(cb, 5 * 60 * 1000);
vi.advanceTimersByTime(4 * 60 * 1000 + 59_999);
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledOnce();
});
it('resets timer on new QR rotation', () => {
const cb = vi.fn()
let timer = setTimeout(cb, 5 * 60 * 1000)
it("resets timer on new QR rotation", () => {
const cb = vi.fn();
let timer = setTimeout(cb, 5 * 60 * 1000);
// At 4:00 a new QR arrives → reset timer
vi.advanceTimersByTime(4 * 60 * 1000)
clearTimeout(timer)
timer = setTimeout(cb, 5 * 60 * 1000)
vi.advanceTimersByTime(4 * 60 * 1000);
clearTimeout(timer);
timer = setTimeout(cb, 5 * 60 * 1000);
// Old deadline (t=5m) has passed but cb should not have fired
vi.advanceTimersByTime(60_001)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(60_001);
expect(cb).not.toHaveBeenCalled();
// New deadline (t=9m) fires
vi.advanceTimersByTime(4 * 60 * 1000)
expect(cb).toHaveBeenCalledOnce()
})
})
vi.advanceTimersByTime(4 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
});
// ─── AC5: 1h reconnect window → DEAD ─────────────────────────────────────────
// Mirrors the decision in scheduleReconnect():
// if (elapsed > MAX_RECONNECT_WINDOW_MS) → setStatus('DEAD')
describe('AC5: 1h reconnect window → DEAD', () => {
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000
describe("AC5: 1h reconnect window → DEAD", () => {
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000;
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', () => {
const start = new Date(0)
const now = new Date(MAX_RECONNECT_WINDOW_MS - 1)
expect(shouldMarkDead(start, now)).toBe(false)
})
it("does not mark DEAD at 59m 59s", () => {
const start = new Date(0);
const now = new Date(MAX_RECONNECT_WINDOW_MS - 1);
expect(shouldMarkDead(start, now)).toBe(false);
});
it('marks DEAD when elapsed exceeds 1h', () => {
const start = new Date(0)
const now = new Date(MAX_RECONNECT_WINDOW_MS + 1)
expect(shouldMarkDead(start, now)).toBe(true)
})
it("marks DEAD when elapsed exceeds 1h", () => {
const start = new Date(0);
const now = new Date(MAX_RECONNECT_WINDOW_MS + 1);
expect(shouldMarkDead(start, now)).toBe(true);
});
it('marks DEAD after multiple hours', () => {
const start = new Date(0)
const now = new Date(3 * MAX_RECONNECT_WINDOW_MS)
expect(shouldMarkDead(start, now)).toBe(true)
})
it("marks DEAD after multiple hours", () => {
const start = new Date(0);
const now = new Date(3 * MAX_RECONNECT_WINDOW_MS);
expect(shouldMarkDead(start, now)).toBe(true);
});
it('marks DEAD using timer with fake timers', async () => {
vi.useFakeTimers()
const cb = vi.fn()
it("marks DEAD using timer with fake timers", async () => {
vi.useFakeTimers();
const cb = vi.fn();
// Simulate the 1h window expiring on the next reconnect attempt
setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1)
vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(cb).toHaveBeenCalledOnce()
vi.useRealTimers()
})
})
setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1);
vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS);
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(2);
expect(cb).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
// ─── AC3: initialize() — resumable status set ────────────────────────────────
// initialize() queries sessions with status in ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
// and calls startSession() for each. Tests verify which statuses are resumed.
describe('AC3: session resume on restart', () => {
const RESUMABLE = ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
const TERMINAL = ['LOGGED_OUT', 'DEAD']
const PENDING = ['PENDING_QR', 'PENDING_PAIRING']
describe("AC3: session resume on restart", () => {
const RESUMABLE = ["CONNECTED", "DISCONNECTED", "CONNECTING"];
const TERMINAL = ["LOGGED_OUT", "DEAD"];
const PENDING = ["PENDING_QR", "PENDING_PAIRING"];
it('resumes CONNECTED sessions', () => expect(RESUMABLE).toContain('CONNECTED'))
it('resumes DISCONNECTED sessions', () => expect(RESUMABLE).toContain('DISCONNECTED'))
it('resumes CONNECTING sessions', () => expect(RESUMABLE).toContain('CONNECTING'))
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("resumes CONNECTED sessions", () =>
expect(RESUMABLE).toContain("CONNECTED"));
it("resumes DISCONNECTED sessions", () =>
expect(RESUMABLE).toContain("DISCONNECTED"));
it("resumes CONNECTING sessions", () =>
expect(RESUMABLE).toContain("CONNECTING"));
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]) {
expect(RESUMABLE).not.toContain(s)
expect(RESUMABLE).not.toContain(s);
}
})
})
});
});
// ─── AC4: auto-reconnect timing ──────────────────────────────────────────────
// Kill network for 30s → should reconnect. Backoff starts at 1s, so first
// 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 {
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)', () => {
expect(backoffDelay(0)).toBe(1_000)
expect(backoffDelay(0)).toBeLessThan(30_000)
})
it("first reconnect attempt at 1s (well within 30s)", () => {
expect(backoffDelay(0)).toBe(1_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
const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0)
expect(total).toBeLessThan(30_000)
})
const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0);
expect(total).toBeLessThan(30_000);
});
it('5th attempt (16s) also within 30s', () => {
expect(backoffDelay(4)).toBeLessThanOrEqual(30_000)
})
})
it("5th attempt (16s) also within 30s", () => {
expect(backoffDelay(4)).toBeLessThanOrEqual(30_000);
});
});
// ─── AC1 partial: QR session initial state ───────────────────────────────────
// Full AC1 (scan → CONNECTED) requires live WhatsApp device.
// We test the QR code memory store returns null for unknown sessions.
describe('AC1 partial: QR code in-memory store', () => {
it('getQrCode returns null for session not in active map', () => {
describe("AC1 partial: QR code in-memory store", () => {
it("getQrCode returns null for session not in active map", () => {
// Mirrors active.get(sessionId)?.qrCode ?? null
const active = new Map<string, { qrCode: string | null }>()
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
expect(getQrCode('nonexistent')).toBeNull()
})
const active = new Map<string, { qrCode: string | null }>();
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
expect(getQrCode("nonexistent")).toBeNull();
});
it('getQrCode returns stored QR string', () => {
const active = new Map<string, { qrCode: string | null }>()
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
active.set('sess-1', { qrCode: '2@abc123,xyz,def' })
expect(getQrCode('sess-1')).toBe('2@abc123,xyz,def')
})
it("getQrCode returns stored QR string", () => {
const active = new Map<string, { qrCode: string | null }>();
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
active.set("sess-1", { qrCode: "2@abc123,xyz,def" });
expect(getQrCode("sess-1")).toBe("2@abc123,xyz,def");
});
it('QR string clears to null after CONNECTED', () => {
const active = new Map<string, { qrCode: string | null }>()
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
active.set('sess-1', { qrCode: '2@abc123' })
expect(getQrCode('sess-1')).not.toBeNull()
active.get('sess-1')!.qrCode = null // mirrors connection='open' handler
expect(getQrCode('sess-1')).toBeNull()
})
})
it("QR string clears to null after CONNECTED", () => {
const active = new Map<string, { qrCode: string | null }>();
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
active.set("sess-1", { qrCode: "2@abc123" });
expect(getQrCode("sess-1")).not.toBeNull();
active.get("sess-1")!.qrCode = null; // mirrors connection='open' handler
expect(getQrCode("sess-1")).toBeNull();
});
});
// ─── Status enum ─────────────────────────────────────────────────────────────
describe('session status values', () => {
const VALID = ['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD']
describe("session status values", () => {
const VALID = [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD",
];
it('contains all 7 expected statuses', () => {
expect(VALID).toHaveLength(7)
})
it("contains all 7 expected statuses", () => {
expect(VALID).toHaveLength(7);
});
it('terminal statuses are LOGGED_OUT and DEAD', () => {
const terminal = VALID.filter((s) => s === 'LOGGED_OUT' || s === 'DEAD')
expect(terminal).toEqual(['LOGGED_OUT', 'DEAD'])
})
it("terminal statuses are LOGGED_OUT and DEAD", () => {
const terminal = VALID.filter((s) => s === "LOGGED_OUT" || s === "DEAD");
expect(terminal).toEqual(["LOGGED_OUT", "DEAD"]);
});
it('active statuses exclude terminal', () => {
const active = VALID.filter((s) => !['LOGGED_OUT', 'DEAD'].includes(s))
expect(active).toEqual(['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'])
})
})
it("active statuses exclude terminal", () => {
const active = VALID.filter((s) => !["LOGGED_OUT", "DEAD"].includes(s));
expect(active).toEqual([
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
]);
});
});
// ─── Phone number sanitization ────────────────────────────────────────────────
describe('phone number digit extraction', () => {
describe("phone number digit extraction", () => {
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 dashes', () => 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))
})
it("strips + and spaces", () =>
expect(digitsOnly("+1 800 555 1234")).toBe("18005551234"));
it("strips dashes", () =>
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": {
"target": "ES2022",

View file

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