style: format prettier eslint
This commit is contained in:
parent
0aef5bca86
commit
c37f8f77c2
92 changed files with 3782 additions and 2662 deletions
10
.cta.json
10
.cta.json
|
|
@ -16,11 +16,5 @@
|
||||||
"routerOnly": false,
|
"routerOnly": false,
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"framework": "react",
|
"framework": "react",
|
||||||
"chosenAddOns": [
|
"chosenAddOns": ["eslint", "nitro", "shadcn", "drizzle", "table"]
|
||||||
"eslint",
|
}
|
||||||
"nitro",
|
|
||||||
"shadcn",
|
|
||||||
"drizzle",
|
|
||||||
"table"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"jsxSingleQuote": false
|
||||||
|
}
|
||||||
45
CLAUDE.md
45
CLAUDE.md
|
|
@ -27,12 +27,14 @@ Add shadcn components: `pnpm dlx shadcn@latest add <component>` (uses pnpm, not
|
||||||
Milestone-by-milestone plan lives in `.playground/plan/`.
|
Milestone-by-milestone plan lives in `.playground/plan/`.
|
||||||
|
|
||||||
**Before starting any session:**
|
**Before starting any session:**
|
||||||
|
|
||||||
1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns.
|
1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns.
|
||||||
2. Open the relevant milestone file (e.g. `M1-foundations.md`).
|
2. Open the relevant milestone file (e.g. `M1-foundations.md`).
|
||||||
3. Work tasks top-to-bottom; check boxes as you complete them.
|
3. Work tasks top-to-bottom; check boxes as you complete them.
|
||||||
4. After each task or at session end, update `STATUS.md`: progress, new blockers, decisions made.
|
4. After each task or at session end, update `STATUS.md`: progress, new blockers, decisions made.
|
||||||
|
|
||||||
**Status conventions in milestone files:**
|
**Status conventions in milestone files:**
|
||||||
|
|
||||||
- `⬜` ready to start
|
- `⬜` ready to start
|
||||||
- `🟡` concern / needs decision
|
- `🟡` concern / needs decision
|
||||||
- `🔴` blocked
|
- `🔴` blocked
|
||||||
|
|
@ -44,13 +46,13 @@ Milestone-by-milestone plan lives in `.playground/plan/`.
|
||||||
|
|
||||||
Project docs live in `docs/`:
|
Project docs live in `docs/`:
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
| ---------------------- | ----------------------------------------------- |
|
||||||
| `docs/architecture.md` | System overview, component map, key files |
|
| `docs/architecture.md` | System overview, component map, key files |
|
||||||
| `docs/api.md` | REST endpoint reference |
|
| `docs/api.md` | REST endpoint reference |
|
||||||
| `docs/webhooks.md` | Event catalog, HMAC verification snippets |
|
| `docs/webhooks.md` | Event catalog, HMAC verification snippets |
|
||||||
| `docs/security.md` | Encryption model, key rotation, audit log |
|
| `docs/security.md` | Encryption model, key rotation, audit log |
|
||||||
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
|
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
|
||||||
|
|
||||||
Update docs when adding endpoints, changing the schema, or changing security behavior.
|
Update docs when adding endpoints, changing the schema, or changing security behavior.
|
||||||
|
|
||||||
|
|
@ -74,25 +76,32 @@ Update docs when adding endpoints, changing the schema, or changing security beh
|
||||||
|
|
||||||
All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`).
|
All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`).
|
||||||
|
|
||||||
| File | Domain |
|
| File | Domain |
|
||||||
|------|--------|
|
| ---------------------- | ----------------- |
|
||||||
| `actions/apps.ts` | App CRUD |
|
| `actions/apps.ts` | App CRUD |
|
||||||
| `actions/api-keys.ts` | API key lifecycle |
|
| `actions/api-keys.ts` | API key lifecycle |
|
||||||
| `actions/sessions.ts` | Session lifecycle |
|
| `actions/sessions.ts` | Session lifecycle |
|
||||||
| `actions/auth.ts` | User lookup, TOTP |
|
| `actions/auth.ts` | User lookup, TOTP |
|
||||||
| `actions/audit-log.ts` | Audit log queries |
|
| `actions/audit-log.ts` | Audit log queries |
|
||||||
|
|
||||||
**Scope pattern** — when a function needs different authorization checks (user-owned vs app-scoped), use a discriminated union scope parameter instead of separate functions:
|
**Scope pattern** — when a function needs different authorization checks (user-owned vs app-scoped), use a discriminated union scope parameter instead of separate functions:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type Scope = { appId: string } | { userId: string }
|
type Scope = { appId: string } | { userId: string };
|
||||||
export async function findSession(sessionId: string, scope: Scope): Promise<Session | null>
|
export async function findSession(
|
||||||
|
sessionId: string,
|
||||||
|
scope: Scope,
|
||||||
|
): Promise<Session | null>;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
|
**Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export async function createSession(appId: string, via: 'qr' | 'pairing', ownerUserId?: string)
|
export async function createSession(
|
||||||
|
appId: string,
|
||||||
|
via: "qr" | "pairing",
|
||||||
|
ownerUserId?: string,
|
||||||
|
);
|
||||||
// admin: createSession(appId, via, sess.userId)
|
// admin: createSession(appId, via, sess.userId)
|
||||||
// api key: createSession(appId, via)
|
// api key: createSession(appId, via)
|
||||||
```
|
```
|
||||||
|
|
@ -100,8 +109,8 @@ export async function createSession(appId: string, via: 'qr' | 'pairing', ownerU
|
||||||
**Result type** — actions that can fail return a discriminated union, not thrown errors:
|
**Result type** — actions that can fail return a discriminated union, not thrown errors:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
return { ok: false, error: 'App not found', status: 404 }
|
return { ok: false, error: "App not found", status: 404 };
|
||||||
return { ok: true, session }
|
return { ok: true, session };
|
||||||
// route: if (!result.ok) return jsonError(result.error, result.status)
|
// route: if (!result.ok) return jsonError(result.error, result.status)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
61
README.md
61
README.md
|
|
@ -1,4 +1,4 @@
|
||||||
Welcome to your new TanStack Start app!
|
Welcome to your new TanStack Start app!
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
|
|
@ -40,7 +40,6 @@ If you prefer not to use Tailwind CSS:
|
||||||
|
|
||||||
## Linting & Formatting
|
## Linting & Formatting
|
||||||
|
|
||||||
|
|
||||||
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
|
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -49,7 +48,6 @@ bun --bun run format
|
||||||
bun --bun run check
|
bun --bun run check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Deploy with Nitro
|
## Deploy with Nitro
|
||||||
|
|
||||||
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
|
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
|
||||||
|
|
@ -63,7 +61,6 @@ The build output is a self-contained Node server. To deploy, push the `dist/` di
|
||||||
|
|
||||||
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
|
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
|
||||||
|
|
||||||
|
|
||||||
## Shadcn
|
## Shadcn
|
||||||
|
|
||||||
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
||||||
|
|
@ -72,8 +69,6 @@ Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
||||||
pnpm dlx shadcn@latest add button
|
pnpm dlx shadcn@latest add button
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
||||||
|
|
@ -111,14 +106,14 @@ In the File Based Routing setup the layout is located in `src/routes/__root.tsx`
|
||||||
Here is an example layout that includes a header:
|
Here is an example layout that includes a header:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ charSet: 'utf-8' },
|
{ charSet: "utf-8" },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||||
{ title: 'My App' },
|
{ title: "My App" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
shellComponent: ({ children }) => (
|
shellComponent: ({ children }) => (
|
||||||
|
|
@ -138,7 +133,7 @@ export const Route = createRootRoute({
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||||
|
|
@ -148,23 +143,23 @@ More information on layouts can be found in the [Layouts documentation](https://
|
||||||
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
const getServerTime = createServerFn({
|
const getServerTime = createServerFn({
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
}).handler(async () => {
|
}).handler(async () => {
|
||||||
return new Date().toISOString()
|
return new Date().toISOString();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Use in a component
|
// Use in a component
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const [time, setTime] = useState('')
|
const [time, setTime] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getServerTime().then(setTime)
|
getServerTime().then(setTime);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return <div>Server time: {time}</div>
|
return <div>Server time: {time}</div>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -173,16 +168,16 @@ function MyComponent() {
|
||||||
You can create API routes by using the `server` property in your route definitions:
|
You can create API routes by using the `server` property in your route definitions:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json } from '@tanstack/react-start'
|
import { json } from "@tanstack/react-start";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/hello')({
|
export const Route = createFileRoute("/api/hello")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: () => json({ message: 'Hello, World!' }),
|
GET: () => json({ message: "Hello, World!" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Fetching
|
## Data Fetching
|
||||||
|
|
@ -192,25 +187,25 @@ There are multiple ways to fetch data in your application. You can use TanStack
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute('/people')({
|
export const Route = createFileRoute("/people")({
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const response = await fetch('https://swapi.dev/api/people')
|
const response = await fetch("https://swapi.dev/api/people");
|
||||||
return response.json()
|
return response.json();
|
||||||
},
|
},
|
||||||
component: PeopleComponent,
|
component: PeopleComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function PeopleComponent() {
|
function PeopleComponent() {
|
||||||
const data = Route.useLoaderData()
|
const data = Route.useLoaderData();
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
{data.results.map((person) => (
|
{data.results.map((person) => (
|
||||||
<li key={person.name}>{person.name}</li>
|
<li key={person.name}>{person.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,4 @@
|
||||||
"hooks": "#/hooks"
|
"hooks": "#/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,13 @@ services:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ Header: `X-Signature: sha256=<hex>`.
|
||||||
Verify (Node.js):
|
Verify (Node.js):
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const crypto = require('crypto')
|
const crypto = require("crypto");
|
||||||
function verify(secret, body, timestamp, sig) {
|
function verify(secret, body, timestamp, sig) {
|
||||||
const expected = crypto
|
const expected = crypto
|
||||||
.createHmac('sha256', secret)
|
.createHmac("sha256", secret)
|
||||||
.update(body + '.' + timestamp)
|
.update(body + "." + timestamp)
|
||||||
.digest('hex')
|
.digest("hex");
|
||||||
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
|
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,18 +73,18 @@ Signature covers: `raw_body + "." + X-Timestamp`.
|
||||||
**Node.js**
|
**Node.js**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const crypto = require('crypto')
|
const crypto = require("crypto");
|
||||||
|
|
||||||
function verifyWebhook(secret, rawBody, timestamp, signature) {
|
function verifyWebhook(secret, rawBody, timestamp, signature) {
|
||||||
const expected = crypto
|
const expected = crypto
|
||||||
.createHmac('sha256', secret)
|
.createHmac("sha256", secret)
|
||||||
.update(rawBody + '.' + timestamp)
|
.update(rawBody + "." + timestamp)
|
||||||
.digest('hex')
|
.digest("hex");
|
||||||
const received = signature.replace('sha256=', '')
|
const received = signature.replace("sha256=", "");
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(
|
||||||
Buffer.from(expected, 'hex'),
|
Buffer.from(expected, "hex"),
|
||||||
Buffer.from(received, 'hex'),
|
Buffer.from(received, "hex"),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
import { config } from 'dotenv'
|
import { config } from "dotenv";
|
||||||
import { defineConfig } from 'drizzle-kit'
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
config({ path: ['.env.local', '.env'] })
|
config({ path: [".env.local", ".env"] });
|
||||||
|
|
||||||
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env
|
const {
|
||||||
|
POSTGRES_USER,
|
||||||
|
POSTGRES_PASSWORD,
|
||||||
|
POSTGRES_HOST,
|
||||||
|
POSTGRES_PORT,
|
||||||
|
POSTGRES_DB,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: "./drizzle",
|
||||||
schema: './src/db/schema.ts',
|
schema: "./src/db/schema.ts",
|
||||||
dialect: 'postgresql',
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
host: POSTGRES_HOST ?? 'localhost',
|
host: POSTGRES_HOST ?? "localhost",
|
||||||
port: Number(POSTGRES_PORT ?? 5432),
|
port: Number(POSTGRES_PORT ?? 5432),
|
||||||
user: POSTGRES_USER ?? 'wahoo',
|
user: POSTGRES_USER ?? "wahoo",
|
||||||
password: POSTGRES_PASSWORD ?? 'wahoo',
|
password: POSTGRES_PASSWORD ?? "wahoo",
|
||||||
database: POSTGRES_DB ?? 'wahoo',
|
database: POSTGRES_DB ?? "wahoo",
|
||||||
ssl: false,
|
ssl: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,8 @@
|
||||||
"name": "api_keys_app_id_apps_id_fk",
|
"name": "api_keys_app_id_apps_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -148,12 +144,8 @@
|
||||||
"name": "apps_owner_user_id_users_id_fk",
|
"name": "apps_owner_user_id_users_id_fk",
|
||||||
"tableFrom": "apps",
|
"tableFrom": "apps",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["owner_user_id"],
|
||||||
"owner_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -349,9 +341,7 @@
|
||||||
"users_email_unique": {
|
"users_email_unique": {
|
||||||
"name": "users_email_unique",
|
"name": "users_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -363,10 +353,7 @@
|
||||||
"public.actor_type": {
|
"public.actor_type": {
|
||||||
"name": "actor_type",
|
"name": "actor_type",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["user", "api_key"]
|
||||||
"user",
|
|
||||||
"api_key"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
|
|
@ -379,4 +366,4 @@
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {}
|
"tables": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,8 @@
|
||||||
"name": "api_keys_app_id_apps_id_fk",
|
"name": "api_keys_app_id_apps_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -148,12 +144,8 @@
|
||||||
"name": "apps_owner_user_id_users_id_fk",
|
"name": "apps_owner_user_id_users_id_fk",
|
||||||
"tableFrom": "apps",
|
"tableFrom": "apps",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["owner_user_id"],
|
||||||
"owner_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -347,12 +339,8 @@
|
||||||
"name": "session_keys_session_id_sessions_id_fk",
|
"name": "session_keys_session_id_sessions_id_fk",
|
||||||
"tableFrom": "session_keys",
|
"tableFrom": "session_keys",
|
||||||
"tableTo": "sessions",
|
"tableTo": "sessions",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["session_id"],
|
||||||
"session_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -362,11 +350,7 @@
|
||||||
"session_keys_unique": {
|
"session_keys_unique": {
|
||||||
"name": "session_keys_unique",
|
"name": "session_keys_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["session_id", "key_type", "key_id"]
|
||||||
"session_id",
|
|
||||||
"key_type",
|
|
||||||
"key_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -474,12 +458,8 @@
|
||||||
"name": "sessions_app_id_apps_id_fk",
|
"name": "sessions_app_id_apps_id_fk",
|
||||||
"tableFrom": "sessions",
|
"tableFrom": "sessions",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -541,9 +521,7 @@
|
||||||
"users_email_unique": {
|
"users_email_unique": {
|
||||||
"name": "users_email_unique",
|
"name": "users_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -555,10 +533,7 @@
|
||||||
"public.actor_type": {
|
"public.actor_type": {
|
||||||
"name": "actor_type",
|
"name": "actor_type",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["user", "api_key"]
|
||||||
"user",
|
|
||||||
"api_key"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.session_status": {
|
"public.session_status": {
|
||||||
"name": "session_status",
|
"name": "session_status",
|
||||||
|
|
@ -584,4 +559,4 @@
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {}
|
"tables": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,4 @@
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
import { tanstackConfig } from "@tanstack/eslint-config";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
...tanstackConfig,
|
...tanstackConfig,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'import/no-cycle': 'off',
|
"import/no-cycle": "off",
|
||||||
'import/order': 'off',
|
"import/order": "off",
|
||||||
'sort-imports': 'off',
|
"sort-imports": "off",
|
||||||
'@typescript-eslint/array-type': 'off',
|
"@typescript-eslint/array-type": "off",
|
||||||
'@typescript-eslint/require-await': 'off',
|
"@typescript-eslint/require-await": "off",
|
||||||
'pnpm/json-enforce-catalog': 'off',
|
"pnpm/json-enforce-catalog": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['eslint.config.js', 'prettier.config.js'],
|
ignores: ["eslint.config.js", "prettier.config.js"],
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
|
||||||
|
|
@ -82,4 +82,4 @@
|
||||||
"lightningcss"
|
"lightningcss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: "./tests",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
reporter: 'list',
|
reporter: "list",
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: "http://localhost:3000",
|
||||||
headless: true,
|
headless: true,
|
||||||
channel: 'chrome',
|
channel: "chrome",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chrome',
|
name: "chrome",
|
||||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,21 @@
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { users } from '../src/db/schema.ts'
|
import { users } from "../src/db/schema.ts";
|
||||||
import argon2 from 'argon2'
|
import argon2 from "argon2";
|
||||||
import { config } from 'dotenv'
|
import { config } from "dotenv";
|
||||||
|
|
||||||
config({ path: ['.env.local', '.env'] })
|
config({ path: [".env.local", ".env"] });
|
||||||
|
|
||||||
const db = drizzle(
|
const db = drizzle(
|
||||||
`postgresql://${process.env.POSTGRES_USER ?? 'postgres'}:${process.env.POSTGRES_PASSWORD ?? 'postgres'}@${process.env.POSTGRES_HOST ?? 'localhost'}:${process.env.POSTGRES_PORT ?? '5432'}/${process.env.POSTGRES_DB ?? 'wahoo'}`,
|
`postgresql://${process.env.POSTGRES_USER ?? "postgres"}:${process.env.POSTGRES_PASSWORD ?? "postgres"}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB ?? "wahoo"}`,
|
||||||
{ schema: { users } },
|
{ schema: { users } },
|
||||||
)
|
);
|
||||||
|
|
||||||
const email = 'test@wahoo.local'
|
const email = "test@wahoo.local";
|
||||||
const password = 'TestPassword123!'
|
const password = "TestPassword123!";
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id })
|
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||||
|
|
||||||
await db
|
await db.insert(users).values({ email, passwordHash }).onConflictDoNothing();
|
||||||
.insert(users)
|
|
||||||
.values({ email, passwordHash })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
|
|
||||||
console.log(`Seeded user: ${email} / ${password}`)
|
console.log(`Seeded user: ${email} / ${password}`);
|
||||||
process.exit(0)
|
process.exit(0);
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,35 @@
|
||||||
import { Link, useLocation, useRouter } from '@tanstack/react-router'
|
import { Link, useLocation, useRouter } from "@tanstack/react-router";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
LayoutGrid, Zap, ScrollText, LogOut, MessageSquare,
|
LayoutGrid,
|
||||||
ChevronLeft, Key,
|
Zap,
|
||||||
} from 'lucide-react'
|
ScrollText,
|
||||||
import { appQuery } from '#/lib/query'
|
LogOut,
|
||||||
import { cn } from '#/lib/utils'
|
MessageSquare,
|
||||||
|
ChevronLeft,
|
||||||
|
Key,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { appQuery } from "#/lib/query";
|
||||||
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
// Detect app context: /apps/<uuid>[/*]
|
// Detect app context: /apps/<uuid>[/*]
|
||||||
const appContextMatch = location.pathname.match(
|
const appContextMatch = location.pathname.match(
|
||||||
/^\/apps\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\/|$)/i,
|
/^\/apps\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\/|$)/i,
|
||||||
)
|
);
|
||||||
const currentAppId = appContextMatch?.[1] ?? null
|
const currentAppId = appContextMatch?.[1] ?? null;
|
||||||
|
|
||||||
const { data: currentApp } = useQuery({
|
const { data: currentApp } = useQuery({
|
||||||
...appQuery(currentAppId ?? ''),
|
...appQuery(currentAppId ?? ""),
|
||||||
enabled: !!currentAppId,
|
enabled: !!currentAppId,
|
||||||
})
|
});
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
router.navigate({ to: '/login' })
|
router.navigate({ to: "/login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,11 +37,15 @@ export function Sidebar() {
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-zinc-900">
|
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-zinc-900">
|
||||||
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/30 flex-shrink-0">
|
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/30 flex-shrink-0">
|
||||||
<MessageSquare size={14} className="text-white fill-white" strokeWidth={1.5} />
|
<MessageSquare
|
||||||
|
size={14}
|
||||||
|
className="text-white fill-white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-white font-semibold text-[15px] tracking-tight"
|
className="text-white font-semibold text-[15px] tracking-tight"
|
||||||
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
|
style={{ fontFamily: "Fraunces, Georgia, serif" }}
|
||||||
>
|
>
|
||||||
wahoo
|
wahoo
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -66,47 +75,47 @@ export function Sidebar() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GlobalNav({ pathname }: { pathname: string }) {
|
function GlobalNav({ pathname }: { pathname: string }) {
|
||||||
const items = [
|
const items = [
|
||||||
{ to: '/apps' as const, label: 'Apps', icon: LayoutGrid },
|
{ to: "/apps" as const, label: "Apps", icon: LayoutGrid },
|
||||||
{ to: '/audit-log' as const, label: 'Audit Log', icon: ScrollText },
|
{ to: "/audit-log" as const, label: "Audit Log", icon: ScrollText },
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{items.map(({ to, label, icon: Icon }) => {
|
{items.map(({ to, label, icon: Icon }) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
to === '/apps'
|
to === "/apps"
|
||||||
? pathname === '/apps' || pathname === '/apps/'
|
? pathname === "/apps" || pathname === "/apps/"
|
||||||
: to === '/sessions'
|
: to === "/sessions"
|
||||||
? pathname.startsWith('/sessions')
|
? pathname.startsWith("/sessions")
|
||||||
: pathname === to
|
: pathname === to;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full',
|
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full",
|
||||||
isActive
|
isActive
|
||||||
? 'bg-zinc-800 text-white'
|
? "bg-zinc-800 text-white"
|
||||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
|
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={14}
|
size={14}
|
||||||
strokeWidth={isActive ? 2 : 1.75}
|
strokeWidth={isActive ? 2 : 1.75}
|
||||||
className={isActive ? 'text-emerald-400' : ''}
|
className={isActive ? "text-emerald-400" : ""}
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppContextNav({
|
function AppContextNav({
|
||||||
|
|
@ -114,19 +123,20 @@ function AppContextNav({
|
||||||
appName,
|
appName,
|
||||||
pathname,
|
pathname,
|
||||||
}: {
|
}: {
|
||||||
appId: string
|
appId: string;
|
||||||
appName: string | undefined
|
appName: string | undefined;
|
||||||
pathname: string
|
pathname: string;
|
||||||
}) {
|
}) {
|
||||||
const keysPath = `/apps/${appId}/`
|
const keysPath = `/apps/${appId}/`;
|
||||||
const sessionsPath = `/apps/${appId}/sessions`
|
const sessionsPath = `/apps/${appId}/sessions`;
|
||||||
|
|
||||||
const isKeys =
|
const isKeys =
|
||||||
pathname === `/apps/${appId}` ||
|
pathname === `/apps/${appId}` ||
|
||||||
pathname === `/apps/${appId}/` ||
|
pathname === `/apps/${appId}/` ||
|
||||||
(pathname.startsWith(`/apps/${appId}/`) && !pathname.startsWith(sessionsPath))
|
(pathname.startsWith(`/apps/${appId}/`) &&
|
||||||
const isSessions = pathname.startsWith(sessionsPath)
|
!pathname.startsWith(sessionsPath));
|
||||||
const isAuditLog = pathname === '/audit-log'
|
const isSessions = pathname.startsWith(sessionsPath);
|
||||||
|
const isAuditLog = pathname === "/audit-log";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -143,10 +153,14 @@ function AppContextNav({
|
||||||
<div className="px-3 py-1.5 mb-0.5">
|
<div className="px-3 py-1.5 mb-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-5 h-5 rounded bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center flex-shrink-0">
|
<div className="w-5 h-5 rounded bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center flex-shrink-0">
|
||||||
<LayoutGrid size={10} className="text-emerald-400" strokeWidth={2} />
|
<LayoutGrid
|
||||||
|
size={10}
|
||||||
|
className="text-emerald-400"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[12px] font-semibold text-zinc-300 truncate">
|
<span className="text-[12px] font-semibold text-zinc-300 truncate">
|
||||||
{appName ?? '…'}
|
{appName ?? "…"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,16 +170,16 @@ function AppContextNav({
|
||||||
to="/apps/$appId/"
|
to="/apps/$appId/"
|
||||||
params={{ appId }}
|
params={{ appId }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1',
|
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
|
||||||
isKeys
|
isKeys
|
||||||
? 'bg-zinc-800 text-white'
|
? "bg-zinc-800 text-white"
|
||||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
|
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key
|
<Key
|
||||||
size={13}
|
size={13}
|
||||||
strokeWidth={isKeys ? 2 : 1.75}
|
strokeWidth={isKeys ? 2 : 1.75}
|
||||||
className={isKeys ? 'text-emerald-400' : ''}
|
className={isKeys ? "text-emerald-400" : ""}
|
||||||
/>
|
/>
|
||||||
API Keys
|
API Keys
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -174,16 +188,16 @@ function AppContextNav({
|
||||||
to="/apps/$appId/sessions/"
|
to="/apps/$appId/sessions/"
|
||||||
params={{ appId }}
|
params={{ appId }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1',
|
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
|
||||||
isSessions
|
isSessions
|
||||||
? 'bg-zinc-800 text-white'
|
? "bg-zinc-800 text-white"
|
||||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
|
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Zap
|
<Zap
|
||||||
size={13}
|
size={13}
|
||||||
strokeWidth={isSessions ? 2 : 1.75}
|
strokeWidth={isSessions ? 2 : 1.75}
|
||||||
className={isSessions ? 'text-emerald-400' : ''}
|
className={isSessions ? "text-emerald-400" : ""}
|
||||||
/>
|
/>
|
||||||
Sessions
|
Sessions
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -195,19 +209,19 @@ function AppContextNav({
|
||||||
<Link
|
<Link
|
||||||
to="/audit-log"
|
to="/audit-log"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150',
|
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150",
|
||||||
isAuditLog
|
isAuditLog
|
||||||
? 'bg-zinc-800 text-white'
|
? "bg-zinc-800 text-white"
|
||||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-900',
|
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ScrollText
|
<ScrollText
|
||||||
size={13}
|
size={13}
|
||||||
strokeWidth={isAuditLog ? 2 : 1.75}
|
strokeWidth={isAuditLog ? 2 : 1.75}
|
||||||
className={isAuditLog ? 'text-emerald-400' : ''}
|
className={isAuditLog ? "text-emerald-400" : ""}
|
||||||
/>
|
/>
|
||||||
Audit Log
|
Audit Log
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
import { Slot } from "radix-ui"
|
import type {VariantProps} from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
|
@ -23,8 +24,8 @@ const badgeVariants = cva(
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
|
|
@ -33,7 +34,7 @@ function Badge({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot.Root : "span"
|
const Comp = asChild ? Slot.Root : "span";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|
@ -42,7 +43,7 @@ function Badge({
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
import { Slot } from "radix-ui"
|
import type {VariantProps} from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
|
@ -35,8 +36,8 @@ const buttonVariants = cva(
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
|
|
@ -46,9 +47,9 @@ function Button({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|
@ -58,7 +59,7 @@ function Button({
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className={cn("px-6", className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -89,4 +89,4 @@ export {
|
||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
import { Button } from "#/components/ui/button.tsx"
|
import { Button } from "#/components/ui/button.tsx";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
|
|
@ -40,11 +40,11 @@ function DialogOverlay({
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
|
|
@ -53,7 +53,7 @@ function DialogContent({
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
|
@ -62,7 +62,7 @@ function DialogContent({
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -78,7 +78,7 @@ function DialogContent({
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|
@ -88,7 +88,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
|
|
@ -97,14 +97,14 @@ function DialogFooter({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -115,7 +115,7 @@ function DialogFooter({
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
|
|
@ -128,7 +128,7 @@ function DialogTitle({
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
|
|
@ -141,7 +141,7 @@ function DialogDescription({
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -155,4 +155,4 @@ export {
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
|
|
@ -12,11 +12,11 @@ function Label({
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
import { Select as SelectPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
|
|
@ -30,7 +30,7 @@ function SelectTrigger({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
|
|
@ -38,7 +38,7 @@ function SelectTrigger({
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -47,7 +47,7 @@ function SelectTrigger({
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
|
|
@ -65,7 +65,7 @@ function SelectContent({
|
||||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
align={align}
|
align={align}
|
||||||
|
|
@ -76,7 +76,7 @@ function SelectContent({
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -84,7 +84,7 @@ function SelectContent({
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
|
|
@ -97,7 +97,7 @@ function SelectLabel({
|
||||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
|
|
@ -110,7 +110,7 @@ function SelectItem({
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -124,7 +124,7 @@ function SelectItem({
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
|
|
@ -137,7 +137,7 @@ function SelectSeparator({
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
|
|
@ -149,13 +149,13 @@ function SelectScrollUpButton({
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
|
|
@ -167,13 +167,13 @@ function SelectScrollDownButton({
|
||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -187,4 +187,4 @@ export {
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
|
|
@ -16,11 +16,11 @@ function Separator({
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "#/lib/utils.ts"
|
import { cn } from "#/lib/utils.ts";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
|
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
className={cn("[&_tr]:border-b", className)}
|
className={cn("[&_tr]:border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
|
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
|
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
|
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
|
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
|
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
function TableCaption({
|
||||||
|
|
@ -99,7 +99,7 @@ function TableCaption({
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -111,4 +111,4 @@ export {
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
|
||||||
import * as schema from './schema.ts'
|
import * as schema from "./schema.ts";
|
||||||
|
|
||||||
function getDbUrl(): string {
|
function getDbUrl(): string {
|
||||||
const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env
|
const {
|
||||||
const user = POSTGRES_USER ?? 'wahoo'
|
POSTGRES_USER,
|
||||||
const pass = POSTGRES_PASSWORD ?? 'wahoo'
|
POSTGRES_PASSWORD,
|
||||||
const host = POSTGRES_HOST ?? 'localhost'
|
POSTGRES_HOST,
|
||||||
const port = POSTGRES_PORT ?? '5432'
|
POSTGRES_PORT,
|
||||||
const db = POSTGRES_DB ?? 'wahoo'
|
POSTGRES_DB,
|
||||||
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}`
|
} = process.env;
|
||||||
|
const user = POSTGRES_USER ?? "wahoo";
|
||||||
|
const pass = POSTGRES_PASSWORD ?? "wahoo";
|
||||||
|
const host = POSTGRES_HOST ?? "localhost";
|
||||||
|
const port = POSTGRES_PORT ?? "5432";
|
||||||
|
const db = POSTGRES_DB ?? "wahoo";
|
||||||
|
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = drizzle(getDbUrl(), { schema })
|
export const db = drizzle(getDbUrl(), { schema });
|
||||||
|
|
|
||||||
172
src/db/schema.ts
172
src/db/schema.ts
|
|
@ -9,132 +9,138 @@ import {
|
||||||
timestamp,
|
timestamp,
|
||||||
unique,
|
unique,
|
||||||
uuid,
|
uuid,
|
||||||
} from 'drizzle-orm/pg-core'
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from 'drizzle-orm'
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
export const actorTypeEnum = pgEnum('actor_type', ['user', 'api_key'])
|
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
|
||||||
|
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable("users", {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
email: text('email').notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text("password_hash").notNull(),
|
||||||
totpSecret: text('totp_secret'),
|
totpSecret: text("totp_secret"),
|
||||||
totpEnabled: boolean('totp_enabled').notNull().default(false),
|
totpEnabled: boolean("totp_enabled").notNull().default(false),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const apps = pgTable('apps', {
|
export const apps = pgTable("apps", {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text('name').notNull(),
|
name: text("name").notNull(),
|
||||||
ownerUserId: uuid('owner_user_id')
|
ownerUserId: uuid("owner_user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
dekEncrypted: text('dek_encrypted').notNull(),
|
dekEncrypted: text("dek_encrypted").notNull(),
|
||||||
maxSessions: text('max_sessions').notNull().default('10'),
|
maxSessions: text("max_sessions").notNull().default("10"),
|
||||||
deletedAt: timestamp('deleted_at'),
|
deletedAt: timestamp("deleted_at"),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const apiKeys = pgTable(
|
export const apiKeys = pgTable(
|
||||||
'api_keys',
|
"api_keys",
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
appId: uuid('app_id')
|
appId: uuid("app_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => apps.id),
|
.references(() => apps.id),
|
||||||
keyHash: text('key_hash').notNull(),
|
keyHash: text("key_hash").notNull(),
|
||||||
keyPrefix: text('key_prefix').notNull(),
|
keyPrefix: text("key_prefix").notNull(),
|
||||||
label: text('label').notNull().default(''),
|
label: text("label").notNull().default(""),
|
||||||
revokedAt: timestamp('revoked_at'),
|
revokedAt: timestamp("revoked_at"),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index('api_keys_app_id_idx').on(t.appId)],
|
(t) => [index("api_keys_app_id_idx").on(t.appId)],
|
||||||
)
|
);
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
apps: many(apps),
|
apps: many(apps),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export const appsRelations = relations(apps, ({ one, many }) => ({
|
export const appsRelations = relations(apps, ({ one, many }) => ({
|
||||||
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
|
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
|
||||||
apiKeys: many(apiKeys),
|
apiKeys: many(apiKeys),
|
||||||
sessions: many(sessions),
|
sessions: many(sessions),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||||
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
|
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export const sessionStatusEnum = pgEnum('session_status', [
|
export const sessionStatusEnum = pgEnum("session_status", [
|
||||||
'PENDING_QR',
|
"PENDING_QR",
|
||||||
'PENDING_PAIRING',
|
"PENDING_PAIRING",
|
||||||
'CONNECTING',
|
"CONNECTING",
|
||||||
'CONNECTED',
|
"CONNECTED",
|
||||||
'DISCONNECTED',
|
"DISCONNECTED",
|
||||||
'LOGGED_OUT',
|
"LOGGED_OUT",
|
||||||
'DEAD',
|
"DEAD",
|
||||||
])
|
]);
|
||||||
|
|
||||||
export const sessions = pgTable(
|
export const sessions = pgTable(
|
||||||
'sessions',
|
"sessions",
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
appId: uuid('app_id')
|
appId: uuid("app_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => apps.id),
|
.references(() => apps.id),
|
||||||
status: sessionStatusEnum('status').notNull().default('PENDING_QR'),
|
status: sessionStatusEnum("status").notNull().default("PENDING_QR"),
|
||||||
phoneNumber: text('phone_number'),
|
phoneNumber: text("phone_number"),
|
||||||
displayName: text('display_name'),
|
displayName: text("display_name"),
|
||||||
credentialsEncrypted: text('credentials_encrypted'),
|
credentialsEncrypted: text("credentials_encrypted"),
|
||||||
reconnectCount: integer('reconnect_count').notNull().default(0),
|
reconnectCount: integer("reconnect_count").notNull().default(0),
|
||||||
lastSeen: timestamp('last_seen'),
|
lastSeen: timestamp("last_seen"),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index('sessions_app_id_idx').on(t.appId), index('sessions_status_idx').on(t.status)],
|
(t) => [
|
||||||
)
|
index("sessions_app_id_idx").on(t.appId),
|
||||||
|
index("sessions_status_idx").on(t.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sessionKeys = pgTable(
|
export const sessionKeys = pgTable(
|
||||||
'session_keys',
|
"session_keys",
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
sessionId: uuid('session_id')
|
sessionId: uuid("session_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sessions.id, { onDelete: 'cascade' }),
|
.references(() => sessions.id, { onDelete: "cascade" }),
|
||||||
keyType: text('key_type').notNull(),
|
keyType: text("key_type").notNull(),
|
||||||
keyId: text('key_id').notNull(),
|
keyId: text("key_id").notNull(),
|
||||||
valueEncrypted: text('value_encrypted').notNull(),
|
valueEncrypted: text("value_encrypted").notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => [unique('session_keys_unique').on(t.sessionId, t.keyType, t.keyId)],
|
(t) => [unique("session_keys_unique").on(t.sessionId, t.keyType, t.keyId)],
|
||||||
)
|
);
|
||||||
|
|
||||||
export const sessionsRelations = relations(sessions, ({ one, many }) => ({
|
export const sessionsRelations = relations(sessions, ({ one, many }) => ({
|
||||||
app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
|
app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
|
||||||
keys: many(sessionKeys),
|
keys: many(sessionKeys),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({
|
export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({
|
||||||
session: one(sessions, { fields: [sessionKeys.sessionId], references: [sessions.id] }),
|
session: one(sessions, {
|
||||||
}))
|
fields: [sessionKeys.sessionId],
|
||||||
|
references: [sessions.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const auditLog = pgTable(
|
export const auditLog = pgTable(
|
||||||
'audit_log',
|
"audit_log",
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
actorType: actorTypeEnum('actor_type').notNull(),
|
actorType: actorTypeEnum("actor_type").notNull(),
|
||||||
actorId: uuid('actor_id').notNull(),
|
actorId: uuid("actor_id").notNull(),
|
||||||
action: text('action').notNull(),
|
action: text("action").notNull(),
|
||||||
resourceType: text('resource_type').notNull(),
|
resourceType: text("resource_type").notNull(),
|
||||||
resourceId: text('resource_id'),
|
resourceId: text("resource_id"),
|
||||||
ip: text('ip'),
|
ip: text("ip"),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text("user_agent"),
|
||||||
diffBefore: jsonb('diff_before'),
|
diffBefore: jsonb("diff_before"),
|
||||||
diffAfter: jsonb('diff_after'),
|
diffAfter: jsonb("diff_after"),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index('audit_log_actor_idx').on(t.actorType, t.actorId),
|
index("audit_log_actor_idx").on(t.actorType, t.actorId),
|
||||||
index('audit_log_action_idx').on(t.action),
|
index("audit_log_action_idx").on(t.action),
|
||||||
index('audit_log_created_at_idx').on(t.createdAt),
|
index("audit_log_created_at_idx").on(t.createdAt),
|
||||||
],
|
],
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,60 @@
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { apiKeys } from '#/db/schema'
|
import { apiKeys } from "#/db/schema";
|
||||||
import { generateApiKey, hashApiKey } from '#/lib/api-keys'
|
import { generateApiKey, hashApiKey } from "#/lib/api-keys";
|
||||||
import { writeAudit } from '#/lib/audit'
|
import { writeAudit } from "#/lib/audit";
|
||||||
|
|
||||||
export async function listAppApiKeys(appId: string) {
|
export async function listAppApiKeys(appId: string) {
|
||||||
const keys = await db.query.apiKeys.findMany({
|
const keys = await db.query.apiKeys.findMany({
|
||||||
where: eq(apiKeys.appId, appId),
|
where: eq(apiKeys.appId, appId),
|
||||||
columns: { id: true, keyPrefix: true, label: true, revokedAt: true, createdAt: true },
|
columns: {
|
||||||
})
|
id: true,
|
||||||
return keys.map((k) => ({ ...k, display: `••••${k.keyPrefix}` }))
|
keyPrefix: true,
|
||||||
|
label: true,
|
||||||
|
revokedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return keys.map((k) => ({ ...k, display: `••••${k.keyPrefix}` }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAppApiKey(
|
export async function createAppApiKey(
|
||||||
appId: string,
|
appId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
label?: string,
|
label?: string,
|
||||||
env?: 'live' | 'test',
|
env?: "live" | "test",
|
||||||
audit?: { ip?: string; userAgent?: string },
|
audit?: { ip?: string; userAgent?: string },
|
||||||
) {
|
) {
|
||||||
const { key, prefix } = generateApiKey(env ?? 'live')
|
const { key, prefix } = generateApiKey(env ?? "live");
|
||||||
const keyHash = await hashApiKey(key)
|
const keyHash = await hashApiKey(key);
|
||||||
|
|
||||||
const [created] = await db.insert(apiKeys).values({
|
const [created] = await db
|
||||||
appId,
|
.insert(apiKeys)
|
||||||
keyHash,
|
.values({
|
||||||
keyPrefix: prefix,
|
appId,
|
||||||
label: label ?? '',
|
keyHash,
|
||||||
}).returning({ id: apiKeys.id, keyPrefix: apiKeys.keyPrefix, label: apiKeys.label, createdAt: apiKeys.createdAt })
|
keyPrefix: prefix,
|
||||||
|
label: label ?? "",
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: apiKeys.id,
|
||||||
|
keyPrefix: apiKeys.keyPrefix,
|
||||||
|
label: apiKeys.label,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
actorType: 'user',
|
actorType: "user",
|
||||||
actorId: userId,
|
actorId: userId,
|
||||||
action: 'api_key.create',
|
action: "api_key.create",
|
||||||
resourceType: 'api_key',
|
resourceType: "api_key",
|
||||||
resourceId: created.id,
|
resourceId: created.id,
|
||||||
ip: audit?.ip,
|
ip: audit?.ip,
|
||||||
userAgent: audit?.userAgent,
|
userAgent: audit?.userAgent,
|
||||||
diffAfter: { label: created.label, appId },
|
diffAfter: { label: created.label, appId },
|
||||||
})
|
});
|
||||||
|
|
||||||
return { ...created, key }
|
return { ...created, key };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeAppApiKey(
|
export async function revokeAppApiKey(
|
||||||
|
|
@ -51,21 +65,25 @@ export async function revokeAppApiKey(
|
||||||
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
|
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
|
||||||
const key = await db.query.apiKeys.findFirst({
|
const key = await db.query.apiKeys.findFirst({
|
||||||
where: and(eq(apiKeys.id, keyId), eq(apiKeys.appId, appId)),
|
where: and(eq(apiKeys.id, keyId), eq(apiKeys.appId, appId)),
|
||||||
})
|
});
|
||||||
if (!key) return { ok: false, error: 'Key not found', status: 404 }
|
if (!key) return { ok: false, error: "Key not found", status: 404 };
|
||||||
if (key.revokedAt) return { ok: false, error: 'Key already revoked', status: 409 }
|
if (key.revokedAt)
|
||||||
|
return { ok: false, error: "Key already revoked", status: 409 };
|
||||||
|
|
||||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId))
|
await db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(eq(apiKeys.id, keyId));
|
||||||
|
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
actorType: 'user',
|
actorType: "user",
|
||||||
actorId: userId,
|
actorId: userId,
|
||||||
action: 'api_key.revoke',
|
action: "api_key.revoke",
|
||||||
resourceType: 'api_key',
|
resourceType: "api_key",
|
||||||
resourceId: keyId,
|
resourceId: keyId,
|
||||||
ip: audit?.ip,
|
ip: audit?.ip,
|
||||||
userAgent: audit?.userAgent,
|
userAgent: audit?.userAgent,
|
||||||
})
|
});
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,31 @@
|
||||||
import { and, eq, isNull } from 'drizzle-orm'
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { apps, apiKeys } from '#/db/schema'
|
import { apps, apiKeys } from "#/db/schema";
|
||||||
import { generateDek, wrapDek } from '#/lib/crypto'
|
import { generateDek, wrapDek } from "#/lib/crypto";
|
||||||
import { writeAudit } from '#/lib/audit'
|
import { writeAudit } from "#/lib/audit";
|
||||||
|
|
||||||
type App = typeof apps.$inferSelect
|
type App = typeof apps.$inferSelect;
|
||||||
|
|
||||||
export async function listUserApps(userId: string) {
|
export async function listUserApps(userId: string) {
|
||||||
return db.query.apps.findMany({
|
return db.query.apps.findMany({
|
||||||
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
||||||
columns: { id: true, name: true, maxSessions: true, createdAt: true },
|
columns: { id: true, name: true, maxSessions: true, createdAt: true },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findUserApp(appId: string, userId: string): Promise<App | null> {
|
export async function findUserApp(
|
||||||
return (await db.query.apps.findFirst({
|
appId: string,
|
||||||
where: and(eq(apps.id, appId), eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
userId: string,
|
||||||
})) ?? null
|
): Promise<App | null> {
|
||||||
|
return (
|
||||||
|
(await db.query.apps.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(apps.id, appId),
|
||||||
|
eq(apps.ownerUserId, userId),
|
||||||
|
isNull(apps.deletedAt),
|
||||||
|
),
|
||||||
|
})) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUserApp(
|
export async function createUserApp(
|
||||||
|
|
@ -25,28 +34,31 @@ export async function createUserApp(
|
||||||
maxSessions?: number,
|
maxSessions?: number,
|
||||||
audit?: { ip?: string; userAgent?: string },
|
audit?: { ip?: string; userAgent?: string },
|
||||||
) {
|
) {
|
||||||
const dek = generateDek()
|
const dek = generateDek();
|
||||||
const dekEncrypted = wrapDek(dek)
|
const dekEncrypted = wrapDek(dek);
|
||||||
|
|
||||||
const [app] = await db.insert(apps).values({
|
const [app] = await db
|
||||||
name: name.trim(),
|
.insert(apps)
|
||||||
ownerUserId: userId,
|
.values({
|
||||||
dekEncrypted,
|
name: name.trim(),
|
||||||
maxSessions: String(maxSessions ?? 10),
|
ownerUserId: userId,
|
||||||
}).returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt })
|
dekEncrypted,
|
||||||
|
maxSessions: String(maxSessions ?? 10),
|
||||||
|
})
|
||||||
|
.returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt });
|
||||||
|
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
actorType: 'user',
|
actorType: "user",
|
||||||
actorId: userId,
|
actorId: userId,
|
||||||
action: 'app.create',
|
action: "app.create",
|
||||||
resourceType: 'app',
|
resourceType: "app",
|
||||||
resourceId: app.id,
|
resourceId: app.id,
|
||||||
ip: audit?.ip,
|
ip: audit?.ip,
|
||||||
userAgent: audit?.userAgent,
|
userAgent: audit?.userAgent,
|
||||||
diffAfter: { name: app.name },
|
diffAfter: { name: app.name },
|
||||||
})
|
});
|
||||||
|
|
||||||
return app
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserApp(
|
export async function updateUserApp(
|
||||||
|
|
@ -54,31 +66,38 @@ export async function updateUserApp(
|
||||||
userId: string,
|
userId: string,
|
||||||
updates: { name?: string; maxSessions?: number },
|
updates: { name?: string; maxSessions?: number },
|
||||||
audit?: { ip?: string; userAgent?: string },
|
audit?: { ip?: string; userAgent?: string },
|
||||||
): Promise<typeof apps.$inferSelect & { maxSessions: string } | null> {
|
): Promise<(typeof apps.$inferSelect & { maxSessions: string }) | null> {
|
||||||
const app = await findUserApp(appId, userId)
|
const app = await findUserApp(appId, userId);
|
||||||
if (!app) return null
|
if (!app) return null;
|
||||||
|
|
||||||
const dbUpdates: Partial<typeof apps.$inferInsert> = {}
|
const dbUpdates: Partial<typeof apps.$inferInsert> = {};
|
||||||
if (updates.name !== undefined) dbUpdates.name = updates.name.trim()
|
if (updates.name !== undefined) dbUpdates.name = updates.name.trim();
|
||||||
if (updates.maxSessions !== undefined) dbUpdates.maxSessions = String(updates.maxSessions)
|
if (updates.maxSessions !== undefined)
|
||||||
|
dbUpdates.maxSessions = String(updates.maxSessions);
|
||||||
|
|
||||||
const [updated] = await db.update(apps).set(dbUpdates).where(eq(apps.id, appId)).returning({
|
const [updated] = await db
|
||||||
id: apps.id, name: apps.name, maxSessions: apps.maxSessions,
|
.update(apps)
|
||||||
})
|
.set(dbUpdates)
|
||||||
|
.where(eq(apps.id, appId))
|
||||||
|
.returning({
|
||||||
|
id: apps.id,
|
||||||
|
name: apps.name,
|
||||||
|
maxSessions: apps.maxSessions,
|
||||||
|
});
|
||||||
|
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
actorType: 'user',
|
actorType: "user",
|
||||||
actorId: userId,
|
actorId: userId,
|
||||||
action: 'app.update',
|
action: "app.update",
|
||||||
resourceType: 'app',
|
resourceType: "app",
|
||||||
resourceId: appId,
|
resourceId: appId,
|
||||||
ip: audit?.ip,
|
ip: audit?.ip,
|
||||||
userAgent: audit?.userAgent,
|
userAgent: audit?.userAgent,
|
||||||
diffBefore: { name: app.name, maxSessions: app.maxSessions },
|
diffBefore: { name: app.name, maxSessions: app.maxSessions },
|
||||||
diffAfter: dbUpdates,
|
diffAfter: dbUpdates,
|
||||||
})
|
});
|
||||||
|
|
||||||
return updated as App & { maxSessions: string }
|
return updated as App & { maxSessions: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUserApp(
|
export async function deleteUserApp(
|
||||||
|
|
@ -86,27 +105,28 @@ export async function deleteUserApp(
|
||||||
userId: string,
|
userId: string,
|
||||||
audit?: { ip?: string; userAgent?: string },
|
audit?: { ip?: string; userAgent?: string },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const app = await findUserApp(appId, userId)
|
const app = await findUserApp(appId, userId);
|
||||||
if (!app) return false
|
if (!app) return false;
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, appId))
|
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, appId));
|
||||||
await tx.update(apiKeys).set({ revokedAt: now }).where(
|
await tx
|
||||||
and(eq(apiKeys.appId, appId), isNull(apiKeys.revokedAt)),
|
.update(apiKeys)
|
||||||
)
|
.set({ revokedAt: now })
|
||||||
})
|
.where(and(eq(apiKeys.appId, appId), isNull(apiKeys.revokedAt)));
|
||||||
|
});
|
||||||
|
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
actorType: 'user',
|
actorType: "user",
|
||||||
actorId: userId,
|
actorId: userId,
|
||||||
action: 'app.delete',
|
action: "app.delete",
|
||||||
resourceType: 'app',
|
resourceType: "app",
|
||||||
resourceId: appId,
|
resourceId: appId,
|
||||||
ip: audit?.ip,
|
ip: audit?.ip,
|
||||||
userAgent: audit?.userAgent,
|
userAgent: audit?.userAgent,
|
||||||
diffBefore: { name: app.name },
|
diffBefore: { name: app.name },
|
||||||
})
|
});
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,38 @@
|
||||||
import { and, desc, eq, gte, lte } from 'drizzle-orm'
|
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { auditLog } from '#/db/schema'
|
import { auditLog } from "#/db/schema";
|
||||||
|
|
||||||
interface AuditLogFilters {
|
interface AuditLogFilters {
|
||||||
page?: number
|
page?: number;
|
||||||
limit?: number
|
limit?: number;
|
||||||
action?: string | null
|
action?: string | null;
|
||||||
resourceType?: string | null
|
resourceType?: string | null;
|
||||||
since?: string | null
|
since?: string | null;
|
||||||
until?: string | null
|
until?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryAuditLog(userId: string, filters: AuditLogFilters = {}) {
|
export async function queryAuditLog(
|
||||||
const page = Math.max(1, filters.page ?? 1)
|
userId: string,
|
||||||
const limit = Math.min(100, Math.max(1, filters.limit ?? 50))
|
filters: AuditLogFilters = {},
|
||||||
|
) {
|
||||||
|
const page = Math.max(1, filters.page ?? 1);
|
||||||
|
const limit = Math.min(100, Math.max(1, filters.limit ?? 50));
|
||||||
|
|
||||||
const conditions = [eq(auditLog.actorId, userId)]
|
const conditions = [eq(auditLog.actorId, userId)];
|
||||||
if (filters.action) conditions.push(eq(auditLog.action, filters.action))
|
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
|
||||||
if (filters.resourceType) conditions.push(eq(auditLog.resourceType, filters.resourceType))
|
if (filters.resourceType)
|
||||||
if (filters.since) conditions.push(gte(auditLog.createdAt, new Date(filters.since)))
|
conditions.push(eq(auditLog.resourceType, filters.resourceType));
|
||||||
if (filters.until) conditions.push(lte(auditLog.createdAt, new Date(filters.until)))
|
if (filters.since)
|
||||||
|
conditions.push(gte(auditLog.createdAt, new Date(filters.since)));
|
||||||
|
if (filters.until)
|
||||||
|
conditions.push(lte(auditLog.createdAt, new Date(filters.until)));
|
||||||
|
|
||||||
const rows = await db.query.auditLog.findMany({
|
const rows = await db.query.auditLog.findMany({
|
||||||
where: and(...conditions),
|
where: and(...conditions),
|
||||||
orderBy: [desc(auditLog.createdAt)],
|
orderBy: [desc(auditLog.createdAt)],
|
||||||
limit,
|
limit,
|
||||||
offset: (page - 1) * limit,
|
offset: (page - 1) * limit,
|
||||||
})
|
});
|
||||||
|
|
||||||
return { data: rows, page, limit }
|
return { data: rows, page, limit };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,60 @@
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { users } from '#/db/schema'
|
import { users } from "#/db/schema";
|
||||||
import { generateTotpSecret, getTotpUri, verifyTotp } from '#/lib/totp'
|
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
|
||||||
|
|
||||||
export async function findUserByEmail(email: string) {
|
export async function findUserByEmail(email: string) {
|
||||||
return (await db.query.users.findFirst({ where: eq(users.email, email.toLowerCase()) })) ?? null
|
return (
|
||||||
|
(await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
})) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findUserById(userId: string) {
|
export async function findUserById(userId: string) {
|
||||||
return (await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
|
return (
|
||||||
|
(await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrollTotpSecret(
|
export async function enrollTotpSecret(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ ok: true; secret: string; uri: string } | { ok: false; error: string; status: number }> {
|
): Promise<
|
||||||
const user = await findUserById(userId)
|
| { ok: true; secret: string; uri: string }
|
||||||
if (!user) return { ok: false, error: 'User not found', status: 404 }
|
| { ok: false; error: string; status: number }
|
||||||
if (user.totpEnabled) return { ok: false, error: 'TOTP already enabled', status: 409 }
|
> {
|
||||||
|
const user = await findUserById(userId);
|
||||||
|
if (!user) return { ok: false, error: "User not found", status: 404 };
|
||||||
|
if (user.totpEnabled)
|
||||||
|
return { ok: false, error: "TOTP already enabled", status: 409 };
|
||||||
|
|
||||||
const secret = generateTotpSecret()
|
const secret = generateTotpSecret();
|
||||||
await db.update(users).set({ totpSecret: secret }).where(eq(users.id, userId))
|
await db
|
||||||
return { ok: true, secret, uri: getTotpUri(user.email, secret) }
|
.update(users)
|
||||||
|
.set({ totpSecret: secret })
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
return { ok: true, secret, uri: getTotpUri(user.email, secret) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmTotpEnrollment(
|
export async function confirmTotpEnrollment(
|
||||||
userId: string,
|
userId: string,
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
|
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
|
||||||
const user = await findUserById(userId)
|
const user = await findUserById(userId);
|
||||||
if (!user?.totpSecret) return { ok: false, error: 'Enroll first', status: 400 }
|
if (!user?.totpSecret)
|
||||||
if (user.totpEnabled) return { ok: false, error: 'TOTP already enabled', status: 409 }
|
return { ok: false, error: "Enroll first", status: 400 };
|
||||||
if (!verifyTotp(user.totpSecret, token)) return { ok: false, error: 'Invalid token', status: 401 }
|
if (user.totpEnabled)
|
||||||
|
return { ok: false, error: "TOTP already enabled", status: 409 };
|
||||||
|
if (!verifyTotp(user.totpSecret, token))
|
||||||
|
return { ok: false, error: "Invalid token", status: 401 };
|
||||||
|
|
||||||
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, userId))
|
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, userId));
|
||||||
return { ok: true }
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableTotp(userId: string): Promise<void> {
|
export async function disableTotp(userId: string): Promise<void> {
|
||||||
await db.update(users).set({ totpEnabled: false, totpSecret: null }).where(eq(users.id, userId))
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ totpEnabled: false, totpSecret: null })
|
||||||
|
.where(eq(users.id, userId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,179 @@
|
||||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { apps, sessions, sessionKeys } from '#/db/schema'
|
import { apps, sessions, sessionKeys } from "#/db/schema";
|
||||||
import { startSession, stopSession, emitWebhook } from '#/lib/baileys'
|
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
|
||||||
|
|
||||||
type Session = typeof sessions.$inferSelect
|
type Session = typeof sessions.$inferSelect;
|
||||||
type SessionStatus = Session['status']
|
type SessionStatus = Session["status"];
|
||||||
|
|
||||||
type SessionScope = { appId: string } | { userId: string }
|
type SessionScope = { appId: string } | { userId: string };
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'] as const
|
const ACTIVE_STATUSES = [
|
||||||
|
"PENDING_QR",
|
||||||
|
"PENDING_PAIRING",
|
||||||
|
"CONNECTING",
|
||||||
|
"CONNECTED",
|
||||||
|
"DISCONNECTED",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export async function findSession(sessionId: string, scope: SessionScope): Promise<Session | null> {
|
export async function findSession(
|
||||||
if ('appId' in scope) {
|
sessionId: string,
|
||||||
return (await db.query.sessions.findFirst({
|
scope: SessionScope,
|
||||||
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
|
): Promise<Session | null> {
|
||||||
})) ?? null
|
if ("appId" in scope) {
|
||||||
|
return (
|
||||||
|
(await db.query.sessions.findFirst({
|
||||||
|
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
|
||||||
|
})) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userApps = await db.query.apps.findMany({
|
const userApps = await db.query.apps.findMany({
|
||||||
where: and(eq(apps.ownerUserId, scope.userId), isNull(apps.deletedAt)),
|
where: and(eq(apps.ownerUserId, scope.userId), isNull(apps.deletedAt)),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
})
|
});
|
||||||
if (userApps.length === 0) return null
|
if (userApps.length === 0) return null;
|
||||||
const appIds = userApps.map((a) => a.id)
|
const appIds = userApps.map((a) => a.id);
|
||||||
return (await db.query.sessions.findFirst({
|
return (
|
||||||
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
|
(await db.query.sessions.findFirst({
|
||||||
})) ?? null
|
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
|
||||||
|
})) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listUserSessions(userId: string) {
|
export async function listUserSessions(userId: string) {
|
||||||
const userApps = await db.query.apps.findMany({
|
const userApps = await db.query.apps.findMany({
|
||||||
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
|
||||||
columns: { id: true, name: true },
|
columns: { id: true, name: true },
|
||||||
})
|
});
|
||||||
if (userApps.length === 0) return []
|
if (userApps.length === 0) return [];
|
||||||
|
|
||||||
const appIds = userApps.map((a) => a.id)
|
const appIds = userApps.map((a) => a.id);
|
||||||
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]))
|
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]));
|
||||||
|
|
||||||
const rows = await db.query.sessions.findMany({
|
const rows = await db.query.sessions.findMany({
|
||||||
where: (s, { inArray: _inArray }) => _inArray(s.appId, appIds),
|
where: (s, { inArray: _inArray }) => _inArray(s.appId, appIds),
|
||||||
columns: { id: true, appId: true, status: true, reconnectCount: true, lastSeen: true, createdAt: true },
|
columns: {
|
||||||
|
id: true,
|
||||||
|
appId: true,
|
||||||
|
status: true,
|
||||||
|
reconnectCount: true,
|
||||||
|
lastSeen: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
||||||
})
|
});
|
||||||
|
|
||||||
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || '' }))
|
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAppSessions(appId: string) {
|
export async function listAppSessions(appId: string) {
|
||||||
return db.query.sessions.findMany({
|
return db.query.sessions.findMany({
|
||||||
where: eq(sessions.appId, appId),
|
where: eq(sessions.appId, appId),
|
||||||
columns: { id: true, status: true, lastSeen: true, createdAt: true, reconnectCount: true },
|
columns: {
|
||||||
})
|
id: true,
|
||||||
|
status: true,
|
||||||
|
lastSeen: true,
|
||||||
|
createdAt: true,
|
||||||
|
reconnectCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
appId: string,
|
appId: string,
|
||||||
via: 'qr' | 'pairing',
|
via: "qr" | "pairing",
|
||||||
ownerUserId?: string,
|
ownerUserId?: string,
|
||||||
): Promise<{ ok: true; session: { id: string; status: string; createdAt: Date } } | { ok: false; error: string; status: number }> {
|
): Promise<
|
||||||
|
| { ok: true; session: { id: string; status: string; createdAt: Date } }
|
||||||
|
| { ok: false; error: string; status: number }
|
||||||
|
> {
|
||||||
const appWhere = ownerUserId
|
const appWhere = ownerUserId
|
||||||
? and(eq(apps.id, appId), eq(apps.ownerUserId, ownerUserId), isNull(apps.deletedAt))
|
? and(
|
||||||
: and(eq(apps.id, appId), isNull(apps.deletedAt))
|
eq(apps.id, appId),
|
||||||
|
eq(apps.ownerUserId, ownerUserId),
|
||||||
|
isNull(apps.deletedAt),
|
||||||
|
)
|
||||||
|
: and(eq(apps.id, appId), isNull(apps.deletedAt));
|
||||||
|
|
||||||
const app = await db.query.apps.findFirst({ where: appWhere, columns: { id: true, maxSessions: true } })
|
const app = await db.query.apps.findFirst({
|
||||||
if (!app) return { ok: false, error: 'App not found', status: 404 }
|
where: appWhere,
|
||||||
|
columns: { id: true, maxSessions: true },
|
||||||
|
});
|
||||||
|
if (!app) return { ok: false, error: "App not found", status: 404 };
|
||||||
|
|
||||||
const existing = await db.query.sessions.findMany({
|
const existing = await db.query.sessions.findMany({
|
||||||
where: eq(sessions.appId, appId),
|
where: eq(sessions.appId, appId),
|
||||||
columns: { status: true },
|
columns: { status: true },
|
||||||
})
|
});
|
||||||
const activeCount = existing.filter((s) => (ACTIVE_STATUSES as readonly string[]).includes(s.status)).length
|
const activeCount = existing.filter((s) =>
|
||||||
|
(ACTIVE_STATUSES as readonly string[]).includes(s.status),
|
||||||
|
).length;
|
||||||
if (activeCount >= Number(app.maxSessions)) {
|
if (activeCount >= Number(app.maxSessions)) {
|
||||||
return { ok: false, error: `Session limit reached (max ${app.maxSessions})`, status: 429 }
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Session limit reached (max ${app.maxSessions})`,
|
||||||
|
status: 429,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialStatus: SessionStatus = via === 'pairing' ? 'PENDING_PAIRING' : 'PENDING_QR'
|
const initialStatus: SessionStatus =
|
||||||
|
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
|
||||||
const [session] = await db
|
const [session] = await db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({ appId, status: initialStatus })
|
.values({ appId, status: initialStatus })
|
||||||
.returning({ id: sessions.id, status: sessions.status, createdAt: sessions.createdAt })
|
.returning({
|
||||||
|
id: sessions.id,
|
||||||
|
status: sessions.status,
|
||||||
|
createdAt: sessions.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
if (via === 'qr') startSession(session.id, appId).catch(console.error)
|
if (via === "qr") startSession(session.id, appId).catch(console.error);
|
||||||
|
|
||||||
return { ok: true, session }
|
return { ok: true, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSessionStatus(sessionId: string, status: SessionStatus): Promise<void> {
|
export async function setSessionStatus(
|
||||||
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId))
|
sessionId: string,
|
||||||
|
status: SessionStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disconnectSession(sessionId: string, appId: string): Promise<void> {
|
export async function disconnectSession(
|
||||||
await stopSession(sessionId)
|
sessionId: string,
|
||||||
await setSessionStatus(sessionId, 'DISCONNECTED')
|
appId: string,
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' })
|
): Promise<void> {
|
||||||
|
await stopSession(sessionId);
|
||||||
|
await setSessionStatus(sessionId, "DISCONNECTED");
|
||||||
|
emitWebhook(appId, "session.status", { sessionId, status: "DISCONNECTED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(sessionId: string, appId: string): Promise<void> {
|
export async function deleteSession(
|
||||||
await stopSession(sessionId)
|
sessionId: string,
|
||||||
await db.delete(sessions).where(eq(sessions.id, sessionId))
|
appId: string,
|
||||||
emitWebhook(appId, 'session.deleted', { sessionId })
|
): Promise<void> {
|
||||||
|
await stopSession(sessionId);
|
||||||
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||||
|
emitWebhook(appId, "session.deleted", { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutAppSession(sessionId: string, appId: string): Promise<void> {
|
export async function logoutAppSession(
|
||||||
await stopSession(sessionId)
|
sessionId: string,
|
||||||
|
appId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await stopSession(sessionId);
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId))
|
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
|
||||||
await tx.update(sessions).set({
|
await tx
|
||||||
status: 'LOGGED_OUT',
|
.update(sessions)
|
||||||
credentialsEncrypted: null,
|
.set({
|
||||||
phoneNumber: null,
|
status: "LOGGED_OUT",
|
||||||
displayName: null,
|
credentialsEncrypted: null,
|
||||||
}).where(eq(sessions.id, sessionId))
|
phoneNumber: null,
|
||||||
})
|
displayName: null,
|
||||||
emitWebhook(appId, 'session.deleted', { sessionId })
|
})
|
||||||
|
.where(eq(sessions.id, sessionId));
|
||||||
|
});
|
||||||
|
emitWebhook(appId, "session.deleted", { sessionId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,36 @@
|
||||||
import { and, eq, isNull } from 'drizzle-orm'
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { apiKeys } from '#/db/schema'
|
import { apiKeys } from "#/db/schema";
|
||||||
import { verifyApiKey } from './api-keys'
|
import { verifyApiKey } from "./api-keys";
|
||||||
import { jsonError } from './session-api'
|
import { jsonError } from "./session-api";
|
||||||
|
|
||||||
export interface AuthedRequest {
|
export interface AuthedRequest {
|
||||||
appId: string
|
appId: string;
|
||||||
keyId: string
|
keyId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticateApiKey(request: Request): Promise<AuthedRequest | Response> {
|
export async function authenticateApiKey(
|
||||||
const auth = request.headers.get('authorization')
|
request: Request,
|
||||||
if (!auth?.startsWith('Bearer ')) return jsonError('Missing Authorization header', 401)
|
): Promise<AuthedRequest | Response> {
|
||||||
|
const auth = request.headers.get("authorization");
|
||||||
|
if (!auth?.startsWith("Bearer "))
|
||||||
|
return jsonError("Missing Authorization header", 401);
|
||||||
|
|
||||||
const key = auth.slice(7).trim()
|
const key = auth.slice(7).trim();
|
||||||
if (!key.startsWith('wak_')) return jsonError('Invalid API key format', 401)
|
if (!key.startsWith("wak_")) return jsonError("Invalid API key format", 401);
|
||||||
|
|
||||||
const prefix = key.slice(-4)
|
const prefix = key.slice(-4);
|
||||||
|
|
||||||
const candidates = await db.query.apiKeys.findMany({
|
const candidates = await db.query.apiKeys.findMany({
|
||||||
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
|
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
|
||||||
with: { app: { columns: { id: true, deletedAt: true } } },
|
with: { app: { columns: { id: true, deletedAt: true } } },
|
||||||
})
|
});
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (candidate.app?.deletedAt) continue
|
if (candidate.app?.deletedAt) continue;
|
||||||
const valid = await verifyApiKey(key, candidate.keyHash)
|
const valid = await verifyApiKey(key, candidate.keyHash);
|
||||||
if (valid) return { appId: candidate.appId, keyId: candidate.id }
|
if (valid) return { appId: candidate.appId, keyId: candidate.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonError('Invalid or revoked API key', 401)
|
return jsonError("Invalid or revoked API key", 401);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,29 @@
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from "node:crypto";
|
||||||
import argon2 from 'argon2'
|
import argon2 from "argon2";
|
||||||
|
|
||||||
export type ApiKeyEnv = 'live' | 'test'
|
export type ApiKeyEnv = "live" | "test";
|
||||||
|
|
||||||
export function generateApiKey(env: ApiKeyEnv = 'live'): { key: string; prefix: string } {
|
export function generateApiKey(env: ApiKeyEnv = "live"): {
|
||||||
const rand = randomBytes(24).toString('base64url')
|
key: string;
|
||||||
const key = `wak_${env}_${rand}`
|
prefix: string;
|
||||||
const prefix = key.slice(-4)
|
} {
|
||||||
return { key, prefix }
|
const rand = randomBytes(24).toString("base64url");
|
||||||
|
const key = `wak_${env}_${rand}`;
|
||||||
|
const prefix = key.slice(-4);
|
||||||
|
return { key, prefix };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hashApiKey(key: string): Promise<string> {
|
export async function hashApiKey(key: string): Promise<string> {
|
||||||
return argon2.hash(key, { type: argon2.argon2id })
|
return argon2.hash(key, { type: argon2.argon2id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyApiKey(key: string, hash: string): Promise<boolean> {
|
export async function verifyApiKey(
|
||||||
|
key: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return await argon2.verify(hash, key)
|
return await argon2.verify(hash, key);
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { auditLog } from '#/db/schema'
|
import { auditLog } from "#/db/schema";
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
actorType: 'user' | 'api_key'
|
actorType: "user" | "api_key";
|
||||||
actorId: string
|
actorId: string;
|
||||||
action: string
|
action: string;
|
||||||
resourceType: string
|
resourceType: string;
|
||||||
resourceId?: string
|
resourceId?: string;
|
||||||
ip?: string
|
ip?: string;
|
||||||
userAgent?: string
|
userAgent?: string;
|
||||||
diffBefore?: unknown
|
diffBefore?: unknown;
|
||||||
diffAfter?: unknown
|
diffAfter?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeAudit(entry: AuditEntry): Promise<void> {
|
export async function writeAudit(entry: AuditEntry): Promise<void> {
|
||||||
|
|
@ -24,5 +24,5 @@ export async function writeAudit(entry: AuditEntry): Promise<void> {
|
||||||
userAgent: entry.userAgent,
|
userAgent: entry.userAgent,
|
||||||
diffBefore: entry.diffBefore ?? null,
|
diffBefore: entry.diffBefore ?? null,
|
||||||
diffAfter: entry.diffAfter ?? null,
|
diffAfter: entry.diffAfter ?? null,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { BufferJSON, initAuthCreds, makeCacheableSignalKeyStore } from '@whiskeysockets/baileys'
|
import {
|
||||||
import type { AuthenticationState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '@whiskeysockets/baileys'
|
BufferJSON,
|
||||||
import { db } from '#/db/index'
|
initAuthCreds,
|
||||||
import { sessionKeys, sessions } from '#/db/schema'
|
makeCacheableSignalKeyStore,
|
||||||
import { decryptColumn, encryptColumn } from '#/lib/crypto'
|
} from "@whiskeysockets/baileys";
|
||||||
|
import type {
|
||||||
|
AuthenticationState,
|
||||||
|
SignalDataSet,
|
||||||
|
SignalDataTypeMap,
|
||||||
|
SignalKeyStore,
|
||||||
|
} from "@whiskeysockets/baileys";
|
||||||
|
import { db } from "#/db/index";
|
||||||
|
import { sessionKeys, sessions } from "#/db/schema";
|
||||||
|
import { decryptColumn, encryptColumn } from "#/lib/crypto";
|
||||||
|
|
||||||
export async function usePostgresAuthState(
|
export async function usePostgresAuthState(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|
@ -12,43 +21,43 @@ export async function usePostgresAuthState(
|
||||||
const row = await db.query.sessions.findFirst({
|
const row = await db.query.sessions.findFirst({
|
||||||
where: eq(sessions.id, sessionId),
|
where: eq(sessions.id, sessionId),
|
||||||
columns: { credentialsEncrypted: true },
|
columns: { credentialsEncrypted: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
let creds = initAuthCreds()
|
let creds = initAuthCreds();
|
||||||
if (row?.credentialsEncrypted) {
|
if (row?.credentialsEncrypted) {
|
||||||
try {
|
try {
|
||||||
const json = decryptColumn(row.credentialsEncrypted, appDek)
|
const json = decryptColumn(row.credentialsEncrypted, appDek);
|
||||||
creds = JSON.parse(json, BufferJSON.reviver)
|
creds = JSON.parse(json, BufferJSON.reviver);
|
||||||
} catch {
|
} catch {
|
||||||
creds = initAuthCreds()
|
creds = initAuthCreds();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const store: SignalKeyStore = {
|
const store: SignalKeyStore = {
|
||||||
async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) {
|
async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) {
|
||||||
if (ids.length === 0) return {} as { [id: string]: SignalDataTypeMap[T] }
|
if (ids.length === 0) return {};
|
||||||
const rows = await db.query.sessionKeys.findMany({
|
const rows = await db.query.sessionKeys.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(sessionKeys.sessionId, sessionId),
|
eq(sessionKeys.sessionId, sessionId),
|
||||||
eq(sessionKeys.keyType, type),
|
eq(sessionKeys.keyType, type),
|
||||||
inArray(sessionKeys.keyId, ids),
|
inArray(sessionKeys.keyId, ids),
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
const result: { [id: string]: SignalDataTypeMap[T] } = {}
|
const result: { [id: string]: SignalDataTypeMap[T] } = {};
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
const json = decryptColumn(r.valueEncrypted, appDek)
|
const json = decryptColumn(r.valueEncrypted, appDek);
|
||||||
result[r.keyId] = JSON.parse(json, BufferJSON.reviver)
|
result[r.keyId] = JSON.parse(json, BufferJSON.reviver);
|
||||||
} catch {
|
} catch {
|
||||||
// skip corrupt key
|
// skip corrupt key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async set(data: SignalDataSet) {
|
async set(data: SignalDataSet) {
|
||||||
for (const [type, typeData] of Object.entries(data)) {
|
for (const [type, typeData] of Object.entries(data)) {
|
||||||
if (!typeData) continue
|
if (!typeData) continue;
|
||||||
for (const [id, value] of Object.entries(typeData)) {
|
for (const [id, value] of Object.entries(typeData)) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
await db
|
await db
|
||||||
|
|
@ -59,34 +68,46 @@ export async function usePostgresAuthState(
|
||||||
eq(sessionKeys.keyType, type),
|
eq(sessionKeys.keyType, type),
|
||||||
eq(sessionKeys.keyId, id),
|
eq(sessionKeys.keyId, id),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
const json = JSON.stringify(value, BufferJSON.replacer)
|
const json = JSON.stringify(value, BufferJSON.replacer);
|
||||||
const encrypted = encryptColumn(json, appDek)
|
const encrypted = encryptColumn(json, appDek);
|
||||||
await db
|
await db
|
||||||
.insert(sessionKeys)
|
.insert(sessionKeys)
|
||||||
.values({ sessionId, keyType: type, keyId: id, valueEncrypted: encrypted })
|
.values({
|
||||||
.onConflictDoUpdate({
|
sessionId,
|
||||||
target: [sessionKeys.sessionId, sessionKeys.keyType, sessionKeys.keyId],
|
keyType: type,
|
||||||
set: { valueEncrypted: encrypted, updatedAt: new Date() },
|
keyId: id,
|
||||||
|
valueEncrypted: encrypted,
|
||||||
})
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
sessionKeys.sessionId,
|
||||||
|
sessionKeys.keyType,
|
||||||
|
sessionKeys.keyId,
|
||||||
|
],
|
||||||
|
set: { valueEncrypted: encrypted, updatedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async clear() {
|
async clear() {
|
||||||
await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId))
|
await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const keys = makeCacheableSignalKeyStore(store)
|
const keys = makeCacheableSignalKeyStore(store);
|
||||||
|
|
||||||
async function saveCreds() {
|
async function saveCreds() {
|
||||||
const json = JSON.stringify(creds, BufferJSON.replacer)
|
const json = JSON.stringify(creds, BufferJSON.replacer);
|
||||||
const encrypted = encryptColumn(json, appDek)
|
const encrypted = encryptColumn(json, appDek);
|
||||||
await db.update(sessions).set({ credentialsEncrypted: encrypted }).where(eq(sessions.id, sessionId))
|
await db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ credentialsEncrypted: encrypted })
|
||||||
|
.where(eq(sessions.id, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state: { creds, keys }, saveCreds }
|
return { state: { creds, keys }, saveCreds };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { usePostgresAuthState } from './auth-state'
|
export { usePostgresAuthState } from "./auth-state";
|
||||||
export {
|
export {
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
|
|
@ -7,5 +7,5 @@ export {
|
||||||
isActive,
|
isActive,
|
||||||
initialize,
|
initialize,
|
||||||
getDecryptedField,
|
getDecryptedField,
|
||||||
} from './session-manager'
|
} from "./session-manager";
|
||||||
export { emitWebhook } from './webhook'
|
export { emitWebhook } from "./webhook";
|
||||||
|
|
|
||||||
|
|
@ -1,222 +1,274 @@
|
||||||
import { eq, inArray } from 'drizzle-orm'
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
Browsers,
|
Browsers,
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
jidDecode,
|
jidDecode,
|
||||||
makeWASocket,
|
makeWASocket,
|
||||||
} from '@whiskeysockets/baileys'
|
} from "@whiskeysockets/baileys";
|
||||||
import type { WASocket } from '@whiskeysockets/baileys'
|
import type { WASocket } from "@whiskeysockets/baileys";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { apps, sessions } from '#/db/schema'
|
import { apps, sessions } from "#/db/schema";
|
||||||
import { decryptColumn, encryptColumn, unwrapDek } from '#/lib/crypto'
|
import { decryptColumn, encryptColumn, unwrapDek } from "#/lib/crypto";
|
||||||
import { usePostgresAuthState } from './auth-state'
|
import { usePostgresAuthState } from "./auth-state";
|
||||||
import { emitWebhook } from './webhook'
|
import { emitWebhook } from "./webhook";
|
||||||
|
|
||||||
type SessionStatus = (typeof sessions.$inferSelect)['status']
|
type SessionStatus = (typeof sessions.$inferSelect)["status"];
|
||||||
|
|
||||||
interface SessionEntry {
|
interface SessionEntry {
|
||||||
socket: WASocket
|
socket: WASocket;
|
||||||
qrCode: string | null
|
qrCode: string | null;
|
||||||
appId: string
|
appId: string;
|
||||||
dek: Buffer
|
dek: Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReconnectEntry {
|
interface ReconnectEntry {
|
||||||
start: Date
|
start: Date;
|
||||||
count: number
|
count: number;
|
||||||
timer: ReturnType<typeof setTimeout> | null
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000 // 1h
|
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
|
||||||
const QR_TIMEOUT_MS = 5 * 60 * 1000 // 5 min
|
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
|
||||||
|
|
||||||
const active = new Map<string, SessionEntry>()
|
const active = new Map<string, SessionEntry>();
|
||||||
const reconnecting = new Map<string, ReconnectEntry>()
|
const reconnecting = new Map<string, ReconnectEntry>();
|
||||||
const qrTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
const qrTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
async function getAppDek(appId: string): Promise<Buffer> {
|
async function getAppDek(appId: string): Promise<Buffer> {
|
||||||
const app = await db.query.apps.findFirst({
|
const app = await db.query.apps.findFirst({
|
||||||
where: eq(apps.id, appId),
|
where: eq(apps.id, appId),
|
||||||
columns: { dekEncrypted: true },
|
columns: { dekEncrypted: true },
|
||||||
})
|
});
|
||||||
if (!app) throw new Error(`App ${appId} not found`)
|
if (!app) throw new Error(`App ${appId} not found`);
|
||||||
return unwrapDek(app.dekEncrypted)
|
return unwrapDek(app.dekEncrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setStatus(sessionId: string, status: SessionStatus): Promise<void> {
|
async function setStatus(
|
||||||
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId))
|
sessionId: string,
|
||||||
|
status: SessionStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleReconnect(sessionId: string, appId: string): Promise<void> {
|
async function scheduleReconnect(
|
||||||
let rec = reconnecting.get(sessionId)
|
sessionId: string,
|
||||||
|
appId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let rec = reconnecting.get(sessionId);
|
||||||
|
|
||||||
if (rec) {
|
if (rec) {
|
||||||
const elapsed = Date.now() - rec.start.getTime()
|
const elapsed = Date.now() - rec.start.getTime();
|
||||||
if (elapsed > MAX_RECONNECT_WINDOW_MS) {
|
if (elapsed > MAX_RECONNECT_WINDOW_MS) {
|
||||||
reconnecting.delete(sessionId)
|
reconnecting.delete(sessionId);
|
||||||
await setStatus(sessionId, 'DEAD')
|
await setStatus(sessionId, "DEAD");
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' })
|
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rec = { start: new Date(), count: 0, timer: null }
|
rec = { start: new Date(), count: 0, timer: null };
|
||||||
reconnecting.set(sessionId, rec)
|
reconnecting.set(sessionId, rec);
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000)
|
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000);
|
||||||
rec.count++
|
rec.count++;
|
||||||
|
|
||||||
await db.update(sessions).set({ reconnectCount: rec.count }).where(eq(sessions.id, sessionId))
|
await db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ reconnectCount: rec.count })
|
||||||
|
.where(eq(sessions.id, sessionId));
|
||||||
|
|
||||||
rec.timer = setTimeout(() => {
|
rec.timer = setTimeout(() => {
|
||||||
reconnecting.get(sessionId)!.timer = null
|
reconnecting.get(sessionId)!.timer = null;
|
||||||
startSession(sessionId, appId).catch(console.error)
|
startSession(sessionId, appId).catch(console.error);
|
||||||
}, delay)
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startSession(sessionId: string, appId: string): Promise<void> {
|
export async function startSession(
|
||||||
if (active.has(sessionId)) return
|
sessionId: string,
|
||||||
|
appId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (active.has(sessionId)) return;
|
||||||
|
|
||||||
const dek = await getAppDek(appId)
|
const dek = await getAppDek(appId);
|
||||||
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek)
|
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek);
|
||||||
|
|
||||||
const sock = makeWASocket({
|
const sock = makeWASocket({
|
||||||
auth: state,
|
auth: state,
|
||||||
browser: Browsers.ubuntu('Chrome'),
|
browser: Browsers.ubuntu("Chrome"),
|
||||||
printQRInTerminal: false,
|
printQRInTerminal: false,
|
||||||
syncFullHistory: false,
|
syncFullHistory: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek }
|
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek };
|
||||||
active.set(sessionId, entry)
|
active.set(sessionId, entry);
|
||||||
|
|
||||||
sock.ev.on('creds.update', async (patch) => {
|
sock.ev.on("creds.update", async (patch) => {
|
||||||
Object.assign(state.creds, patch)
|
Object.assign(state.creds, patch);
|
||||||
await saveCreds().catch(console.error)
|
await saveCreds().catch(console.error);
|
||||||
})
|
});
|
||||||
|
|
||||||
sock.ev.on('connection.update', async (update) => {
|
sock.ev.on("connection.update", async (update) => {
|
||||||
const { connection, lastDisconnect, qr } = update
|
const { connection, lastDisconnect, qr } = update;
|
||||||
|
|
||||||
if (qr) {
|
if (qr) {
|
||||||
entry.qrCode = qr
|
entry.qrCode = qr;
|
||||||
|
|
||||||
// Reset the 5-min expiry timer on each new QR rotation
|
// Reset the 5-min expiry timer on each new QR rotation
|
||||||
const existing = qrTimers.get(sessionId)
|
const existing = qrTimers.get(sessionId);
|
||||||
if (existing) clearTimeout(existing)
|
if (existing) clearTimeout(existing);
|
||||||
qrTimers.set(
|
qrTimers.set(
|
||||||
sessionId,
|
sessionId,
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
qrTimers.delete(sessionId)
|
qrTimers.delete(sessionId);
|
||||||
const e = active.get(sessionId)
|
const e = active.get(sessionId);
|
||||||
if (!e) return
|
if (!e) return;
|
||||||
// Only expire if still waiting for QR
|
// Only expire if still waiting for QR
|
||||||
const row = await db.query.sessions.findFirst({
|
const row = await db.query.sessions.findFirst({
|
||||||
where: eq(sessions.id, sessionId),
|
where: eq(sessions.id, sessionId),
|
||||||
columns: { status: true },
|
columns: { status: true },
|
||||||
})
|
});
|
||||||
if (row?.status === 'PENDING_QR') {
|
if (row?.status === "PENDING_QR") {
|
||||||
active.delete(sessionId)
|
active.delete(sessionId);
|
||||||
try { e.socket.end(undefined) } catch { /* ignore */ }
|
try {
|
||||||
await setStatus(sessionId, 'DEAD')
|
e.socket.end(undefined);
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' })
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
await setStatus(sessionId, "DEAD");
|
||||||
|
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
|
||||||
}
|
}
|
||||||
}, QR_TIMEOUT_MS),
|
}, QR_TIMEOUT_MS),
|
||||||
)
|
);
|
||||||
|
|
||||||
await setStatus(sessionId, 'PENDING_QR')
|
await setStatus(sessionId, "PENDING_QR");
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'PENDING_QR' })
|
emitWebhook(appId, "session.status", { sessionId, status: "PENDING_QR" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection === 'connecting') {
|
if (connection === "connecting") {
|
||||||
await setStatus(sessionId, 'CONNECTING')
|
await setStatus(sessionId, "CONNECTING");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection === 'open') {
|
if (connection === "open") {
|
||||||
entry.qrCode = null
|
entry.qrCode = null;
|
||||||
reconnecting.delete(sessionId)
|
reconnecting.delete(sessionId);
|
||||||
const qt = qrTimers.get(sessionId)
|
const qt = qrTimers.get(sessionId);
|
||||||
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
|
if (qt) {
|
||||||
|
clearTimeout(qt);
|
||||||
|
qrTimers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
const meId = state.creds.me?.id
|
const meId = state.creds.me?.id;
|
||||||
const mePhone = meId ? jidDecode(meId)?.user : undefined
|
const mePhone = meId ? jidDecode(meId)?.user : undefined;
|
||||||
const meName = state.creds.me?.name
|
const meName = state.creds.me?.name;
|
||||||
|
|
||||||
await db.update(sessions).set({
|
await db
|
||||||
status: 'CONNECTED',
|
.update(sessions)
|
||||||
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
|
.set({
|
||||||
displayName: meName ? encryptColumn(meName, dek) : undefined,
|
status: "CONNECTED",
|
||||||
lastSeen: new Date(),
|
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
|
||||||
reconnectCount: 0,
|
displayName: meName ? encryptColumn(meName, dek) : undefined,
|
||||||
}).where(eq(sessions.id, sessionId))
|
lastSeen: new Date(),
|
||||||
|
reconnectCount: 0,
|
||||||
|
})
|
||||||
|
.where(eq(sessions.id, sessionId));
|
||||||
|
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'CONNECTED' })
|
emitWebhook(appId, "session.status", { sessionId, status: "CONNECTED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection === 'close') {
|
if (connection === "close") {
|
||||||
active.delete(sessionId)
|
active.delete(sessionId);
|
||||||
const qt = qrTimers.get(sessionId)
|
const qt = qrTimers.get(sessionId);
|
||||||
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
|
if (qt) {
|
||||||
|
clearTimeout(qt);
|
||||||
|
qrTimers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
|
const code = (
|
||||||
?.output?.statusCode
|
lastDisconnect?.error as
|
||||||
|
| { output?: { statusCode?: number } }
|
||||||
|
| undefined
|
||||||
|
)?.output?.statusCode;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
code === DisconnectReason.loggedOut ||
|
code === DisconnectReason.loggedOut ||
|
||||||
code === DisconnectReason.forbidden ||
|
code === DisconnectReason.forbidden ||
|
||||||
code === DisconnectReason.connectionReplaced
|
code === DisconnectReason.connectionReplaced
|
||||||
) {
|
) {
|
||||||
reconnecting.delete(sessionId)
|
reconnecting.delete(sessionId);
|
||||||
await setStatus(sessionId, 'LOGGED_OUT')
|
await setStatus(sessionId, "LOGGED_OUT");
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'LOGGED_OUT' })
|
emitWebhook(appId, "session.status", {
|
||||||
return
|
sessionId,
|
||||||
|
status: "LOGGED_OUT",
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setStatus(sessionId, 'DISCONNECTED')
|
await setStatus(sessionId, "DISCONNECTED");
|
||||||
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' })
|
emitWebhook(appId, "session.status", {
|
||||||
await scheduleReconnect(sessionId, appId)
|
sessionId,
|
||||||
|
status: "DISCONNECTED",
|
||||||
|
});
|
||||||
|
await scheduleReconnect(sessionId, appId);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopSession(sessionId: string): Promise<void> {
|
export async function stopSession(sessionId: string): Promise<void> {
|
||||||
const rec = reconnecting.get(sessionId)
|
const rec = reconnecting.get(sessionId);
|
||||||
if (rec?.timer) clearTimeout(rec.timer)
|
if (rec?.timer) clearTimeout(rec.timer);
|
||||||
reconnecting.delete(sessionId)
|
reconnecting.delete(sessionId);
|
||||||
const qt = qrTimers.get(sessionId)
|
const qt = qrTimers.get(sessionId);
|
||||||
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
|
if (qt) {
|
||||||
|
clearTimeout(qt);
|
||||||
|
qrTimers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
const entry = active.get(sessionId)
|
const entry = active.get(sessionId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
try { entry.socket.end(undefined) } catch { /* ignore */ }
|
try {
|
||||||
active.delete(sessionId)
|
entry.socket.end(undefined);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
active.delete(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQrCode(sessionId: string): string | null {
|
export function getQrCode(sessionId: string): string | null {
|
||||||
return active.get(sessionId)?.qrCode ?? null
|
return active.get(sessionId)?.qrCode ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSocket(sessionId: string): WASocket | null {
|
export function getSocket(sessionId: string): WASocket | null {
|
||||||
return active.get(sessionId)?.socket ?? null
|
return active.get(sessionId)?.socket ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isActive(sessionId: string): boolean {
|
export function isActive(sessionId: string): boolean {
|
||||||
return active.has(sessionId)
|
return active.has(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initialize(): Promise<void> {
|
export async function initialize(): Promise<void> {
|
||||||
const rows = await db.query.sessions.findMany({
|
const rows = await db.query.sessions.findMany({
|
||||||
where: inArray(sessions.status, ['CONNECTED', 'DISCONNECTED', 'CONNECTING']),
|
where: inArray(sessions.status, [
|
||||||
|
"CONNECTED",
|
||||||
|
"DISCONNECTED",
|
||||||
|
"CONNECTING",
|
||||||
|
]),
|
||||||
columns: { id: true, appId: true },
|
columns: { id: true, appId: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId)))
|
await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDecryptedField(sessionId: string, encryptedValue: string | null): string | null {
|
export function getDecryptedField(
|
||||||
if (!encryptedValue) return null
|
sessionId: string,
|
||||||
const entry = active.get(sessionId)
|
encryptedValue: string | null,
|
||||||
if (!entry) return null
|
): string | null {
|
||||||
try { return decryptColumn(encryptedValue, entry.dek) } catch { return null }
|
if (!encryptedValue) return null;
|
||||||
|
const entry = active.get(sessionId);
|
||||||
|
if (!entry) return null;
|
||||||
|
try {
|
||||||
|
return decryptColumn(encryptedValue, entry.dek);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
// Webhook stub — replaced by full dispatcher in M5
|
// Webhook stub — replaced by full dispatcher in M5
|
||||||
export function emitWebhook(appId: string, event: string, payload: Record<string, unknown>): void {
|
export function emitWebhook(
|
||||||
console.log('[webhook]', appId, event, JSON.stringify(payload))
|
appId: string,
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
console.log("[webhook]", appId, event, JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,77 @@
|
||||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
const ALG = 'aes-256-gcm'
|
const ALG = "aes-256-gcm";
|
||||||
const IV_BYTES = 12
|
const IV_BYTES = 12;
|
||||||
const TAG_BYTES = 16
|
const TAG_BYTES = 16;
|
||||||
|
|
||||||
function getMasterKey(): Buffer {
|
function getMasterKey(): Buffer {
|
||||||
const raw = process.env.MASTER_ENCRYPTION_KEY
|
const raw = process.env.MASTER_ENCRYPTION_KEY;
|
||||||
if (!raw) throw new Error('MASTER_ENCRYPTION_KEY is not set — server cannot start')
|
if (!raw)
|
||||||
const buf = Buffer.from(raw, 'base64')
|
throw new Error("MASTER_ENCRYPTION_KEY is not set — server cannot start");
|
||||||
|
const buf = Buffer.from(raw, "base64");
|
||||||
if (buf.byteLength !== 32)
|
if (buf.byteLength !== 32)
|
||||||
throw new Error('MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)')
|
throw new Error(
|
||||||
return buf
|
"MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)",
|
||||||
|
);
|
||||||
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateDek(): Buffer {
|
export function generateDek(): Buffer {
|
||||||
return randomBytes(32)
|
return randomBytes(32);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapDek(dek: Buffer): string {
|
export function wrapDek(dek: Buffer): string {
|
||||||
const kek = getMasterKey()
|
const kek = getMasterKey();
|
||||||
const iv = randomBytes(IV_BYTES)
|
const iv = randomBytes(IV_BYTES);
|
||||||
const cipher = createCipheriv(ALG, kek, iv)
|
const cipher = createCipheriv(ALG, kek, iv);
|
||||||
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()])
|
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]);
|
||||||
const tag = cipher.getAuthTag()
|
const tag = cipher.getAuthTag();
|
||||||
return Buffer.concat([iv, tag, encrypted]).toString('base64')
|
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwrapDek(wrapped: string): Buffer {
|
export function unwrapDek(wrapped: string): Buffer {
|
||||||
const kek = getMasterKey()
|
const kek = getMasterKey();
|
||||||
const buf = Buffer.from(wrapped, 'base64')
|
const buf = Buffer.from(wrapped, "base64");
|
||||||
const iv = buf.subarray(0, IV_BYTES)
|
const iv = buf.subarray(0, IV_BYTES);
|
||||||
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)
|
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
||||||
const data = buf.subarray(IV_BYTES + TAG_BYTES)
|
const data = buf.subarray(IV_BYTES + TAG_BYTES);
|
||||||
const decipher = createDecipheriv(ALG, kek, iv)
|
const decipher = createDecipheriv(ALG, kek, iv);
|
||||||
decipher.setAuthTag(tag)
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([decipher.update(data), decipher.final()])
|
return Buffer.concat([decipher.update(data), decipher.final()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encryptColumn(plaintext: string, dek: Buffer): string {
|
export function encryptColumn(plaintext: string, dek: Buffer): string {
|
||||||
const iv = randomBytes(IV_BYTES)
|
const iv = randomBytes(IV_BYTES);
|
||||||
const cipher = createCipheriv(ALG, dek, iv)
|
const cipher = createCipheriv(ALG, dek, iv);
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
const encrypted = Buffer.concat([
|
||||||
const tag = cipher.getAuthTag()
|
cipher.update(plaintext, "utf8"),
|
||||||
return Buffer.concat([iv, tag, encrypted]).toString('base64')
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptColumn(ciphertext: string, dek: Buffer): string {
|
export function decryptColumn(ciphertext: string, dek: Buffer): string {
|
||||||
const buf = Buffer.from(ciphertext, 'base64')
|
const buf = Buffer.from(ciphertext, "base64");
|
||||||
const iv = buf.subarray(0, IV_BYTES)
|
const iv = buf.subarray(0, IV_BYTES);
|
||||||
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)
|
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
||||||
const data = buf.subarray(IV_BYTES + TAG_BYTES)
|
const data = buf.subarray(IV_BYTES + TAG_BYTES);
|
||||||
const decipher = createDecipheriv(ALG, dek, iv)
|
const decipher = createDecipheriv(ALG, dek, iv);
|
||||||
decipher.setAuthTag(tag)
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function blindIndex(value: string, dek: Buffer): string {
|
export function blindIndex(value: string, dek: Buffer): string {
|
||||||
return createHmac('sha256', dek).update(value, 'utf8').digest('hex')
|
return createHmac("sha256", dek).update(value, "utf8").digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateMasterKey(): void {
|
export function validateMasterKey(): void {
|
||||||
getMasterKey()
|
getMasterKey();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import argon2 from 'argon2'
|
import argon2 from "argon2";
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return argon2.hash(password, { type: argon2.argon2id })
|
return argon2.hash(password, { type: argon2.argon2id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return await argon2.verify(hash, password)
|
return await argon2.verify(hash, password);
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { QueryClient, queryOptions } from '@tanstack/react-query'
|
import { QueryClient, queryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -7,63 +7,63 @@ export const queryClient = new QueryClient({
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
async function apiFetch(url: string, init?: RequestInit) {
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
const res = await fetch(url, { credentials: 'include', ...init })
|
const res = await fetch(url, { credentials: "include", ...init });
|
||||||
if (!res.ok) throw new Error(await res.text())
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json()
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appsQuery = queryOptions({
|
export const appsQuery = queryOptions({
|
||||||
queryKey: ['apps'],
|
queryKey: ["apps"],
|
||||||
queryFn: () => apiFetch('/api/v1/admin/apps'),
|
queryFn: () => apiFetch("/api/v1/admin/apps"),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const appQuery = (appId: string) =>
|
export const appQuery = (appId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['apps', appId],
|
queryKey: ["apps", appId],
|
||||||
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`),
|
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const apiKeysQuery = (appId: string) =>
|
export const apiKeysQuery = (appId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['apps', appId, 'api-keys'],
|
queryKey: ["apps", appId, "api-keys"],
|
||||||
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`),
|
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const sessionsQuery = queryOptions({
|
export const sessionsQuery = queryOptions({
|
||||||
queryKey: ['sessions'],
|
queryKey: ["sessions"],
|
||||||
queryFn: () => apiFetch('/api/v1/admin/sessions'),
|
queryFn: () => apiFetch("/api/v1/admin/sessions"),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const auditLogQuery = queryOptions({
|
export const auditLogQuery = queryOptions({
|
||||||
queryKey: ['audit-log'],
|
queryKey: ["audit-log"],
|
||||||
queryFn: () => apiFetch('/api/v1/admin/audit-log?limit=50'),
|
queryFn: () => apiFetch("/api/v1/admin/audit-log?limit=50"),
|
||||||
})
|
});
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string
|
id: string;
|
||||||
appId: string
|
appId: string;
|
||||||
appName: string
|
appName: string;
|
||||||
status: string
|
status: string;
|
||||||
lastSeen: string | null
|
lastSeen: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
reconnectCount: number
|
reconnectCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminSessionDetailQuery = (sessionId: string) =>
|
export const adminSessionDetailQuery = (sessionId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['sessions', sessionId, 'admin-detail'],
|
queryKey: ["sessions", sessionId, "admin-detail"],
|
||||||
queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`),
|
queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const appSessionsQuery = (appId: string) =>
|
export const appSessionsQuery = (appId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['apps', appId, 'sessions'],
|
queryKey: ["apps", appId, "sessions"],
|
||||||
queryFn: async (): Promise<SessionRow[]> => {
|
queryFn: async (): Promise<SessionRow[]> => {
|
||||||
const data: SessionRow[] = await apiFetch('/api/v1/admin/sessions')
|
const data: SessionRow[] = await apiFetch("/api/v1/admin/sessions");
|
||||||
return data.filter(s => s.appId === appId)
|
return data.filter((s) => s.appId === appId);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,60 @@
|
||||||
import { sealData, unsealData } from 'iron-session'
|
import { sealData, unsealData } from "iron-session";
|
||||||
import type { SessionData } from './session'
|
import type { SessionData } from "./session";
|
||||||
|
|
||||||
const COOKIE_NAME = 'wahoo_session'
|
const COOKIE_NAME = "wahoo_session";
|
||||||
const TTL = 60 * 60 * 24 * 7
|
const TTL = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
function getPassword(): string {
|
function getPassword(): string {
|
||||||
const s = process.env.SESSION_SECRET
|
const s = process.env.SESSION_SECRET;
|
||||||
if (!s) throw new Error('SESSION_SECRET not set')
|
if (!s) throw new Error("SESSION_SECRET not set");
|
||||||
return s
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSession(req: Request): Promise<SessionData | null> {
|
export async function readSession(req: Request): Promise<SessionData | null> {
|
||||||
const cookieHeader = req.headers.get('cookie') ?? ''
|
const cookieHeader = req.headers.get("cookie") ?? "";
|
||||||
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`))
|
const match = cookieHeader.match(
|
||||||
if (!match) return null
|
new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`),
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
try {
|
try {
|
||||||
return await unsealData<SessionData>(match[1], { password: getPassword(), ttl: TTL })
|
return await unsealData<SessionData>(match[1], {
|
||||||
|
password: getPassword(),
|
||||||
|
ttl: TTL,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSessionCookie(data: SessionData): Promise<string> {
|
export async function createSessionCookie(data: SessionData): Promise<string> {
|
||||||
const sealed = await sealData(data, { password: getPassword(), ttl: TTL })
|
const sealed = await sealData(data, { password: getPassword(), ttl: TTL });
|
||||||
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||||
return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}`
|
return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionCookie(): string {
|
export function clearSessionCookie(): string {
|
||||||
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`
|
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function json(data: unknown, status = 200, extraHeaders?: Record<string, string>): Response {
|
export function json(
|
||||||
|
data: unknown,
|
||||||
|
status = 200,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Response {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
headers: { 'content-type': 'application/json', ...extraHeaders },
|
headers: { "content-type": "application/json", ...extraHeaders },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonError(message: string, status: number): Response {
|
export function jsonError(message: string, status: number): Response {
|
||||||
return json({ error: message }, status)
|
return json({ error: message }, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(req: Request): Promise<SessionData | Response> {
|
export async function requireAuth(
|
||||||
const session = await readSession(req)
|
req: Request,
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
): Promise<SessionData | Response> {
|
||||||
return session
|
const session = await readSession(req);
|
||||||
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
userId: string
|
userId: string;
|
||||||
email: string
|
email: string;
|
||||||
totpVerified: boolean
|
totpVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
import * as OTPAuth from 'otpauth'
|
import * as OTPAuth from "otpauth";
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
const ISSUER = 'Wahoo'
|
const ISSUER = "Wahoo";
|
||||||
|
|
||||||
export function generateTotpSecret(): string {
|
export function generateTotpSecret(): string {
|
||||||
return randomBytes(20).toString('base64').replace(/[^A-Z2-7]/gi, 'A').slice(0, 32).toUpperCase()
|
return randomBytes(20)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/[^A-Z2-7]/gi, "A")
|
||||||
|
.slice(0, 32)
|
||||||
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTotpUri(email: string, secret: string): string {
|
export function getTotpUri(email: string, secret: string): string {
|
||||||
const totp = new OTPAuth.TOTP({ issuer: ISSUER, label: email, secret, digits: 6, period: 30 })
|
const totp = new OTPAuth.TOTP({
|
||||||
return totp.toString()
|
issuer: ISSUER,
|
||||||
|
label: email,
|
||||||
|
secret,
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
});
|
||||||
|
return totp.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyTotp(secret: string, token: string): boolean {
|
export function verifyTotp(secret: string, token: string): boolean {
|
||||||
const totp = new OTPAuth.TOTP({ issuer: ISSUER, secret, digits: 6, period: 30 })
|
const totp = new OTPAuth.TOTP({
|
||||||
const delta = totp.validate({ token, window: 1 })
|
issuer: ISSUER,
|
||||||
return delta !== null
|
secret,
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
});
|
||||||
|
const delta = totp.validate({ token, window: 1 });
|
||||||
|
return delta !== null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ClassValue } from 'clsx'
|
import type { ClassValue } from "clsx";
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from "clsx";
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: "intent",
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
return router
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof getRouter>
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,37 @@
|
||||||
import { HeadContent, Outlet, Scripts, createRootRoute, useLocation } from '@tanstack/react-router'
|
import {
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
HeadContent,
|
||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
Outlet,
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
Scripts,
|
||||||
import { queryClient } from '#/lib/query'
|
createRootRoute,
|
||||||
import { Sidebar } from '#/components/layout/sidebar'
|
useLocation,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||||
|
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "#/lib/query";
|
||||||
|
import { Sidebar } from "#/components/layout/sidebar";
|
||||||
|
|
||||||
import appCss from '../styles.css?url'
|
import appCss from "../styles.css?url";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ charSet: 'utf-8' },
|
{ charSet: "utf-8" },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||||
{ title: 'Wahoo — WhatsApp API Gateway' },
|
{ title: "Wahoo — WhatsApp API Gateway" },
|
||||||
],
|
],
|
||||||
links: [{ rel: 'stylesheet', href: appCss }],
|
links: [{ rel: "stylesheet", href: appCss }],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const isLogin = location.pathname === '/login'
|
const isLogin = location.pathname === "/login";
|
||||||
|
|
||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
return <Outlet />
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -35,7 +41,7 @@ function RootLayout() {
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -48,12 +54,17 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{ position: 'bottom-right' }}
|
config={{ position: "bottom-right" }}
|
||||||
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
|
plugins={[
|
||||||
|
{
|
||||||
|
name: "Tanstack Router",
|
||||||
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,63 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { verifyPassword } from '#/lib/password'
|
import { verifyPassword } from "#/lib/password";
|
||||||
import { verifyTotp } from '#/lib/totp'
|
import { verifyTotp } from "#/lib/totp";
|
||||||
import { createSessionCookie, json, jsonError } from '#/lib/session-api'
|
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
||||||
import { writeAudit } from '#/lib/audit'
|
import { writeAudit } from "#/lib/audit";
|
||||||
import { findUserByEmail } from '#/lib/actions/auth'
|
import { findUserByEmail } from "#/lib/actions/auth";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/auth/login')({
|
export const Route = createFileRoute("/api/auth/login")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
let body: { email: string; password: string; totpToken?: string }
|
let body: { email: string; password: string; totpToken?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return jsonError('Invalid JSON', 400)
|
return jsonError("Invalid JSON", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password, totpToken } = body
|
const { email, password, totpToken } = body;
|
||||||
if (!email || !password) return jsonError('email and password required', 400)
|
if (!email || !password)
|
||||||
|
return jsonError("email and password required", 400);
|
||||||
|
|
||||||
const user = await findUserByEmail(email)
|
const user = await findUserByEmail(email);
|
||||||
if (!user) return jsonError('Invalid credentials', 401)
|
if (!user) return jsonError("Invalid credentials", 401);
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash)
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
if (!valid) return jsonError('Invalid credentials', 401)
|
if (!valid) return jsonError("Invalid credentials", 401);
|
||||||
|
|
||||||
if (user.totpEnabled) {
|
if (user.totpEnabled) {
|
||||||
if (!totpToken) return json({ requireTotp: true }, 200)
|
if (!totpToken) return json({ requireTotp: true }, 200);
|
||||||
if (!verifyTotp(user.totpSecret!, totpToken)) return jsonError('Invalid TOTP token', 401)
|
if (!verifyTotp(user.totpSecret!, totpToken))
|
||||||
|
return jsonError("Invalid TOTP token", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? undefined
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for") ??
|
||||||
|
request.headers.get("x-real-ip") ??
|
||||||
|
undefined;
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
actorType: 'user',
|
actorType: "user",
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
action: 'login',
|
action: "login",
|
||||||
resourceType: 'user',
|
resourceType: "user",
|
||||||
resourceId: user.id,
|
resourceId: user.id,
|
||||||
ip,
|
ip,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
})
|
});
|
||||||
|
|
||||||
const cookie = await createSessionCookie({
|
const cookie = await createSessionCookie({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
totpVerified: user.totpEnabled,
|
totpVerified: user.totpEnabled,
|
||||||
})
|
});
|
||||||
|
|
||||||
return json({ ok: true, user: { id: user.id, email: user.email } }, 200, { 'set-cookie': cookie })
|
return json(
|
||||||
|
{ ok: true, user: { id: user.id, email: user.email } },
|
||||||
|
200,
|
||||||
|
{ "set-cookie": cookie },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { clearSessionCookie, json } from '#/lib/session-api'
|
import { clearSessionCookie, json } from "#/lib/session-api";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/auth/logout')({
|
export const Route = createFileRoute("/api/auth/logout")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: () => json({ ok: true }, 200, { 'set-cookie': clearSessionCookie() }),
|
POST: () =>
|
||||||
|
json({ ok: true }, 200, { "set-cookie": clearSessionCookie() }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/auth/me')({
|
export const Route = createFileRoute("/api/auth/me")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
return json({ user: { id: session.userId, email: session.email } })
|
return json({ user: { id: session.userId, email: session.email } });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,47 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { enrollTotpSecret, confirmTotpEnrollment, disableTotp } from '#/lib/actions/auth'
|
import {
|
||||||
|
enrollTotpSecret,
|
||||||
|
confirmTotpEnrollment,
|
||||||
|
disableTotp,
|
||||||
|
} from "#/lib/actions/auth";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/auth/totp/enroll')({
|
export const Route = createFileRoute("/api/auth/totp/enroll")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const result = await enrollTotpSecret(session.userId)
|
const result = await enrollTotpSecret(session.userId);
|
||||||
if (!result.ok) return jsonError(result.error, result.status)
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
return json({ secret: result.secret, uri: result.uri })
|
return json({ secret: result.secret, uri: result.uri });
|
||||||
},
|
},
|
||||||
|
|
||||||
PUT: async ({ request }) => {
|
PUT: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
let body: { token: string }
|
let body: { token: string };
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try {
|
||||||
if (!body.token) return jsonError('token required', 400)
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
if (!body.token) return jsonError("token required", 400);
|
||||||
|
|
||||||
const result = await confirmTotpEnrollment(session.userId, body.token)
|
const result = await confirmTotpEnrollment(session.userId, body.token);
|
||||||
if (!result.ok) return jsonError(result.error, result.status)
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
return json({ ok: true })
|
return json({ ok: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
DELETE: async ({ request }) => {
|
DELETE: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
await disableTotp(session.userId)
|
await disableTotp(session.userId);
|
||||||
return json({ ok: true })
|
return json({ ok: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/health')({
|
export const Route = createFileRoute("/api/health")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: () => new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'content-type': 'application/json' } }),
|
GET: () =>
|
||||||
|
new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,38 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { db } from '#/db/index'
|
import { db } from "#/db/index";
|
||||||
import { sql } from 'drizzle-orm'
|
import { sql } from "drizzle-orm";
|
||||||
import Redis from 'ioredis'
|
import Redis from "ioredis";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/ready')({
|
export const Route = createFileRoute("/api/ready")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async () => {
|
GET: async () => {
|
||||||
const checks: Record<string, boolean> = { db: false, redis: false }
|
const checks: Record<string, boolean> = { db: false, redis: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.execute(sql`SELECT 1`)
|
await db.execute(sql`SELECT 1`);
|
||||||
checks.db = true
|
checks.db = true;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', { lazyConnect: true, connectTimeout: 2000 })
|
const redis = new Redis(
|
||||||
await redis.ping()
|
process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||||
await redis.quit()
|
{ lazyConnect: true, connectTimeout: 2000 },
|
||||||
checks.redis = true
|
);
|
||||||
|
await redis.ping();
|
||||||
|
await redis.quit();
|
||||||
|
checks.redis = true;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const ok = Object.values(checks).every(Boolean)
|
const ok = Object.values(checks).every(Boolean);
|
||||||
return new Response(JSON.stringify({ status: ok ? 'ok' : 'degraded', checks }), {
|
return new Response(
|
||||||
status: ok ? 200 : 503,
|
JSON.stringify({ status: ok ? "ok" : "degraded", checks }),
|
||||||
headers: { 'content-type': 'application/json' },
|
{
|
||||||
})
|
status: ok ? 200 : 503,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,31 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { findUserApp } from '#/lib/actions/apps'
|
import { findUserApp } from "#/lib/actions/apps";
|
||||||
import { revokeAppApiKey } from '#/lib/actions/api-keys'
|
import { revokeAppApiKey } from "#/lib/actions/api-keys";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/$keyId')({
|
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/$keyId")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
DELETE: async ({ request, params }) => {
|
DELETE: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const app = await findUserApp(params.id, session.userId)
|
const app = await findUserApp(params.id, session.userId);
|
||||||
if (!app) return jsonError('Not found', 404)
|
if (!app) return jsonError("Not found", 404);
|
||||||
|
|
||||||
const result = await revokeAppApiKey(params.keyId, params.id, session.userId, {
|
const result = await revokeAppApiKey(
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
params.keyId,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
params.id,
|
||||||
})
|
session.userId,
|
||||||
if (!result.ok) return jsonError(result.error, result.status)
|
{
|
||||||
|
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||||
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
|
|
||||||
return json({ ok: true })
|
return json({ ok: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,46 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { findUserApp } from '#/lib/actions/apps'
|
import { findUserApp } from "#/lib/actions/apps";
|
||||||
import { listAppApiKeys, createAppApiKey } from '#/lib/actions/api-keys'
|
import { listAppApiKeys, createAppApiKey } from "#/lib/actions/api-keys";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/apps/$id/api-keys/')({
|
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const app = await findUserApp(params.id, session.userId)
|
const app = await findUserApp(params.id, session.userId);
|
||||||
if (!app) return jsonError('Not found', 404)
|
if (!app) return jsonError("Not found", 404);
|
||||||
|
|
||||||
return json(await listAppApiKeys(params.id))
|
return json(await listAppApiKeys(params.id));
|
||||||
},
|
},
|
||||||
|
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const app = await findUserApp(params.id, session.userId)
|
const app = await findUserApp(params.id, session.userId);
|
||||||
if (!app) return jsonError('Not found', 404)
|
if (!app) return jsonError("Not found", 404);
|
||||||
|
|
||||||
let body: { label?: string; env?: 'live' | 'test' } = {}
|
let body: { label?: string; env?: "live" | "test" } = {};
|
||||||
try { body = await request.json() } catch {}
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const created = await createAppApiKey(params.id, session.userId, body.label, body.env, {
|
const created = await createAppApiKey(
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
params.id,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
session.userId,
|
||||||
})
|
body.label,
|
||||||
|
body.env,
|
||||||
|
{
|
||||||
|
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||||
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return json(created, 201)
|
return json(created, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,68 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { findUserApp, updateUserApp, deleteUserApp } from '#/lib/actions/apps'
|
import { findUserApp, updateUserApp, deleteUserApp } from "#/lib/actions/apps";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/apps/$id/')({
|
export const Route = createFileRoute("/api/v1/admin/apps/$id/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const app = await findUserApp(params.id, session.userId)
|
const app = await findUserApp(params.id, session.userId);
|
||||||
if (!app) return jsonError('Not found', 404)
|
if (!app) return jsonError("Not found", 404);
|
||||||
return json({ id: app.id, name: app.name, maxSessions: app.maxSessions, createdAt: app.createdAt })
|
return json({
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
maxSessions: app.maxSessions,
|
||||||
|
createdAt: app.createdAt,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
PATCH: async ({ request, params }) => {
|
PATCH: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
let body: { name?: string; maxSessions?: number }
|
let body: { name?: string; maxSessions?: number };
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
|
||||||
const updates: { name?: string; maxSessions?: number } = {}
|
const updates: { name?: string; maxSessions?: number } = {};
|
||||||
if (body.name !== undefined) updates.name = body.name
|
if (body.name !== undefined) updates.name = body.name;
|
||||||
if (body.maxSessions !== undefined) updates.maxSessions = body.maxSessions
|
if (body.maxSessions !== undefined)
|
||||||
if (Object.keys(updates).length === 0) return jsonError('Nothing to update', 400)
|
updates.maxSessions = body.maxSessions;
|
||||||
|
if (Object.keys(updates).length === 0)
|
||||||
|
return jsonError("Nothing to update", 400);
|
||||||
|
|
||||||
const updated = await updateUserApp(params.id, session.userId, updates, {
|
const updated = await updateUserApp(
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
params.id,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
session.userId,
|
||||||
})
|
updates,
|
||||||
if (!updated) return jsonError('Not found', 404)
|
{
|
||||||
|
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||||
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!updated) return jsonError("Not found", 404);
|
||||||
|
|
||||||
return json(updated)
|
return json(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
DELETE: async ({ request, params }) => {
|
DELETE: async ({ request, params }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const deleted = await deleteUserApp(params.id, session.userId, {
|
const deleted = await deleteUserApp(params.id, session.userId, {
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
})
|
});
|
||||||
if (!deleted) return jsonError('Not found', 404)
|
if (!deleted) return jsonError("Not found", 404);
|
||||||
|
|
||||||
return json({ ok: true })
|
return json({ ok: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,42 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { listUserApps, createUserApp } from '#/lib/actions/apps'
|
import { listUserApps, createUserApp } from "#/lib/actions/apps";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/apps/')({
|
export const Route = createFileRoute("/api/v1/admin/apps/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const list = await listUserApps(session.userId)
|
const list = await listUserApps(session.userId);
|
||||||
return json(list)
|
return json(list);
|
||||||
},
|
},
|
||||||
|
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
let body: { name: string; maxSessions?: number }
|
let body: { name: string; maxSessions?: number };
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try {
|
||||||
if (!body.name?.trim()) return jsonError('name required', 400)
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
if (!body.name?.trim()) return jsonError("name required", 400);
|
||||||
|
|
||||||
const app = await createUserApp(session.userId, body.name, body.maxSessions, {
|
const app = await createUserApp(
|
||||||
ip: request.headers.get('x-forwarded-for') ?? undefined,
|
session.userId,
|
||||||
userAgent: request.headers.get('user-agent') ?? undefined,
|
body.name,
|
||||||
})
|
body.maxSessions,
|
||||||
|
{
|
||||||
|
ip: request.headers.get("x-forwarded-for") ?? undefined,
|
||||||
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return json(app, 201)
|
return json(app, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { queryAuditLog } from '#/lib/actions/audit-log'
|
import { queryAuditLog } from "#/lib/actions/audit-log";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/audit-log')({
|
export const Route = createFileRoute("/api/v1/admin/audit-log")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url);
|
||||||
const result = await queryAuditLog(session.userId, {
|
const result = await queryAuditLog(session.userId, {
|
||||||
page: Number(url.searchParams.get('page') ?? 1),
|
page: Number(url.searchParams.get("page") ?? 1),
|
||||||
limit: Number(url.searchParams.get('limit') ?? 50),
|
limit: Number(url.searchParams.get("limit") ?? 50),
|
||||||
action: url.searchParams.get('action'),
|
action: url.searchParams.get("action"),
|
||||||
resourceType: url.searchParams.get('resourceType'),
|
resourceType: url.searchParams.get("resourceType"),
|
||||||
since: url.searchParams.get('since'),
|
since: url.searchParams.get("since"),
|
||||||
until: url.searchParams.get('until'),
|
until: url.searchParams.get("until"),
|
||||||
})
|
});
|
||||||
|
|
||||||
return json(result)
|
return json(result);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { findSession, disconnectSession } from '#/lib/actions/sessions'
|
import { findSession, disconnectSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/sessions/$id/disconnect')({
|
export const Route = createFileRoute("/api/v1/admin/sessions/$id/disconnect")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
const sess = await readSession(request)
|
const sess = await readSession(request);
|
||||||
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const row = await findSession(params.id, { userId: sess.userId })
|
const row = await findSession(params.id, { userId: sess.userId });
|
||||||
if (!row) return jsonError('Not found', 404)
|
if (!row) return jsonError("Not found", 404);
|
||||||
|
|
||||||
if (['DISCONNECTED', 'LOGGED_OUT', 'DEAD'].includes(row.status)) {
|
if (["DISCONNECTED", "LOGGED_OUT", "DEAD"].includes(row.status)) {
|
||||||
return json({ ok: true, status: row.status })
|
return json({ ok: true, status: row.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
await disconnectSession(params.id, row.appId)
|
await disconnectSession(params.id, row.appId);
|
||||||
return json({ ok: true, status: 'DISCONNECTED' })
|
return json({ ok: true, status: "DISCONNECTED" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { getDecryptedField } from '#/lib/baileys'
|
import { getDecryptedField } from "#/lib/baileys";
|
||||||
import { findSession, deleteSession } from '#/lib/actions/sessions'
|
import { findSession, deleteSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/sessions/$id/')({
|
export const Route = createFileRoute("/api/v1/admin/sessions/$id/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const sess = await readSession(request)
|
const sess = await readSession(request);
|
||||||
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const row = await findSession(params.id, { userId: sess.userId })
|
const row = await findSession(params.id, { userId: sess.userId });
|
||||||
if (!row) return jsonError('Not found', 404)
|
if (!row) return jsonError("Not found", 404);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -22,19 +22,19 @@ export const Route = createFileRoute('/api/v1/admin/sessions/$id/')({
|
||||||
reconnectCount: row.reconnectCount,
|
reconnectCount: row.reconnectCount,
|
||||||
lastSeen: row.lastSeen,
|
lastSeen: row.lastSeen,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
DELETE: async ({ request, params }) => {
|
DELETE: async ({ request, params }) => {
|
||||||
const sess = await readSession(request)
|
const sess = await readSession(request);
|
||||||
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const row = await findSession(params.id, { userId: sess.userId })
|
const row = await findSession(params.id, { userId: sess.userId });
|
||||||
if (!row) return jsonError('Not found', 404)
|
if (!row) return jsonError("Not found", 404);
|
||||||
|
|
||||||
await deleteSession(params.id, row.appId)
|
await deleteSession(params.id, row.appId);
|
||||||
return json({ ok: true })
|
return json({ ok: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,54 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { startSession, getSocket } from '#/lib/baileys'
|
import { startSession, getSocket } from "#/lib/baileys";
|
||||||
import { findSession, setSessionStatus } from '#/lib/actions/sessions'
|
import { findSession, setSessionStatus } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/sessions/$id/pair')({
|
export const Route = createFileRoute("/api/v1/admin/sessions/$id/pair")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
const sess = await readSession(request)
|
const sess = await readSession(request);
|
||||||
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const row = await findSession(params.id, { userId: sess.userId })
|
const row = await findSession(params.id, { userId: sess.userId });
|
||||||
if (!row) return jsonError('Not found', 404)
|
if (!row) return jsonError("Not found", 404);
|
||||||
if (row.status === 'CONNECTED') return jsonError('Already connected', 409)
|
if (row.status === "CONNECTED")
|
||||||
if (row.status === 'DEAD' || row.status === 'LOGGED_OUT') {
|
return jsonError("Already connected", 409);
|
||||||
return jsonError('Session is no longer active', 410)
|
if (row.status === "DEAD" || row.status === "LOGGED_OUT") {
|
||||||
|
return jsonError("Session is no longer active", 410);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { phone?: string }
|
let body: { phone?: string };
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try {
|
||||||
if (!body.phone) return jsonError('phone required', 400)
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
if (!body.phone) return jsonError("phone required", 400);
|
||||||
|
|
||||||
const phoneDigits = body.phone.replace(/\D/g, '')
|
const phoneDigits = body.phone.replace(/\D/g, "");
|
||||||
if (phoneDigits.length < 7) return jsonError('Invalid phone number', 400)
|
if (phoneDigits.length < 7)
|
||||||
|
return jsonError("Invalid phone number", 400);
|
||||||
|
|
||||||
await setSessionStatus(params.id, 'PENDING_PAIRING')
|
await setSessionStatus(params.id, "PENDING_PAIRING");
|
||||||
|
|
||||||
if (!getSocket(params.id)) {
|
if (!getSocket(params.id)) {
|
||||||
await startSession(params.id, row.appId)
|
await startSession(params.id, row.appId);
|
||||||
await new Promise((r) => setTimeout(r, 2000))
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sock = getSocket(params.id)
|
const sock = getSocket(params.id);
|
||||||
if (!sock) return jsonError('Session not ready — retry in a moment', 503)
|
if (!sock)
|
||||||
|
return jsonError("Session not ready — retry in a moment", 503);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pairingCode = await sock.requestPairingCode(phoneDigits)
|
const pairingCode = await sock.requestPairingCode(phoneDigits);
|
||||||
return json({ pairingCode })
|
return json({ pairingCode });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return jsonError(`Pairing code request failed: ${msg}`, 502)
|
return jsonError(`Pairing code request failed: ${msg}`, 502);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,31 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import QRCode from 'qrcode'
|
import QRCode from "qrcode";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { getQrCode } from '#/lib/baileys'
|
import { getQrCode } from "#/lib/baileys";
|
||||||
import { findSession } from '#/lib/actions/sessions'
|
import { findSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/sessions/$id/qr')({
|
export const Route = createFileRoute("/api/v1/admin/sessions/$id/qr")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const sess = await readSession(request)
|
const sess = await readSession(request);
|
||||||
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const row = await findSession(params.id, { userId: sess.userId })
|
const row = await findSession(params.id, { userId: sess.userId });
|
||||||
if (!row) return jsonError('Not found', 404)
|
if (!row) return jsonError("Not found", 404);
|
||||||
|
|
||||||
if (row.status === 'CONNECTED') return json({ error: 'Already connected' }, 409)
|
if (row.status === "CONNECTED")
|
||||||
if (row.status === 'DEAD' || row.status === 'LOGGED_OUT') {
|
return json({ error: "Already connected" }, 409);
|
||||||
return json({ error: 'Session is no longer active' }, 410)
|
if (row.status === "DEAD" || row.status === "LOGGED_OUT") {
|
||||||
|
return json({ error: "Session is no longer active" }, 410);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrRaw = getQrCode(params.id)
|
const qrRaw = getQrCode(params.id);
|
||||||
if (!qrRaw) return json({ waiting: true }, 202)
|
if (!qrRaw) return json({ waiting: true }, 202);
|
||||||
|
|
||||||
const qrPng = await QRCode.toDataURL(qrRaw)
|
const qrPng = await QRCode.toDataURL(qrRaw);
|
||||||
return json({ qr: qrPng, qrRaw })
|
return json({ qr: qrPng, qrRaw });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,36 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json, jsonError, readSession } from '#/lib/session-api'
|
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||||
import { listUserSessions, createSession } from '#/lib/actions/sessions'
|
import { listUserSessions, createSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/admin/sessions/')({
|
export const Route = createFileRoute("/api/v1/admin/sessions/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
const session = await readSession(request)
|
const session = await readSession(request);
|
||||||
if (!session?.userId) return jsonError('Unauthorized', 401)
|
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
const rows = await listUserSessions(session.userId)
|
const rows = await listUserSessions(session.userId);
|
||||||
return json(rows)
|
return json(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
const sess = await readSession(request)
|
const sess = await readSession(request);
|
||||||
if (!sess?.userId) return jsonError('Unauthorized', 401)
|
if (!sess?.userId) return jsonError("Unauthorized", 401);
|
||||||
|
|
||||||
let body: { appId?: string; connect_via?: 'qr' | 'pairing' } = {}
|
let body: { appId?: string; connect_via?: "qr" | "pairing" } = {};
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try {
|
||||||
if (!body.appId) return jsonError('appId required', 400)
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
if (!body.appId) return jsonError("appId required", 400);
|
||||||
|
|
||||||
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
|
const via = body.connect_via === "pairing" ? "pairing" : "qr";
|
||||||
const result = await createSession(body.appId, via, sess.userId)
|
const result = await createSession(body.appId, via, sess.userId);
|
||||||
|
|
||||||
if (!result.ok) return jsonError(result.error, result.status)
|
|
||||||
return json(result.session, 201)
|
|
||||||
|
|
||||||
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
|
return json(result.session, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { findSession, disconnectSession } from '#/lib/actions/sessions'
|
import { findSession, disconnectSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/disconnect')({
|
export const Route = createFileRoute("/api/v1/sessions/$id/disconnect")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
const session = await findSession(params.id, { appId: auth.appId })
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError("Not found", 404);
|
||||||
|
|
||||||
if (session.status === 'DISCONNECTED' || session.status === 'LOGGED_OUT' || session.status === 'DEAD') {
|
if (
|
||||||
return json({ ok: true, status: session.status })
|
session.status === "DISCONNECTED" ||
|
||||||
|
session.status === "LOGGED_OUT" ||
|
||||||
|
session.status === "DEAD"
|
||||||
|
) {
|
||||||
|
return json({ ok: true, status: session.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
await disconnectSession(params.id, auth.appId)
|
await disconnectSession(params.id, auth.appId);
|
||||||
return json({ ok: true, status: 'DISCONNECTED' })
|
return json({ ok: true, status: "DISCONNECTED" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { isActive } from '#/lib/baileys'
|
import { isActive } from "#/lib/baileys";
|
||||||
import { findSession } from '#/lib/actions/sessions'
|
import { findSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/health')({
|
export const Route = createFileRoute("/api/v1/sessions/$id/health")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
const session = await findSession(params.id, { appId: auth.appId })
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError("Not found", 404);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
id: session.id,
|
id: session.id,
|
||||||
|
|
@ -20,8 +20,8 @@ export const Route = createFileRoute('/api/v1/sessions/$id/health')({
|
||||||
lastSeen: session.lastSeen,
|
lastSeen: session.lastSeen,
|
||||||
reconnectCount: session.reconnectCount,
|
reconnectCount: session.reconnectCount,
|
||||||
socketAlive: isActive(session.id),
|
socketAlive: isActive(session.id),
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { getDecryptedField } from '#/lib/baileys'
|
import { getDecryptedField } from "#/lib/baileys";
|
||||||
import { findSession, logoutAppSession } from '#/lib/actions/sessions'
|
import { findSession, logoutAppSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/')({
|
export const Route = createFileRoute("/api/v1/sessions/$id/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
const session = await findSession(params.id, { appId: auth.appId })
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError("Not found", 404);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
id: session.id,
|
id: session.id,
|
||||||
|
|
@ -22,19 +22,19 @@ export const Route = createFileRoute('/api/v1/sessions/$id/')({
|
||||||
reconnectCount: session.reconnectCount,
|
reconnectCount: session.reconnectCount,
|
||||||
lastSeen: session.lastSeen,
|
lastSeen: session.lastSeen,
|
||||||
createdAt: session.createdAt,
|
createdAt: session.createdAt,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
DELETE: async ({ request, params }) => {
|
DELETE: async ({ request, params }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
const session = await findSession(params.id, { appId: auth.appId })
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError("Not found", 404);
|
||||||
|
|
||||||
await logoutAppSession(params.id, auth.appId)
|
await logoutAppSession(params.id, auth.appId);
|
||||||
return json({ ok: true })
|
return json({ ok: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,56 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { startSession, getSocket } from '#/lib/baileys'
|
import { startSession, getSocket } from "#/lib/baileys";
|
||||||
import { findSession, setSessionStatus } from '#/lib/actions/sessions'
|
import { findSession, setSessionStatus } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
|
export const Route = createFileRoute("/api/v1/sessions/$id/pair")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, params }) => {
|
POST: async ({ request, params }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
const session = await findSession(params.id, { appId: auth.appId })
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError("Not found", 404);
|
||||||
|
|
||||||
if (session.status === 'CONNECTED') return jsonError('Already connected', 409)
|
if (session.status === "CONNECTED")
|
||||||
if (session.status === 'DEAD' || session.status === 'LOGGED_OUT') {
|
return jsonError("Already connected", 409);
|
||||||
return jsonError('Session is no longer active', 410)
|
if (session.status === "DEAD" || session.status === "LOGGED_OUT") {
|
||||||
|
return jsonError("Session is no longer active", 410);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { phone?: string }
|
let body: { phone?: string };
|
||||||
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
|
try {
|
||||||
if (!body.phone) return jsonError('phone required', 400)
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonError("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
if (!body.phone) return jsonError("phone required", 400);
|
||||||
|
|
||||||
const phoneDigits = body.phone.replace(/\D/g, '')
|
const phoneDigits = body.phone.replace(/\D/g, "");
|
||||||
if (phoneDigits.length < 7) return jsonError('Invalid phone number', 400)
|
if (phoneDigits.length < 7)
|
||||||
|
return jsonError("Invalid phone number", 400);
|
||||||
|
|
||||||
await setSessionStatus(params.id, 'PENDING_PAIRING')
|
await setSessionStatus(params.id, "PENDING_PAIRING");
|
||||||
|
|
||||||
if (!getSocket(params.id)) {
|
if (!getSocket(params.id)) {
|
||||||
await startSession(params.id, auth.appId)
|
await startSession(params.id, auth.appId);
|
||||||
await new Promise((r) => setTimeout(r, 2000))
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sock = getSocket(params.id)
|
const sock = getSocket(params.id);
|
||||||
if (!sock) return jsonError('Session not ready — retry in a moment', 503)
|
if (!sock)
|
||||||
|
return jsonError("Session not ready — retry in a moment", 503);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pairingCode = await sock.requestPairingCode(phoneDigits)
|
const pairingCode = await sock.requestPairingCode(phoneDigits);
|
||||||
return json({ pairingCode })
|
return json({ pairingCode });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return jsonError(`Pairing code request failed: ${msg}`, 502)
|
return jsonError(`Pairing code request failed: ${msg}`, 502);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import QRCode from 'qrcode'
|
import QRCode from "qrcode";
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { getQrCode } from '#/lib/baileys'
|
import { getQrCode } from "#/lib/baileys";
|
||||||
import { findSession } from '#/lib/actions/sessions'
|
import { findSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
|
export const Route = createFileRoute("/api/v1/sessions/$id/qr")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request, params }) => {
|
GET: async ({ request, params }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
const session = await findSession(params.id, { appId: auth.appId })
|
const session = await findSession(params.id, { appId: auth.appId });
|
||||||
if (!session) return jsonError('Not found', 404)
|
if (!session) return jsonError("Not found", 404);
|
||||||
|
|
||||||
if (session.status === 'CONNECTED') {
|
if (session.status === "CONNECTED") {
|
||||||
return json({ error: 'Session already connected' }, 409)
|
return json({ error: "Session already connected" }, 409);
|
||||||
}
|
}
|
||||||
if (session.status === 'DEAD' || session.status === 'LOGGED_OUT') {
|
if (session.status === "DEAD" || session.status === "LOGGED_OUT") {
|
||||||
return json({ error: 'Session is no longer active' }, 410)
|
return json({ error: "Session is no longer active" }, 410);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrRaw = getQrCode(params.id)
|
const qrRaw = getQrCode(params.id);
|
||||||
if (!qrRaw) return json({ waiting: true }, 202)
|
if (!qrRaw) return json({ waiting: true }, 202);
|
||||||
|
|
||||||
const qrPng = await QRCode.toDataURL(qrRaw)
|
const qrPng = await QRCode.toDataURL(qrRaw);
|
||||||
return json({ qr: qrPng, qrRaw })
|
return json({ qr: qrPng, qrRaw });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,35 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { authenticateApiKey } from '#/lib/api-key-auth'
|
import { authenticateApiKey } from "#/lib/api-key-auth";
|
||||||
import { json, jsonError } from '#/lib/session-api'
|
import { json, jsonError } from "#/lib/session-api";
|
||||||
import { listAppSessions, createSession } from '#/lib/actions/sessions'
|
import { listAppSessions, createSession } from "#/lib/actions/sessions";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/v1/sessions/')({
|
export const Route = createFileRoute("/api/v1/sessions/")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
return json(await listAppSessions(auth.appId))
|
return json(await listAppSessions(auth.appId));
|
||||||
},
|
},
|
||||||
|
|
||||||
POST: async ({ request }) => {
|
POST: async ({ request }) => {
|
||||||
const auth = await authenticateApiKey(request)
|
const auth = await authenticateApiKey(request);
|
||||||
if (auth instanceof Response) return auth
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
let body: { connect_via?: 'qr' | 'pairing' } = {}
|
let body: { connect_via?: "qr" | "pairing" } = {};
|
||||||
try { body = await request.json() } catch { /* default */ }
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
/* default */
|
||||||
|
}
|
||||||
|
|
||||||
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
|
const via = body.connect_via === "pairing" ? "pairing" : "qr";
|
||||||
const result = await createSession(auth.appId, via)
|
const result = await createSession(auth.appId, via);
|
||||||
if (!result.ok) return jsonError(result.error, result.status)
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
|
|
||||||
return json(result.session, 201)
|
return json(result.session, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId')({
|
export const Route = createFileRoute("/apps/$appId")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
component: () => <Outlet />,
|
component: () => <Outlet />,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,107 @@
|
||||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { Key, Plus, Copy, Check, Trash2, AlertTriangle } from 'lucide-react'
|
import { Key, Plus, Copy, Check, Trash2, AlertTriangle } from "lucide-react";
|
||||||
import { appQuery, apiKeysQuery } from '#/lib/query'
|
import { appQuery, apiKeysQuery } from "#/lib/query";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from '#/components/ui/input'
|
import { Input } from "#/components/ui/input";
|
||||||
import { Label } from '#/components/ui/label'
|
import { Label } from "#/components/ui/label";
|
||||||
import { Badge } from '#/components/ui/badge'
|
import { Badge } from "#/components/ui/badge";
|
||||||
import { Separator } from '#/components/ui/separator'
|
import { Separator } from "#/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '#/components/ui/dialog'
|
} from "#/components/ui/dialog";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/')({
|
export const Route = createFileRoute("/apps/$appId/")({
|
||||||
component: AppKeysPage,
|
component: AppKeysPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
id: string
|
id: string;
|
||||||
display: string
|
display: string;
|
||||||
label: string
|
label: string;
|
||||||
revokedAt: string | null
|
revokedAt: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppKeysPage() {
|
function AppKeysPage() {
|
||||||
const { appId } = Route.useParams()
|
const { appId } = Route.useParams();
|
||||||
const { data: app } = useQuery(appQuery(appId))
|
const { data: app } = useQuery(appQuery(appId));
|
||||||
const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(apiKeysQuery(appId))
|
const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(
|
||||||
const queryClient = useQueryClient()
|
apiKeysQuery(appId),
|
||||||
const router = useRouter()
|
);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [genLabel, setGenLabel] = useState('')
|
const [genLabel, setGenLabel] = useState("");
|
||||||
const [showingKey, setShowingKey] = useState<string | null>(null)
|
const [showingKey, setShowingKey] = useState<string | null>(null);
|
||||||
const [genOpen, setGenOpen] = useState(false)
|
const [genOpen, setGenOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
async function generateKey(e: React.FormEvent) {
|
async function generateKey(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError("");
|
||||||
setGenerating(true)
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
|
const res = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { "content-type": "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ label: genLabel || undefined }),
|
body: JSON.stringify({ label: genLabel || undefined }),
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
if (!res.ok) {
|
||||||
setShowingKey(data.key)
|
setError(data.error ?? "Failed");
|
||||||
setGenLabel('')
|
return;
|
||||||
setGenOpen(false)
|
}
|
||||||
refetchKeys()
|
setShowingKey(data.key);
|
||||||
|
setGenLabel("");
|
||||||
|
setGenOpen(false);
|
||||||
|
refetchKeys();
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeKey(keyId: string) {
|
async function revokeKey(keyId: string) {
|
||||||
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, {
|
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
})
|
});
|
||||||
refetchKeys()
|
refetchKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteApp() {
|
async function deleteApp() {
|
||||||
setDeleting(true)
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/admin/apps/${appId}`, { method: 'DELETE', credentials: 'include' })
|
await fetch(`/api/v1/admin/apps/${appId}`, {
|
||||||
queryClient.invalidateQueries({ queryKey: ['apps'] })
|
method: "DELETE",
|
||||||
router.navigate({ to: '/apps' })
|
credentials: "include",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["apps"] });
|
||||||
|
router.navigate({ to: "/apps" });
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyKey() {
|
async function copyKey() {
|
||||||
if (!showingKey) return
|
if (!showingKey) return;
|
||||||
await navigator.clipboard.writeText(showingKey)
|
await navigator.clipboard.writeText(showingKey);
|
||||||
setCopied(true)
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app) return <div className="min-h-full" />
|
if (!app) return <div className="min-h-full" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
|
|
@ -135,7 +143,7 @@ function AppKeysPage() {
|
||||||
className="h-7 px-2.5 text-[12px] border-amber-300 text-amber-700 hover:bg-amber-100 gap-1.5"
|
className="h-7 px-2.5 text-[12px] border-amber-300 text-amber-700 hover:bg-amber-100 gap-1.5"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={11} /> : <Copy size={11} />}
|
{copied ? <Check size={11} /> : <Copy size={11} />}
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? "Copied!" : "Copy"}
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowingKey(null)}
|
onClick={() => setShowingKey(null)}
|
||||||
|
|
@ -155,9 +163,9 @@ function AppKeysPage() {
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-[13px] font-semibold text-gray-900">Keys</h2>
|
<h2 className="text-[13px] font-semibold text-gray-900">Keys</h2>
|
||||||
{keys.filter(k => !k.revokedAt).length > 0 && (
|
{keys.filter((k) => !k.revokedAt).length > 0 && (
|
||||||
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
||||||
{keys.filter(k => !k.revokedAt).length} active
|
{keys.filter((k) => !k.revokedAt).length} active
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -168,7 +176,9 @@ function AppKeysPage() {
|
||||||
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center mx-auto mb-3">
|
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center mx-auto mb-3">
|
||||||
<Key size={16} className="text-gray-400" />
|
<Key size={16} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-medium text-gray-700 mb-1">No API keys</p>
|
<p className="text-[13px] font-medium text-gray-700 mb-1">
|
||||||
|
No API keys
|
||||||
|
</p>
|
||||||
<p className="text-[12px] text-gray-400 mb-4">
|
<p className="text-[12px] text-gray-400 mb-4">
|
||||||
Generate a key to authenticate API requests.
|
Generate a key to authenticate API requests.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -187,15 +197,17 @@ function AppKeysPage() {
|
||||||
<div
|
<div
|
||||||
key={k.id}
|
key={k.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between gap-4 px-4 py-3',
|
"flex items-center justify-between gap-4 px-4 py-3",
|
||||||
k.revokedAt && 'opacity-50',
|
k.revokedAt && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className={cn(
|
<div
|
||||||
'w-1.5 h-1.5 rounded-full flex-shrink-0',
|
className={cn(
|
||||||
k.revokedAt ? 'bg-red-400' : 'bg-emerald-400',
|
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
||||||
)} />
|
k.revokedAt ? "bg-red-400" : "bg-emerald-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<code className="text-[12px] font-mono text-gray-700 truncate">
|
<code className="text-[12px] font-mono text-gray-700 truncate">
|
||||||
|
|
@ -207,17 +219,20 @@ function AppKeysPage() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{k.revokedAt && (
|
{k.revokedAt && (
|
||||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="text-[10px] px-1.5 py-0 h-4"
|
||||||
|
>
|
||||||
Revoked
|
Revoked
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-400 mt-0.5">
|
<p className="text-[11px] text-gray-400 mt-0.5">
|
||||||
Created{' '}
|
Created{" "}
|
||||||
{new Date(k.createdAt).toLocaleDateString('en', {
|
{new Date(k.createdAt).toLocaleDateString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,9 +261,12 @@ function AppKeysPage() {
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white border border-red-200 rounded-xl p-4 flex items-center justify-between gap-4">
|
<div className="bg-white border border-red-200 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium text-gray-900">Delete app</p>
|
<p className="text-[13px] font-medium text-gray-900">
|
||||||
|
Delete app
|
||||||
|
</p>
|
||||||
<p className="text-[12px] text-gray-500 mt-0.5">
|
<p className="text-[12px] text-gray-500 mt-0.5">
|
||||||
Permanently delete this app and revoke all API keys. This cannot be undone.
|
Permanently delete this app and revoke all API keys. This cannot
|
||||||
|
be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -268,8 +286,11 @@ function AppKeysPage() {
|
||||||
<Dialog
|
<Dialog
|
||||||
open={genOpen}
|
open={genOpen}
|
||||||
onOpenChange={(o) => {
|
onOpenChange={(o) => {
|
||||||
setGenOpen(o)
|
setGenOpen(o);
|
||||||
if (!o) { setGenLabel(''); setError('') }
|
if (!o) {
|
||||||
|
setGenLabel("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[380px]">
|
<DialogContent className="sm:max-w-[380px]">
|
||||||
|
|
@ -282,14 +303,15 @@ function AppKeysPage() {
|
||||||
<form onSubmit={generateKey} className="space-y-4 pt-1">
|
<form onSubmit={generateKey} className="space-y-4 pt-1">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="key-label" className="text-[13px] text-zinc-700">
|
<Label htmlFor="key-label" className="text-[13px] text-zinc-700">
|
||||||
Label <span className="text-gray-400 font-normal">(optional)</span>
|
Label{" "}
|
||||||
|
<span className="text-gray-400 font-normal">(optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="key-label"
|
id="key-label"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="e.g. Production, CI"
|
placeholder="e.g. Production, CI"
|
||||||
value={genLabel}
|
value={genLabel}
|
||||||
onChange={e => setGenLabel(e.target.value)}
|
onChange={(e) => setGenLabel(e.target.value)}
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -303,7 +325,11 @@ function AppKeysPage() {
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setGenOpen(false); setGenLabel(''); setError('') }}
|
onClick={() => {
|
||||||
|
setGenOpen(false);
|
||||||
|
setGenLabel("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -314,7 +340,7 @@ function AppKeysPage() {
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||||
>
|
>
|
||||||
{generating ? 'Generating…' : 'Generate'}
|
{generating ? "Generating…" : "Generate"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -325,9 +351,12 @@ function AppKeysPage() {
|
||||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<DialogContent className="sm:max-w-[380px]">
|
<DialogContent className="sm:max-w-[380px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-[15px] text-red-600">Delete "{app.name}"?</DialogTitle>
|
<DialogTitle className="text-[15px] text-red-600">
|
||||||
|
Delete "{app.name}"?
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription className="text-[13px]">
|
<DialogDescription className="text-[13px]">
|
||||||
This will permanently delete the app, revoke all API keys, and cannot be undone.
|
This will permanently delete the app, revoke all API keys, and
|
||||||
|
cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
<div className="flex justify-end gap-2 pt-3">
|
||||||
|
|
@ -347,11 +376,11 @@ function AppKeysPage() {
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
>
|
>
|
||||||
{deleting ? 'Deleting…' : 'Delete app'}
|
{deleting ? "Deleting…" : "Delete app"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,222 @@
|
||||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Phone, User, Clock, RefreshCw, QrCode, Smartphone,
|
ChevronLeft,
|
||||||
Power, Trash2, Key, AlertTriangle, ChevronDown,
|
Phone,
|
||||||
} from 'lucide-react'
|
User,
|
||||||
import { adminSessionDetailQuery, apiKeysQuery } from '#/lib/query'
|
Clock,
|
||||||
import { Button } from '#/components/ui/button'
|
RefreshCw,
|
||||||
import { Input } from '#/components/ui/input'
|
QrCode,
|
||||||
import { Label } from '#/components/ui/label'
|
Smartphone,
|
||||||
|
Power,
|
||||||
|
Trash2,
|
||||||
|
Key,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminSessionDetailQuery, apiKeysQuery } from "#/lib/query";
|
||||||
|
import { Button } from "#/components/ui/button";
|
||||||
|
import { Input } from "#/components/ui/input";
|
||||||
|
import { Label } from "#/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '#/components/ui/dialog'
|
} from "#/components/ui/dialog";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
|
const STATUS_STYLES: Record<
|
||||||
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
|
string,
|
||||||
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
|
{ dot: string; badge: string; label: string } | undefined
|
||||||
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
|
> = {
|
||||||
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
|
CONNECTED: {
|
||||||
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
|
dot: "bg-emerald-500 animate-pulse-dot",
|
||||||
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
|
badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
|
label: "Connected",
|
||||||
}
|
},
|
||||||
|
CONNECTING: {
|
||||||
|
dot: "bg-blue-500 animate-pulse-dot",
|
||||||
|
badge: "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
label: "Connecting",
|
||||||
|
},
|
||||||
|
PENDING_QR: {
|
||||||
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
label: "Pending QR",
|
||||||
|
},
|
||||||
|
PENDING_PAIRING: {
|
||||||
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
label: "Pending Pairing",
|
||||||
|
},
|
||||||
|
DISCONNECTED: {
|
||||||
|
dot: "bg-gray-400",
|
||||||
|
badge: "bg-gray-50 text-gray-600 border-gray-200",
|
||||||
|
label: "Disconnected",
|
||||||
|
},
|
||||||
|
LOGGED_OUT: {
|
||||||
|
dot: "bg-red-400",
|
||||||
|
badge: "bg-red-50 text-red-600 border-red-200",
|
||||||
|
label: "Logged Out",
|
||||||
|
},
|
||||||
|
DEAD: {
|
||||||
|
dot: "bg-red-600",
|
||||||
|
badge: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
label: "Dead",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
id: string
|
id: string;
|
||||||
appId: string
|
appId: string;
|
||||||
status: string
|
status: string;
|
||||||
phoneNumber: string | null
|
phoneNumber: string | null;
|
||||||
displayName: string | null
|
displayName: string | null;
|
||||||
reconnectCount: number
|
reconnectCount: number;
|
||||||
lastSeen: string | null
|
lastSeen: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
id: string
|
id: string;
|
||||||
display: string
|
display: string;
|
||||||
label: string
|
label: string;
|
||||||
revokedAt: string | null
|
revokedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/sessions/$sessionId')({
|
export const Route = createFileRoute("/apps/$appId/sessions/$sessionId")({
|
||||||
loader: ({ params }) => ({ sessionId: params.sessionId, appId: params.appId }),
|
loader: ({ params }) => ({
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
appId: params.appId,
|
||||||
|
}),
|
||||||
component: AppSessionDetailPage,
|
component: AppSessionDetailPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AppSessionDetailPage() {
|
function AppSessionDetailPage() {
|
||||||
const { sessionId, appId } = Route.useLoaderData()
|
const { sessionId, appId } = Route.useLoaderData();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
|
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
|
||||||
adminSessionDetailQuery(sessionId),
|
adminSessionDetailQuery(sessionId),
|
||||||
)
|
);
|
||||||
const { data: allKeys = [] } = useQuery<ApiKey[]>(apiKeysQuery(appId))
|
const { data: allKeys = [] } = useQuery<ApiKey[]>(apiKeysQuery(appId));
|
||||||
const activeKeys = allKeys.filter(k => !k.revokedAt)
|
const activeKeys = allKeys.filter((k) => !k.revokedAt);
|
||||||
|
|
||||||
const [selectedKeyId, setSelectedKeyId] = useState('')
|
const [selectedKeyId, setSelectedKeyId] = useState("");
|
||||||
const [qr, setQr] = useState<string | null>(null)
|
const [qr, setQr] = useState<string | null>(null);
|
||||||
const [pairingPhone, setPairingPhone] = useState('')
|
const [pairingPhone, setPairingPhone] = useState("");
|
||||||
const [pairingCode, setPairingCode] = useState<string | null>(null)
|
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [disconnectOpen, setDisconnectOpen] = useState(false)
|
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Auto-select first active key
|
// Auto-select first active key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedKeyId && activeKeys.length > 0) {
|
if (!selectedKeyId && activeKeys.length > 0) {
|
||||||
setSelectedKeyId(activeKeys[0].id)
|
setSelectedKeyId(activeKeys[0].id);
|
||||||
}
|
}
|
||||||
}, [activeKeys, selectedKeyId])
|
}, [activeKeys, selectedKeyId]);
|
||||||
|
|
||||||
function keyHint() {
|
function keyHint() {
|
||||||
return selectedKeyId ? { 'x-key-id': selectedKeyId } : {}
|
return selectedKeyId ? { "x-key-id": selectedKeyId } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadQr() {
|
async function loadQr() {
|
||||||
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
|
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: keyHint(),
|
headers: keyHint(),
|
||||||
})
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setQr(data.qr ?? null)
|
setQr(data.qr ?? null);
|
||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
setQr(null)
|
setQr(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return
|
if (!session) return;
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
|
||||||
if (session.status === 'PENDING_QR') {
|
if (session.status === "PENDING_QR") {
|
||||||
loadQr()
|
loadQr();
|
||||||
pollRef.current = setInterval(() => { refetchSession(); loadQr() }, 3000)
|
pollRef.current = setInterval(() => {
|
||||||
} else if (session.status === 'CONNECTING') {
|
refetchSession();
|
||||||
pollRef.current = setInterval(() => refetchSession(), 2000)
|
loadQr();
|
||||||
|
}, 3000);
|
||||||
|
} else if (session.status === "CONNECTING") {
|
||||||
|
pollRef.current = setInterval(() => refetchSession(), 2000);
|
||||||
} else {
|
} else {
|
||||||
setQr(null)
|
setQr(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
return () => {
|
||||||
}, [session?.status])
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [session?.status]);
|
||||||
|
|
||||||
async function requestPairingCode() {
|
async function requestPairingCode() {
|
||||||
if (!pairingPhone) { setError('Enter phone number'); return }
|
if (!pairingPhone) {
|
||||||
setError('')
|
setError("Enter phone number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
|
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: { 'content-type': 'application/json', ...keyHint() },
|
headers: { "content-type": "application/json", ...keyHint() },
|
||||||
body: JSON.stringify({ phone: pairingPhone }),
|
body: JSON.stringify({ phone: pairingPhone }),
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
if (!res.ok) {
|
||||||
setPairingCode(data.pairingCode)
|
setError(data.error ?? "Failed");
|
||||||
pollRef.current = setInterval(() => refetchSession(), 3000)
|
return;
|
||||||
|
}
|
||||||
|
setPairingCode(data.pairingCode);
|
||||||
|
pollRef.current = setInterval(() => refetchSession(), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function disconnect() {
|
||||||
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
|
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: keyHint(),
|
headers: keyHint(),
|
||||||
})
|
});
|
||||||
setDisconnectOpen(false)
|
setDisconnectOpen(false);
|
||||||
refetchSession()
|
refetchSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSession() {
|
async function deleteSession() {
|
||||||
setDeleting(true)
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
|
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: keyHint(),
|
headers: keyHint(),
|
||||||
})
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['apps', appId, 'sessions'] })
|
queryClient.invalidateQueries({ queryKey: ["apps", appId, "sessions"] });
|
||||||
router.navigate({ to: '/apps/$appId/sessions', params: { appId } })
|
router.navigate({ to: "/apps/$appId/sessions", params: { appId } });
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = session ? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!) : null
|
const style = session
|
||||||
const canDisconnect = session &&
|
? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!)
|
||||||
['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status)
|
: null;
|
||||||
|
const canDisconnect =
|
||||||
|
session &&
|
||||||
|
["CONNECTED", "CONNECTING", "PENDING_QR", "PENDING_PAIRING"].includes(
|
||||||
|
session.status,
|
||||||
|
);
|
||||||
|
|
||||||
const selectedKey = activeKeys.find(k => k.id === selectedKeyId)
|
const selectedKey = activeKeys.find((k) => k.id === selectedKeyId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
|
|
@ -171,11 +231,18 @@ function AppSessionDetailPage() {
|
||||||
<ChevronLeft size={16} strokeWidth={2} />
|
<ChevronLeft size={16} strokeWidth={2} />
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gray-300">/</span>
|
<span className="text-gray-300">/</span>
|
||||||
<code className="text-[13px] text-gray-700 font-mono truncate">{sessionId.slice(0, 12)}…</code>
|
<code className="text-[13px] text-gray-700 font-mono truncate">
|
||||||
|
{sessionId.slice(0, 12)}…
|
||||||
|
</code>
|
||||||
{style && (
|
{style && (
|
||||||
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
|
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
|
||||||
<div className={cn('w-1.5 h-1.5 rounded-full', style.dot)} />
|
<div className={cn("w-1.5 h-1.5 rounded-full", style.dot)} />
|
||||||
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border",
|
||||||
|
style.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{style.label}
|
{style.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,19 +253,26 @@ function AppSessionDetailPage() {
|
||||||
{/* API key selector */}
|
{/* API key selector */}
|
||||||
{activeKeys.length > 0 && (
|
{activeKeys.length > 0 && (
|
||||||
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
|
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
|
||||||
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
|
<Key
|
||||||
|
size={11}
|
||||||
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={selectedKeyId}
|
value={selectedKeyId}
|
||||||
onChange={e => setSelectedKeyId(e.target.value)}
|
onChange={(e) => setSelectedKeyId(e.target.value)}
|
||||||
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{activeKeys.map(k => (
|
{activeKeys.map((k) => (
|
||||||
<option key={k.id} value={k.id}>
|
<option key={k.id} value={k.id}>
|
||||||
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
|
<ChevronDown
|
||||||
|
size={10}
|
||||||
|
className="text-gray-400 absolute right-2 pointer-events-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -238,42 +312,73 @@ function AppSessionDetailPage() {
|
||||||
{session.displayName && (
|
{session.displayName && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<User size={13} className="text-gray-400 flex-shrink-0" />
|
<User size={13} className="text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Display name</span>
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
<span className="text-[13px] text-gray-900 font-medium">{session.displayName}</span>
|
Display name
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-900 font-medium">
|
||||||
|
{session.displayName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.phoneNumber && (
|
{session.phoneNumber && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<Phone size={13} className="text-gray-400 flex-shrink-0" />
|
<Phone
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Phone</span>
|
size={13}
|
||||||
<code className="text-[13px] text-gray-900 font-mono">{session.phoneNumber}</code>
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
|
Phone
|
||||||
|
</span>
|
||||||
|
<code className="text-[13px] text-gray-900 font-mono">
|
||||||
|
{session.phoneNumber}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.lastSeen && (
|
{session.lastSeen && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<Clock size={13} className="text-gray-400 flex-shrink-0" />
|
<Clock
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Last seen</span>
|
size={13}
|
||||||
<span className="text-[13px] text-gray-600">{new Date(session.lastSeen).toLocaleString()}</span>
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
|
Last seen
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-600">
|
||||||
|
{new Date(session.lastSeen).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.reconnectCount > 0 && (
|
{session.reconnectCount > 0 && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<RefreshCw size={13} className="text-gray-400 flex-shrink-0" />
|
<RefreshCw
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Reconnects</span>
|
size={13}
|
||||||
<span className="text-[13px] text-gray-600">{session.reconnectCount}</span>
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
|
Reconnects
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-600">
|
||||||
|
{session.reconnectCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<Clock size={13} className="text-gray-400 flex-shrink-0" />
|
<Clock size={13} className="text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Created</span>
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
<span className="text-[13px] text-gray-600">{new Date(session.createdAt).toLocaleString()}</span>
|
Created
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-600">
|
||||||
|
{new Date(session.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">Actions</p>
|
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
Actions
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canDisconnect && (
|
{canDisconnect && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -301,11 +406,13 @@ function AppSessionDetailPage() {
|
||||||
|
|
||||||
{/* Right column — QR / pairing / status */}
|
{/* Right column — QR / pairing / status */}
|
||||||
<div>
|
<div>
|
||||||
{session.status === 'PENDING_QR' && (
|
{session.status === "PENDING_QR" && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<QrCode size={14} className="text-amber-600" />
|
<QrCode size={14} className="text-amber-600" />
|
||||||
<p className="text-[13px] font-semibold text-gray-900">Scan QR code</p>
|
<p className="text-[13px] font-semibold text-gray-900">
|
||||||
|
Scan QR code
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{qr ? (
|
{qr ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -317,7 +424,8 @@ function AppSessionDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-[11px] text-gray-400">
|
<p className="text-center text-[11px] text-gray-400">
|
||||||
Open WhatsApp → Settings → Linked Devices → Link a device
|
Open WhatsApp → Settings → Linked Devices → Link a
|
||||||
|
device
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
|
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
|
||||||
|
|
@ -332,22 +440,26 @@ function AppSessionDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.status === 'PENDING_PAIRING' && !pairingCode && (
|
{session.status === "PENDING_PAIRING" && !pairingCode && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Smartphone size={14} className="text-amber-600" />
|
<Smartphone size={14} className="text-amber-600" />
|
||||||
<p className="text-[13px] font-semibold text-gray-900">Get pairing code</p>
|
<p className="text-[13px] font-semibold text-gray-900">
|
||||||
|
Get pairing code
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-gray-500">
|
<p className="text-[12px] text-gray-500">
|
||||||
Enter your WhatsApp phone number to receive a pairing code.
|
Enter your WhatsApp phone number to receive a pairing code.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[13px] text-zinc-700">Phone number</Label>
|
<Label className="text-[13px] text-zinc-700">
|
||||||
|
Phone number
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="+1 234 567 8900"
|
placeholder="+1 234 567 8900"
|
||||||
value={pairingPhone}
|
value={pairingPhone}
|
||||||
onChange={e => setPairingPhone(e.target.value)}
|
onChange={(e) => setPairingPhone(e.target.value)}
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -361,11 +473,13 @@ function AppSessionDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pairingCode && session.status !== 'CONNECTED' && (
|
{pairingCode && session.status !== "CONNECTED" && (
|
||||||
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-5 space-y-3">
|
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-5 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Smartphone size={14} className="text-amber-600" />
|
<Smartphone size={14} className="text-amber-600" />
|
||||||
<p className="text-[13px] font-semibold text-amber-900">Enter this code</p>
|
<p className="text-[13px] font-semibold text-amber-900">
|
||||||
|
Enter this code
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-3">
|
<div className="text-center py-3">
|
||||||
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">
|
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">
|
||||||
|
|
@ -373,19 +487,23 @@ function AppSessionDetailPage() {
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-amber-700 text-center">
|
<p className="text-[11px] text-amber-700 text-center">
|
||||||
WhatsApp → Linked Devices → Link a Device → Enter phone number
|
WhatsApp → Linked Devices → Link a Device → Enter phone
|
||||||
|
number
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.status === 'CONNECTED' && (
|
{session.status === "CONNECTED" && (
|
||||||
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5 space-y-2.5">
|
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5 space-y-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot flex-shrink-0" />
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot flex-shrink-0" />
|
||||||
<p className="text-[13px] font-semibold text-emerald-800">Session connected</p>
|
<p className="text-[13px] font-semibold text-emerald-800">
|
||||||
|
Session connected
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-emerald-700 leading-relaxed">
|
<p className="text-[12px] text-emerald-700 leading-relaxed">
|
||||||
This session is actively connected and ready to send/receive messages via the API.
|
This session is actively connected and ready to send/receive
|
||||||
|
messages via the API.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -398,14 +516,30 @@ function AppSessionDetailPage() {
|
||||||
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
||||||
<DialogContent className="sm:max-w-[360px]">
|
<DialogContent className="sm:max-w-[360px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-[15px]">Disconnect session?</DialogTitle>
|
<DialogTitle className="text-[15px]">
|
||||||
|
Disconnect session?
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription className="text-[13px]">
|
<DialogDescription className="text-[13px]">
|
||||||
The session will be logged out from WhatsApp. You can reconnect later.
|
The session will be logged out from WhatsApp. You can reconnect
|
||||||
|
later.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
<div className="flex justify-end gap-2 pt-3">
|
||||||
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
|
<Button
|
||||||
<Button size="sm" onClick={disconnect} className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Disconnect</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDisconnectOpen(false)}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={disconnect}
|
||||||
|
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -423,13 +557,26 @@ function AppSessionDetailPage() {
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
<div className="flex justify-end gap-2 pt-3">
|
||||||
<Button variant="outline" size="sm" onClick={() => setDeleteOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
|
<Button
|
||||||
<Button variant="destructive" size="sm" onClick={deleteSession} disabled={deleting} className="h-8 px-3 text-[13px]">
|
variant="outline"
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
size="sm"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={deleteSession}
|
||||||
|
disabled={deleting}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,80 @@
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { Zap, Plus, RefreshCw, Key, ChevronDown } from 'lucide-react'
|
import { Zap, Plus, RefreshCw, Key, ChevronDown } from "lucide-react";
|
||||||
import { appSessionsQuery, apiKeysQuery } from '#/lib/query'
|
import { appSessionsQuery, apiKeysQuery } from "#/lib/query";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
|
const STATUS_STYLES: Record<
|
||||||
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
|
string,
|
||||||
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
|
{ dot: string; badge: string; label: string } | undefined
|
||||||
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
|
> = {
|
||||||
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
|
CONNECTED: {
|
||||||
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
|
dot: "bg-emerald-500 animate-pulse-dot",
|
||||||
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
|
badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
|
label: "Connected",
|
||||||
}
|
},
|
||||||
|
CONNECTING: {
|
||||||
|
dot: "bg-blue-500 animate-pulse-dot",
|
||||||
|
badge: "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
label: "Connecting",
|
||||||
|
},
|
||||||
|
PENDING_QR: {
|
||||||
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
label: "Pending QR",
|
||||||
|
},
|
||||||
|
PENDING_PAIRING: {
|
||||||
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
label: "Pending Pairing",
|
||||||
|
},
|
||||||
|
DISCONNECTED: {
|
||||||
|
dot: "bg-gray-400",
|
||||||
|
badge: "bg-gray-50 text-gray-600 border-gray-200",
|
||||||
|
label: "Disconnected",
|
||||||
|
},
|
||||||
|
LOGGED_OUT: {
|
||||||
|
dot: "bg-red-400",
|
||||||
|
badge: "bg-red-50 text-red-600 border-red-200",
|
||||||
|
label: "Logged Out",
|
||||||
|
},
|
||||||
|
DEAD: {
|
||||||
|
dot: "bg-red-600",
|
||||||
|
badge: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
label: "Dead",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const FALLBACK_STYLE = STATUS_STYLES.DISCONNECTED!
|
const FALLBACK_STYLE = STATUS_STYLES.DISCONNECTED!;
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/sessions/')({
|
export const Route = createFileRoute("/apps/$appId/sessions/")({
|
||||||
component: AppSessionsPage,
|
component: AppSessionsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AppSessionsPage() {
|
function AppSessionsPage() {
|
||||||
const { appId } = Route.useParams()
|
const { appId } = Route.useParams();
|
||||||
const { data: sessions = [], refetch } = useQuery(appSessionsQuery(appId))
|
const { data: sessions = [], refetch } = useQuery(appSessionsQuery(appId));
|
||||||
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId))
|
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId));
|
||||||
const [selectedKeyId, setSelectedKeyId] = useState<string>('')
|
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
|
||||||
|
|
||||||
const activeKeys = apiKeys.filter((k: { revokedAt: string | null }) => !k.revokedAt)
|
const activeKeys = apiKeys.filter(
|
||||||
|
(k: { revokedAt: string | null }) => !k.revokedAt,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeKeys.length > 0 && !selectedKeyId) {
|
if (activeKeys.length > 0 && !selectedKeyId) {
|
||||||
setSelectedKeyId(activeKeys[0].id)
|
setSelectedKeyId(activeKeys[0].id);
|
||||||
}
|
}
|
||||||
}, [activeKeys, selectedKeyId])
|
}, [activeKeys, selectedKeyId]);
|
||||||
|
|
||||||
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length
|
const connectedCount = sessions.filter(
|
||||||
const pendingCount = sessions.filter(s =>
|
(s) => s.status === "CONNECTED",
|
||||||
['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING'].includes(s.status),
|
).length;
|
||||||
).length
|
const pendingCount = sessions.filter((s) =>
|
||||||
|
["PENDING_QR", "PENDING_PAIRING", "CONNECTING"].includes(s.status),
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
|
|
@ -65,19 +100,28 @@ function AppSessionsPage() {
|
||||||
{/* API key selector */}
|
{/* API key selector */}
|
||||||
{activeKeys.length > 0 && (
|
{activeKeys.length > 0 && (
|
||||||
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
|
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
|
||||||
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
|
<Key
|
||||||
|
size={11}
|
||||||
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={selectedKeyId}
|
value={selectedKeyId}
|
||||||
onChange={e => setSelectedKeyId(e.target.value)}
|
onChange={(e) => setSelectedKeyId(e.target.value)}
|
||||||
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{activeKeys.map((k: { id: string; label: string; display: string }) => (
|
{activeKeys.map(
|
||||||
<option key={k.id} value={k.id}>
|
(k: { id: string; label: string; display: string }) => (
|
||||||
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
<option key={k.id} value={k.id}>
|
||||||
</option>
|
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
||||||
))}
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
|
<ChevronDown
|
||||||
|
size={10}
|
||||||
|
className="text-gray-400 absolute right-2 pointer-events-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
@ -106,7 +150,9 @@ function AppSessionsPage() {
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
||||||
<Zap size={20} className="text-gray-400" strokeWidth={1.5} />
|
<Zap size={20} className="text-gray-400" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900 mb-1">No sessions yet</p>
|
<p className="text-sm font-medium text-gray-900 mb-1">
|
||||||
|
No sessions yet
|
||||||
|
</p>
|
||||||
<p className="text-[13px] text-gray-500 mb-5">
|
<p className="text-[13px] text-gray-500 mb-5">
|
||||||
Create a session to connect a WhatsApp number to this app.
|
Create a session to connect a WhatsApp number to this app.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -124,16 +170,24 @@ function AppSessionsPage() {
|
||||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<div className="grid grid-cols-[1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
<div className="grid grid-cols-[1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Session</span>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span>
|
Session
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Last seen</span>
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Reconnects</span>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Last seen
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Reconnects
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{sessions.map((s) => {
|
{sessions.map((s) => {
|
||||||
const style = STATUS_STYLES[s.status] ?? FALLBACK_STYLE
|
const style = STATUS_STYLES[s.status] ?? FALLBACK_STYLE;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|
@ -145,34 +199,44 @@ function AppSessionsPage() {
|
||||||
{s.id.slice(0, 12)}…
|
{s.id.slice(0, 12)}…
|
||||||
</code>
|
</code>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', style.dot)} />
|
<div
|
||||||
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
||||||
|
style.dot,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border",
|
||||||
|
style.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{style.label}
|
{style.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[12px] text-gray-400">
|
<span className="text-[12px] text-gray-400">
|
||||||
{s.lastSeen
|
{s.lastSeen
|
||||||
? new Date(s.lastSeen).toLocaleString('en', {
|
? new Date(s.lastSeen).toLocaleString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
})
|
})
|
||||||
: new Date(s.createdAt).toLocaleDateString('en', {
|
: new Date(s.createdAt).toLocaleDateString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] text-gray-400">
|
<span className="text-[12px] text-gray-400">
|
||||||
{s.reconnectCount > 0 ? s.reconnectCount : '—'}
|
{s.reconnectCount > 0 ? s.reconnectCount : "—"}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,65 @@
|
||||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import { ChevronLeft, QrCode, Smartphone, Key } from 'lucide-react'
|
import { ChevronLeft, QrCode, Smartphone, Key } from "lucide-react";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiKeysQuery } from '#/lib/query'
|
import { apiKeysQuery } from "#/lib/query";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { Label } from '#/components/ui/label'
|
import { Label } from "#/components/ui/label";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/sessions/new')({
|
export const Route = createFileRoute("/apps/$appId/sessions/new")({
|
||||||
component: AppNewSessionPage,
|
component: AppNewSessionPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AppNewSessionPage() {
|
function AppNewSessionPage() {
|
||||||
const { appId } = Route.useParams()
|
const { appId } = Route.useParams();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId))
|
const { data: apiKeys = [] } = useQuery(apiKeysQuery(appId));
|
||||||
const activeKeys = apiKeys.filter((k: { revokedAt: string | null }) => !k.revokedAt)
|
const activeKeys = apiKeys.filter(
|
||||||
const [selectedKeyId, setSelectedKeyId] = useState<string>('')
|
(k: { revokedAt: string | null }) => !k.revokedAt,
|
||||||
|
);
|
||||||
|
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeKeys.length > 0 && !selectedKeyId) {
|
if (activeKeys.length > 0 && !selectedKeyId) {
|
||||||
setSelectedKeyId(activeKeys[0].id)
|
setSelectedKeyId(activeKeys[0].id);
|
||||||
}
|
}
|
||||||
}, [activeKeys, selectedKeyId])
|
}, [activeKeys, selectedKeyId]);
|
||||||
|
|
||||||
const [via, setVia] = useState<'qr' | 'pairing'>('qr')
|
const [via, setVia] = useState<"qr" | "pairing">("qr");
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
if (!selectedKeyId) { setError('Select an API key'); return }
|
if (!selectedKeyId) {
|
||||||
setError('')
|
setError("Select an API key");
|
||||||
setLoading(true)
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/admin/sessions', {
|
const res = await fetch("/api/v1/admin/sessions", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: { 'content-type': 'application/json', 'x-key-id': selectedKeyId },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-key-id": selectedKeyId,
|
||||||
|
},
|
||||||
body: JSON.stringify({ appId, connect_via: via }),
|
body: JSON.stringify({ appId, connect_via: via }),
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed to create session'); return }
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "Failed to create session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.navigate({
|
router.navigate({
|
||||||
to: '/apps/$appId/sessions/$sessionId',
|
to: "/apps/$appId/sessions/$sessionId",
|
||||||
params: { appId, sessionId: data.id },
|
params: { appId, sessionId: data.id },
|
||||||
})
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,9 +83,12 @@ function AppNewSessionPage() {
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium text-gray-900 mb-0.5">Connect WhatsApp</p>
|
<p className="text-[13px] font-medium text-gray-900 mb-0.5">
|
||||||
|
Connect WhatsApp
|
||||||
|
</p>
|
||||||
<p className="text-[12px] text-gray-500">
|
<p className="text-[12px] text-gray-500">
|
||||||
Select an API key and choose how to authenticate the WhatsApp number.
|
Select an API key and choose how to authenticate the WhatsApp
|
||||||
|
number.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -85,25 +99,44 @@ function AppNewSessionPage() {
|
||||||
{activeKeys.length === 0 ? (
|
{activeKeys.length === 0 ? (
|
||||||
<div className="rounded-md bg-amber-50 border border-amber-200 px-3 py-2">
|
<div className="rounded-md bg-amber-50 border border-amber-200 px-3 py-2">
|
||||||
<p className="text-[12px] text-amber-700">
|
<p className="text-[12px] text-amber-700">
|
||||||
No active API keys found. Create one on the API Keys page first.
|
No active API keys found. Create one on the API Keys page
|
||||||
|
first.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-9">
|
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-9">
|
||||||
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
|
<Key
|
||||||
|
size={11}
|
||||||
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={selectedKeyId}
|
value={selectedKeyId}
|
||||||
onChange={e => setSelectedKeyId(e.target.value)}
|
onChange={(e) => setSelectedKeyId(e.target.value)}
|
||||||
className="flex-1 text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
className="flex-1 text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{activeKeys.map((k: { id: string; label: string; display: string }) => (
|
{activeKeys.map(
|
||||||
<option key={k.id} value={k.id}>
|
(k: { id: string; label: string; display: string }) => (
|
||||||
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
<option key={k.id} value={k.id}>
|
||||||
</option>
|
{k.label
|
||||||
))}
|
? `${k.label} · ${k.display}…`
|
||||||
|
: `${k.display}…`}
|
||||||
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<svg className="text-gray-400 absolute right-2.5 pointer-events-none w-3 h-3" viewBox="0 0 12 12" fill="none">
|
<svg
|
||||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
className="text-gray-400 absolute right-2.5 pointer-events-none w-3 h-3"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 4l4 4 4-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -111,33 +144,49 @@ function AppNewSessionPage() {
|
||||||
|
|
||||||
{/* Connection method */}
|
{/* Connection method */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[13px] text-zinc-700">Connection method</Label>
|
<Label className="text-[13px] text-zinc-700">
|
||||||
|
Connection method
|
||||||
|
</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{([
|
{(
|
||||||
{ value: 'qr', label: 'QR Code', desc: 'Scan with WhatsApp', icon: QrCode },
|
[
|
||||||
{ value: 'pairing', label: 'Pairing Code', desc: 'Enter a code', icon: Smartphone },
|
{
|
||||||
] as const).map(({ value, label, desc, icon: Icon }) => (
|
value: "qr",
|
||||||
|
label: "QR Code",
|
||||||
|
desc: "Scan with WhatsApp",
|
||||||
|
icon: QrCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pairing",
|
||||||
|
label: "Pairing Code",
|
||||||
|
desc: "Enter a code",
|
||||||
|
icon: Smartphone,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map(({ value, label, desc, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVia(value)}
|
onClick={() => setVia(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all',
|
"flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all",
|
||||||
via === value
|
via === value
|
||||||
? 'border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900'
|
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||||
: 'border-gray-200 hover:border-gray-300',
|
: "border-gray-200 hover:border-gray-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Icon
|
<Icon
|
||||||
size={13}
|
size={13}
|
||||||
className={via === value ? 'text-zinc-900' : 'text-gray-400'}
|
className={
|
||||||
|
via === value ? "text-zinc-900" : "text-gray-400"
|
||||||
|
}
|
||||||
strokeWidth={1.75}
|
strokeWidth={1.75}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[12px] font-semibold',
|
"text-[12px] font-semibold",
|
||||||
via === value ? 'text-zinc-900' : 'text-gray-600',
|
via === value ? "text-zinc-900" : "text-gray-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -161,7 +210,7 @@ function AppNewSessionPage() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
|
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Creating…' : 'Create session'}
|
{loading ? "Creating…" : "Create session"}
|
||||||
</Button>
|
</Button>
|
||||||
<Link to="/apps/$appId/sessions" params={{ appId }}>
|
<Link to="/apps/$appId/sessions" params={{ appId }}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -178,5 +227,5 @@ function AppNewSessionPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,64 @@
|
||||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { Plus, LayoutGrid, ChevronRight, Zap, Calendar, X } from 'lucide-react'
|
import { Plus, LayoutGrid, ChevronRight, Zap, Calendar, X } from "lucide-react";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
import { appsQuery } from '#/lib/query'
|
import { appsQuery } from "#/lib/query";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from '#/components/ui/input'
|
import { Input } from "#/components/ui/input";
|
||||||
import { Label } from '#/components/ui/label'
|
import { Label } from "#/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '#/components/ui/dialog'
|
} from "#/components/ui/dialog";
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/')({
|
export const Route = createFileRoute("/apps/")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
component: AppsPage,
|
component: AppsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
interface App {
|
interface App {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
maxSessions: string
|
maxSessions: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppsPage() {
|
function AppsPage() {
|
||||||
const { data: apps = [] } = useQuery<App[]>(appsQuery)
|
const { data: apps = [] } = useQuery<App[]>(appsQuery);
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState("");
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
async function createApp(e: React.FormEvent) {
|
async function createApp(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError("");
|
||||||
setCreating(true)
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/admin/apps', {
|
const res = await fetch("/api/v1/admin/apps", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { "content-type": "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
})
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json()
|
const d = await res.json();
|
||||||
setError(d.error ?? 'Failed to create app')
|
setError(d.error ?? "Failed to create app");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setDialogOpen(false)
|
setDialogOpen(false);
|
||||||
setName('')
|
setName("");
|
||||||
queryClient.invalidateQueries({ queryKey: ['apps'] })
|
queryClient.invalidateQueries({ queryKey: ["apps"] });
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,9 +85,15 @@ function AppsPage() {
|
||||||
{apps.length === 0 ? (
|
{apps.length === 0 ? (
|
||||||
<div className="mt-16 flex flex-col items-center justify-center text-center">
|
<div className="mt-16 flex flex-col items-center justify-center text-center">
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
||||||
<LayoutGrid size={20} className="text-gray-400" strokeWidth={1.5} />
|
<LayoutGrid
|
||||||
|
size={20}
|
||||||
|
className="text-gray-400"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900 mb-1">No apps yet</p>
|
<p className="text-sm font-medium text-gray-900 mb-1">
|
||||||
|
No apps yet
|
||||||
|
</p>
|
||||||
<p className="text-[13px] text-gray-500 mb-5">
|
<p className="text-[13px] text-gray-500 mb-5">
|
||||||
Create an app to start managing WhatsApp sessions.
|
Create an app to start managing WhatsApp sessions.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -111,7 +117,11 @@ function AppsPage() {
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="w-9 h-9 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center">
|
<div className="w-9 h-9 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center">
|
||||||
<LayoutGrid size={15} className="text-emerald-600" strokeWidth={1.75} />
|
<LayoutGrid
|
||||||
|
size={15}
|
||||||
|
className="text-emerald-600"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
size={15}
|
size={15}
|
||||||
|
|
@ -120,7 +130,9 @@ function AppsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="font-semibold text-[14px] text-gray-900 mb-1 truncate">{app.name}</p>
|
<p className="font-semibold text-[14px] text-gray-900 mb-1 truncate">
|
||||||
|
{app.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-3">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
|
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
|
||||||
|
|
@ -129,9 +141,9 @@ function AppsPage() {
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
|
<span className="flex items-center gap-1.5 text-[12px] text-gray-400">
|
||||||
<Calendar size={11} strokeWidth={1.75} />
|
<Calendar size={11} strokeWidth={1.75} />
|
||||||
{new Date(app.createdAt).toLocaleDateString('en', {
|
{new Date(app.createdAt).toLocaleDateString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,7 +154,16 @@ function AppsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create App Dialog */}
|
{/* Create App Dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) { setName(''); setError('') } }}>
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setName("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-[15px]">Create app</DialogTitle>
|
<DialogTitle className="text-[15px]">Create app</DialogTitle>
|
||||||
|
|
@ -152,14 +173,16 @@ function AppsPage() {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={createApp} className="space-y-4 pt-1">
|
<form onSubmit={createApp} className="space-y-4 pt-1">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="app-name" className="text-[13px] text-zinc-700">App name</Label>
|
<Label htmlFor="app-name" className="text-[13px] text-zinc-700">
|
||||||
|
App name
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="app-name"
|
id="app-name"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
placeholder="My WhatsApp Bot"
|
placeholder="My WhatsApp Bot"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,7 +196,11 @@ function AppsPage() {
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setDialogOpen(false); setName(''); setError('') }}
|
onClick={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setName("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -184,12 +211,12 @@ function AppsPage() {
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||||
>
|
>
|
||||||
{creating ? 'Creating…' : 'Create app'}
|
{creating ? "Creating…" : "Create app"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,70 @@
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { ScrollText, Search, RefreshCw } from 'lucide-react'
|
import { ScrollText, Search, RefreshCw } from "lucide-react";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
import { auditLogQuery } from '#/lib/query'
|
import { auditLogQuery } from "#/lib/query";
|
||||||
import { Input } from '#/components/ui/input'
|
import { Input } from "#/components/ui/input";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute('/audit-log')({
|
export const Route = createFileRoute("/audit-log")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
component: AuditLogPage,
|
component: AuditLogPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
id: string
|
id: string;
|
||||||
actorType: string
|
actorType: string;
|
||||||
actorId: string
|
actorId: string;
|
||||||
action: string
|
action: string;
|
||||||
resourceType: string
|
resourceType: string;
|
||||||
resourceId: string | null
|
resourceId: string | null;
|
||||||
ip: string | null
|
ip: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
login: 'bg-blue-50 text-blue-700 border-blue-200',
|
login: "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
logout: 'bg-gray-50 text-gray-600 border-gray-200',
|
logout: "bg-gray-50 text-gray-600 border-gray-200",
|
||||||
'app.create': 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
"app.create": "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
'app.delete': 'bg-red-50 text-red-600 border-red-200',
|
"app.delete": "bg-red-50 text-red-600 border-red-200",
|
||||||
'session.create': 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
"session.create": "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
'session.delete': 'bg-red-50 text-red-600 border-red-200',
|
"session.delete": "bg-red-50 text-red-600 border-red-200",
|
||||||
'session.disconnect': 'bg-orange-50 text-orange-700 border-orange-200',
|
"session.disconnect": "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
'api_key.create': 'bg-violet-50 text-violet-700 border-violet-200',
|
"api_key.create": "bg-violet-50 text-violet-700 border-violet-200",
|
||||||
'api_key.revoke': 'bg-red-50 text-red-600 border-red-200',
|
"api_key.revoke": "bg-red-50 text-red-600 border-red-200",
|
||||||
}
|
};
|
||||||
|
|
||||||
function ActionBadge({ action }: { action: string }) {
|
function ActionBadge({ action }: { action: string }) {
|
||||||
const color = ACTION_COLORS[action] ?? 'bg-gray-50 text-gray-600 border-gray-200'
|
const color =
|
||||||
|
ACTION_COLORS[action] ?? "bg-gray-50 text-gray-600 border-gray-200";
|
||||||
return (
|
return (
|
||||||
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border font-mono', color)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border font-mono",
|
||||||
|
color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{action}
|
{action}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuditLogPage() {
|
function AuditLogPage() {
|
||||||
const { data, refetch } = useQuery<{ data: AuditEntry[] }>(auditLogQuery)
|
const { data, refetch } = useQuery<{ data: AuditEntry[] }>(auditLogQuery);
|
||||||
const entries = data?.data ?? []
|
const entries = data?.data ?? [];
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState("");
|
||||||
|
|
||||||
const filtered = entries.filter(e =>
|
const filtered = entries.filter(
|
||||||
!filter ||
|
(e) =>
|
||||||
e.action.toLowerCase().includes(filter.toLowerCase()) ||
|
!filter ||
|
||||||
e.resourceType.toLowerCase().includes(filter.toLowerCase()) ||
|
e.action.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
e.actorType.toLowerCase().includes(filter.toLowerCase()),
|
e.resourceType.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
)
|
e.actorType.toLowerCase().includes(filter.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
|
|
@ -85,11 +92,14 @@ function AuditLogPage() {
|
||||||
<div className="px-8 py-6 animate-fade-up">
|
<div className="px-8 py-6 animate-fade-up">
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="relative max-w-xs mb-5">
|
<div className="relative max-w-xs mb-5">
|
||||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search
|
||||||
|
size={13}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter by action, resource…"
|
placeholder="Filter by action, resource…"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={e => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
className="h-8 pl-8 text-[13px] bg-white"
|
className="h-8 pl-8 text-[13px] bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,22 +108,30 @@ function AuditLogPage() {
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<ScrollText size={28} className="text-gray-300 mx-auto mb-3" />
|
<ScrollText size={28} className="text-gray-300 mx-auto mb-3" />
|
||||||
<p className="text-[13px] font-medium text-gray-500">
|
<p className="text-[13px] font-medium text-gray-500">
|
||||||
{filter ? 'No matching entries' : 'No audit log entries yet'}
|
{filter ? "No matching entries" : "No audit log entries yet"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
<div className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Time</span>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Action</span>
|
Time
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Resource</span>
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">IP</span>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Action
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Resource
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
IP
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{filtered.map(e => (
|
{filtered.map((e) => (
|
||||||
<div
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 items-center hover:bg-gray-50 transition-colors"
|
className="grid grid-cols-[160px_1fr_1fr_120px] gap-4 px-4 py-2.5 items-center hover:bg-gray-50 transition-colors"
|
||||||
|
|
@ -121,16 +139,16 @@ function AuditLogPage() {
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-gray-500 font-mono whitespace-nowrap">
|
<p className="text-[11px] text-gray-500 font-mono whitespace-nowrap">
|
||||||
{new Date(e.createdAt).toLocaleDateString('en', {
|
{new Date(e.createdAt).toLocaleDateString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-gray-400 font-mono">
|
<p className="text-[11px] text-gray-400 font-mono">
|
||||||
{new Date(e.createdAt).toLocaleTimeString('en', {
|
{new Date(e.createdAt).toLocaleTimeString("en", {
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
second: '2-digit',
|
second: "2-digit",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,11 +157,15 @@ function AuditLogPage() {
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<ActionBadge action={e.action} />
|
<ActionBadge action={e.action} />
|
||||||
<span className="text-[11px] text-gray-400">
|
<span className="text-[11px] text-gray-400">
|
||||||
by{' '}
|
by{" "}
|
||||||
<span className={cn(
|
<span
|
||||||
'font-medium',
|
className={cn(
|
||||||
e.actorType === 'user' ? 'text-blue-600' : 'text-violet-600',
|
"font-medium",
|
||||||
)}>
|
e.actorType === "user"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-violet-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{e.actorType}
|
{e.actorType}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -151,7 +173,9 @@ function AuditLogPage() {
|
||||||
|
|
||||||
{/* Resource */}
|
{/* Resource */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[12px] text-gray-700">{e.resourceType}</span>
|
<span className="text-[12px] text-gray-700">
|
||||||
|
{e.resourceType}
|
||||||
|
</span>
|
||||||
{e.resourceId && (
|
{e.resourceId && (
|
||||||
<code className="text-[11px] text-gray-400 ml-1.5 font-mono">
|
<code className="text-[11px] text-gray-400 ml-1.5 font-mono">
|
||||||
{e.resourceId.slice(0, 8)}
|
{e.resourceId.slice(0, 8)}
|
||||||
|
|
@ -161,7 +185,7 @@ function AuditLogPage() {
|
||||||
|
|
||||||
{/* IP */}
|
{/* IP */}
|
||||||
<span className="text-[11px] text-gray-400 font-mono">
|
<span className="text-[11px] text-gray-400 font-mono">
|
||||||
{e.ip ?? '—'}
|
{e.ip ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -170,5 +194,5 @@ function AuditLogPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute("/")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
throw redirect({ to: session?.userId ? '/apps' : '/login' })
|
throw redirect({ to: session?.userId ? "/apps" : "/login" });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,64 @@
|
||||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { MessageSquare, Shield, Zap, Globe } from 'lucide-react'
|
import { MessageSquare, Shield, Zap, Globe } from "lucide-react";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from '#/components/ui/input'
|
import { Input } from "#/components/ui/input";
|
||||||
import { Label } from '#/components/ui/label'
|
import { Label } from "#/components/ui/label";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute('/login')({
|
export const Route = createFileRoute("/login")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (session?.userId) throw redirect({ to: '/apps' })
|
if (session?.userId) throw redirect({ to: "/apps" });
|
||||||
},
|
},
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{ icon: Zap, label: 'Session management', desc: 'QR and pairing code flows' },
|
{ icon: Zap, label: "Session management", desc: "QR and pairing code flows" },
|
||||||
{ icon: Shield, label: 'API key auth', desc: 'Per-app bearer tokens' },
|
{ icon: Shield, label: "API key auth", desc: "Per-app bearer tokens" },
|
||||||
{ icon: Globe, label: 'Webhook events', desc: 'Real-time message delivery' },
|
{ icon: Globe, label: "Webhook events", desc: "Real-time message delivery" },
|
||||||
]
|
];
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState("");
|
||||||
const [totpToken, setTotpToken] = useState('')
|
const [totpToken, setTotpToken] = useState("");
|
||||||
const [requireTotp, setRequireTotp] = useState(false)
|
const [requireTotp, setRequireTotp] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError("");
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { "content-type": "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ email, password, totpToken: totpToken || undefined }),
|
body: JSON.stringify({
|
||||||
})
|
email,
|
||||||
const data = await res.json()
|
password,
|
||||||
if (!res.ok) { setError(data.error ?? 'Login failed'); return }
|
totpToken: totpToken || undefined,
|
||||||
if (data.requireTotp) { setRequireTotp(true); return }
|
}),
|
||||||
router.navigate({ to: '/apps' })
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "Login failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.requireTotp) {
|
||||||
|
setRequireTotp(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.navigate({ to: "/apps" });
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError("Network error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,8 +71,8 @@ function LoginPage() {
|
||||||
className="absolute inset-0 opacity-[0.04]"
|
className="absolute inset-0 opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
|
"linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)",
|
||||||
backgroundSize: '32px 32px',
|
backgroundSize: "32px 32px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Glow accent */}
|
{/* Glow accent */}
|
||||||
|
|
@ -71,11 +81,15 @@ function LoginPage() {
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/40">
|
<div className="w-9 h-9 rounded-xl bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/40">
|
||||||
<MessageSquare size={18} className="text-white fill-white" strokeWidth={1.5} />
|
<MessageSquare
|
||||||
|
size={18}
|
||||||
|
className="text-white fill-white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-white text-xl font-semibold tracking-tight"
|
className="text-white text-xl font-semibold tracking-tight"
|
||||||
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
|
style={{ fontFamily: "Fraunces, Georgia, serif" }}
|
||||||
>
|
>
|
||||||
wahoo
|
wahoo
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -85,25 +99,30 @@ function LoginPage() {
|
||||||
<div className="relative space-y-6">
|
<div className="relative space-y-6">
|
||||||
<h1
|
<h1
|
||||||
className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight"
|
className="text-white text-[42px] leading-[1.15] font-semibold tracking-tight"
|
||||||
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
|
style={{ fontFamily: "Fraunces, Georgia, serif" }}
|
||||||
>
|
>
|
||||||
WhatsApp sessions,{' '}
|
WhatsApp sessions,{" "}
|
||||||
<span className="text-emerald-400">delivered</span>{' '}
|
<span className="text-emerald-400">delivered</span> via API.
|
||||||
via API.
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-400 text-[15px] leading-relaxed max-w-sm">
|
<p className="text-zinc-400 text-[15px] leading-relaxed max-w-sm">
|
||||||
Manage WhatsApp connections at scale. Generate API keys, track sessions, and integrate
|
Manage WhatsApp connections at scale. Generate API keys, track
|
||||||
messaging into your stack without limits.
|
sessions, and integrate messaging into your stack without limits.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
{features.map(({ icon: Icon, label, desc }) => (
|
{features.map(({ icon: Icon, label, desc }) => (
|
||||||
<div key={label} className="flex items-center gap-3">
|
<div key={label} className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0">
|
<div className="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center flex-shrink-0">
|
||||||
<Icon size={14} className="text-emerald-400" strokeWidth={1.75} />
|
<Icon
|
||||||
|
size={14}
|
||||||
|
className="text-emerald-400"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-zinc-100 text-[13px] font-medium">{label}</span>
|
<span className="text-zinc-100 text-[13px] font-medium">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span className="text-zinc-500 text-[13px]"> — {desc}</span>
|
<span className="text-zinc-500 text-[13px]"> — {desc}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -123,11 +142,15 @@ function LoginPage() {
|
||||||
{/* Mobile logo */}
|
{/* Mobile logo */}
|
||||||
<div className="flex lg:hidden items-center gap-2 mb-2">
|
<div className="flex lg:hidden items-center gap-2 mb-2">
|
||||||
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center">
|
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center">
|
||||||
<MessageSquare size={13} className="text-white fill-white" strokeWidth={1.5} />
|
<MessageSquare
|
||||||
|
size={13}
|
||||||
|
className="text-white fill-white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-zinc-900 font-semibold text-base"
|
className="text-zinc-900 font-semibold text-base"
|
||||||
style={{ fontFamily: 'Fraunces, Georgia, serif' }}
|
style={{ fontFamily: "Fraunces, Georgia, serif" }}
|
||||||
>
|
>
|
||||||
wahoo
|
wahoo
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -135,12 +158,12 @@ function LoginPage() {
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h2 className="text-[22px] font-semibold text-zinc-900 tracking-tight">
|
<h2 className="text-[22px] font-semibold text-zinc-900 tracking-tight">
|
||||||
{requireTotp ? 'Two-factor auth' : 'Sign in'}
|
{requireTotp ? "Two-factor auth" : "Sign in"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-zinc-500 text-sm">
|
<p className="text-zinc-500 text-sm">
|
||||||
{requireTotp
|
{requireTotp
|
||||||
? 'Enter the 6-digit code from your authenticator app.'
|
? "Enter the 6-digit code from your authenticator app."
|
||||||
: 'Enter your credentials to access the dashboard.'}
|
: "Enter your credentials to access the dashboard."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -148,7 +171,9 @@ function LoginPage() {
|
||||||
{!requireTotp ? (
|
{!requireTotp ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email" className="text-zinc-700 text-[13px]">Email</Label>
|
<Label htmlFor="email" className="text-zinc-700 text-[13px]">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -156,12 +181,17 @@ function LoginPage() {
|
||||||
required
|
required
|
||||||
placeholder="you@company.com"
|
placeholder="you@company.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password" className="text-zinc-700 text-[13px]">Password</Label>
|
<Label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-zinc-700 text-[13px]"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -169,14 +199,16 @@ function LoginPage() {
|
||||||
required
|
required
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
className="h-9 text-sm border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="totp" className="text-zinc-700 text-[13px]">Authenticator code</Label>
|
<Label htmlFor="totp" className="text-zinc-700 text-[13px]">
|
||||||
|
Authenticator code
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="totp"
|
id="totp"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -187,7 +219,7 @@ function LoginPage() {
|
||||||
required
|
required
|
||||||
placeholder="000 000"
|
placeholder="000 000"
|
||||||
value={totpToken}
|
value={totpToken}
|
||||||
onChange={e => setTotpToken(e.target.value)}
|
onChange={(e) => setTotpToken(e.target.value)}
|
||||||
className="h-9 text-sm tracking-[0.2em] font-mono border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
className="h-9 text-sm tracking-[0.2em] font-mono border-zinc-200 focus-visible:ring-emerald-500/20 focus-visible:border-emerald-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -203,17 +235,20 @@ function LoginPage() {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-9 text-sm font-medium bg-zinc-900 hover:bg-zinc-800 text-white transition-colors',
|
"w-full h-9 text-sm font-medium bg-zinc-900 hover:bg-zinc-800 text-white transition-colors",
|
||||||
loading && 'opacity-60 cursor-not-allowed',
|
loading && "opacity-60 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{loading ? 'Signing in…' : requireTotp ? 'Verify' : 'Sign in'}
|
{loading ? "Signing in…" : requireTotp ? "Verify" : "Sign in"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{requireTotp && (
|
{requireTotp && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setRequireTotp(false); setTotpToken('') }}
|
onClick={() => {
|
||||||
|
setRequireTotp(false);
|
||||||
|
setTotpToken("");
|
||||||
|
}}
|
||||||
className="w-full text-[13px] text-zinc-400 hover:text-zinc-700 transition-colors"
|
className="w-full text-[13px] text-zinc-400 hover:text-zinc-700 transition-colors"
|
||||||
>
|
>
|
||||||
← Back to password
|
← Back to password
|
||||||
|
|
@ -223,5 +258,5 @@ function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,184 +1,256 @@
|
||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Phone, User, Clock, RefreshCw, QrCode, Smartphone,
|
createFileRoute,
|
||||||
Power, Trash2, Key, AlertTriangle, ChevronDown,
|
Link,
|
||||||
} from 'lucide-react'
|
redirect,
|
||||||
import { getSessionFn } from '#/server/auth'
|
useRouter,
|
||||||
import { adminSessionDetailQuery, apiKeysQuery } from '#/lib/query'
|
} from "@tanstack/react-router";
|
||||||
import { Button } from '#/components/ui/button'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Input } from '#/components/ui/input'
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Label } from '#/components/ui/label'
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
QrCode,
|
||||||
|
Smartphone,
|
||||||
|
Power,
|
||||||
|
Trash2,
|
||||||
|
Key,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getSessionFn } from "#/server/auth";
|
||||||
|
import { adminSessionDetailQuery, apiKeysQuery } from "#/lib/query";
|
||||||
|
import { Button } from "#/components/ui/button";
|
||||||
|
import { Input } from "#/components/ui/input";
|
||||||
|
import { Label } from "#/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '#/components/ui/dialog'
|
} from "#/components/ui/dialog";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
|
const STATUS_STYLES: Record<
|
||||||
CONNECTED: { dot: 'bg-emerald-500 animate-pulse-dot', badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', label: 'Connected' },
|
string,
|
||||||
CONNECTING: { dot: 'bg-blue-500 animate-pulse-dot', badge: 'bg-blue-50 text-blue-700 border-blue-200', label: 'Connecting' },
|
{ dot: string; badge: string; label: string } | undefined
|
||||||
PENDING_QR: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending QR' },
|
> = {
|
||||||
PENDING_PAIRING: { dot: 'bg-amber-500 animate-pulse-dot', badge: 'bg-amber-50 text-amber-700 border-amber-200', label: 'Pending Pairing' },
|
CONNECTED: {
|
||||||
DISCONNECTED: { dot: 'bg-gray-400', badge: 'bg-gray-50 text-gray-600 border-gray-200', label: 'Disconnected' },
|
dot: "bg-emerald-500 animate-pulse-dot",
|
||||||
LOGGED_OUT: { dot: 'bg-red-400', badge: 'bg-red-50 text-red-600 border-red-200', label: 'Logged Out' },
|
badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
DEAD: { dot: 'bg-red-600', badge: 'bg-red-50 text-red-700 border-red-200', label: 'Dead' },
|
label: "Connected",
|
||||||
}
|
},
|
||||||
|
CONNECTING: {
|
||||||
|
dot: "bg-blue-500 animate-pulse-dot",
|
||||||
|
badge: "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
|
label: "Connecting",
|
||||||
|
},
|
||||||
|
PENDING_QR: {
|
||||||
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
label: "Pending QR",
|
||||||
|
},
|
||||||
|
PENDING_PAIRING: {
|
||||||
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
label: "Pending Pairing",
|
||||||
|
},
|
||||||
|
DISCONNECTED: {
|
||||||
|
dot: "bg-gray-400",
|
||||||
|
badge: "bg-gray-50 text-gray-600 border-gray-200",
|
||||||
|
label: "Disconnected",
|
||||||
|
},
|
||||||
|
LOGGED_OUT: {
|
||||||
|
dot: "bg-red-400",
|
||||||
|
badge: "bg-red-50 text-red-600 border-red-200",
|
||||||
|
label: "Logged Out",
|
||||||
|
},
|
||||||
|
DEAD: {
|
||||||
|
dot: "bg-red-600",
|
||||||
|
badge: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
label: "Dead",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
id: string
|
id: string;
|
||||||
appId: string
|
appId: string;
|
||||||
status: string
|
status: string;
|
||||||
phoneNumber: string | null
|
phoneNumber: string | null;
|
||||||
displayName: string | null
|
displayName: string | null;
|
||||||
reconnectCount: number
|
reconnectCount: number;
|
||||||
lastSeen: string | null
|
lastSeen: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
id: string
|
id: string;
|
||||||
display: string
|
display: string;
|
||||||
label: string
|
label: string;
|
||||||
revokedAt: string | null
|
revokedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/sessions/$id')({
|
export const Route = createFileRoute("/sessions/$id")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
loader: async ({ params }) => ({ sessionId: params.id }),
|
loader: async ({ params }) => ({ sessionId: params.id }),
|
||||||
component: SessionDetailPage,
|
component: SessionDetailPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function SessionDetailPage() {
|
function SessionDetailPage() {
|
||||||
const { sessionId } = Route.useLoaderData()
|
const { sessionId } = Route.useLoaderData();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
|
const { data: session, refetch: refetchSession } = useQuery<SessionDetail>(
|
||||||
adminSessionDetailQuery(sessionId),
|
adminSessionDetailQuery(sessionId),
|
||||||
)
|
);
|
||||||
|
|
||||||
const appId = session?.appId ?? ''
|
const appId = session?.appId ?? "";
|
||||||
const { data: allKeys = [] } = useQuery<ApiKey[]>({
|
const { data: allKeys = [] } = useQuery<ApiKey[]>({
|
||||||
...apiKeysQuery(appId),
|
...apiKeysQuery(appId),
|
||||||
enabled: !!appId,
|
enabled: !!appId,
|
||||||
})
|
});
|
||||||
const activeKeys = allKeys.filter(k => !k.revokedAt)
|
const activeKeys = allKeys.filter((k) => !k.revokedAt);
|
||||||
|
|
||||||
const [selectedKeyId, setSelectedKeyId] = useState('')
|
const [selectedKeyId, setSelectedKeyId] = useState("");
|
||||||
const [qr, setQr] = useState<string | null>(null)
|
const [qr, setQr] = useState<string | null>(null);
|
||||||
const [pairingPhone, setPairingPhone] = useState('')
|
const [pairingPhone, setPairingPhone] = useState("");
|
||||||
const [pairingCode, setPairingCode] = useState<string | null>(null)
|
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [disconnectOpen, setDisconnectOpen] = useState(false)
|
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedKeyId && activeKeys.length > 0) {
|
if (!selectedKeyId && activeKeys.length > 0) {
|
||||||
setSelectedKeyId(activeKeys[0].id)
|
setSelectedKeyId(activeKeys[0].id);
|
||||||
}
|
}
|
||||||
}, [activeKeys, selectedKeyId])
|
}, [activeKeys, selectedKeyId]);
|
||||||
|
|
||||||
function keyHint() {
|
function keyHint() {
|
||||||
return selectedKeyId ? { 'x-key-id': selectedKeyId } : {}
|
return selectedKeyId ? { "x-key-id": selectedKeyId } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadQr() {
|
async function loadQr() {
|
||||||
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
|
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/qr`, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: keyHint(),
|
headers: keyHint(),
|
||||||
})
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setQr(data.qr ?? null)
|
setQr(data.qr ?? null);
|
||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
setQr(null)
|
setQr(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return
|
if (!session) return;
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
|
||||||
if (session.status === 'PENDING_QR') {
|
if (session.status === "PENDING_QR") {
|
||||||
loadQr()
|
loadQr();
|
||||||
pollRef.current = setInterval(() => { refetchSession(); loadQr() }, 3000)
|
pollRef.current = setInterval(() => {
|
||||||
} else if (session.status === 'CONNECTING') {
|
refetchSession();
|
||||||
pollRef.current = setInterval(() => refetchSession(), 2000)
|
loadQr();
|
||||||
|
}, 3000);
|
||||||
|
} else if (session.status === "CONNECTING") {
|
||||||
|
pollRef.current = setInterval(() => refetchSession(), 2000);
|
||||||
} else {
|
} else {
|
||||||
setQr(null)
|
setQr(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
return () => {
|
||||||
}, [session?.status])
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [session?.status]);
|
||||||
|
|
||||||
async function requestPairingCode() {
|
async function requestPairingCode() {
|
||||||
if (!pairingPhone) { setError('Enter phone number'); return }
|
if (!pairingPhone) {
|
||||||
setError('')
|
setError("Enter phone number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
|
const res = await fetch(`/api/v1/admin/sessions/${sessionId}/pair`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: { 'content-type': 'application/json', ...keyHint() },
|
headers: { "content-type": "application/json", ...keyHint() },
|
||||||
body: JSON.stringify({ phone: pairingPhone }),
|
body: JSON.stringify({ phone: pairingPhone }),
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
if (!res.ok) {
|
||||||
setPairingCode(data.pairingCode)
|
setError(data.error ?? "Failed");
|
||||||
pollRef.current = setInterval(() => refetchSession(), 3000)
|
return;
|
||||||
|
}
|
||||||
|
setPairingCode(data.pairingCode);
|
||||||
|
pollRef.current = setInterval(() => refetchSession(), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function disconnect() {
|
||||||
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
|
await fetch(`/api/v1/admin/sessions/${sessionId}/disconnect`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: keyHint(),
|
headers: keyHint(),
|
||||||
})
|
});
|
||||||
setDisconnectOpen(false)
|
setDisconnectOpen(false);
|
||||||
refetchSession()
|
refetchSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSession() {
|
async function deleteSession() {
|
||||||
setDeleting(true)
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
|
await fetch(`/api/v1/admin/sessions/${sessionId}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: keyHint(),
|
headers: keyHint(),
|
||||||
})
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['sessions'] })
|
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||||
router.navigate({ to: '/sessions' })
|
router.navigate({ to: "/sessions" });
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = session ? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!) : null
|
const style = session
|
||||||
const canDisconnect = session &&
|
? (STATUS_STYLES[session.status] ?? STATUS_STYLES.DISCONNECTED!)
|
||||||
['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status)
|
: null;
|
||||||
|
const canDisconnect =
|
||||||
|
session &&
|
||||||
|
["CONNECTED", "CONNECTING", "PENDING_QR", "PENDING_PAIRING"].includes(
|
||||||
|
session.status,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Link to="/sessions" className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0">
|
<Link
|
||||||
|
to="/sessions"
|
||||||
|
className="text-gray-400 hover:text-gray-700 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
<ChevronLeft size={16} strokeWidth={2} />
|
<ChevronLeft size={16} strokeWidth={2} />
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gray-300">/</span>
|
<span className="text-gray-300">/</span>
|
||||||
<code className="text-[13px] text-gray-700 font-mono truncate">{sessionId.slice(0, 12)}…</code>
|
<code className="text-[13px] text-gray-700 font-mono truncate">
|
||||||
|
{sessionId.slice(0, 12)}…
|
||||||
|
</code>
|
||||||
{style && (
|
{style && (
|
||||||
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
|
<div className="flex items-center gap-1.5 ml-1 flex-shrink-0">
|
||||||
<div className={cn('w-1.5 h-1.5 rounded-full', style.dot)} />
|
<div className={cn("w-1.5 h-1.5 rounded-full", style.dot)} />
|
||||||
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md border', style.badge)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border",
|
||||||
|
style.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{style.label}
|
{style.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,19 +261,26 @@ function SessionDetailPage() {
|
||||||
{/* API key selector */}
|
{/* API key selector */}
|
||||||
{activeKeys.length > 0 && (
|
{activeKeys.length > 0 && (
|
||||||
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
|
<div className="relative flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-lg px-2.5 py-1.5 h-8">
|
||||||
<Key size={11} className="text-gray-400 flex-shrink-0" strokeWidth={2} />
|
<Key
|
||||||
|
size={11}
|
||||||
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
value={selectedKeyId}
|
value={selectedKeyId}
|
||||||
onChange={e => setSelectedKeyId(e.target.value)}
|
onChange={(e) => setSelectedKeyId(e.target.value)}
|
||||||
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
className="text-[12px] text-gray-700 bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{activeKeys.map(k => (
|
{activeKeys.map((k) => (
|
||||||
<option key={k.id} value={k.id}>
|
<option key={k.id} value={k.id}>
|
||||||
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
{k.label ? `${k.label} · ${k.display}…` : `${k.display}…`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown size={10} className="text-gray-400 absolute right-2 pointer-events-none" />
|
<ChevronDown
|
||||||
|
size={10}
|
||||||
|
className="text-gray-400 absolute right-2 pointer-events-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -240,50 +319,88 @@ function SessionDetailPage() {
|
||||||
{session.displayName && (
|
{session.displayName && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<User size={13} className="text-gray-400 flex-shrink-0" />
|
<User size={13} className="text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Display name</span>
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
<span className="text-[13px] text-gray-900 font-medium">{session.displayName}</span>
|
Display name
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-900 font-medium">
|
||||||
|
{session.displayName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.phoneNumber && (
|
{session.phoneNumber && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<Phone size={13} className="text-gray-400 flex-shrink-0" />
|
<Phone
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Phone</span>
|
size={13}
|
||||||
<code className="text-[13px] text-gray-900 font-mono">{session.phoneNumber}</code>
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
|
Phone
|
||||||
|
</span>
|
||||||
|
<code className="text-[13px] text-gray-900 font-mono">
|
||||||
|
{session.phoneNumber}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.lastSeen && (
|
{session.lastSeen && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<Clock size={13} className="text-gray-400 flex-shrink-0" />
|
<Clock
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Last seen</span>
|
size={13}
|
||||||
<span className="text-[13px] text-gray-600">{new Date(session.lastSeen).toLocaleString()}</span>
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
|
Last seen
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-600">
|
||||||
|
{new Date(session.lastSeen).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.reconnectCount > 0 && (
|
{session.reconnectCount > 0 && (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<RefreshCw size={13} className="text-gray-400 flex-shrink-0" />
|
<RefreshCw
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Reconnects</span>
|
size={13}
|
||||||
<span className="text-[13px] text-gray-600">{session.reconnectCount}</span>
|
className="text-gray-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
|
Reconnects
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-600">
|
||||||
|
{session.reconnectCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<Clock size={13} className="text-gray-400 flex-shrink-0" />
|
<Clock size={13} className="text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">Created</span>
|
<span className="text-[12px] text-gray-500 w-24 flex-shrink-0">
|
||||||
<span className="text-[13px] text-gray-600">{new Date(session.createdAt).toLocaleString()}</span>
|
Created
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-gray-600">
|
||||||
|
{new Date(session.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">Actions</p>
|
<p className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
Actions
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canDisconnect && (
|
{canDisconnect && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(true)} className="h-8 px-3 text-[13px] gap-1.5">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDisconnectOpen(true)}
|
||||||
|
className="h-8 px-3 text-[13px] gap-1.5"
|
||||||
|
>
|
||||||
<Power size={12} strokeWidth={2} />
|
<Power size={12} strokeWidth={2} />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline" size="sm" onClick={() => setDeleteOpen(true)}
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
className="h-8 px-3 text-[13px] text-red-500 hover:text-red-700 hover:bg-red-50 border-red-200 gap-1.5"
|
className="h-8 px-3 text-[13px] text-red-500 hover:text-red-700 hover:bg-red-50 border-red-200 gap-1.5"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} strokeWidth={2} />
|
<Trash2 size={12} strokeWidth={2} />
|
||||||
|
|
@ -294,19 +411,26 @@ function SessionDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{session.status === 'PENDING_QR' && (
|
{session.status === "PENDING_QR" && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<QrCode size={14} className="text-amber-600" />
|
<QrCode size={14} className="text-amber-600" />
|
||||||
<p className="text-[13px] font-semibold text-gray-900">Scan QR code</p>
|
<p className="text-[13px] font-semibold text-gray-900">
|
||||||
|
Scan QR code
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{qr ? (
|
{qr ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<img src={qr} alt="WhatsApp QR code" className="w-52 h-52 rounded-xl border border-gray-200 bg-white p-2" />
|
<img
|
||||||
|
src={qr}
|
||||||
|
alt="WhatsApp QR code"
|
||||||
|
className="w-52 h-52 rounded-xl border border-gray-200 bg-white p-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-[11px] text-gray-400">
|
<p className="text-center text-[11px] text-gray-400">
|
||||||
Open WhatsApp → Settings → Linked Devices → Link a device
|
Open WhatsApp → Settings → Linked Devices → Link a
|
||||||
|
device
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
|
<div className="flex items-center justify-center gap-1.5 text-[11px] text-amber-600">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse-dot" />
|
||||||
|
|
@ -321,19 +445,36 @@ function SessionDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.status === 'PENDING_PAIRING' && !pairingCode && (
|
{session.status === "PENDING_PAIRING" && !pairingCode && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Smartphone size={14} className="text-amber-600" />
|
<Smartphone size={14} className="text-amber-600" />
|
||||||
<p className="text-[13px] font-semibold text-gray-900">Get pairing code</p>
|
<p className="text-[13px] font-semibold text-gray-900">
|
||||||
|
Get pairing code
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-gray-500">Enter your WhatsApp phone number.</p>
|
<p className="text-[12px] text-gray-500">
|
||||||
|
Enter your WhatsApp phone number.
|
||||||
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[13px] text-zinc-700">Phone number</Label>
|
<Label className="text-[13px] text-zinc-700">
|
||||||
<Input type="tel" placeholder="+1 234 567 8900" value={pairingPhone} onChange={e => setPairingPhone(e.target.value)} className="h-9 text-sm" />
|
Phone number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
placeholder="+1 234 567 8900"
|
||||||
|
value={pairingPhone}
|
||||||
|
onChange={(e) => setPairingPhone(e.target.value)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-[12px] text-red-600">{error}</p>}
|
{error && <p className="text-[12px] text-red-600">{error}</p>}
|
||||||
<Button onClick={requestPairingCode} className="w-full h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Get code</Button>
|
<Button
|
||||||
|
onClick={requestPairingCode}
|
||||||
|
className="w-full h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||||
|
>
|
||||||
|
Get code
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -341,25 +482,33 @@ function SessionDetailPage() {
|
||||||
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-5 space-y-3">
|
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-5 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Smartphone size={14} className="text-amber-600" />
|
<Smartphone size={14} className="text-amber-600" />
|
||||||
<p className="text-[13px] font-semibold text-amber-900">Enter this code</p>
|
<p className="text-[13px] font-semibold text-amber-900">
|
||||||
|
Enter this code
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-3">
|
<div className="text-center py-3">
|
||||||
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">{pairingCode}</code>
|
<code className="text-3xl font-mono tracking-[0.25em] text-amber-800 font-bold">
|
||||||
|
{pairingCode}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-amber-700 text-center">
|
<p className="text-[11px] text-amber-700 text-center">
|
||||||
WhatsApp → Linked Devices → Link a Device → Enter phone number
|
WhatsApp → Linked Devices → Link a Device → Enter phone
|
||||||
|
number
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.status === 'CONNECTED' && (
|
{session.status === "CONNECTED" && (
|
||||||
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5">
|
<div className="bg-emerald-50/60 border border-emerald-200 rounded-xl p-5">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot" />
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse-dot" />
|
||||||
<p className="text-[13px] font-semibold text-emerald-800">Session connected</p>
|
<p className="text-[13px] font-semibold text-emerald-800">
|
||||||
|
Session connected
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-emerald-700">
|
<p className="text-[12px] text-emerald-700">
|
||||||
This session is actively connected and ready to use via the API.
|
This session is actively connected and ready to use via the
|
||||||
|
API.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -371,12 +520,29 @@ function SessionDetailPage() {
|
||||||
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
||||||
<DialogContent className="sm:max-w-[360px]">
|
<DialogContent className="sm:max-w-[360px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-[15px]">Disconnect session?</DialogTitle>
|
<DialogTitle className="text-[15px]">
|
||||||
<DialogDescription className="text-[13px]">The session will be logged out. You can reconnect later.</DialogDescription>
|
Disconnect session?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-[13px]">
|
||||||
|
The session will be logged out. You can reconnect later.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
<div className="flex justify-end gap-2 pt-3">
|
||||||
<Button variant="outline" size="sm" onClick={() => setDisconnectOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
|
<Button
|
||||||
<Button size="sm" onClick={disconnect} className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white">Disconnect</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDisconnectOpen(false)}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={disconnect}
|
||||||
|
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -388,16 +554,31 @@ function SessionDetailPage() {
|
||||||
<AlertTriangle size={15} className="text-red-500" />
|
<AlertTriangle size={15} className="text-red-500" />
|
||||||
Delete session?
|
Delete session?
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-[13px]">All credentials will be permanently wiped.</DialogDescription>
|
<DialogDescription className="text-[13px]">
|
||||||
|
All credentials will be permanently wiped.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2 pt-3">
|
<div className="flex justify-end gap-2 pt-3">
|
||||||
<Button variant="outline" size="sm" onClick={() => setDeleteOpen(false)} className="h-8 px-3 text-[13px]">Cancel</Button>
|
<Button
|
||||||
<Button variant="destructive" size="sm" onClick={deleteSession} disabled={deleting} className="h-8 px-3 text-[13px]">
|
variant="outline"
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
size="sm"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={deleteSession}
|
||||||
|
disabled={deleting}
|
||||||
|
className="h-8 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,83 @@
|
||||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Zap, Plus, RefreshCw } from 'lucide-react'
|
import { Zap, Plus, RefreshCw } from "lucide-react";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
import { sessionsQuery } from '#/lib/query'
|
import { sessionsQuery } from "#/lib/query";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, { dot: string; badge: string; label: string } | undefined> = {
|
const STATUS_STYLES: Record<
|
||||||
|
string,
|
||||||
|
{ dot: string; badge: string; label: string } | undefined
|
||||||
|
> = {
|
||||||
CONNECTED: {
|
CONNECTED: {
|
||||||
dot: 'bg-emerald-500 animate-pulse-dot',
|
dot: "bg-emerald-500 animate-pulse-dot",
|
||||||
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
badge: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
label: 'Connected',
|
label: "Connected",
|
||||||
},
|
},
|
||||||
CONNECTING: {
|
CONNECTING: {
|
||||||
dot: 'bg-blue-500 animate-pulse-dot',
|
dot: "bg-blue-500 animate-pulse-dot",
|
||||||
badge: 'bg-blue-50 text-blue-700 border-blue-200',
|
badge: "bg-blue-50 text-blue-700 border-blue-200",
|
||||||
label: 'Connecting',
|
label: "Connecting",
|
||||||
},
|
},
|
||||||
PENDING_QR: {
|
PENDING_QR: {
|
||||||
dot: 'bg-amber-500 animate-pulse-dot',
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
label: 'Pending QR',
|
label: "Pending QR",
|
||||||
},
|
},
|
||||||
PENDING_PAIRING: {
|
PENDING_PAIRING: {
|
||||||
dot: 'bg-amber-500 animate-pulse-dot',
|
dot: "bg-amber-500 animate-pulse-dot",
|
||||||
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
badge: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
label: 'Pending Pairing',
|
label: "Pending Pairing",
|
||||||
},
|
},
|
||||||
DISCONNECTED: {
|
DISCONNECTED: {
|
||||||
dot: 'bg-gray-400',
|
dot: "bg-gray-400",
|
||||||
badge: 'bg-gray-50 text-gray-600 border-gray-200',
|
badge: "bg-gray-50 text-gray-600 border-gray-200",
|
||||||
label: 'Disconnected',
|
label: "Disconnected",
|
||||||
},
|
},
|
||||||
LOGGED_OUT: {
|
LOGGED_OUT: {
|
||||||
dot: 'bg-red-400',
|
dot: "bg-red-400",
|
||||||
badge: 'bg-red-50 text-red-600 border-red-200',
|
badge: "bg-red-50 text-red-600 border-red-200",
|
||||||
label: 'Logged Out',
|
label: "Logged Out",
|
||||||
},
|
},
|
||||||
DEAD: {
|
DEAD: {
|
||||||
dot: 'bg-red-600',
|
dot: "bg-red-600",
|
||||||
badge: 'bg-red-50 text-red-700 border-red-200',
|
badge: "bg-red-50 text-red-700 border-red-200",
|
||||||
label: 'Dead',
|
label: "Dead",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string
|
id: string;
|
||||||
appId: string
|
appId: string;
|
||||||
appName: string
|
appName: string;
|
||||||
status: string
|
status: string;
|
||||||
lastSeen: string | null
|
lastSeen: string | null;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
reconnectCount: number
|
reconnectCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/sessions/')({
|
export const Route = createFileRoute("/sessions/")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
component: SessionsPage,
|
component: SessionsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function SessionsPage() {
|
function SessionsPage() {
|
||||||
const { data: sessions = [], refetch } = useQuery<SessionRow[]>(sessionsQuery)
|
const { data: sessions = [], refetch } =
|
||||||
|
useQuery<SessionRow[]>(sessionsQuery);
|
||||||
|
|
||||||
const connectedCount = sessions.filter(s => s.status === 'CONNECTED').length
|
const connectedCount = sessions.filter(
|
||||||
const pendingCount = sessions.filter(s =>
|
(s) => s.status === "CONNECTED",
|
||||||
s.status === 'PENDING_QR' || s.status === 'PENDING_PAIRING' || s.status === 'CONNECTING',
|
).length;
|
||||||
).length
|
const pendingCount = sessions.filter(
|
||||||
|
(s) =>
|
||||||
|
s.status === "PENDING_QR" ||
|
||||||
|
s.status === "PENDING_PAIRING" ||
|
||||||
|
s.status === "CONNECTING",
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
|
|
@ -117,7 +126,9 @@ function SessionsPage() {
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
<div className="w-12 h-12 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center mb-4">
|
||||||
<Zap size={20} className="text-gray-400" strokeWidth={1.5} />
|
<Zap size={20} className="text-gray-400" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900 mb-1">No sessions yet</p>
|
<p className="text-sm font-medium text-gray-900 mb-1">
|
||||||
|
No sessions yet
|
||||||
|
</p>
|
||||||
<p className="text-[13px] text-gray-500 mb-5">
|
<p className="text-[13px] text-gray-500 mb-5">
|
||||||
Create a session to connect a WhatsApp number.
|
Create a session to connect a WhatsApp number.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -135,17 +146,28 @@ function SessionsPage() {
|
||||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden max-w-5xl">
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<div className="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
<div className="grid grid-cols-[1fr_1fr_140px_140px_80px] gap-4 px-4 py-2.5 border-b border-gray-100 bg-gray-50">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Session</span>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">App</span>
|
Session
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Status</span>
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Last seen</span>
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">Reconnects</span>
|
App
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Last seen
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Reconnects
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table rows */}
|
{/* Table rows */}
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{sessions.map((s) => {
|
{sessions.map((s) => {
|
||||||
const style = STATUS_STYLES[s.status] ?? STATUS_STYLES.DISCONNECTED
|
const style =
|
||||||
|
STATUS_STYLES[s.status] ?? STATUS_STYLES.DISCONNECTED;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|
@ -159,14 +181,21 @@ function SessionsPage() {
|
||||||
</code>
|
</code>
|
||||||
|
|
||||||
{/* App name */}
|
{/* App name */}
|
||||||
<span className="text-[13px] text-gray-700 truncate">{s.appName}</span>
|
<span className="text-[13px] text-gray-700 truncate">
|
||||||
|
{s.appName}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Status badge */}
|
{/* Status badge */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', style.dot)} />
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
||||||
|
style.dot,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[11px] font-medium px-1.5 py-0.5 rounded-md border',
|
"text-[11px] font-medium px-1.5 py-0.5 rounded-md border",
|
||||||
style.badge,
|
style.badge,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -177,29 +206,29 @@ function SessionsPage() {
|
||||||
{/* Last seen */}
|
{/* Last seen */}
|
||||||
<span className="text-[12px] text-gray-400">
|
<span className="text-[12px] text-gray-400">
|
||||||
{s.lastSeen
|
{s.lastSeen
|
||||||
? new Date(s.lastSeen).toLocaleString('en', {
|
? new Date(s.lastSeen).toLocaleString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
})
|
})
|
||||||
: new Date(s.createdAt).toLocaleDateString('en', {
|
: new Date(s.createdAt).toLocaleDateString("en", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Reconnect count */}
|
{/* Reconnect count */}
|
||||||
<span className="text-[12px] text-gray-400">
|
<span className="text-[12px] text-gray-400">
|
||||||
{s.reconnectCount > 0 ? s.reconnectCount : '—'}
|
{s.reconnectCount > 0 ? s.reconnectCount : "—"}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,61 @@
|
||||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { ChevronLeft, QrCode, Smartphone } from 'lucide-react'
|
import { ChevronLeft, QrCode, Smartphone } from "lucide-react";
|
||||||
import { getSessionFn } from '#/server/auth'
|
import { getSessionFn } from "#/server/auth";
|
||||||
import { appsQuery } from '#/lib/query'
|
import { appsQuery } from "#/lib/query";
|
||||||
import { Button } from '#/components/ui/button'
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from '#/components/ui/input'
|
import { Input } from "#/components/ui/input";
|
||||||
import { Label } from '#/components/ui/label'
|
import { Label } from "#/components/ui/label";
|
||||||
import { cn } from '#/lib/utils'
|
import { cn } from "#/lib/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute('/sessions/new')({
|
export const Route = createFileRoute("/sessions/new")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSessionFn()
|
const session = await getSessionFn();
|
||||||
if (!session?.userId) throw redirect({ to: '/login' })
|
if (!session?.userId) throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
component: NewSessionPage,
|
component: NewSessionPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function NewSessionPage() {
|
function NewSessionPage() {
|
||||||
const { data: apps = [] } = useQuery<{ id: string; name: string }[]>(appsQuery)
|
const { data: apps = [] } =
|
||||||
const router = useRouter()
|
useQuery<{ id: string; name: string }[]>(appsQuery);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [appId, setAppId] = useState('')
|
const [appId, setAppId] = useState("");
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [via, setVia] = useState<'qr' | 'pairing'>('qr')
|
const [via, setVia] = useState<"qr" | "pairing">("qr");
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const selectedApp = apps.find(a => a.id === (appId || apps[0]?.id))
|
const selectedApp = apps.find((a) => a.id === (appId || apps[0]?.id));
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
const resolvedAppId = appId || apps[0]?.id
|
const resolvedAppId = appId || apps[0]?.id;
|
||||||
if (!resolvedAppId || !apiKey) {
|
if (!resolvedAppId || !apiKey) {
|
||||||
setError('Select an app and enter an API key')
|
setError("Select an app and enter an API key");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setError('')
|
setError("");
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/sessions', {
|
const res = await fetch("/api/v1/sessions", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
body: JSON.stringify({ connect_via: via }),
|
body: JSON.stringify({ connect_via: via }),
|
||||||
})
|
});
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed to create session'); return }
|
if (!res.ok) {
|
||||||
router.navigate({ to: '/sessions/$id', params: { id: data.id } })
|
setError(data.error ?? "Failed to create session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.navigate({ to: "/sessions/$id", params: { id: data.id } });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,9 +78,12 @@ function NewSessionPage() {
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium text-gray-900 mb-0.5">Connect WhatsApp</p>
|
<p className="text-[13px] font-medium text-gray-900 mb-0.5">
|
||||||
|
Connect WhatsApp
|
||||||
|
</p>
|
||||||
<p className="text-[12px] text-gray-500">
|
<p className="text-[12px] text-gray-500">
|
||||||
Select an app, provide its API key, and choose how to authenticate.
|
Select an app, provide its API key, and choose how to
|
||||||
|
authenticate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -82,15 +92,17 @@ function NewSessionPage() {
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[13px] text-zinc-700">App</Label>
|
<Label className="text-[13px] text-zinc-700">App</Label>
|
||||||
<select
|
<select
|
||||||
value={appId || apps[0]?.id || ''}
|
value={appId || apps[0]?.id || ""}
|
||||||
onChange={e => setAppId(e.target.value)}
|
onChange={(e) => setAppId(e.target.value)}
|
||||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] text-gray-900 focus:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-200 transition-shadow"
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-[13px] text-gray-900 focus:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-200 transition-shadow"
|
||||||
>
|
>
|
||||||
{apps.length === 0 ? (
|
{apps.length === 0 ? (
|
||||||
<option value="">No apps — create one first</option>
|
<option value="">No apps — create one first</option>
|
||||||
) : (
|
) : (
|
||||||
apps.map(a => (
|
apps.map((a) => (
|
||||||
<option key={a.id} value={a.id}>{a.name}</option>
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -103,7 +115,7 @@ function NewSessionPage() {
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="wak_live_…"
|
placeholder="wak_live_…"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={e => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
className="h-9 text-sm font-mono"
|
className="h-9 text-sm font-mono"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-400">
|
<p className="text-[11px] text-gray-400">
|
||||||
|
|
@ -113,33 +125,49 @@ function NewSessionPage() {
|
||||||
|
|
||||||
{/* Connection method */}
|
{/* Connection method */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[13px] text-zinc-700">Connection method</Label>
|
<Label className="text-[13px] text-zinc-700">
|
||||||
|
Connection method
|
||||||
|
</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{([
|
{(
|
||||||
{ value: 'qr', label: 'QR Code', desc: 'Scan with WhatsApp', icon: QrCode },
|
[
|
||||||
{ value: 'pairing', label: 'Pairing Code', desc: 'Enter a code', icon: Smartphone },
|
{
|
||||||
] as const).map(({ value, label, desc, icon: Icon }) => (
|
value: "qr",
|
||||||
|
label: "QR Code",
|
||||||
|
desc: "Scan with WhatsApp",
|
||||||
|
icon: QrCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pairing",
|
||||||
|
label: "Pairing Code",
|
||||||
|
desc: "Enter a code",
|
||||||
|
icon: Smartphone,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map(({ value, label, desc, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVia(value)}
|
onClick={() => setVia(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all',
|
"flex flex-col items-start gap-1 px-3 py-3 rounded-lg border text-left transition-all",
|
||||||
via === value
|
via === value
|
||||||
? 'border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900'
|
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||||
: 'border-gray-200 hover:border-gray-300',
|
: "border-gray-200 hover:border-gray-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Icon
|
<Icon
|
||||||
size={13}
|
size={13}
|
||||||
className={via === value ? 'text-zinc-900' : 'text-gray-400'}
|
className={
|
||||||
|
via === value ? "text-zinc-900" : "text-gray-400"
|
||||||
|
}
|
||||||
strokeWidth={1.75}
|
strokeWidth={1.75}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[12px] font-semibold',
|
"text-[12px] font-semibold",
|
||||||
via === value ? 'text-zinc-900' : 'text-gray-600',
|
via === value ? "text-zinc-900" : "text-gray-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -163,7 +191,7 @@ function NewSessionPage() {
|
||||||
disabled={loading || apps.length === 0}
|
disabled={loading || apps.length === 0}
|
||||||
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
|
className="flex-1 h-9 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Creating…' : 'Create session'}
|
{loading ? "Creating…" : "Create session"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -179,5 +207,5 @@ function NewSessionPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getRequest } from '@tanstack/react-start/server'
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { readSession } from '#/lib/session-api'
|
import { readSession } from "#/lib/session-api";
|
||||||
|
|
||||||
export const getSessionFn = createServerFn({ method: 'GET' }).handler(async () => {
|
export const getSessionFn = createServerFn({ method: "GET" }).handler(
|
||||||
const request = getRequest()
|
async () => {
|
||||||
return readSession(request)
|
const request = getRequest();
|
||||||
})
|
return readSession(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { initialize } from '#/lib/baileys'
|
import { initialize } from "#/lib/baileys";
|
||||||
|
|
||||||
// Nitro plugins export a default function; defineNitroPlugin is an identity wrapper.
|
// Nitro plugins export a default function; defineNitroPlugin is an identity wrapper.
|
||||||
export default async function () {
|
export default async function () {
|
||||||
try {
|
try {
|
||||||
await initialize()
|
await initialize();
|
||||||
console.log('[session-startup] sessions initialized')
|
console.log("[session-startup] sessions initialized");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[session-startup] failed to initialize sessions:', err)
|
console.error("[session-startup] failed to initialize sessions:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|
@ -118,8 +118,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
|
@ -179,7 +179,7 @@ code,
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-title {
|
.display-title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: "Fraunces", Georgia, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-up {
|
@keyframes fade-up {
|
||||||
|
|
@ -198,8 +198,13 @@ code,
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
@keyframes pulse-dot {
|
||||||
0%, 100% { opacity: 1; }
|
0%,
|
||||||
50% { opacity: 0.4; }
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-pulse-dot {
|
.animate-pulse-dot {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,40 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
const BASE = 'http://localhost:3000'
|
const BASE = "http://localhost:3000";
|
||||||
|
|
||||||
async function login(page: any) {
|
async function login(page: any) {
|
||||||
await page.goto(`${BASE}/login`)
|
await page.goto(`${BASE}/login`);
|
||||||
await page.getByLabel('Email').fill('test@wahoo.local')
|
await page.getByLabel("Email").fill("test@wahoo.local");
|
||||||
await page.getByLabel('Password').fill('TestPassword123!')
|
await page.getByLabel("Password").fill("TestPassword123!");
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
await page.waitForURL(/\/apps/)
|
await page.waitForURL(/\/apps/);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('re-navigate to /apps shows New app button', async ({ page }) => {
|
test("re-navigate to /apps shows New app button", async ({ page }) => {
|
||||||
await login(page)
|
await login(page);
|
||||||
// This is what createAppAndKey does
|
// This is what createAppAndKey does
|
||||||
await page.goto(`${BASE}/apps`)
|
await page.goto(`${BASE}/apps`);
|
||||||
// wait for dom ready, not network idle
|
// wait for dom ready, not network idle
|
||||||
await page.waitForLoadState('domcontentloaded')
|
await page.waitForLoadState("domcontentloaded");
|
||||||
const content = (await page.content()).slice(0, 2000)
|
const content = (await page.content()).slice(0, 2000);
|
||||||
const hasBtn = content.includes('New app')
|
const hasBtn = content.includes("New app");
|
||||||
const hasErr = content.includes('fetch() URL is invalid')
|
const hasErr = content.includes("fetch() URL is invalid");
|
||||||
console.log('Has New app:', hasBtn, '| Has error:', hasErr)
|
console.log("Has New app:", hasBtn, "| Has error:", hasErr);
|
||||||
// If button found, click and proceed
|
// If button found, click and proceed
|
||||||
await expect(page.getByRole('button', { name: 'New app' })).toBeVisible({ timeout: 10000 })
|
await expect(page.getByRole("button", { name: "New app" })).toBeVisible({
|
||||||
})
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('sessions page after login', async ({ page }) => {
|
test("sessions page after login", async ({ page }) => {
|
||||||
await login(page)
|
await login(page);
|
||||||
await page.goto(`${BASE}/sessions`)
|
await page.goto(`${BASE}/sessions`);
|
||||||
await page.waitForLoadState('domcontentloaded')
|
await page.waitForLoadState("domcontentloaded");
|
||||||
const content = (await page.content()).slice(0, 1000)
|
const content = (await page.content()).slice(0, 1000);
|
||||||
const hasHeading = content.includes('Sessions')
|
const hasHeading = content.includes("Sessions");
|
||||||
const hasErr = content.includes('fetch() URL is invalid')
|
const hasErr = content.includes("fetch() URL is invalid");
|
||||||
console.log('Has Sessions:', hasHeading, '| Has error:', hasErr)
|
console.log("Has Sessions:", hasHeading, "| Has error:", hasErr);
|
||||||
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible({ timeout: 10000 })
|
await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible({
|
||||||
})
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,85 @@
|
||||||
import { test, expect, type Page } from '@playwright/test'
|
import { test, expect } from "@playwright/test";
|
||||||
|
import type {Page} from "@playwright/test";
|
||||||
|
|
||||||
const BASE = 'http://localhost:3000'
|
const BASE = "http://localhost:3000";
|
||||||
const EMAIL = 'test@wahoo.local'
|
const EMAIL = "test@wahoo.local";
|
||||||
const PASSWORD = 'TestPassword123!'
|
const PASSWORD = "TestPassword123!";
|
||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto(`${BASE}/login`)
|
await page.goto(`${BASE}/login`);
|
||||||
await page.getByLabel('Email').fill(EMAIL)
|
await page.getByLabel("Email").fill(EMAIL);
|
||||||
await page.getByLabel('Password').fill(PASSWORD)
|
await page.getByLabel("Password").fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
await page.waitForURL(/\/apps/)
|
await page.waitForURL(/\/apps/);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('redirect / → /login when unauthenticated', async ({ page }) => {
|
test("redirect / → /login when unauthenticated", async ({ page }) => {
|
||||||
await page.goto(BASE)
|
await page.goto(BASE);
|
||||||
await expect(page).toHaveURL(/\/login/)
|
await expect(page).toHaveURL(/\/login/);
|
||||||
})
|
});
|
||||||
|
|
||||||
test('login with email + password', async ({ page }) => {
|
test("login with email + password", async ({ page }) => {
|
||||||
await login(page)
|
await login(page);
|
||||||
await expect(page).toHaveURL(/\/apps/)
|
await expect(page).toHaveURL(/\/apps/);
|
||||||
await expect(page.getByRole('heading', { name: 'Apps' })).toBeVisible()
|
await expect(page.getByRole("heading", { name: "Apps" })).toBeVisible();
|
||||||
})
|
});
|
||||||
|
|
||||||
test('create app, generate API key (shown once), revoke key', async ({ page }) => {
|
test("create app, generate API key (shown once), revoke key", async ({
|
||||||
await login(page)
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
// Create app
|
// Create app
|
||||||
await page.getByRole('button', { name: 'New app' }).click()
|
await page.getByRole("button", { name: "New app" }).click();
|
||||||
await page.getByPlaceholder('App name').fill('Test App')
|
await page.getByPlaceholder("App name").fill("Test App");
|
||||||
await page.getByRole('button', { name: 'Create' }).click()
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
|
|
||||||
// Navigate to app detail
|
// Navigate to app detail
|
||||||
await page.getByText('Test App').click()
|
await page.getByText("Test App").click();
|
||||||
await expect(page.getByRole('heading', { name: 'Test App' })).toBeVisible()
|
await expect(page.getByRole("heading", { name: "Test App" })).toBeVisible();
|
||||||
|
|
||||||
// Generate API key
|
// Generate API key
|
||||||
await page.getByRole('button', { name: 'Generate key' }).click()
|
await page.getByRole("button", { name: "Generate key" }).click();
|
||||||
|
|
||||||
// Key shown once banner
|
// Key shown once banner
|
||||||
const banner = page.locator('text=Copy this key')
|
const banner = page.locator("text=Copy this key");
|
||||||
await expect(banner).toBeVisible()
|
await expect(banner).toBeVisible();
|
||||||
const keyText = await page.locator('code').first().innerText()
|
const keyText = await page.locator("code").first().innerText();
|
||||||
expect(keyText).toMatch(/^wak_live_/)
|
expect(keyText).toMatch(/^wak_live_/);
|
||||||
|
|
||||||
// Authenticate an API request using the key
|
// Authenticate an API request using the key
|
||||||
const authRes = await page.evaluate(async (key) => {
|
const authRes = await page.evaluate(async (key) => {
|
||||||
const r = await fetch('/api/auth/me', {
|
const r = await fetch("/api/auth/me", {
|
||||||
headers: { Authorization: `Bearer ${key}` },
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
})
|
});
|
||||||
return r.status
|
return r.status;
|
||||||
}, keyText)
|
}, keyText);
|
||||||
// /api/auth/me uses session not API key — just verify key is structurally valid
|
// /api/auth/me uses session not API key — just verify key is structurally valid
|
||||||
expect(keyText.startsWith('wak_live_')).toBe(true)
|
expect(keyText.startsWith("wak_live_")).toBe(true);
|
||||||
|
|
||||||
// Revoke key
|
// Revoke key
|
||||||
page.once('dialog', (d) => d.accept())
|
page.once("dialog", (d) => d.accept());
|
||||||
await page.getByRole('button', { name: 'Revoke' }).first().click()
|
await page.getByRole("button", { name: "Revoke" }).first().click();
|
||||||
await expect(page.locator('text=Revoked')).toBeVisible()
|
await expect(page.locator("text=Revoked")).toBeVisible();
|
||||||
})
|
});
|
||||||
|
|
||||||
test('audit log records events', async ({ page }) => {
|
test("audit log records events", async ({ page }) => {
|
||||||
await login(page)
|
await login(page);
|
||||||
await page.getByRole('link', { name: 'Audit log' }).click()
|
await page.getByRole("link", { name: "Audit log" }).click();
|
||||||
await expect(page).toHaveURL(`${BASE}/audit-log`)
|
await expect(page).toHaveURL(`${BASE}/audit-log`);
|
||||||
|
|
||||||
// At minimum login event should exist
|
// At minimum login event should exist
|
||||||
await expect(page.locator('code', { hasText: 'login' }).first()).toBeVisible()
|
await expect(
|
||||||
})
|
page.locator("code", { hasText: "login" }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('logout clears session', async ({ page }) => {
|
test("logout clears session", async ({ page }) => {
|
||||||
await login(page)
|
await login(page);
|
||||||
await page.getByRole('button', { name: 'Sign out' }).click()
|
await page.getByRole("button", { name: "Sign out" }).click();
|
||||||
await expect(page).toHaveURL(/\/login/)
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
// Navigating to protected route redirects back
|
// Navigating to protected route redirects back
|
||||||
await page.goto(`${BASE}/apps/`)
|
await page.goto(`${BASE}/apps/`);
|
||||||
await expect(page).toHaveURL(/\/login/)
|
await expect(page).toHaveURL(/\/login/);
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,44 +9,45 @@
|
||||||
* and pairing code entry are not automatable; those paths are tested up to
|
* and pairing code entry are not automatable; those paths are tested up to
|
||||||
* the point of waiting-for-device.
|
* the point of waiting-for-device.
|
||||||
*/
|
*/
|
||||||
import { test, expect, type Page } from '@playwright/test'
|
import { test, expect } from "@playwright/test";
|
||||||
|
import type {Page} from "@playwright/test";
|
||||||
|
|
||||||
const BASE = 'http://localhost:3000'
|
const BASE = "http://localhost:3000";
|
||||||
const EMAIL = 'test@wahoo.local'
|
const EMAIL = "test@wahoo.local";
|
||||||
const PASSWORD = 'TestPassword123!'
|
const PASSWORD = "TestPassword123!";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function login(page: Page) {
|
async function login(page: Page) {
|
||||||
await page.goto(`${BASE}/login`)
|
await page.goto(`${BASE}/login`);
|
||||||
await page.getByLabel('Email').fill(EMAIL)
|
await page.getByLabel("Email").fill(EMAIL);
|
||||||
await page.getByLabel('Password').fill(PASSWORD)
|
await page.getByLabel("Password").fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
await page.waitForURL(/\/apps/)
|
await page.waitForURL(/\/apps/);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAppAndKey(
|
async function createAppAndKey(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ appId: string; apiKey: string }> {
|
): Promise<{ appId: string; apiKey: string }> {
|
||||||
const appName = `M2TestApp-${Date.now()}`
|
const appName = `M2TestApp-${Date.now()}`;
|
||||||
|
|
||||||
await page.goto(`${BASE}/apps`)
|
await page.goto(`${BASE}/apps`);
|
||||||
await page.getByRole('button', { name: 'New app' }).click()
|
await page.getByRole("button", { name: "New app" }).click();
|
||||||
await page.getByPlaceholder('App name').fill(appName)
|
await page.getByPlaceholder("App name").fill(appName);
|
||||||
await page.getByRole('button', { name: 'Create' }).click()
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
await page.getByText(appName).click()
|
await page.getByText(appName).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Generate key' }).click()
|
await page.getByRole("button", { name: "Generate key" }).click();
|
||||||
await expect(page.locator('text=Copy this key')).toBeVisible()
|
await expect(page.locator("text=Copy this key")).toBeVisible();
|
||||||
const apiKey = await page.locator('code').first().innerText()
|
const apiKey = await page.locator("code").first().innerText();
|
||||||
|
|
||||||
// extract appId from URL
|
// extract appId from URL
|
||||||
const appId = page.url().split('/apps/')[1].replace(/\/$/, '')
|
const appId = page.url().split("/apps/")[1].replace(/\/$/, "");
|
||||||
|
|
||||||
return { appId, apiKey }
|
return { appId, apiKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiResult = { status: number; body: Record<string, unknown> }
|
type ApiResult = { status: number; body: Record<string, unknown> };
|
||||||
|
|
||||||
async function api(
|
async function api(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|
@ -56,297 +57,398 @@ async function api(
|
||||||
): Promise<ApiResult> {
|
): Promise<ApiResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ method, url, apiKey, body }) => {
|
async ({ method, url, apiKey, body }) => {
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {};
|
||||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
})
|
});
|
||||||
return { status: res.status, body: await res.json() }
|
return { status: res.status, body: await res.json() };
|
||||||
},
|
},
|
||||||
{ method, url: `${BASE}${path}`, apiKey: opts.apiKey, body: opts.body },
|
{ method, url: `${BASE}${path}`, apiKey: opts.apiKey, body: opts.body },
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('M2 Session Core', () => {
|
test.describe("M2 Session Core", () => {
|
||||||
test('sessions page renders after login', async ({ page }) => {
|
test("sessions page renders after login", async ({ page }) => {
|
||||||
await login(page)
|
await login(page);
|
||||||
await page.goto(`${BASE}/sessions`)
|
await page.goto(`${BASE}/sessions`);
|
||||||
await expect(page).toHaveURL(/\/sessions/)
|
await expect(page).toHaveURL(/\/sessions/);
|
||||||
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible()
|
await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: 'New session' })).toBeVisible()
|
await expect(page.getByRole("link", { name: "New session" })).toBeVisible();
|
||||||
})
|
});
|
||||||
|
|
||||||
test('new session page renders with connect method selector', async ({ page }) => {
|
test("new session page renders with connect method selector", async ({
|
||||||
await login(page)
|
page,
|
||||||
await page.goto(`${BASE}/sessions/new`)
|
}) => {
|
||||||
await expect(page.getByText('Connect via')).toBeVisible()
|
await login(page);
|
||||||
await expect(page.getByRole('radio', { name: 'QR code' })).toBeVisible()
|
await page.goto(`${BASE}/sessions/new`);
|
||||||
await expect(page.getByRole('radio', { name: 'Pairing code' })).toBeVisible()
|
await expect(page.getByText("Connect via")).toBeVisible();
|
||||||
})
|
await expect(page.getByRole("radio", { name: "QR code" })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("radio", { name: "Pairing code" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /v1/sessions — creates session with PENDING_QR status', async ({ page }) => {
|
test("POST /v1/sessions — creates session with PENDING_QR status", async ({
|
||||||
await login(page)
|
page,
|
||||||
const { apiKey } = await createAppAndKey(page)
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
const { status, body } = await api(page, 'POST', '/api/v1/sessions', {
|
const { status, body } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
apiKey,
|
apiKey,
|
||||||
body: { connect_via: 'qr' },
|
body: { connect_via: "qr" },
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(status).toBe(201)
|
expect(status).toBe(201);
|
||||||
expect(body.id).toBeTruthy()
|
expect(body.id).toBeTruthy();
|
||||||
expect(body.status).toBe('PENDING_QR')
|
expect(body.status).toBe("PENDING_QR");
|
||||||
expect(body.createdAt).toBeTruthy()
|
expect(body.createdAt).toBeTruthy();
|
||||||
})
|
});
|
||||||
|
|
||||||
test('POST /v1/sessions — creates session with PENDING_PAIRING status', async ({ page }) => {
|
test("POST /v1/sessions — creates session with PENDING_PAIRING status", async ({
|
||||||
await login(page)
|
page,
|
||||||
const { apiKey } = await createAppAndKey(page)
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
const { status, body } = await api(page, 'POST', '/api/v1/sessions', {
|
const { status, body } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
apiKey,
|
apiKey,
|
||||||
body: { connect_via: 'pairing' },
|
body: { connect_via: "pairing" },
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(status).toBe(201)
|
expect(status).toBe(201);
|
||||||
expect(body.status).toBe('PENDING_PAIRING')
|
expect(body.status).toBe("PENDING_PAIRING");
|
||||||
})
|
});
|
||||||
|
|
||||||
test('GET /v1/sessions — lists sessions for authenticated app', async ({ page }) => {
|
test("GET /v1/sessions — lists sessions for authenticated app", async ({
|
||||||
await login(page)
|
page,
|
||||||
const { apiKey } = await createAppAndKey(page)
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
// Create two sessions
|
// Create two sessions
|
||||||
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
await api(page, "POST", "/api/v1/sessions", { apiKey, body: {} });
|
||||||
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
await api(page, "POST", "/api/v1/sessions", { apiKey, body: {} });
|
||||||
|
|
||||||
const { status, body } = await api(page, 'GET', '/api/v1/sessions', { apiKey })
|
const { status, body } = await api(page, "GET", "/api/v1/sessions", {
|
||||||
expect(status).toBe(200)
|
|
||||||
expect(Array.isArray(body)).toBe(true)
|
|
||||||
expect((body as unknown as unknown[]).length).toBeGreaterThanOrEqual(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /v1/sessions/:id — returns session detail', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
|
|
||||||
expect(status).toBe(200)
|
|
||||||
expect(body.id).toBe(sessionId)
|
|
||||||
expect(body.status).toBe('PENDING_QR')
|
|
||||||
expect(body.reconnectCount).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /v1/sessions/:id/health — returns health info', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/health`, { apiKey })
|
|
||||||
expect(status).toBe(200)
|
|
||||||
expect(body.id).toBe(sessionId)
|
|
||||||
expect(typeof body.socketAlive).toBe('boolean')
|
|
||||||
expect(typeof body.reconnectCount).toBe('number')
|
|
||||||
})
|
|
||||||
|
|
||||||
// AC1 partial — QR endpoint responds while waiting for scan (full scan→CONNECTED requires live device)
|
|
||||||
test('GET /v1/sessions/:id/qr — QR session returns 200 or 202', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
|
|
||||||
apiKey,
|
apiKey,
|
||||||
body: { connect_via: 'qr' },
|
});
|
||||||
})
|
expect(status).toBe(200);
|
||||||
const sessionId = (created as { id: string }).id
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect((body as unknown as unknown[]).length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
|
test("GET /v1/sessions/:id — returns session detail", async ({ page }) => {
|
||||||
// 200 = QR ready (qr + qrRaw in body); 202 = waiting (waiting: true)
|
await login(page);
|
||||||
expect([200, 202]).toContain(status)
|
const { apiKey } = await createAppAndKey(page);
|
||||||
if (status === 200) {
|
|
||||||
expect(body.qr).toBeTruthy()
|
|
||||||
expect(body.qrRaw).toBeTruthy()
|
|
||||||
} else {
|
|
||||||
expect(body.waiting).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /v1/sessions/:id/qr — pairing session returns 202 while waiting', async ({ page }) => {
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
// pairing session → no QR generated
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
|
|
||||||
apiKey,
|
|
||||||
body: { connect_via: 'pairing' },
|
|
||||||
})
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
|
|
||||||
expect([200, 202]).toContain(status)
|
|
||||||
if (status === 202) expect(body.waiting).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// AC1 partial — DEAD/LOGGED_OUT sessions return 410 from /qr
|
|
||||||
test('GET /v1/sessions/:id/qr — 410 for LOGGED_OUT session', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
// Delete session → sets status to LOGGED_OUT
|
|
||||||
await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey })
|
|
||||||
|
|
||||||
const { status } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
|
|
||||||
expect(status).toBe(410)
|
|
||||||
})
|
|
||||||
|
|
||||||
// AC2 partial — pairing flow validation (full flow requires live device)
|
|
||||||
test('POST /v1/sessions/:id/pair — rejects missing phone', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
|
|
||||||
apiKey,
|
|
||||||
body: { connect_via: 'pairing' },
|
|
||||||
})
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
const { status, body } = await api(page, 'POST', `/api/v1/sessions/${sessionId}/pair`, {
|
|
||||||
apiKey,
|
apiKey,
|
||||||
body: {},
|
body: {},
|
||||||
})
|
});
|
||||||
expect(status).toBe(400)
|
const sessionId = (created as { id: string }).id;
|
||||||
expect(body.error).toMatch(/phone/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /v1/sessions/:id/pair — rejects too-short phone', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
|
|
||||||
apiKey,
|
|
||||||
body: { connect_via: 'pairing' },
|
|
||||||
})
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
const { status, body } = await api(page, 'POST', `/api/v1/sessions/${sessionId}/pair`, {
|
|
||||||
apiKey,
|
|
||||||
body: { phone: '123' },
|
|
||||||
})
|
|
||||||
expect(status).toBe(400)
|
|
||||||
expect(body.error).toMatch(/phone/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('POST /v1/sessions/:id/disconnect — disconnects active session', async ({ page }) => {
|
|
||||||
await login(page)
|
|
||||||
const { apiKey } = await createAppAndKey(page)
|
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
|
||||||
const sessionId = (created as { id: string }).id
|
|
||||||
|
|
||||||
const { status, body } = await api(
|
const { status, body } = await api(
|
||||||
page,
|
page,
|
||||||
'POST',
|
"GET",
|
||||||
`/api/v1/sessions/${sessionId}/disconnect`,
|
`/api/v1/sessions/${sessionId}`,
|
||||||
{ apiKey },
|
{ apiKey },
|
||||||
)
|
);
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200);
|
||||||
expect(body.ok).toBe(true)
|
expect(body.id).toBe(sessionId);
|
||||||
|
expect(body.status).toBe("PENDING_QR");
|
||||||
|
expect(body.reconnectCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
// Verify status is now DISCONNECTED
|
test("GET /v1/sessions/:id/health — returns health info", async ({
|
||||||
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
|
page,
|
||||||
expect(detail.status).toBe('DISCONNECTED')
|
}) => {
|
||||||
})
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
// AC6 — session delete wipes encrypted credentials from DB
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
test('DELETE /v1/sessions/:id — wipes credentials and returns ok', async ({ page }) => {
|
apiKey,
|
||||||
await login(page)
|
body: {},
|
||||||
const { apiKey } = await createAppAndKey(page)
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
const { status, body } = await api(
|
||||||
const sessionId = (created as { id: string }).id
|
page,
|
||||||
|
"GET",
|
||||||
|
`/api/v1/sessions/${sessionId}/health`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.id).toBe(sessionId);
|
||||||
|
expect(typeof body.socketAlive).toBe("boolean");
|
||||||
|
expect(typeof body.reconnectCount).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
const { status, body } = await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey })
|
// AC1 partial — QR endpoint responds while waiting for scan (full scan→CONNECTED requires live device)
|
||||||
expect(status).toBe(200)
|
test("GET /v1/sessions/:id/qr — QR session returns 200 or 202", async ({
|
||||||
expect(body.ok).toBe(true)
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
// Session still exists but status = LOGGED_OUT; credentials wiped
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
|
apiKey,
|
||||||
expect(detail.status).toBe('LOGGED_OUT')
|
body: { connect_via: "qr" },
|
||||||
expect(detail.phoneNumber).toBeNull()
|
});
|
||||||
expect(detail.displayName).toBeNull()
|
const sessionId = (created as { id: string }).id;
|
||||||
})
|
|
||||||
|
|
||||||
test('session limit enforced — rejects beyond maxSessions', async ({ page }) => {
|
const { status, body } = await api(
|
||||||
await login(page)
|
page,
|
||||||
|
"GET",
|
||||||
|
`/api/v1/sessions/${sessionId}/qr`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
// 200 = QR ready (qr + qrRaw in body); 202 = waiting (waiting: true)
|
||||||
|
expect([200, 202]).toContain(status);
|
||||||
|
if (status === 200) {
|
||||||
|
expect(body.qr).toBeTruthy();
|
||||||
|
expect(body.qrRaw).toBeTruthy();
|
||||||
|
} else {
|
||||||
|
expect(body.waiting).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// create app with maxSessions=1 via admin API
|
test("GET /v1/sessions/:id/qr — pairing session returns 202 while waiting", async ({
|
||||||
const { apiKey, appId: _appId } = await createAppAndKey(page)
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
// PATCH app to maxSessions=1
|
// pairing session → no QR generated
|
||||||
await api(page, 'PATCH', `/api/v1/admin/apps/${_appId}`, {
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
body: { maxSessions: 1 },
|
apiKey,
|
||||||
})
|
body: { connect_via: "pairing" },
|
||||||
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
const { status, body } = await api(
|
||||||
|
page,
|
||||||
|
"GET",
|
||||||
|
`/api/v1/sessions/${sessionId}/qr`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect([200, 202]).toContain(status);
|
||||||
|
if (status === 202) expect(body.waiting).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
const { status, body } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
// AC1 partial — DEAD/LOGGED_OUT sessions return 410 from /qr
|
||||||
expect(status).toBe(429)
|
test("GET /v1/sessions/:id/qr — 410 for LOGGED_OUT session", async ({
|
||||||
expect((body.error as string).toLowerCase()).toMatch(/limit/)
|
page,
|
||||||
})
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
test('unknown session id returns 404', async ({ page }) => {
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
await login(page)
|
apiKey,
|
||||||
const { apiKey } = await createAppAndKey(page)
|
body: {},
|
||||||
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
|
// Delete session → sets status to LOGGED_OUT
|
||||||
|
await api(page, "DELETE", `/api/v1/sessions/${sessionId}`, { apiKey });
|
||||||
|
|
||||||
const { status } = await api(
|
const { status } = await api(
|
||||||
page,
|
page,
|
||||||
'GET',
|
"GET",
|
||||||
'/api/v1/sessions/00000000-0000-0000-0000-000000000000',
|
`/api/v1/sessions/${sessionId}/qr`,
|
||||||
{ apiKey },
|
{ apiKey },
|
||||||
)
|
);
|
||||||
expect(status).toBe(404)
|
expect(status).toBe(410);
|
||||||
})
|
});
|
||||||
|
|
||||||
test('missing Authorization header returns 401', async ({ page }) => {
|
// AC2 partial — pairing flow validation (full flow requires live device)
|
||||||
const { status } = await api(page, 'GET', '/api/v1/sessions')
|
test("POST /v1/sessions/:id/pair — rejects missing phone", async ({
|
||||||
expect(status).toBe(401)
|
page,
|
||||||
})
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
test('invalid API key returns 401', async ({ page }) => {
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
const { status } = await api(page, 'GET', '/api/v1/sessions', {
|
apiKey,
|
||||||
apiKey: 'wak_live_invalid_key_xxxx',
|
body: { connect_via: "pairing" },
|
||||||
})
|
});
|
||||||
expect(status).toBe(401)
|
const sessionId = (created as { id: string }).id;
|
||||||
})
|
|
||||||
|
|
||||||
test('session detail page loads for created session', async ({ page }) => {
|
const { status, body } = await api(
|
||||||
await login(page)
|
page,
|
||||||
const { apiKey } = await createAppAndKey(page)
|
"POST",
|
||||||
|
`/api/v1/sessions/${sessionId}/pair`,
|
||||||
|
{
|
||||||
|
apiKey,
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body.error).toMatch(/phone/);
|
||||||
|
});
|
||||||
|
|
||||||
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
|
test("POST /v1/sessions/:id/pair — rejects too-short phone", async ({
|
||||||
const sessionId = (created as { id: string }).id
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
await page.goto(`${BASE}/sessions/${sessionId}`)
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
|
apiKey,
|
||||||
|
body: { connect_via: "pairing" },
|
||||||
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
|
const { status, body } = await api(
|
||||||
|
page,
|
||||||
|
"POST",
|
||||||
|
`/api/v1/sessions/${sessionId}/pair`,
|
||||||
|
{
|
||||||
|
apiKey,
|
||||||
|
body: { phone: "123" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body.error).toMatch(/phone/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /v1/sessions/:id/disconnect — disconnects active session", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
|
apiKey,
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
|
const { status, body } = await api(
|
||||||
|
page,
|
||||||
|
"POST",
|
||||||
|
`/api/v1/sessions/${sessionId}/disconnect`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
|
||||||
|
// Verify status is now DISCONNECTED
|
||||||
|
const { body: detail } = await api(
|
||||||
|
page,
|
||||||
|
"GET",
|
||||||
|
`/api/v1/sessions/${sessionId}`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect(detail.status).toBe("DISCONNECTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC6 — session delete wipes encrypted credentials from DB
|
||||||
|
test("DELETE /v1/sessions/:id — wipes credentials and returns ok", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
|
apiKey,
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
|
const { status, body } = await api(
|
||||||
|
page,
|
||||||
|
"DELETE",
|
||||||
|
`/api/v1/sessions/${sessionId}`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
|
||||||
|
// Session still exists but status = LOGGED_OUT; credentials wiped
|
||||||
|
const { body: detail } = await api(
|
||||||
|
page,
|
||||||
|
"GET",
|
||||||
|
`/api/v1/sessions/${sessionId}`,
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect(detail.status).toBe("LOGGED_OUT");
|
||||||
|
expect(detail.phoneNumber).toBeNull();
|
||||||
|
expect(detail.displayName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session limit enforced — rejects beyond maxSessions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
// create app with maxSessions=1 via admin API
|
||||||
|
const { apiKey, appId: _appId } = await createAppAndKey(page);
|
||||||
|
|
||||||
|
// PATCH app to maxSessions=1
|
||||||
|
await api(page, "PATCH", `/api/v1/admin/apps/${_appId}`, {
|
||||||
|
body: { maxSessions: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await api(page, "POST", "/api/v1/sessions", { apiKey, body: {} });
|
||||||
|
|
||||||
|
const { status, body } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
|
apiKey,
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(status).toBe(429);
|
||||||
|
expect((body.error as string).toLowerCase()).toMatch(/limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown session id returns 404", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
|
const { status } = await api(
|
||||||
|
page,
|
||||||
|
"GET",
|
||||||
|
"/api/v1/sessions/00000000-0000-0000-0000-000000000000",
|
||||||
|
{ apiKey },
|
||||||
|
);
|
||||||
|
expect(status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing Authorization header returns 401", async ({ page }) => {
|
||||||
|
const { status } = await api(page, "GET", "/api/v1/sessions");
|
||||||
|
expect(status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid API key returns 401", async ({ page }) => {
|
||||||
|
const { status } = await api(page, "GET", "/api/v1/sessions", {
|
||||||
|
apiKey: "wak_live_invalid_key_xxxx",
|
||||||
|
});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session detail page loads for created session", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const { apiKey } = await createAppAndKey(page);
|
||||||
|
|
||||||
|
const { body: created } = await api(page, "POST", "/api/v1/sessions", {
|
||||||
|
apiKey,
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const sessionId = (created as { id: string }).id;
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/sessions/${sessionId}`);
|
||||||
// Needs API key input
|
// Needs API key input
|
||||||
await expect(page.getByPlaceholder('wak_...')).toBeVisible()
|
await expect(page.getByPlaceholder("wak_...")).toBeVisible();
|
||||||
|
|
||||||
await page.getByPlaceholder('wak_...').fill(apiKey)
|
await page.getByPlaceholder("wak_...").fill(apiKey);
|
||||||
await page.getByRole('button', { name: 'Load' }).click()
|
await page.getByRole("button", { name: "Load" }).click();
|
||||||
|
|
||||||
// Status badge should appear
|
// Status badge should appear
|
||||||
await expect(page.locator('header')).toContainText('PENDING_QR')
|
await expect(page.locator("header")).toContainText("PENDING_QR");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,213 +8,238 @@
|
||||||
* AC4 partial — reconnect timing within 30s window
|
* AC4 partial — reconnect timing within 30s window
|
||||||
* AC5 — 1h reconnect window → DEAD
|
* AC5 — 1h reconnect window → DEAD
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
// ─── Reconnect backoff ────────────────────────────────────────────────────────
|
// ─── Reconnect backoff ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('reconnect backoff calculation', () => {
|
describe("reconnect backoff calculation", () => {
|
||||||
function backoffDelay(count: number): number {
|
function backoffDelay(count: number): number {
|
||||||
return Math.min(1000 * Math.pow(2, count), 60_000)
|
return Math.min(1000 * Math.pow(2, count), 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('starts at 1s', () => expect(backoffDelay(0)).toBe(1000))
|
it("starts at 1s", () => expect(backoffDelay(0)).toBe(1000));
|
||||||
it('doubles each attempt', () => {
|
it("doubles each attempt", () => {
|
||||||
expect(backoffDelay(1)).toBe(2000)
|
expect(backoffDelay(1)).toBe(2000);
|
||||||
expect(backoffDelay(2)).toBe(4000)
|
expect(backoffDelay(2)).toBe(4000);
|
||||||
expect(backoffDelay(3)).toBe(8000)
|
expect(backoffDelay(3)).toBe(8000);
|
||||||
expect(backoffDelay(4)).toBe(16_000)
|
expect(backoffDelay(4)).toBe(16_000);
|
||||||
expect(backoffDelay(5)).toBe(32_000)
|
expect(backoffDelay(5)).toBe(32_000);
|
||||||
})
|
});
|
||||||
it('caps at 60s', () => {
|
it("caps at 60s", () => {
|
||||||
expect(backoffDelay(6)).toBe(60_000)
|
expect(backoffDelay(6)).toBe(60_000);
|
||||||
expect(backoffDelay(10)).toBe(60_000)
|
expect(backoffDelay(10)).toBe(60_000);
|
||||||
expect(backoffDelay(100)).toBe(60_000)
|
expect(backoffDelay(100)).toBe(60_000);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── QR timeout ──────────────────────────────────────────────────────────────
|
// ─── QR timeout ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('QR timeout window', () => {
|
describe("QR timeout window", () => {
|
||||||
beforeEach(() => vi.useFakeTimers())
|
beforeEach(() => vi.useFakeTimers());
|
||||||
afterEach(() => vi.useRealTimers())
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it('fires after 5 minutes', () => {
|
it("fires after 5 minutes", () => {
|
||||||
const cb = vi.fn()
|
const cb = vi.fn();
|
||||||
setTimeout(cb, 5 * 60 * 1000)
|
setTimeout(cb, 5 * 60 * 1000);
|
||||||
vi.advanceTimersByTime(4 * 60 * 1000 + 59_999)
|
vi.advanceTimersByTime(4 * 60 * 1000 + 59_999);
|
||||||
expect(cb).not.toHaveBeenCalled()
|
expect(cb).not.toHaveBeenCalled();
|
||||||
vi.advanceTimersByTime(1)
|
vi.advanceTimersByTime(1);
|
||||||
expect(cb).toHaveBeenCalledOnce()
|
expect(cb).toHaveBeenCalledOnce();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('resets timer on new QR rotation', () => {
|
it("resets timer on new QR rotation", () => {
|
||||||
const cb = vi.fn()
|
const cb = vi.fn();
|
||||||
let timer = setTimeout(cb, 5 * 60 * 1000)
|
let timer = setTimeout(cb, 5 * 60 * 1000);
|
||||||
|
|
||||||
// At 4:00 a new QR arrives → reset timer
|
// At 4:00 a new QR arrives → reset timer
|
||||||
vi.advanceTimersByTime(4 * 60 * 1000)
|
vi.advanceTimersByTime(4 * 60 * 1000);
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
timer = setTimeout(cb, 5 * 60 * 1000)
|
timer = setTimeout(cb, 5 * 60 * 1000);
|
||||||
|
|
||||||
// Old deadline (t=5m) has passed but cb should not have fired
|
// Old deadline (t=5m) has passed but cb should not have fired
|
||||||
vi.advanceTimersByTime(60_001)
|
vi.advanceTimersByTime(60_001);
|
||||||
expect(cb).not.toHaveBeenCalled()
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// New deadline (t=9m) fires
|
// New deadline (t=9m) fires
|
||||||
vi.advanceTimersByTime(4 * 60 * 1000)
|
vi.advanceTimersByTime(4 * 60 * 1000);
|
||||||
expect(cb).toHaveBeenCalledOnce()
|
expect(cb).toHaveBeenCalledOnce();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── AC5: 1h reconnect window → DEAD ─────────────────────────────────────────
|
// ─── AC5: 1h reconnect window → DEAD ─────────────────────────────────────────
|
||||||
// Mirrors the decision in scheduleReconnect():
|
// Mirrors the decision in scheduleReconnect():
|
||||||
// if (elapsed > MAX_RECONNECT_WINDOW_MS) → setStatus('DEAD')
|
// if (elapsed > MAX_RECONNECT_WINDOW_MS) → setStatus('DEAD')
|
||||||
|
|
||||||
describe('AC5: 1h reconnect window → DEAD', () => {
|
describe("AC5: 1h reconnect window → DEAD", () => {
|
||||||
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000
|
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
function shouldMarkDead(startTime: Date, now: Date): boolean {
|
function shouldMarkDead(startTime: Date, now: Date): boolean {
|
||||||
return now.getTime() - startTime.getTime() > MAX_RECONNECT_WINDOW_MS
|
return now.getTime() - startTime.getTime() > MAX_RECONNECT_WINDOW_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('does not mark DEAD at 59m 59s', () => {
|
it("does not mark DEAD at 59m 59s", () => {
|
||||||
const start = new Date(0)
|
const start = new Date(0);
|
||||||
const now = new Date(MAX_RECONNECT_WINDOW_MS - 1)
|
const now = new Date(MAX_RECONNECT_WINDOW_MS - 1);
|
||||||
expect(shouldMarkDead(start, now)).toBe(false)
|
expect(shouldMarkDead(start, now)).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('marks DEAD when elapsed exceeds 1h', () => {
|
it("marks DEAD when elapsed exceeds 1h", () => {
|
||||||
const start = new Date(0)
|
const start = new Date(0);
|
||||||
const now = new Date(MAX_RECONNECT_WINDOW_MS + 1)
|
const now = new Date(MAX_RECONNECT_WINDOW_MS + 1);
|
||||||
expect(shouldMarkDead(start, now)).toBe(true)
|
expect(shouldMarkDead(start, now)).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('marks DEAD after multiple hours', () => {
|
it("marks DEAD after multiple hours", () => {
|
||||||
const start = new Date(0)
|
const start = new Date(0);
|
||||||
const now = new Date(3 * MAX_RECONNECT_WINDOW_MS)
|
const now = new Date(3 * MAX_RECONNECT_WINDOW_MS);
|
||||||
expect(shouldMarkDead(start, now)).toBe(true)
|
expect(shouldMarkDead(start, now)).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('marks DEAD using timer with fake timers', async () => {
|
it("marks DEAD using timer with fake timers", async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers();
|
||||||
const cb = vi.fn()
|
const cb = vi.fn();
|
||||||
// Simulate the 1h window expiring on the next reconnect attempt
|
// Simulate the 1h window expiring on the next reconnect attempt
|
||||||
setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1)
|
setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1);
|
||||||
vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS)
|
vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS);
|
||||||
expect(cb).not.toHaveBeenCalled()
|
expect(cb).not.toHaveBeenCalled();
|
||||||
vi.advanceTimersByTime(2)
|
vi.advanceTimersByTime(2);
|
||||||
expect(cb).toHaveBeenCalledOnce()
|
expect(cb).toHaveBeenCalledOnce();
|
||||||
vi.useRealTimers()
|
vi.useRealTimers();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── AC3: initialize() — resumable status set ────────────────────────────────
|
// ─── AC3: initialize() — resumable status set ────────────────────────────────
|
||||||
// initialize() queries sessions with status in ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
|
// initialize() queries sessions with status in ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
|
||||||
// and calls startSession() for each. Tests verify which statuses are resumed.
|
// and calls startSession() for each. Tests verify which statuses are resumed.
|
||||||
|
|
||||||
describe('AC3: session resume on restart', () => {
|
describe("AC3: session resume on restart", () => {
|
||||||
const RESUMABLE = ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
|
const RESUMABLE = ["CONNECTED", "DISCONNECTED", "CONNECTING"];
|
||||||
const TERMINAL = ['LOGGED_OUT', 'DEAD']
|
const TERMINAL = ["LOGGED_OUT", "DEAD"];
|
||||||
const PENDING = ['PENDING_QR', 'PENDING_PAIRING']
|
const PENDING = ["PENDING_QR", "PENDING_PAIRING"];
|
||||||
|
|
||||||
it('resumes CONNECTED sessions', () => expect(RESUMABLE).toContain('CONNECTED'))
|
it("resumes CONNECTED sessions", () =>
|
||||||
it('resumes DISCONNECTED sessions', () => expect(RESUMABLE).toContain('DISCONNECTED'))
|
expect(RESUMABLE).toContain("CONNECTED"));
|
||||||
it('resumes CONNECTING sessions', () => expect(RESUMABLE).toContain('CONNECTING'))
|
it("resumes DISCONNECTED sessions", () =>
|
||||||
it('does not resume DEAD sessions', () => expect(RESUMABLE).not.toContain('DEAD'))
|
expect(RESUMABLE).toContain("DISCONNECTED"));
|
||||||
it('does not resume LOGGED_OUT sessions', () => expect(RESUMABLE).not.toContain('LOGGED_OUT'))
|
it("resumes CONNECTING sessions", () =>
|
||||||
it('does not resume PENDING_QR sessions (re-pair required)', () => expect(RESUMABLE).not.toContain('PENDING_QR'))
|
expect(RESUMABLE).toContain("CONNECTING"));
|
||||||
it('does not resume PENDING_PAIRING sessions', () => expect(RESUMABLE).not.toContain('PENDING_PAIRING'))
|
it("does not resume DEAD sessions", () =>
|
||||||
|
expect(RESUMABLE).not.toContain("DEAD"));
|
||||||
|
it("does not resume LOGGED_OUT sessions", () =>
|
||||||
|
expect(RESUMABLE).not.toContain("LOGGED_OUT"));
|
||||||
|
it("does not resume PENDING_QR sessions (re-pair required)", () =>
|
||||||
|
expect(RESUMABLE).not.toContain("PENDING_QR"));
|
||||||
|
it("does not resume PENDING_PAIRING sessions", () =>
|
||||||
|
expect(RESUMABLE).not.toContain("PENDING_PAIRING"));
|
||||||
|
|
||||||
it('terminal + pending statuses are not in resumable set', () => {
|
it("terminal + pending statuses are not in resumable set", () => {
|
||||||
for (const s of [...TERMINAL, ...PENDING]) {
|
for (const s of [...TERMINAL, ...PENDING]) {
|
||||||
expect(RESUMABLE).not.toContain(s)
|
expect(RESUMABLE).not.toContain(s);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── AC4: auto-reconnect timing ──────────────────────────────────────────────
|
// ─── AC4: auto-reconnect timing ──────────────────────────────────────────────
|
||||||
// Kill network for 30s → should reconnect. Backoff starts at 1s, so first
|
// Kill network for 30s → should reconnect. Backoff starts at 1s, so first
|
||||||
// several attempts all fall within a 30s window before 30s has elapsed.
|
// several attempts all fall within a 30s window before 30s has elapsed.
|
||||||
|
|
||||||
describe('AC4: auto-reconnect within 30s window', () => {
|
describe("AC4: auto-reconnect within 30s window", () => {
|
||||||
function backoffDelay(count: number): number {
|
function backoffDelay(count: number): number {
|
||||||
return Math.min(1000 * Math.pow(2, count), 60_000)
|
return Math.min(1000 * Math.pow(2, count), 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('first reconnect attempt at 1s (well within 30s)', () => {
|
it("first reconnect attempt at 1s (well within 30s)", () => {
|
||||||
expect(backoffDelay(0)).toBe(1_000)
|
expect(backoffDelay(0)).toBe(1_000);
|
||||||
expect(backoffDelay(0)).toBeLessThan(30_000)
|
expect(backoffDelay(0)).toBeLessThan(30_000);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('cumulative delay after 4 attempts still < 30s', () => {
|
it("cumulative delay after 4 attempts still < 30s", () => {
|
||||||
// 1s + 2s + 4s + 8s = 15s — all within 30s window
|
// 1s + 2s + 4s + 8s = 15s — all within 30s window
|
||||||
const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0)
|
const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0);
|
||||||
expect(total).toBeLessThan(30_000)
|
expect(total).toBeLessThan(30_000);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('5th attempt (16s) also within 30s', () => {
|
it("5th attempt (16s) also within 30s", () => {
|
||||||
expect(backoffDelay(4)).toBeLessThanOrEqual(30_000)
|
expect(backoffDelay(4)).toBeLessThanOrEqual(30_000);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── AC1 partial: QR session initial state ───────────────────────────────────
|
// ─── AC1 partial: QR session initial state ───────────────────────────────────
|
||||||
// Full AC1 (scan → CONNECTED) requires live WhatsApp device.
|
// Full AC1 (scan → CONNECTED) requires live WhatsApp device.
|
||||||
// We test the QR code memory store returns null for unknown sessions.
|
// We test the QR code memory store returns null for unknown sessions.
|
||||||
|
|
||||||
describe('AC1 partial: QR code in-memory store', () => {
|
describe("AC1 partial: QR code in-memory store", () => {
|
||||||
it('getQrCode returns null for session not in active map', () => {
|
it("getQrCode returns null for session not in active map", () => {
|
||||||
// Mirrors active.get(sessionId)?.qrCode ?? null
|
// Mirrors active.get(sessionId)?.qrCode ?? null
|
||||||
const active = new Map<string, { qrCode: string | null }>()
|
const active = new Map<string, { qrCode: string | null }>();
|
||||||
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
|
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
|
||||||
expect(getQrCode('nonexistent')).toBeNull()
|
expect(getQrCode("nonexistent")).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('getQrCode returns stored QR string', () => {
|
it("getQrCode returns stored QR string", () => {
|
||||||
const active = new Map<string, { qrCode: string | null }>()
|
const active = new Map<string, { qrCode: string | null }>();
|
||||||
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
|
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
|
||||||
active.set('sess-1', { qrCode: '2@abc123,xyz,def' })
|
active.set("sess-1", { qrCode: "2@abc123,xyz,def" });
|
||||||
expect(getQrCode('sess-1')).toBe('2@abc123,xyz,def')
|
expect(getQrCode("sess-1")).toBe("2@abc123,xyz,def");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('QR string clears to null after CONNECTED', () => {
|
it("QR string clears to null after CONNECTED", () => {
|
||||||
const active = new Map<string, { qrCode: string | null }>()
|
const active = new Map<string, { qrCode: string | null }>();
|
||||||
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
|
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null;
|
||||||
active.set('sess-1', { qrCode: '2@abc123' })
|
active.set("sess-1", { qrCode: "2@abc123" });
|
||||||
expect(getQrCode('sess-1')).not.toBeNull()
|
expect(getQrCode("sess-1")).not.toBeNull();
|
||||||
active.get('sess-1')!.qrCode = null // mirrors connection='open' handler
|
active.get("sess-1")!.qrCode = null; // mirrors connection='open' handler
|
||||||
expect(getQrCode('sess-1')).toBeNull()
|
expect(getQrCode("sess-1")).toBeNull();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── Status enum ─────────────────────────────────────────────────────────────
|
// ─── Status enum ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('session status values', () => {
|
describe("session status values", () => {
|
||||||
const VALID = ['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD']
|
const VALID = [
|
||||||
|
"PENDING_QR",
|
||||||
|
"PENDING_PAIRING",
|
||||||
|
"CONNECTING",
|
||||||
|
"CONNECTED",
|
||||||
|
"DISCONNECTED",
|
||||||
|
"LOGGED_OUT",
|
||||||
|
"DEAD",
|
||||||
|
];
|
||||||
|
|
||||||
it('contains all 7 expected statuses', () => {
|
it("contains all 7 expected statuses", () => {
|
||||||
expect(VALID).toHaveLength(7)
|
expect(VALID).toHaveLength(7);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('terminal statuses are LOGGED_OUT and DEAD', () => {
|
it("terminal statuses are LOGGED_OUT and DEAD", () => {
|
||||||
const terminal = VALID.filter((s) => s === 'LOGGED_OUT' || s === 'DEAD')
|
const terminal = VALID.filter((s) => s === "LOGGED_OUT" || s === "DEAD");
|
||||||
expect(terminal).toEqual(['LOGGED_OUT', 'DEAD'])
|
expect(terminal).toEqual(["LOGGED_OUT", "DEAD"]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('active statuses exclude terminal', () => {
|
it("active statuses exclude terminal", () => {
|
||||||
const active = VALID.filter((s) => !['LOGGED_OUT', 'DEAD'].includes(s))
|
const active = VALID.filter((s) => !["LOGGED_OUT", "DEAD"].includes(s));
|
||||||
expect(active).toEqual(['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'])
|
expect(active).toEqual([
|
||||||
})
|
"PENDING_QR",
|
||||||
})
|
"PENDING_PAIRING",
|
||||||
|
"CONNECTING",
|
||||||
|
"CONNECTED",
|
||||||
|
"DISCONNECTED",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Phone number sanitization ────────────────────────────────────────────────
|
// ─── Phone number sanitization ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('phone number digit extraction', () => {
|
describe("phone number digit extraction", () => {
|
||||||
function digitsOnly(phone: string): string {
|
function digitsOnly(phone: string): string {
|
||||||
return phone.replace(/\D/g, '')
|
return phone.replace(/\D/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
it('strips + and spaces', () => expect(digitsOnly('+1 800 555 1234')).toBe('18005551234'))
|
it("strips + and spaces", () =>
|
||||||
it('strips dashes', () => expect(digitsOnly('+44-20-7946-0958')).toBe('442079460958'))
|
expect(digitsOnly("+1 800 555 1234")).toBe("18005551234"));
|
||||||
it('leaves plain digits untouched', () => expect(digitsOnly('12345678901')).toBe('12345678901'))
|
it("strips dashes", () =>
|
||||||
it('rejects too-short numbers', () => expect(digitsOnly('123').length).toBeLessThan(7))
|
expect(digitsOnly("+44-20-7946-0958")).toBe("442079460958"));
|
||||||
})
|
it("leaves plain digits untouched", () =>
|
||||||
|
expect(digitsOnly("12345678901")).toBe("12345678901"));
|
||||||
|
it("rejects too-short numbers", () =>
|
||||||
|
expect(digitsOnly("123").length).toBeLessThan(7));
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
{
|
{
|
||||||
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"eslint.config.js",
|
||||||
|
"prettier.config.js",
|
||||||
|
"vite.config.js"
|
||||||
|
],
|
||||||
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import { devtools } from '@tanstack/devtools-vite'
|
import { devtools } from "@tanstack/devtools-vite";
|
||||||
|
|
||||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
|
|
||||||
import viteReact from '@vitejs/plugin-react'
|
import viteReact from "@vitejs/plugin-react";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { nitro } from 'nitro/vite'
|
import { nitro } from "nitro/vite";
|
||||||
|
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
resolve: { tsconfigPaths: true },
|
resolve: { tsconfigPaths: true },
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools(),
|
devtools(),
|
||||||
nitro({
|
nitro({
|
||||||
preset: 'node',
|
preset: "node",
|
||||||
rollupConfig: { external: [/^@sentry\//] },
|
rollupConfig: { external: [/^@sentry\//] },
|
||||||
plugins: ['./src/server/plugins/session-startup.ts'],
|
plugins: ["./src/server/plugins/session-startup.ts"],
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
tanstackStart(),
|
tanstackStart(),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
export default config
|
export default config;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue