Compare commits

...

14 Commits

Author SHA1 Message Date
maaz519 73956fd1e3 feat: route-centric rules, approve popup, emoji picker, 5-column sync table
- Schema: SyncRouteRuleMap junction table, ruleIntent on TenantRule,
  ForwardFormat/Frequency/ApprovalMode columns on SyncRoute, INTEREST
  match type
- Worker: remove global AUTO_APPROVE forwarding; per-route rule evaluation
  in ingest processor — POSITIVE rules auto-forward that route, NEGATIVE
  rules block it, fallback to approvalMode
- API: PATCH /routes/:id, rule assign/unassign endpoints, approve now
  accepts targetGroupIds, new GET /messages/:id/routes for popup data
- Web: 5-column route table with settings + rules dialogs, ForwardTarget
  popup with rule-based pre-selection, ruleIntent toggle + emoji dropdown
  (👍/👎 only) + INTEREST type in rules manager

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:58:02 +05:30
maaz519 e598073dc7 feat: enrich AI stream with replyToMessageId, sourceGroupName, organizationId + tenant/source filters
- Extract quotedPlatformMsgId from Baileys contextInfo.stanzaId in normalizer
- Thread quotedPlatformMsgId through IngestJobData → ingest processor → Message record
- Widen ingest processor group/tenant selects to include name and organizationId
- Add replyToMessageId, sourceGroupName, organizationId to StreamMessagePayload and XADD
- Update StreamRecord, parseFields, and streamMessages in AI stream service with new fields
- Add optional ?tenant= and ?source= SSE filters; cursor advances for filtered entries
  so Last-Event-ID resume remains accurate across reconnects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 01:07:42 +05:30
maaz519 5a0f7aa58f fix: route browser→API calls through BFF to avoid CORS
- Create /api/onboard/request-otp and /api/auth/member/login BFF routes
- Update OnboardingForm and member-login page to call BFF instead of API directly
- Add vercel.json with monorepo build config and rootDirectory hint
- Add .vercelignore to exclude .turbo, node_modules, .next (was uploading 2.3GB)
- Add apps/web/.gitignore and update root .gitignore for tsbuildinfo files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:01:05 +05:30
maaz519 a3798b3059 feat: show tenancy scope (Org → Chapter) in chapter & member portals
Surface where the logged-in user sits in the hierarchy via a ScopeCard
in the sidebar, below the brand mark.

API:
- Enrich chapter login/me responses with tenantName + organization
  (LoginResponse.admin, AdminProfile types updated)
- Add GET /my/scope returning member's chapter + organization

Web:
- ScopeCard component (Organisation row + Chapter row, themed icons)
- Chapter portal: scope from auth context, blue accent
- Member portal: scope fetched server-side in layout, emerald accent
- PortalShell gains a `scope` slot rendered under the brand

Org and admin portals intentionally omit it — org IS the scope, admin is
platform-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:27:23 +05:30
maaz519 205418fc4e feat: isolate all four portals with route groups + polished design system
Fix blank /org/login (and /admin/login): the guarded portal layout was
wrapping its own login route, rendering null when unauthenticated. Move
each portal's authed pages into a (authed)/(chapter) route group so login
pages live outside the guard.

Structure:
- app/(chapter)/* — chapter admin pages + guarded layout (was root-level)
- app/org/(authed)/* — org pages + guarded layout; /org/login now free
- app/admin/(authed)/* — admin pages + guarded layout; /admin/login free
- Root layout slimmed to providers only (no shared sidebar)
- Convert moved files' relative imports to @/app alias

Design system:
- PortalShell: shared sidebar shell (brand mark, themed active nav with
  accent bar, avatar dropdown user menu, loading skeletons)
- portal-theme.ts: per-portal theme tokens (violet/slate/blue/emerald)
- PortalLoginShell redesigned: two-column with gradient branding panel,
  dotted pattern, value-prop highlights, built-in back link
- New shadcn components: Avatar, DropdownMenu, Skeleton
- Move shared DraftCard to _components/draft-card.tsx (used by 2 portals)
- Portal selector: remove Super Admin card, icon tiles, hover lift
- All 4 login pages use react-hook-form + Zod + themed shell
- Member nav restored to full 11-section list with icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:20:06 +05:30
maaz519 d7b5988858 feat: portal redesign — shadcn/ui, React Query, Zod, isolated portals
Foundation:
- CLAUDE.md with full coding rules (tech stack, patterns, redirect rules)
- Install @tanstack/react-query, zod, react-hook-form, shadcn/ui deps
- CSS variables design system (Tailwind v4 + tw-animate-css)
- Shared components: Button, Input, Label, Card, Badge, Separator, Form
- PortalLoginShell: two-column login layout (branding left, form right)
- PortalNav: shared sidebar nav used by all portals
- ReactQueryProvider in root layout
- api-client.ts: typed fetch wrapper (no raw fetch in components)

Portal isolation:
- Sidebar now returns null for all non-chapter routes; portals own their layout
- Admin layout: auth guard + PortalNav (slate accent)
- Org layout: auth guard + PortalNav (violet accent)
- Member layout: server-side cookie check → MemberNav client component
- Chapter admin sidebar: PortalNav (blue accent)

Login pages (all use react-hook-form + Zod + PortalLoginShell):
- /login → defaults next to /search (was /) — fixes redirect bug
- /admin/login → replaces /admin
- /org/login → replaces /org
- /member-login → two-step phone+OTP with proper form validation

Portal selector (/) redesigned with accent border-left cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:01:27 +05:30
maaz519 f8b7afcc38 feat: bot assign UI in super admin bot pool page
Click "Assign" on any bot row to expand an inline form with a chapter
dropdown. Selecting a chapter and confirming calls POST /api/admin/bots/:id/assign
with the tenantId. The chapter list is loaded alongside bots on mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:37:08 +05:30
maaz519 8d720278cb feat: portal selector home page + remove self-serve signup
Replace the home page with a portal selector showing three access paths:
- Organisation Portal → /org/login
- Chapter Portal → /login
- Member Portal → /member-login

Remove /signup and its BFF route — tenants are now only created by org
owners through the org portal or assigned by super admins. Self-serve
community creation is no longer supported.

Update sidebar to bypass auth guard for / (exact match) without
accidentally matching all paths via startsWith.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:28:38 +05:30
maaz519 6bff55db64 feat: org admin can create chapter (tenant) admins
Add POST /org/tenants/:id/admins so org admins can provision the first
login for a chapter's admin team. New chapter admins can then sign in at
/login with email + password.

- OrgService.createTenantAdmin — validates chapter belongs to org, hashes
  password, creates Admin record
- OrgController POST tenants/:id/admins endpoint
- BFF route /api/org/tenants/[id]/admins
- Add Admin form on /org/tenants/[id] page (email, password, role select)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:21:50 +05:30
maaz519 b26a635283 fix: exclude /org paths from tenant auth guard in sidebar
/org/* is the org-admin portal — it has its own auth context and should
not redirect to /login. Add ORG_PATHS bypass and render null sidebar
on those routes (same pattern as /my/*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:17:54 +05:30
maaz519 484ae7bf9d fix: add OrgAdminProvider to root layout
/org/login was throwing "useOrgAdmin must be used within <OrgAdminProvider>"
because the provider was never mounted in the app shell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:15:21 +05:30
maaz519 8a61908df1 fix: accept role field when creating OrgAdmin
ValidationPipe forbids unknown properties; the UI sends role so the DTO
must declare it. Pass it through to the service so ORG_OWNER/ORG_ADMIN
is honoured on creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:08:58 +05:30
maaz519 abb6799c3d feat: admin org management API + member login fixes
Add NestJS endpoints the admin panel org UI was calling (all returning 404):
- GET/POST /admin/orgs — list and create organizations
- GET /admin/orgs/:id — org detail with tenants and orgAdmins
- POST /admin/orgs/:id/admins — create OrgAdmin with hashed password
- PATCH /admin/tenants/:id/org — assign/unassign tenant from org

Also ship the member login page and sidebar fix from the previous session:
- Move /my/login to /member-login to break infinite redirect loop
- Add /member-login to PUBLIC_PATHS in sidebar so it is not intercepted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:03:51 +05:30
maaz519 a1d2c6e6c2 feat: member phone login — POST /public/auth/member-login + /my/login page
Returning members can now sign in with just their phone number.
The bot DMs them a 6-digit OTP; on verify a 30-day session cookie is set.
First-time users are directed to their invite link from the login page.

- Make OtpChallenge.groupId optional (migration) for re-login challenges
- Add memberLogin / memberVerify service methods
- Add POST /public/auth/member-login and /member-verify controller endpoints
- Add /api/my/login BFF route (sets tower_member_token cookie)
- Add /my/login page (phone → OTP two-step form)
- /my/* now redirects to /my/login instead of /onboard on no session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 16:37:19 +05:30
116 changed files with 4886 additions and 1141 deletions
+2
View File
@@ -7,3 +7,5 @@ coverage
*.env.local
.env.production
sessions/
*.tsbuildinfo
.vercel
+14
View File
@@ -0,0 +1,14 @@
node_modules
.turbo
.next
dist
build
coverage
*.tsbuildinfo
.git
apps/worker/sessions
apps/api/sessions
sessions
.env
.env.*
!.env.example
+171
View File
@@ -0,0 +1,171 @@
# TOWER — Claude Code Rules
## Project Overview
TOWER is a multi-tenant WhatsApp community management platform. It has four portals served by a single Next.js app and a single NestJS API:
| Portal | Path prefix | Auth cookie | Guard |
|--------|------------|-------------|-------|
| Portal selector | `/` | — | public |
| Chapter admin | `/`, `/search`, `/groups`, `/messages`, `/drafts`, `/settings`, `/threads`, `/claim-group` | `tower_token` | `JwtAuthGuard` |
| Organisation admin | `/org/*` | `tower_org_token` | `OrgAdminGuard` |
| Super admin | `/admin/*` | `tower_super_token` | `SuperAdminGuard` |
| Member | `/my/*`, `/onboard`, `/member-login` | `tower_member_token` | `MemberAuthGuard` |
## Tech Stack
- **Next.js 16** (App Router, TypeScript) — `apps/web`
- **NestJS** (TypeScript) — `apps/api`
- **Prisma** — ORM, PostgreSQL
- **BullMQ** + **Redis** — job queues
- **Tailwind CSS v4** — utility-first CSS
- **shadcn/ui** — component library (new-york style, CSS variables)
- **TanStack React Query** — all data fetching and mutations, no raw `fetch` in components
- **Zod** — all schema validation (forms, API response shapes)
- **react-hook-form** + **@hookform/resolvers/zod** — all forms
- **lucide-react** — icons
## File & Folder Conventions
```
apps/web/app/
_lib/ # Shared utilities and contexts
api.ts # getApiBaseUrl, jsonResponse, cookie helpers
query-client.tsx # QueryClientProvider wrapper
auth-context.tsx # Chapter admin auth (tower_token)
super-admin-context.tsx # Super admin auth (tower_super_token)
org-admin-context.tsx # Org admin auth (tower_org_token)
utils.ts # cn() helper
_components/ # Shared UI components (shadcn + custom)
ui/ # Raw shadcn components (never edit directly)
portal-shell.tsx # Shared portal layout wrapper
nav-user.tsx # User dropdown in nav
data-table.tsx # Shared table component
api/ # Next.js BFF route handlers only
auth/…
admin/…
org/…
my/…
(public)/ # Portal selector + all login pages (no auth)
admin/ # Super admin portal
org/ # Org admin portal
my/ # Member portal
[chapter pages]/ # Chapter admin pages at root level
```
## Coding Rules
### General
- **No raw `fetch` in components.** All API calls go through TanStack Query hooks. Define queries in a co-located `_queries.ts` or inline `useQuery`/`useMutation`.
- **No `useState` for server data.** Use React Query. `useState` is for UI-only state (modal open, tab selection, etc.).
- **All forms use react-hook-form + Zod.** Schema first: define a `z.object({...})` schema, derive the type with `z.infer`, pass resolver to `useForm`.
- **Shared components live in `app/_components/`.** Never duplicate a button, input, or card across portals.
- **Each portal has its own layout** with nav/sidebar. Do not put portal UI logic in the root layout.
- **No `any` types.** Use proper types or `unknown` + type narrowing.
### TanStack React Query
```tsx
// Define query key factories
export const tenantKeys = {
all: ['tenants'] as const,
list: () => [...tenantKeys.all, 'list'] as const,
detail: (id: string) => [...tenantKeys.all, 'detail', id] as const,
};
// In component
const { data, isPending } = useQuery({
queryKey: tenantKeys.list(),
queryFn: () => api.get<Tenant[]>('/api/admin/tenants'),
});
// Mutations always invalidate related queries
const mutation = useMutation({
mutationFn: (body: CreateTenantInput) => api.post('/api/admin/tenants', body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: tenantKeys.all }),
});
```
### Forms
```tsx
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormValues = z.infer<typeof schema>;
function MyForm() {
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</form>
</Form>
);
}
```
### API Helper
Use the typed `api` helper from `_lib/api-client.ts` — never call `fetch` directly in components or hooks:
```ts
// app/_lib/api-client.ts
export const api = {
get: <T>(url: string) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
post: <T>(url: string, body: unknown) => fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
patch: <T>(url: string, body: unknown) => fetch(url, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
del: (url: string) => fetch(url, { method: 'DELETE' }).then(r => { if (!r.ok) return Promise.reject(r); }),
};
```
### Auth Contexts
Each portal's auth context provides:
- `user` / `admin` — the authenticated entity (undefined when loading)
- `loading` — boolean
- `login(...)` — sets cookie via BFF, updates context
- `logout()` — clears cookie, redirects to portal login
Auth guard pattern (in portal layout, server component):
```tsx
// In a portal layout — always server component
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function PortalLayout({ children }) {
const token = (await cookies()).get('tower_token')?.value;
if (!token) redirect('/login');
return <PortalShell>{children}</PortalShell>;
}
```
### Styling
- Use shadcn/ui components as the base. Extend with Tailwind utilities.
- **No inline styles.** All styles via Tailwind classes.
- **No magic numbers in class names** — use design tokens (`text-sm`, `p-4`, `gap-2` — not arbitrary `p-[13px]`).
- Colors: use semantic tokens (`text-foreground`, `bg-muted`, `border`) not raw colors.
- Dark mode: set up via `class` strategy but only if explicitly requested.
### NestJS API Rules
- All protected endpoints use a Guard decorator — never trust the request without it.
- DTOs use `class-validator` decorators. `ValidationPipe` with `whitelist: true, forbidNonWhitelisted: true` is global.
- Always use `select` in Prisma queries to avoid leaking `passwordHash` or other sensitive fields.
- Org-scoped queries always include `organizationId` in the `where` clause.
- Tenant-scoped queries always include `tenantId` in the `where` clause.
### Redirect Rules (IMPORTANT)
After successful login, each portal redirects to its own home:
- Chapter admin: `/search` (or `next` param if set)
- Org admin: `/org`
- Super admin: `/admin`
- Member: `/my`
Never redirect to `/` after login — that is the public portal selector.
### Commit Style
Conventional commits: `feat:`, `fix:`, `refactor:`, `chore:`
Co-author every commit with Claude.
@@ -0,0 +1,3 @@
-- Make groupId optional on OtpChallenge so member re-login challenges
-- (which have no group context) can be created without a groupId.
ALTER TABLE "OtpChallenge" ALTER COLUMN "groupId" DROP NOT NULL;
@@ -0,0 +1,45 @@
-- CreateEnum
CREATE TYPE "RuleIntent" AS ENUM ('POSITIVE', 'NEGATIVE');
CREATE TYPE "ForwardFormat" AS ENUM ('THREADED', 'DIGEST', 'SUMMARY');
CREATE TYPE "ForwardFrequency" AS ENUM ('INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS');
CREATE TYPE "ApprovalMode" AS ENUM ('MANUAL', 'AUTO');
-- AlterEnum: Add INTEREST to RuleMatchType
ALTER TYPE "RuleMatchType" ADD VALUE IF NOT EXISTS 'INTEREST';
-- AlterTable: TenantRule - add ruleIntent
ALTER TABLE "TenantRule" ADD COLUMN IF NOT EXISTS "ruleIntent" "RuleIntent" NOT NULL DEFAULT 'POSITIVE';
-- AlterTable: SyncRoute - add new columns (updatedAt with default NOW() for existing rows)
ALTER TABLE "SyncRoute"
ADD COLUMN IF NOT EXISTS "forwardFormat" "ForwardFormat" NOT NULL DEFAULT 'THREADED',
ADD COLUMN IF NOT EXISTS "withAttachment" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "frequency" "ForwardFrequency" NOT NULL DEFAULT 'INSTANT',
ADD COLUMN IF NOT EXISTS "approvalMode" "ApprovalMode" NOT NULL DEFAULT 'MANUAL',
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
-- CreateTable: SyncRouteRuleMap
CREATE TABLE IF NOT EXISTS "SyncRouteRuleMap" (
"id" TEXT NOT NULL,
"syncRouteId" TEXT NOT NULL,
"tenantRuleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SyncRouteRuleMap_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_tenantRuleId_key"
ON "SyncRouteRuleMap"("syncRouteId", "tenantRuleId");
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_idx"
ON "SyncRouteRuleMap"("syncRouteId");
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_tenantRuleId_idx"
ON "SyncRouteRuleMap"("tenantRuleId");
-- AddForeignKey
ALTER TABLE "SyncRouteRuleMap"
ADD CONSTRAINT "SyncRouteRuleMap_syncRouteId_fkey"
FOREIGN KEY ("syncRouteId") REFERENCES "SyncRoute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SyncRouteRuleMap"
ADD CONSTRAINT "SyncRouteRuleMap_tenantRuleId_fkey"
FOREIGN KEY ("tenantRuleId") REFERENCES "TenantRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+69 -20
View File
@@ -337,20 +337,40 @@ enum ApprovalDecision {
}
model SyncRoute {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
targetGroupId String
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
targetGroupId String
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
isActive Boolean @default(true)
forwardFormat ForwardFormat @default(THREADED)
withAttachment Boolean @default(true)
frequency ForwardFrequency @default(INSTANT)
approvalMode ApprovalMode @default(MANUAL)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedRules SyncRouteRuleMap[]
@@unique([sourceGroupId, targetGroupId])
@@index([tenantId])
}
model SyncRouteRuleMap {
id String @id @default(cuid())
syncRouteId String
syncRoute SyncRoute @relation(fields: [syncRouteId], references: [id], onDelete: Cascade)
tenantRuleId String
tenantRule TenantRule @relation(fields: [tenantRuleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([syncRouteId, tenantRuleId])
@@index([syncRouteId])
@@index([tenantRuleId])
}
// ============================================================================
// Group claiming + sharing
// ============================================================================
@@ -496,7 +516,7 @@ model OtpChallenge {
scopes ConsentScope[]
retentionDays Int @default(90)
policyVersion String
groupId String
groupId String?
expiresAt DateTime
consumedAt DateTime?
sentAt DateTime?
@@ -515,6 +535,7 @@ enum RuleMatchType {
HASHTAG
PREFIX
REACTION_EMOJI
INTEREST
}
enum RuleAction {
@@ -524,17 +545,45 @@ enum RuleAction {
REJECT
}
enum RuleIntent {
POSITIVE
NEGATIVE
}
enum ForwardFormat {
THREADED
DIGEST
SUMMARY
}
enum ForwardFrequency {
INSTANT
FIVE_MIN
FIFTEEN_MIN
ONE_HOUR
ONE_DAY
SEVEN_DAYS
}
enum ApprovalMode {
MANUAL
AUTO
}
model TenantRule {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
ruleIntent RuleIntent @default(POSITIVE)
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRouteRuleMaps SyncRouteRuleMap[]
@@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive])
@@ -24,13 +24,15 @@ export class AiStreamController {
@Sse('stream')
stream(
@Query('after') after?: string,
@Query('tenant') tenant?: string,
@Query('source') source?: string,
@Headers('last-event-id') lastEventId?: string,
): Observable<MessageEvent> {
// Last-Event-ID (set automatically by EventSource on reconnect) wins so the
// client resumes exactly where it dropped. Otherwise honour ?after=, default
// to '$' = only new messages from now on.
const cursor = lastEventId || after || '$';
return this.service.streamMessages(cursor);
return this.service.streamMessages(cursor, { tenantId: tenant, sourceGroupId: source });
}
@Post('drafts')
@@ -18,6 +18,9 @@ interface StreamRecord {
senderJid: string;
senderName: string | null;
sourceGroupId: string;
sourceGroupName: string | null;
organizationId: string | null;
replyToMessageId: string | null;
tags: string[];
effectiveAction: string | null;
traceId: string | null;
@@ -46,7 +49,7 @@ export class AiStreamService {
* '0' → replay the entire retained stream (~50k messages) then follow live
* '<id>' → resume strictly after that entry (used on reconnect)
*/
streamMessages(afterId: string): Observable<MessageEvent> {
streamMessages(afterId: string, filters?: { tenantId?: string; sourceGroupId?: string }): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => {
const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null });
let active = true;
@@ -70,8 +73,11 @@ export class AiStreamService {
for (const [, entries] of res) {
for (const [id, fields] of entries) {
const record = parseFields(fields);
cursor = id;
subscriber.next({ id, data: parseFields(fields) } as MessageEvent);
if (filters?.tenantId && record.tenantId !== filters.tenantId) continue;
if (filters?.sourceGroupId && record.sourceGroupId !== filters.sourceGroupId) continue;
subscriber.next({ id, data: record } as MessageEvent);
}
}
} catch (err) {
@@ -142,6 +148,9 @@ function parseFields(fields: string[]): StreamRecord {
senderJid: obj.senderJid ?? '',
senderName: obj.senderName || null,
sourceGroupId: obj.sourceGroupId ?? '',
sourceGroupName: obj.sourceGroupName || null,
organizationId: obj.organizationId || null,
replyToMessageId: obj.replyToMessageId || null,
tags,
effectiveAction: obj.effectiveAction || null,
traceId: obj.traceId || null,
+13 -4
View File
@@ -26,10 +26,13 @@ export class AuthService {
) {}
async login(req: LoginRequest): Promise<LoginResponse> {
let tenant: { id: string; slug: string } | null = null;
let tenant:
| { id: string; slug: string; name: string; organization: { id: string; slug: string; name: string } | null }
| null = null;
const tenantInclude = { organization: { select: { id: true, slug: true, name: true } } } as const;
if (req.tenantSlug) {
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
if (!tenant) {
throw new UnauthorizedException('Invalid credentials');
}
@@ -48,7 +51,7 @@ export class AuthService {
'Email is registered in multiple tenants — please specify tenantSlug',
);
}
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId } });
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId }, include: tenantInclude });
if (!found) {
throw new UnauthorizedException('Invalid credentials');
}
@@ -93,6 +96,8 @@ export class AuthService {
role: admin.role as AdminRole,
tenantId: admin.tenantId,
tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: tenant.organization,
},
};
}
@@ -100,7 +105,7 @@ export class AuthService {
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
const admin = await this.prisma.admin.findFirst({
where: { id: adminId, tenantId },
include: { tenant: true },
include: { tenant: { include: { organization: { select: { id: true, slug: true, name: true } } } } },
});
if (!admin) throw new UnauthorizedException('Admin not found');
return {
@@ -110,6 +115,8 @@ export class AuthService {
role: admin.role as AdminRole,
tenantId: admin.tenantId,
tenantSlug: admin.tenant.slug,
tenantName: admin.tenant.name,
organization: admin.tenant.organization,
},
};
}
@@ -192,6 +199,8 @@ export class AuthService {
role: PrismaAdminRole.OWNER,
tenantId: tenant.id,
tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: null,
},
};
}
@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { MessagesService } from './messages.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
@@ -6,6 +7,10 @@ import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
class ApproveMessageDto {
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
}
@Controller('admin/messages')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
@@ -22,6 +27,11 @@ export class MessagesController {
return this.messagesService.pendingCount(ctx.tenantId);
}
@Get(':id/routes')
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
}
@Get(':id')
get(
@CurrentTenantContext() ctx: TenantContext,
@@ -34,8 +44,9 @@ export class MessagesController {
approve(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: ApproveMessageDto,
) {
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id);
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
}
@Post('reindex')
@@ -1,7 +1,7 @@
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
import { ForwardJobData, IndexJobData } from '@tower/types';
import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@@ -112,7 +112,76 @@ export class MessagesService {
}));
}
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
async getRoutesForMessage(tenantId: string, messageId: string): Promise<RouteForMessage[]> {
const message = await this.prisma.message.findUnique({
where: { id: messageId },
select: { id: true, tenantId: true, sourceGroupId: true, content: true },
});
if (!message) throw new NotFoundException('Message not found');
if (message.tenantId !== tenantId) throw new NotFoundException('Message not found');
const syncRoutes = await this.prisma.syncRoute.findMany({
where: { sourceGroupId: message.sourceGroupId, isActive: true },
include: {
targetGroup: { select: { id: true, name: true, isActive: true } },
assignedRules: {
include: {
tenantRule: {
select: { matchType: true, matchValue: true, ruleIntent: true, isActive: true },
},
},
},
},
});
return syncRoutes
.filter((r: any) => r.targetGroup?.isActive)
.map((route: any) => {
const activeRules = route.assignedRules
.filter((m: any) => m.tenantRule.isActive)
.map((m: any) => m.tenantRule);
let ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE' = 'NONE';
if (activeRules.length > 0) {
const negativeRules = activeRules.filter((r: any) => r.ruleIntent === 'NEGATIVE');
const positiveRules = activeRules.filter((r: any) => r.ruleIntent === 'POSITIVE');
if (negativeRules.length > 0 && this.matchesAny(message.content, negativeRules)) {
ruleDecision = 'BLOCK';
} else if (positiveRules.length > 0 && this.matchesAny(message.content, positiveRules)) {
ruleDecision = 'FORWARD';
}
}
return {
routeId: route.id,
targetGroupId: route.targetGroup.id,
targetGroupName: route.targetGroup.name,
approvalMode: route.approvalMode,
forwardFormat: route.forwardFormat,
withAttachment: route.withAttachment,
frequency: route.frequency,
ruleDecision,
} as RouteForMessage;
});
}
private matchesAny(content: string, rules: Array<{ matchType: string; matchValue: string }>): boolean {
for (const rule of rules) {
if (rule.matchType === 'HASHTAG') {
const escaped = rule.matchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i').test(content)) return true;
} else if (rule.matchType === 'PREFIX') {
if (content.trimStart().startsWith(rule.matchValue)) return true;
} else if (rule.matchType === 'REACTION_EMOJI') {
if (content.includes(rule.matchValue)) return true;
}
}
return false;
}
async approve(tenantId: string, adminId: string, messageId: string, targetGroupIds?: string[]): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
const message = await this.prisma.message.findUnique({
where: { id: messageId },
include: {
@@ -160,10 +229,14 @@ export class MessagesService {
throw new ConflictException('Message could not be approved (concurrent update)');
}
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
const allRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
(r: any) => r.targetGroup != null,
);
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({
const selectedRoutes = targetGroupIds
? allRoutes.filter((r: any) => targetGroupIds.includes(r.targetGroupId))
: allRoutes;
const forwardJobs: ForwardJobData[] = selectedRoutes.map((route: any) => ({
tenantId: message.tenantId,
messageId: message.id,
content: message.content,
@@ -204,6 +277,7 @@ export class MessagesService {
resourceId: message.id,
payload: {
routesForwarded: forwardJobs.length,
targetGroupIds: targetGroupIds ?? null,
contentPreview: message.content.slice(0, 80),
},
});
+5
View File
@@ -105,6 +105,11 @@ export class MyController {
return this.service.getDashboard(member.sub, member.tenantId);
}
@Get('scope')
scope(@CurrentMember() member: MemberJwtPayload) {
return this.service.getScope(member.sub, member.tenantId);
}
@Get('digest')
digests(@CurrentMember() member: MemberJwtPayload) {
return this.service.getDigests(member.tenantId);
+23
View File
@@ -186,6 +186,29 @@ export class MyService {
};
}
async getScope(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({
where: { id: userId, tenantId },
select: {
displayName: true,
tenant: {
select: {
id: true,
slug: true,
name: true,
organization: { select: { id: true, slug: true, name: true } },
},
},
},
});
if (!user) throw new NotFoundException('User not found');
return {
member: { displayName: user.displayName },
chapter: { id: user.tenant.id, slug: user.tenant.slug, name: user.tenant.name },
organization: user.tenant.organization,
};
}
async getDashboard(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
@@ -224,6 +224,89 @@ export class OnboardingService {
};
}
async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> {
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const user = await this.prisma.towerUser.findFirst({
where: { phoneHash },
select: { id: true, tenantId: true, jid: true },
});
if (!user) throw new NotFoundException('No portal account found for this number. Use your original invite link to register first.');
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
const challengeId = randomBytes(16).toString('hex');
await this.prisma.otpChallenge.create({
data: {
id: challengeId,
tenantId: user.tenantId,
jid: user.jid,
phoneHash,
code,
scopes: DEFAULT_SCOPES,
retentionDays: DEFAULT_RETENTION_DAYS,
policyVersion: this.policyVersion,
expiresAt,
},
});
await this.audit.log({
tenantId: user.tenantId,
action: AuditAction.OTP_REQUESTED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: { jid: user.jid, source: 'member-login' },
});
return { challengeId, expiresInSeconds: OTP_TTL_MIN * 60 };
}
async memberVerify(challengeId: string, phone: string, code: string): Promise<{
memberToken: string;
user: { id: string; tenantId: string; jid: string; displayName: string | null };
}> {
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
if (!challenge) throw new NotFoundException('Challenge not found');
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Code expired');
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
await this.prisma.otpChallenge.update({
where: { id: challengeId },
data: { consumedAt: new Date() },
});
const user = await this.prisma.towerUser.findFirst({
where: { phoneHash, tenantId: challenge.tenantId },
select: { id: true, tenantId: true, jid: true, displayName: true, phoneHash: true },
});
if (!user) throw new NotFoundException('User not found');
await this.audit.log({
tenantId: user.tenantId,
action: AuditAction.OTP_VERIFIED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: { jid: user.jid, source: 'member-login' },
});
const memberToken = await this.jwt.signAsync({
kind: 'member',
sub: user.id,
tenantId: user.tenantId,
jid: user.jid,
phoneHash: user.phoneHash,
} as const);
return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName } };
}
private normalizeJid(jid: string): string {
return jid.trim();
}
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { OnboardingService } from './onboarding.service';
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
import { ConsentScope } from '@tower/types';
@@ -18,6 +18,16 @@ class VerifyOtpDto {
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
}
class MemberLoginDto {
@IsString() @MinLength(6) phone!: string;
}
class MemberVerifyDto {
@IsString() challengeId!: string;
@IsString() @MinLength(6) phone!: string;
@IsString() @MinLength(6) code!: string;
}
@Controller('public')
export class PublicOnboardingController {
constructor(private readonly service: OnboardingService) {}
@@ -46,4 +56,16 @@ export class PublicOnboardingController {
body.retentionDays,
);
}
@Post('auth/member-login')
@Public()
memberLogin(@Body() body: MemberLoginDto) {
return this.service.memberLogin(body.phone);
}
@Post('auth/member-verify')
@Public()
memberVerify(@Body() body: MemberVerifyDto) {
return this.service.memberVerify(body.challengeId, body.phone, body.code);
}
}
@@ -32,6 +32,15 @@ export class OrgController {
return this.orgService.getTenant(admin.organizationId, id);
}
@Post('tenants/:id/admins')
createTenantAdmin(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Param('id') id: string,
@Body() body: { email: string; password: string; role?: string },
) {
return this.orgService.createTenantAdmin(admin.organizationId, id, body.email, body.password, body.role);
}
@Get('rules')
getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getRules(admin.organizationId);
+22
View File
@@ -162,4 +162,26 @@ export class OrgService {
if (!rule) throw new NotFoundException('Rule not found');
await this.prisma.orgRule.delete({ where: { id: ruleId } });
}
async createTenantAdmin(
organizationId: string,
tenantId: string,
email: string,
password: string,
role?: string,
) {
const tenant = await this.prisma.tenant.findFirst({ where: { id: tenantId, organizationId } });
if (!tenant) throw new NotFoundException('Chapter not found or does not belong to this org');
const existing = await this.prisma.admin.findUnique({
where: { tenantId_email: { tenantId, email } },
});
if (existing) throw new ConflictException('An admin with this email already exists for this chapter');
const passwordHash = await bcrypt.hash(password, 10);
return this.prisma.admin.create({
data: { tenantId, email, passwordHash, ...(role ? { role: role as any } : {}) },
select: { id: true, email: true, role: true, createdAt: true },
});
}
}
@@ -1,8 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { RoutesService } from './routes.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { IsString } from 'class-validator';
class CreateRouteDto {
@IsString() sourceGroupId!: string;
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
@IsString({ each: true }) targetGroupIds!: string[];
}
class UpdateRouteDto {
@IsEnum(['THREADED', 'DIGEST', 'SUMMARY']) @IsOptional() forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
@IsBoolean() @IsOptional() withAttachment?: boolean;
@IsEnum(['INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS']) @IsOptional() frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
@IsEnum(['MANUAL', 'AUTO']) @IsOptional() approvalMode?: 'MANUAL' | 'AUTO';
@IsBoolean() @IsOptional() isActive?: boolean;
}
@Controller('routes')
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@@ -36,6 +44,35 @@ export class RoutesController {
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
}
@Patch(':id')
update(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string, @Body() body: UpdateRouteDto) {
return this.routesService.update(ctx.tenantId, id, body);
}
@Get(':id/rules')
listRules(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.routesService.listAssignedRules(ctx.tenantId, id);
}
@Post(':id/rules/:tenantRuleId')
assignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
return this.routesService.assignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id/rules/:tenantRuleId')
@HttpCode(204)
async unassignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
await this.routesService.unassignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id')
@HttpCode(204)
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
skipped: string[];
}
export interface UpdateRouteInput {
forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment?: boolean;
frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode?: 'MANUAL' | 'AUTO';
isActive?: boolean;
}
const routeInclude = {
sourceGroup: { select: { name: true, tenantId: true } },
targetGroup: { select: { name: true, tenantId: true } },
@@ -30,11 +38,70 @@ export class RoutesService {
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
assignedRules: {
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async update(tenantId: string, id: string, dto: UpdateRouteInput) {
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException(`Route ${id} not found`);
return this.prisma.syncRoute.update({ where: { id }, data: dto as any });
}
async assignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const rule = await this.prisma.tenantRule.findFirst({ where: { id: tenantRuleId, tenantId } });
if (!rule) throw new NotFoundException(`Rule ${tenantRuleId} not found`);
try {
return await this.prisma.syncRouteRuleMap.create({
data: { id: `${routeId}_${tenantRuleId}`.slice(0, 25) + Math.random().toString(36).slice(2, 8), syncRouteId: routeId, tenantRuleId },
include: { tenantRule: { select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true } } },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Rule is already assigned to this route');
}
throw e;
}
}
async unassignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const map = await this.prisma.syncRouteRuleMap.findUnique({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
if (!map) throw new NotFoundException('Assignment not found');
await this.prisma.syncRouteRuleMap.delete({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
}
async listAssignedRules(tenantId: string, routeId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const maps = await this.prisma.syncRouteRuleMap.findMany({
where: { syncRouteId: routeId },
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
});
return maps.map((m: any) => ({ ...m.tenantRule, assignedAt: m.createdAt }));
}
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
if (!sourceGroupId || !targetGroupId) {
throw new BadRequestException('sourceGroupId and targetGroupId are required');
+19 -32
View File
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
export class RulesService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string): Promise<TenantRuleData[]> {
const rows = await this.prisma.tenantRule.findMany({
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => ({
private toData(r: any): TenantRuleData {
return {
id: r.id,
tenantId: r.tenantId,
matchType: r.matchType,
matchValue: r.matchValue,
action: r.action,
ruleIntent: r.ruleIntent,
priority: r.priority,
isActive: r.isActive,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}));
};
}
async list(tenantId: string): Promise<TenantRuleData[]> {
const rows = await this.prisma.tenantRule.findMany({
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => this.toData(r));
}
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
const existing = await this.prisma.tenantRule.findUnique({
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } },
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType as any, matchValue: req.matchValue } },
});
if (existing) {
throw new ConflictException('A rule with this matchType + matchValue already exists');
@@ -35,25 +40,16 @@ export class RulesService {
const row = await this.prisma.tenantRule.create({
data: {
tenantId,
matchType: req.matchType,
matchType: req.matchType as any,
matchValue: req.matchValue,
action: req.action,
action: req.action as any,
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
priority: req.priority ?? 0,
isActive: req.isActive ?? true,
},
});
return {
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
return this.toData(row);
}
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
@@ -64,22 +60,13 @@ export class RulesService {
if (req.matchType !== undefined) data.matchType = req.matchType;
if (req.matchValue !== undefined) data.matchValue = req.matchValue;
if (req.action !== undefined) data.action = req.action;
if (req.ruleIntent !== undefined) data.ruleIntent = req.ruleIntent;
if (req.priority !== undefined) data.priority = req.priority;
if (req.isActive !== undefined) data.isActive = req.isActive;
const row = await this.prisma.tenantRule.update({ where: { id }, data });
return {
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
return this.toData(row);
}
async remove(tenantId: string, id: string): Promise<void> {
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { OrgAdminRole } from '@prisma/client';
import { SuperAdminGuard } from './super-admin.guard';
import { SuperAdminService } from './super-admin.service';
class CreateOrgDto {
@IsString() @MinLength(2) slug!: string;
@IsString() @MinLength(1) name!: string;
}
class CreateOrgAdminDto {
@IsString() email!: string;
@IsString() @IsOptional() name?: string;
@IsString() @MinLength(8) password!: string;
@IsEnum(OrgAdminRole) @IsOptional() role?: OrgAdminRole;
}
class AssignTenantOrgDto {
@IsString() @IsOptional() organizationId?: string | null;
}
@UseGuards(SuperAdminGuard)
@Controller('admin')
export class AdminOrgsController {
constructor(private readonly service: SuperAdminService) {}
@Get('orgs')
listOrgs() {
return this.service.listOrgs();
}
@Post('orgs')
createOrg(@Body() body: CreateOrgDto) {
return this.service.createOrg(body.slug, body.name);
}
@Get('orgs/:id')
getOrg(@Param('id') id: string) {
return this.service.getOrg(id);
}
@Post('orgs/:id/admins')
createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) {
return this.service.createOrgAdmin(id, body.email, body.name, body.password, body.role);
}
@Patch('tenants/:id/org')
assignTenantToOrg(@Param('id') id: string, @Body() body: AssignTenantOrgDto) {
return this.service.assignTenantToOrg(id, body.organizationId ?? null);
}
}
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SuperAdminController } from './super-admin.controller';
import { AdminOrgsController } from './admin-orgs.controller';
import { SuperAdminService } from './super-admin.service';
import { SuperAdminGuard } from './super-admin.guard';
import { PrismaModule } from '../../prisma/prisma.module';
@@ -18,7 +19,7 @@ import { PrismaModule } from '../../prisma/prisma.module';
}),
}),
],
controllers: [SuperAdminController],
controllers: [SuperAdminController, AdminOrgsController],
providers: [SuperAdminService, SuperAdminGuard],
exports: [SuperAdminGuard, JwtModule],
})
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
@@ -32,4 +32,81 @@ export class SuperAdminService {
if (!admin) throw new UnauthorizedException('Super admin not found');
return { id: admin.id, email: admin.email, name: admin.name };
}
async listOrgs() {
return this.prisma.organization.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
slug: true,
name: true,
createdAt: true,
_count: { select: { tenants: true, orgAdmins: true } },
},
});
}
async createOrg(slug: string, name: string) {
const existing = await this.prisma.organization.findUnique({ where: { slug } });
if (existing) throw new ConflictException(`Organization with slug "${slug}" already exists`);
return this.prisma.organization.create({
data: { slug, name },
select: { id: true, slug: true, name: true, createdAt: true },
});
}
async getOrg(id: string) {
const org = await this.prisma.organization.findUnique({
where: { id },
select: {
id: true,
slug: true,
name: true,
createdAt: true,
tenants: {
select: { id: true, slug: true, name: true, isActive: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
orgAdmins: {
select: { id: true, email: true, name: true, role: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
},
});
if (!org) throw new NotFoundException('Organization not found');
return org;
}
async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string, role?: string) {
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('Organization not found');
const existing = await this.prisma.orgAdmin.findUnique({
where: { organizationId_email: { organizationId: orgId, email } },
});
if (existing) throw new ConflictException('An admin with this email already exists in this organization');
const passwordHash = await bcrypt.hash(password, 10);
const admin = await this.prisma.orgAdmin.create({
data: { organizationId: orgId, email, name, passwordHash, ...(role ? { role: role as any } : {}) },
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return admin;
}
async assignTenantToOrg(tenantId: string, organizationId: string | null) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
if (organizationId !== null) {
const org = await this.prisma.organization.findUnique({ where: { id: organizationId } });
if (!org) throw new NotFoundException('Organization not found');
}
return this.prisma.tenant.update({
where: { id: tenantId },
data: { organizationId },
select: { id: true, slug: true, name: true, organizationId: true },
});
}
}
+1
View File
@@ -0,0 +1 @@
.vercel
@@ -1,7 +1,7 @@
import { apiFetch } from '../_lib/api';
import { apiFetch } from '@/app/_lib/api';
import { redirect } from 'next/navigation';
import { getToken } from '../_lib/api';
import { DraftCard } from '../admin/drafts/DraftCard';
import { getToken } from '@/app/_lib/api';
import { DraftCard } from '@/app/_components/draft-card';
import Link from 'next/link';
type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
@@ -0,0 +1,250 @@
'use client';
import { useState } from 'react';
import { RouteSettingsDialog } from './RouteSettingsDialog';
import { RouteRulesDialog } from './RouteRulesDialog';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
assignedRules?: Array<{ tenantRule: AssignedRule }>;
}
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
const FREQ_LABELS: Record<ForwardFrequency, string> = {
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
};
const FORMAT_LABELS: Record<ForwardFormat, string> = {
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
};
export function RouteManager({
groups,
initialRoutes,
}: {
groups: Group[];
initialRoutes: Route[];
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) {
const key = r.sourceGroup.name;
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
grouped.get(key)!.targets.push(r);
}
function toggleTarget(id: string) {
setTargetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
setError(null);
}
async function addRoutes() {
if (!sourceId || targetIds.size === 0) return;
setBusy(true); setError(null);
try {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
if (!res.ok) { setError('Failed to create routes'); return; }
const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]);
setSourceId(''); setTargetIds(new Set());
} finally { setBusy(false); }
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
<th className="px-4 py-2.5">Source</th>
<th className="px-4 py-2.5">Target</th>
<th className="px-4 py-2.5">Rules</th>
<th className="px-4 py-2.5">Frequency</th>
<th className="px-4 py-2.5">Format</th>
<th className="px-4 py-2.5">Approval</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{routes.map((r) => {
const ruleCount = r.assignedRules?.length ?? 0;
return (
<tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
<td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
<td className="px-4 py-2.5 text-gray-600">
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Edit settings"
>
</button>
<button
onClick={() => setRulesRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Manage rules"
>
📋
</button>
<button
onClick={() => void deleteRoute(r.id)}
className="text-xs text-gray-400 hover:text-red-600"
title="Delete route"
>
🗑
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add routes</h2>
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center">
<select
value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
<option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
</div>
{sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={targetIds.has(g.id)}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
))}
</div>
)}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
)}
<button
onClick={() => void addRoutes()}
disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
</button>
</div>
</section>
{settingsRoute && (
<RouteSettingsDialog
route={settingsRoute}
onSaved={(updates) => {
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
}}
onClose={() => setSettingsRoute(null)}
/>
)}
{rulesRoute && (
<RouteRulesDialog
routeId={rulesRoute.id}
targetGroupName={rulesRoute.targetGroup.name}
onClose={() => setRulesRoute(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState } from 'react';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
assignedAt: string;
}
interface TenantRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
}
export function RouteRulesDialog({
routeId,
targetGroupName,
onClose,
}: {
routeId: string;
targetGroupName: string;
onClose: () => void;
}) {
const [assigned, setAssigned] = useState<AssignedRule[]>([]);
const [allRules, setAllRules] = useState<TenantRule[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/routes/${routeId}/rules`).then((r) => r.json()),
fetch('/api/rules').then((r) => r.json()),
]).then(([a, all]) => {
setAssigned(Array.isArray(a) ? a : []);
setAllRules(Array.isArray(all) ? all : []);
}).catch(() => {}).finally(() => setLoading(false));
}, [routeId]);
const assignedIds = new Set(assigned.map((r) => r.id));
const available = allRules.filter((r) => r.isActive && !assignedIds.has(r.id));
async function assign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'POST' });
if (res.ok) {
const rule = allRules.find((r) => r.id === tenantRuleId)!;
setAssigned((prev) => [...prev, { ...rule, assignedAt: new Date().toISOString() }]);
}
} finally {
setBusy(null);
}
}
async function unassign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.ok || res.status === 204) {
setAssigned((prev) => prev.filter((r) => r.id !== tenantRuleId));
}
} finally {
setBusy(null);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-5 flex flex-col gap-4 max-h-[80vh]">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">Rules for route</h2>
<p className="text-xs text-gray-500"> {targetGroupName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
{loading ? (
<p className="text-sm text-gray-400">Loading</p>
) : (
<div className="flex flex-col gap-4 overflow-y-auto">
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Assigned ({assigned.length})
</h3>
{assigned.length === 0 ? (
<p className="text-sm text-gray-400">No rules assigned route uses approval mode only.</p>
) : (
<ul className="flex flex-col gap-1.5">
{assigned.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void unassign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
</section>
{available.length > 0 && (
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Add from your rules
</h3>
<ul className="flex flex-col gap-1.5">
{available.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-dashed border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void assign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
+ Add
</button>
</li>
))}
</ul>
</section>
)}
{available.length === 0 && assigned.length > 0 && (
<p className="text-xs text-gray-400">All active rules are assigned to this route.</p>
)}
</div>
)}
<div className="flex justify-end pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Done
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface Route {
id: string;
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
}
const FORMAT_OPTIONS: { value: ForwardFormat; label: string }[] = [
{ value: 'THREADED', label: 'Threaded' },
{ value: 'DIGEST', label: 'Digest' },
{ value: 'SUMMARY', label: 'Summary' },
];
const FREQ_OPTIONS: { value: ForwardFrequency; label: string }[] = [
{ value: 'INSTANT', label: 'Instant' },
{ value: 'FIVE_MIN', label: '5 min' },
{ value: 'FIFTEEN_MIN', label: '15 min' },
{ value: 'ONE_HOUR', label: '1 hour' },
{ value: 'ONE_DAY', label: '1 day' },
{ value: 'SEVEN_DAYS', label: '7 days' },
];
export function RouteSettingsDialog({
route,
onSaved,
onClose,
}: {
route: Route;
onSaved: (updated: Partial<Route>) => void;
onClose: () => void;
}) {
const [forwardFormat, setForwardFormat] = useState<ForwardFormat>(route.forwardFormat);
const [withAttachment, setWithAttachment] = useState(route.withAttachment);
const [frequency, setFrequency] = useState<ForwardFrequency>(route.frequency);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(route.approvalMode);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function save() {
setBusy(true);
setError('');
try {
const res = await fetch(`/api/routes/${route.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ forwardFormat, withAttachment, frequency, approvalMode }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setError(err.message ?? 'Failed to save');
return;
}
onSaved({ forwardFormat, withAttachment, frequency, approvalMode });
onClose();
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Route settings</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Forward format</label>
<select
value={forwardFormat}
onChange={(e) => setForwardFormat(e.target.value as ForwardFormat)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FORMAT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withAttachment}
onChange={(e) => setWithAttachment(e.target.checked)}
className="rounded border-gray-300"
/>
Include attachments
</label>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as ForwardFrequency)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FREQ_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Approval mode</label>
<div className="flex gap-2">
{(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setApprovalMode(m)}
className={`flex-1 rounded px-3 py-2 text-sm font-medium border transition-colors ${
approvalMode === m
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{m === 'MANUAL' ? 'Manual' : 'Auto'}
</button>
))}
</div>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { RouteManager } from './RouteManager';
import { GroupsTabs } from './GroupsTabs';
import { apiFetch } from '../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface Group {
id: string;
@@ -22,6 +22,12 @@ interface Route {
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode: 'MANUAL' | 'AUTO';
isActive: boolean;
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
}
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/app/_lib/auth-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { ScopeCard } from '@/app/_components/scope-card';
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
const NAV = [
{ href: '/search', label: 'Search', icon: <Search /> },
{ href: '/groups', label: 'Groups & Routes', icon: <Users /> },
{ href: '/messages/pending', label: 'Pending', icon: <Inbox /> },
{ href: '/drafts', label: 'AI Drafts', icon: <FileText /> },
{ href: '/settings/rules', label: 'Rules', icon: <SlidersHorizontal /> },
{ href: '/settings/bot', label: 'Bot', icon: <Bot /> },
];
export default function ChapterLayout({ children }: { children: React.ReactNode }) {
const { admin, loading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading && !admin) router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}, [admin, loading, pathname, router]);
return (
<PortalShell
portalName="Chapter"
theme="blue"
navItems={NAV}
loading={loading}
authed={!!admin}
user={admin ? { email: admin.email, name: admin.name, role: admin.role, subtitle: admin.tenantName } : undefined}
scope={admin ? <ScopeCard accent="text-blue-600" organization={admin.organization} chapter={admin.tenantName ? { name: admin.tenantName } : null} /> : undefined}
onLogout={() => { void logout(); router.replace('/login'); }}
>
{children}
</PortalShell>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
interface RouteForMessage {
routeId: string;
targetGroupId: string;
targetGroupName: string;
approvalMode: 'MANUAL' | 'AUTO';
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: string;
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
}
export function ForwardTargetDialog({
messageId,
onApproved,
onClose,
}: {
messageId: string;
onApproved: () => void;
onClose: () => void;
}) {
const [routes, setRoutes] = useState<RouteForMessage[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
useEffect(() => {
fetch(`/api/messages/${messageId}/routes`)
.then((r) => r.json())
.then((data: RouteForMessage[]) => {
setRoutes(Array.isArray(data) ? data : []);
// Pre-select: FORWARD decision, or AUTO approvalMode with no BLOCK
const preSelected = new Set(
(Array.isArray(data) ? data : [])
.filter((r) => r.ruleDecision === 'FORWARD' || (r.ruleDecision === 'NONE' && r.approvalMode === 'AUTO'))
.map((r) => r.targetGroupId),
);
setSelected(preSelected);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [messageId]);
function toggleGroup(groupId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
return next;
});
}
async function confirm() {
setBusy(true);
try {
const res = await fetch(`/api/messages/${messageId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetGroupIds: [...selected] }),
});
if (res.ok) {
onApproved();
onClose();
}
} finally {
setBusy(false);
}
}
const filtered = routes.filter((r) =>
r.targetGroupName.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100">
<h2 className="text-base font-semibold">Approve &amp; Forward</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="px-5 py-3 border-b border-gray-100">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{loading ? (
<p className="text-sm text-gray-400">Loading routes</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-400">No target groups found.</p>
) : (
<ul className="flex flex-col gap-1.5">
{filtered.map((r) => {
const isBlocked = r.ruleDecision === 'BLOCK';
const isChecked = selected.has(r.targetGroupId);
return (
<li key={r.targetGroupId}>
<label
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
isBlocked
? 'opacity-50 cursor-default'
: isChecked
? 'bg-blue-50'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => !isBlocked && toggleGroup(r.targetGroupId)}
disabled={isBlocked}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">{r.targetGroupName}</div>
{isBlocked && (
<div className="text-[10px] text-red-500">Blocked by assigned rule</div>
)}
{r.ruleDecision === 'FORWARD' && (
<div className="text-[10px] text-green-600">Auto-forward by rule</div>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-100">
<span className="text-xs text-gray-500">{selected.size} group{selected.size !== 1 ? 's' : ''} selected</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void confirm()}
disabled={busy || selected.size === 0}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Approving…' : 'Approve & Forward'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { apiFetch } from '../../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface MessageDetail {
id: string;
@@ -0,0 +1,115 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { ForwardTargetDialog } from '../ForwardTargetDialog';
interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
export default function PendingMessagesPage() {
const [messages, setMessages] = useState<PendingMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
fetch('/api/messages/pending')
.then(async (res) => {
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json() as Promise<PendingMessage[]>;
})
.then((data) => { setMessages(data); setError(null); })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Messages waiting for admin approval. Click "Approve &amp; Forward" to choose which groups to send to.
</p>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Failed to load: {error}
</div>
)}
{!loading && !error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow
key={m.id}
message={m}
onApproved={() => setMessages((prev) => prev.filter((x) => x.id !== m.id))}
/>
))}
</ul>
</div>
);
}
function PendingMessageRow({
message,
onApproved,
}: {
message: PendingMessage;
onApproved: () => void;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">{new Date(message.createdAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span key={t} className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">{message.content}</div>
<div className="flex justify-end pt-1">
<button
onClick={() => setDialogOpen(true)}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; Forward
</button>
</div>
{dialogOpen && (
<ForwardTargetDialog
messageId={message.id}
onApproved={onApproved}
onClose={() => setDialogOpen(false)}
/>
)}
</li>
);
}
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { apiFetch } from '../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface MeiliHit {
id: string;
@@ -1,5 +1,5 @@
import { BotSettingsCard } from './BotSettingsCard';
import { apiFetch } from '../../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface BotSummary {
id: string;
@@ -2,48 +2,71 @@
import { useState } from 'react';
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
interface RuleData {
id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
matchType: RuleMatchType;
matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
action: RuleAction;
ruleIntent: RuleIntent;
priority: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
const MATCH_TYPE_LABELS: Record<string, string> = {
const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
HASHTAG: 'Hashtag',
PREFIX: 'Prefix',
REACTION_EMOJI: 'Reaction Emoji',
INTEREST: 'Interest',
};
const ACTION_LABELS: Record<string, string> = {
const ACTION_LABELS: Record<RuleAction, string> = {
FLAG: 'Flag (Pending)',
AUTO_APPROVE: 'Auto-approve',
SKIP: 'Skip (Silent Drop)',
REJECT: 'Reject (Visible)',
};
const REACTION_OPTIONS = [
{ value: '👍', label: '👍 Thumbs Up' },
{ value: '👎', label: '👎 Thumbs Down' },
];
function getValuePlaceholder(matchType: RuleMatchType): string {
if (matchType === 'HASHTAG') return '#important';
if (matchType === 'PREFIX') return '!!';
if (matchType === 'INTEREST') return '#education';
return '';
}
export function RuleManager({ initial }: { initial: RuleData[] }) {
const [rules, setRules] = useState<RuleData[]>(initial);
const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG');
const [matchType, setMatchType] = useState<RuleMatchType>('HASHTAG');
const [matchValue, setMatchValue] = useState('');
const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG');
const [action, setAction] = useState<RuleAction>('FLAG');
const [ruleIntent, setRuleIntent] = useState<RuleIntent>('POSITIVE');
const [priority, setPriority] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
function handleMatchTypeChange(type: RuleMatchType) {
setMatchType(type);
setMatchValue('');
}
async function addRule() {
if (!matchValue.trim()) return;
setBusy(true);
setError('');
setBusy(true); setError('');
try {
const res = await fetch('/api/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, priority }),
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, ruleIntent, priority }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
@@ -52,12 +75,8 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
}
const created: RuleData = await res.json();
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
setMatchValue('');
setAction('FLAG');
setPriority(0);
} finally {
setBusy(false);
}
setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
} finally { setBusy(false); }
}
async function toggleRule(rule: RuleData) {
@@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<label className="text-xs text-gray-500">Type</label>
<select
value={matchType}
onChange={(e) => setMatchType(e.target.value as any)}
onChange={(e) => handleMatchTypeChange(e.target.value as RuleMatchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value="HASHTAG">Hashtag</option>
<option value="PREFIX">Prefix</option>
<option value="REACTION_EMOJI">Reaction Emoji</option>
<option value="INTEREST">Interest</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Value</label>
<input
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
/>
{matchType === 'REACTION_EMOJI' ? (
<select
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-44"
>
<option value="">Select emoji</option>
{REACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : (
<input
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
placeholder={getValuePlaceholder(matchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
/>
)}
{matchType === 'INTEREST' && (
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Intent</label>
<div className="flex gap-1">
{(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => (
<button
key={i}
type="button"
onClick={() => setRuleIntent(i)}
className={`px-3 py-2 text-sm rounded border transition-colors ${
ruleIntent === i
? i === 'POSITIVE'
? 'bg-green-600 text-white border-green-600'
: 'bg-red-600 text-white border-red-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{i === 'POSITIVE' ? '+ Pos' : ' Neg'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Action</label>
<select
value={action}
onChange={(e) => setAction(e.target.value as any)}
onChange={(e) => setAction(e.target.value as RuleAction)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value="FLAG">Flag (Pending)</option>
@@ -116,6 +176,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<option value="REJECT">Reject (Visible)</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Priority</label>
<input
@@ -125,6 +186,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
/>
</div>
<button
type="button"
onClick={() => void addRule()}
@@ -140,13 +202,14 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<section>
<h2 className="text-base font-semibold mb-3">Active Rules</h2>
{rules.length === 0 ? (
<p className="text-sm text-gray-400">No rules configured. Messages without matching rules are ignored.</p>
<p className="text-sm text-gray-400">No rules configured. Assign rules to sync routes to control auto-forwarding.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-gray-500">
<th className="pb-2 pr-3">Type</th>
<th className="pb-2 pr-3">Value</th>
<th className="pb-2 pr-3">Intent</th>
<th className="pb-2 pr-3">Action</th>
<th className="pb-2 pr-3">Priority</th>
<th className="pb-2 pr-3">Active</th>
@@ -158,6 +221,13 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<tr key={rule.id} className="border-b border-gray-100">
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
<td className="py-2 pr-3">
<span className={`text-xs rounded px-2 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
</td>
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
<td className="py-2 pr-3">{rule.priority}</td>
<td className="py-2 pr-3">
@@ -1,11 +1,12 @@
import { RuleManager } from './RuleManager';
import { apiFetch } from '../../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface RuleData {
id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
ruleIntent: 'POSITIVE' | 'NEGATIVE';
priority: number;
isActive: boolean;
createdAt: string;
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { apiFetch } from '../../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface ThreadMessage {
id: string;
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { apiFetch } from '../_lib/api';
import { apiFetch } from '@/app/_lib/api';
interface ThreadSummary {
id: string;
@@ -0,0 +1,85 @@
import Link from 'next/link';
import { cn } from '../_lib/utils';
import { PORTAL_THEMES, type PortalTheme } from './portal-theme';
import { Check, ArrowLeft } from 'lucide-react';
interface PortalLoginShellProps {
title: string;
subtitle: string;
theme: PortalTheme;
/** Short value-prop bullets shown on the branding panel */
highlights?: string[];
children: React.ReactNode;
footer?: React.ReactNode;
}
export function PortalLoginShell({ title, subtitle, theme, highlights = [], children, footer }: PortalLoginShellProps) {
const t = PORTAL_THEMES[theme];
return (
<div className="min-h-screen grid lg:grid-cols-2">
{/* Branding panel */}
<div className={cn('relative hidden lg:flex flex-col justify-between p-12 text-white overflow-hidden', t.gradient)}>
{/* decorative pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
backgroundSize: '24px 24px',
}}
/>
<div className="relative flex items-center gap-3">
<div className="size-9 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center font-bold">T</div>
<span className="font-semibold text-lg tracking-tight">TOWER</span>
</div>
<div className="relative space-y-6">
<h2 className="text-3xl font-bold leading-tight max-w-sm">{title}</h2>
{highlights.length > 0 && (
<ul className="space-y-3">
{highlights.map((h) => (
<li key={h} className="flex items-center gap-3 text-sm text-white/90">
<span className="size-5 rounded-full bg-white/15 flex items-center justify-center shrink-0">
<Check className="size-3" />
</span>
{h}
</li>
))}
</ul>
)}
</div>
<p className="relative text-sm text-white/60">Community Knowledge Infrastructure Platform</p>
</div>
{/* Form panel */}
<div className="flex items-center justify-center p-6 sm:p-8 bg-background">
<div className="w-full max-w-sm space-y-8">
{/* Mobile logo */}
<div className="flex items-center gap-2 lg:hidden">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm', t.gradient)}>T</div>
<span className="font-semibold">TOWER</span>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{children}
<div className="space-y-3 text-center">
{footer}
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3" />
Back to portal selector
</Link>
</div>
</div>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/app/_lib/utils';
import { Skeleton } from './ui/skeleton';
import { Avatar, AvatarFallback } from './ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { PORTAL_THEMES, type PortalTheme, initials } from './portal-theme';
import { ChevronsUpDown, LogOut } from 'lucide-react';
export interface PortalNavItem {
href: string;
label: string;
icon: React.ReactNode;
}
interface PortalUser {
name?: string | null;
email?: string | null;
role?: string | null;
subtitle?: string | null;
}
interface PortalShellProps {
portalName: string;
theme: PortalTheme;
navItems: PortalNavItem[];
user?: PortalUser;
/** Optional scope indicator (e.g. <ScopeCard/>) rendered below the brand */
scope?: React.ReactNode;
loading: boolean;
authed: boolean;
onLogout: () => void;
children: React.ReactNode;
}
export function PortalShell({
portalName, theme, navItems, user, scope, loading, authed, onLogout, children,
}: PortalShellProps) {
const pathname = usePathname();
const t = PORTAL_THEMES[theme];
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-60 shrink-0 flex flex-col border-r bg-sidebar">
{/* Brand */}
<div className="h-16 flex items-center gap-3 px-5 border-b">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm', t.gradient)}>
T
</div>
<div className="flex flex-col leading-none">
<span className="font-semibold text-sm tracking-tight">TOWER</span>
<span className="text-[11px] text-muted-foreground mt-0.5">{portalName}</span>
</div>
</div>
{/* Scope */}
{!loading && authed && scope}
{/* Nav */}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{loading ? (
<div className="space-y-2 p-1">
{Array.from({ length: navItems.length }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
) : (
navItems.map((item) => {
// Portal root (e.g. /admin, /org) is a single segment — match exactly so it
// doesn't stay highlighted on sub-routes. Deeper items match by prefix.
const isRoot = item.href.split('/').filter(Boolean).length === 1;
const isActive = isRoot
? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? cn(t.activeBg, t.activeText, 'font-medium')
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{isActive && <span className={cn('absolute left-0 top-1/2 -translate-y-1/2 h-5 w-1 rounded-r-full', t.bar)} />}
<span className="size-4 shrink-0 [&_svg]:size-4">{item.icon}</span>
{item.label}
</Link>
);
})
)}
</nav>
{/* User */}
<div className="p-3 border-t">
{loading ? (
<div className="flex items-center gap-3 px-1">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2.5 w-16" />
</div>
</div>
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger className="w-full flex items-center gap-3 rounded-lg p-2 hover:bg-accent transition-colors text-left outline-none">
<Avatar>
<AvatarFallback className={cn(t.gradient, 'text-white')}>{initials(user.name, user.email)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name ?? user.email}</p>
<p className="text-xs text-muted-foreground truncate">{user.subtitle ?? user.role ?? user.email}</p>
</div>
<ChevronsUpDown className="size-4 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-52">
<DropdownMenuLabel className="flex flex-col">
<span className="text-sm truncate">{user.name ?? user.email}</span>
<span className="text-xs font-normal text-muted-foreground truncate">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</aside>
{/* Main */}
<main className="flex-1 overflow-auto bg-muted/30">
{!loading && authed ? (
<div className="p-6 lg:p-8 max-w-6xl mx-auto w-full">{children}</div>
) : (
<div className="p-8 space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
</div>
)}
</main>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
export type PortalTheme = 'violet' | 'slate' | 'blue' | 'emerald';
interface ThemeTokens {
/** Gradient for the logo mark and login branding panel */
gradient: string;
/** Text accent for the brand wordmark */
text: string;
/** Active nav item background */
activeBg: string;
/** Active nav item text */
activeText: string;
/** Active left indicator bar */
bar: string;
/** Ring/border tint used in login branding */
soft: string;
}
export const PORTAL_THEMES: Record<PortalTheme, ThemeTokens> = {
violet: {
gradient: 'bg-gradient-to-br from-violet-500 to-purple-700',
text: 'text-violet-600',
activeBg: 'bg-violet-50',
activeText: 'text-violet-700',
bar: 'bg-violet-600',
soft: 'text-violet-100',
},
slate: {
gradient: 'bg-gradient-to-br from-slate-700 to-slate-900',
text: 'text-slate-800',
activeBg: 'bg-slate-100',
activeText: 'text-slate-900',
bar: 'bg-slate-800',
soft: 'text-slate-200',
},
blue: {
gradient: 'bg-gradient-to-br from-blue-500 to-indigo-700',
text: 'text-blue-600',
activeBg: 'bg-blue-50',
activeText: 'text-blue-700',
bar: 'bg-blue-600',
soft: 'text-blue-100',
},
emerald: {
gradient: 'bg-gradient-to-br from-emerald-500 to-teal-700',
text: 'text-emerald-600',
activeBg: 'bg-emerald-50',
activeText: 'text-emerald-700',
bar: 'bg-emerald-600',
soft: 'text-emerald-100',
},
};
export function initials(name?: string | null, email?: string | null): string {
const source = (name ?? email ?? '?').trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
+40
View File
@@ -0,0 +1,40 @@
import { Building2, Network } from 'lucide-react';
import { cn } from '../_lib/utils';
interface ScopeCardProps {
organization?: { name: string } | null;
chapter?: { name: string } | null;
/** Tailwind text color for the icons, e.g. text-blue-600 */
accent?: string;
}
/**
* Shows the tenancy scope (Organisation → Chapter) the current user
* is operating within. Rendered in a portal sidebar below the brand.
*/
export function ScopeCard({ organization, chapter, accent = 'text-muted-foreground' }: ScopeCardProps) {
if (!organization && !chapter) return null;
return (
<div className="mx-3 mt-3 rounded-lg border bg-card divide-y">
{organization && (
<div className="flex items-center gap-2.5 px-3 py-2">
<Network className={cn('size-4 shrink-0', accent)} />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground leading-none">Organisation</p>
<p className="text-sm font-medium truncate mt-0.5">{organization.name}</p>
</div>
</div>
)}
{chapter && (
<div className="flex items-center gap-2.5 px-3 py-2">
<Building2 className={cn('size-4 shrink-0', accent)} />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground leading-none">Chapter</p>
<p className="text-sm font-medium truncate mt-0.5">{chapter.name}</p>
</div>
</div>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/app/_lib/utils';
const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex size-9 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium', className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../_lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-100 text-emerald-700',
warning: 'border-transparent bg-amber-100 text-amber-700',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../_lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { cn } from '../../_lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
@@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/app/_lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
};
+100
View File
@@ -0,0 +1,100 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { Controller, FormProvider, useFormContext, type ControllerProps, type FieldPath, type FieldValues } from 'react-hook-form';
import { cn } from '../../_lib/utils';
import { Label } from './label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { name: TName };
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) throw new Error('useFormField should be used within <FormField>');
const { id } = itemContext;
return { id, name: fieldContext.name, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState };
};
type FormItemContextValue = { id: string };
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<React.ComponentRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn('text-sm text-muted-foreground', className)} {...props} />;
},
);
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : children;
if (!body) return null;
return (
<p ref={ref} id={formMessageId} className={cn('text-sm font-medium text-destructive', className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = 'FormMessage';
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '../../_lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = 'Input';
export { Input };
+19
View File
@@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '../../_lib/utils';
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+21
View File
@@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../../_lib/utils';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/app/_lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };
+29
View File
@@ -0,0 +1,29 @@
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText })) as { message?: string };
throw new Error(body.message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(url: string) =>
fetch(url, { cache: 'no-store' }).then((r) => handleResponse<T>(r)),
post: <T>(url: string, body?: unknown) =>
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
}).then((r) => handleResponse<T>(r)),
patch: <T>(url: string, body: unknown) =>
fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((r) => handleResponse<T>(r)),
del: (url: string) =>
fetch(url, { method: 'DELETE' }).then((r) => handleResponse<void>(r)),
};
+1
View File
@@ -10,6 +10,7 @@ export interface AuthAdmin {
tenantId: string;
tenantSlug: string;
tenantName?: string;
organization?: { id: string; slug: string; name: string } | null;
}
interface AuthState {
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
mutations: { retry: 0 },
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
-130
View File
@@ -1,130 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from './super-admin-context';
import { useAuth } from './auth-context';
const NAV_LINKS = [
{ href: '/search', label: 'Search' },
{ href: '/groups', label: 'Groups & Routes' },
{ href: '/messages/pending', label: 'Pending messages' },
{ href: '/drafts', label: 'AI Drafts' },
{ href: '/settings/rules', label: 'Rules' },
{ href: '/settings/bot', label: 'Bot' },
];
const SUPER_ADMIN_LINKS = [
{ href: '/admin', label: 'Dashboard' },
{ href: '/admin/tenants', label: 'Tenants' },
{ href: '/admin/bots', label: 'Bot Pool' },
{ href: '/admin/drafts', label: 'AI Drafts' },
];
const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
const ADMIN_PATHS = ['/admin'];
const MEMBER_PATHS = ['/my'];
export function Sidebar() {
const { admin, loading, logout } = useAuth();
const { admin: superAdmin, logout: superLogout } = useSuperAdmin();
const pathname = usePathname();
const router = useRouter();
const [pendingCount, setPendingCount] = useState<number | null>(null);
useEffect(() => {
fetch('/api/messages/pending/count')
.then((r) => r.ok ? r.json() : null)
.then((data) => setPendingCount(data?.count ?? null))
.catch(() => setPendingCount(null));
}, []);
useEffect(() => {
if (loading) return;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return;
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) return;
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) return;
if (!admin) {
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}
}, [loading, admin, pathname, router]);
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4">
<span className="font-bold text-base">TOWER</span>
</nav>
);
}
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER Admin</span>
<div className="flex flex-col gap-1 flex-1">
{SUPER_ADMIN_LINKS.map((link) => (
<Link key={link.href} href={link.href} className="rounded px-3 py-2 text-sm hover:bg-gray-100">
{link.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{superAdmin?.email}</div>
<div className="uppercase tracking-wide">Super Admin</div>
</div>
<button
type="button"
onClick={() => void superLogout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
</nav>
);
}
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
return null;
}
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded px-3 py-2 text-sm hover:bg-gray-100 flex items-center justify-between"
>
<span>{link.label}</span>
{link.href === '/messages/pending' && pendingCount !== null && pendingCount > 0 && (
<span className="bg-blue-600 text-white text-[11px] font-bold rounded-full w-5 h-5 flex items-center justify-center">
{pendingCount > 99 ? '99+' : pendingCount}
</span>
)}
</Link>
))}
</div>
{admin && (
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{admin.name ?? admin.email}</div>
<div className="truncate">{admin.tenantName}</div>
<div className="uppercase tracking-wide">{admin.role}</div>
</div>
<button
type="button"
onClick={() => void logout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
)}
</nav>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -1,22 +1,30 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation';
export default function BotsPage() {
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const [bots, setBots] = useState<any[]>([]);
const [tenants, setTenants] = useState<any[]>([]);
const [initiating, setInitiating] = useState(false);
const [pairingInfo, setPairingInfo] = useState<{ pairingToken: string; expiresAt: string } | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrStatus, setQrStatus] = useState<string>('PAIRING');
const [assigningBotId, setAssigningBotId] = useState<string | null>(null);
const [assignTenantId, setAssignTenantId] = useState('');
const [assignBusy, setAssignBusy] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
async function load() {
const res = await fetch('/api/admin/bots');
if (res.ok) setBots(await res.json());
const [botsRes, tenantsRes] = await Promise.all([
fetch('/api/admin/bots'),
fetch('/api/admin/tenants'),
]);
if (botsRes.ok) setBots(await botsRes.json());
if (tenantsRes.ok) setTenants(await tenantsRes.json());
}
useEffect(() => {
@@ -78,6 +86,28 @@ export default function BotsPage() {
}
}
async function assignBot() {
if (!assigningBotId || !assignTenantId) return;
setAssignBusy(true);
try {
const res = await fetch(`/api/admin/bots/${assigningBotId}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantId: assignTenantId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.message ?? 'Failed to assign bot');
return;
}
setAssigningBotId(null);
setAssignTenantId('');
void load();
} finally {
setAssignBusy(false);
}
}
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
@@ -120,34 +150,74 @@ export default function BotsPage() {
<th className="px-4 py-3 font-medium">JID</th>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Tenants</th>
<th className="px-4 py-3 font-medium">Chapters</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{bots.map((b: any) => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs">{b.jid?.slice(0, 30) ?? 'pending...'}</td>
<td className="px-4 py-3">{b.displayName ?? '—'}</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
b.status === 'ACTIVE' ? 'bg-green-100 text-green-700' :
b.status === 'PAIRING' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-500'
}`}>{b.status}</span>
</td>
<td className="px-4 py-3">{b.tenantCount}</td>
<td className="px-4 py-3 text-xs text-gray-500">{new Date(b.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3">
<button
onClick={() => removeBot(b.id)}
className="text-red-600 text-xs hover:underline"
>
Remove
</button>
</td>
</tr>
<>
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs">{b.jid?.slice(0, 30) ?? 'pending...'}</td>
<td className="px-4 py-3">{b.displayName ?? '—'}</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
b.status === 'ACTIVE' ? 'bg-green-100 text-green-700' :
b.status === 'PAIRING' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-500'
}`}>{b.status}</span>
</td>
<td className="px-4 py-3">{b.tenantCount}</td>
<td className="px-4 py-3 text-xs text-gray-500">{new Date(b.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 flex gap-3">
<button
onClick={() => { setAssigningBotId(assigningBotId === b.id ? null : b.id); setAssignTenantId(''); }}
className="text-blue-600 text-xs hover:underline"
>
Assign
</button>
<button
onClick={() => removeBot(b.id)}
className="text-red-600 text-xs hover:underline"
>
Remove
</button>
</td>
</tr>
{assigningBotId === b.id && (
<tr key={`${b.id}-assign`} className="bg-blue-50">
<td colSpan={6} className="px-4 py-3">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-gray-600">Assign to chapter:</span>
<select
value={assignTenantId}
onChange={(e) => setAssignTenantId(e.target.value)}
className="border rounded-lg px-3 py-1.5 text-sm flex-1 max-w-xs bg-white"
>
<option value=""> select chapter </option>
{tenants.map((t: any) => (
<option key={t.id} value={t.id}>{t.name} ({t.slug})</option>
))}
</select>
<button
onClick={() => void assignBot()}
disabled={!assignTenantId || assignBusy}
className="bg-blue-600 text-white rounded-lg px-3 py-1.5 text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
>
{assignBusy ? 'Assigning...' : 'Confirm'}
</button>
<button
onClick={() => { setAssigningBotId(null); setAssignTenantId(''); }}
className="text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
@@ -1,7 +1,7 @@
import { apiFetch } from '../../_lib/api';
import { apiFetch } from '@/app/_lib/api';
import { redirect } from 'next/navigation';
import { getToken } from '../../_lib/api';
import { DraftCard } from './DraftCard';
import { getToken } from '@/app/_lib/api';
import { DraftCard } from '@/app/_components/draft-card';
import Link from 'next/link';
type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { LayoutDashboard, Building2, Network, Server, FileText } from 'lucide-react';
const NAV = [
{ href: '/admin', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/admin/orgs', label: 'Organisations', icon: <Network /> },
{ href: '/admin/tenants', label: 'Chapters', icon: <Building2 /> },
{ href: '/admin/bots', label: 'Bot Pool', icon: <Server /> },
{ href: '/admin/drafts', label: 'AI Drafts', icon: <FileText /> },
];
export default function AdminAuthedLayout({ children }: { children: React.ReactNode }) {
const { admin, loading, logout } = useSuperAdmin();
const router = useRouter();
useEffect(() => {
if (!loading && !admin) router.replace('/admin/login');
}, [admin, loading, router]);
return (
<PortalShell
portalName="Super Admin"
theme="slate"
navItems={NAV}
loading={loading}
authed={!!admin}
user={admin ? { email: admin.email, name: admin.name, role: 'Super Admin' } : undefined}
onLogout={() => { void logout(); router.replace('/admin/login'); }}
>
{children}
</PortalShell>
);
}
@@ -2,7 +2,7 @@
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSuperAdmin } from '../../../_lib/super-admin-context';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
export default function AdminOrgDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../_lib/super-admin-context';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../../_lib/super-admin-context';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter, useParams } from 'next/navigation';
export default function TenantDetailPage() {
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { useSuperAdmin } from '@/app/_lib/super-admin-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
-3
View File
@@ -1,3 +0,0 @@
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+50 -43
View File
@@ -1,60 +1,67 @@
'use client';
import { FormEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { PortalLoginShell } from '../../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../../_components/ui/form';
import { Input } from '../../_components/ui/input';
import { Button } from '../../_components/ui/button';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type FormValues = z.infer<typeof schema>;
export default function SuperAdminLoginPage() {
const { login } = useSuperAdmin();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setBusy(true);
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
async function onSubmit(values: FormValues) {
try {
await login(email, password);
await login(values.email, values.password);
router.replace('/admin');
} catch (err: any) {
setError(err.message ?? 'Login failed');
} finally {
setBusy(false);
} catch (err) {
form.setError('root', { message: err instanceof Error ? err.message : 'Login failed' });
}
}
return (
<div className="max-w-sm mx-auto mt-24">
<h1 className="text-xl font-bold mb-4">Super Admin Login</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border rounded px-3 py-2 text-sm"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border rounded px-3 py-2 text-sm"
/>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={busy}
className="bg-blue-600 text-white rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{busy ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
<PortalLoginShell
title="Super Admin"
subtitle="Platform-wide administration access."
theme="slate"
highlights={['Manage organisations & chapters', 'Provision and pair WhatsApp bots', 'Oversee the entire platform']}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" placeholder="superadmin@tower.com" autoComplete="username" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="password" render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{form.formState.errors.root && (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
)}
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</Form>
</PortalLoginShell>
);
}
@@ -0,0 +1,14 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const upstream = await fetch(`${getApiBaseUrl()}/public/auth/member-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await upstream.json().catch(() => ({})), upstream.status);
}
-19
View File
@@ -1,19 +0,0 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const upstream = await fetch(`${getApiBaseUrl()}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await upstream.json();
if (!upstream.ok) return jsonResponse(payload, upstream.status);
const token: string | undefined = payload?.token;
if (!token) return jsonResponse({ message: 'Signup response missing token' }, 502);
const cookieValue = `tower_token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`;
return jsonResponse({ admin: payload.admin }, 200, { 'Set-Cookie': cookieValue });
}
@@ -3,11 +3,12 @@ import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_req: Request,
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST' });
const reqBody = await req.text().catch(() => '');
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST', body: reqBody || undefined });
const body = await res.text();
let payload: unknown = body;
try {
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/routes`);
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
+25
View File
@@ -0,0 +1,25 @@
import { buildMemberCookie, getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const upstream = await fetch(`${getApiBaseUrl()}/public/auth/member-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = (await upstream.json().catch(() => ({}))) as {
memberToken?: string;
user?: { id: string; tenantId: string; jid: string; displayName: string | null };
message?: string;
};
if (!upstream.ok) return jsonResponse(payload, upstream.status);
if (!payload.memberToken) return jsonResponse({ message: 'Missing token in upstream response' }, 502);
return jsonResponse(
{ user: payload.user },
200,
{ 'Set-Cookie': buildMemberCookie(payload.memberToken) },
);
}
@@ -0,0 +1,14 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const upstream = await fetch(`${getApiBaseUrl()}/public/auth/request-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await upstream.json().catch(() => ({})), upstream.status);
}
@@ -0,0 +1,21 @@
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getOrgToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_org_token')?.value;
}
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const token = await getOrgToken();
const body = await req.json().catch(() => ({}));
const res = await fetch(`${getApiBaseUrl()}/org/tenants/${id}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { apiFetch } from '../../../_lib/api';
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await req.text();
const res = await apiFetch(`/routes/${id}`, { method: 'PATCH', body });
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
@@ -0,0 +1,26 @@
import { apiFetch, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string; tenantRuleId: string }> },
): Promise<Response> {
const { id, tenantRuleId } = await params;
const res = await apiFetch(`/routes/${id}/rules/${tenantRuleId}`, { method: 'POST' });
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; tenantRuleId: string }> },
): Promise<Response> {
const { id, tenantRuleId } = await params;
const res = await apiFetch(`/routes/${id}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.status === 204) return new Response(null, { status: 204 });
const body = await res.text();
return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/routes/${id}/rules`);
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
+79
View File
@@ -1 +1,80 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px);
}
* {
border-color: var(--border);
outline-color: var(--ring);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
-179
View File
@@ -1,179 +0,0 @@
'use client';
import { useState } from 'react';
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
}
export function RouteManager({
groups,
initialRoutes,
}: {
groups: Group[];
initialRoutes: Route[];
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Group routes by source group name
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) {
const key = r.sourceGroup.name;
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
grouped.get(key)!.targets.push(r);
}
function toggleTarget(id: string) {
setTargetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setError(null);
}
async function addRoutes() {
if (!sourceId || targetIds.size === 0) return;
setBusy(true);
setError(null);
try {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (res.status === 409) {
const errBody = await res.json();
setError(errBody.message ?? 'Some routes already exist');
return;
}
if (!res.ok) {
setError('Failed to create routes');
return;
}
const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]);
setSourceId('');
setTargetIds(new Set());
} finally {
setBusy(false);
}
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<ul className="flex flex-col gap-3">
{[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => (
<li key={sId} className="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div className="flex items-start gap-3">
<span className="text-sm font-semibold text-gray-700 whitespace-nowrap mt-1 min-w-[120px]">{sourceName}</span>
<div className="flex flex-col gap-1.5 flex-1">
{targets.map((r) => (
<div key={r.id} className="flex items-center justify-between group">
<span className="text-sm text-gray-600">{r.targetGroup.name}</span>
<button
onClick={() => deleteRoute(r.id)}
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Delete route to ${r.targetGroup.name}`}
>
Delete
</button>
</div>
))}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add routes</h2>
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center">
<select
value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Source group"
>
<option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500">
{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected
</span>
</div>
{sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => {
const checked = targetIds.has(g.id);
return (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
checked ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
);
})}
</div>
)}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
)}
<button
onClick={addRoutes}
disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
</button>
</div>
</section>
</div>
);
}
+11 -10
View File
@@ -1,25 +1,26 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
import { ReactQueryProvider } from './_lib/query-client';
import { AuthProvider } from './_lib/auth-context';
import { SuperAdminProvider } from './_lib/super-admin-context';
import { Sidebar } from './_lib/sidebar';
import { OrgAdminProvider } from './_lib/org-admin-context';
export const metadata: Metadata = {
title: 'Insignia TOWER',
title: 'TOWER',
description: 'Community Knowledge Infrastructure Platform',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="flex min-h-screen bg-gray-50 text-gray-900 antialiased">
<AuthProvider>
<SuperAdminProvider>
<Sidebar />
<main className="flex-1 overflow-auto p-6">{children}</main>
</SuperAdminProvider>
</AuthProvider>
<body className="min-h-screen bg-background text-foreground antialiased">
<ReactQueryProvider>
<AuthProvider>
<SuperAdminProvider>
<OrgAdminProvider>{children}</OrgAdminProvider>
</SuperAdminProvider>
</AuthProvider>
</ReactQueryProvider>
</body>
</html>
);
+64 -91
View File
@@ -1,113 +1,86 @@
'use client';
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuth } from '../_lib/auth-context';
import { useSuperAdmin } from '../_lib/super-admin-context';
import { PortalLoginShell } from '../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../_components/ui/form';
import { Input } from '../_components/ui/input';
import { Button } from '../_components/ui/button';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type FormValues = z.infer<typeof schema>;
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { refresh } = useAuth();
const { admin: superAdmin, loading: superLoading } = useSuperAdmin();
const next = searchParams.get('next') ?? '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redirecting, setRedirecting] = useState(false);
const next = searchParams.get('next') ?? '/search';
useEffect(() => {
if (!superLoading && superAdmin) {
setRedirecting(true);
router.replace('/admin');
}
}, [superAdmin, superLoading, router]);
if (superLoading) {
return <div className="bg-white p-6 rounded-xl border border-gray-200 h-64 animate-pulse" />;
}
if (redirecting) {
return <p className="text-sm text-gray-500">Redirecting to admin panel</p>;
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data?.message ?? 'Invalid email or password');
return;
}
await refresh();
router.push(next);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Network error');
} finally {
setSubmitting(false);
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
async function onSubmit(values: FormValues) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
credentials: 'include',
});
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { message?: string };
form.setError('root', { message: data.message ?? 'Invalid email or password' });
return;
}
await refresh();
router.push(next);
router.refresh();
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-4 bg-white p-6 rounded-xl border border-gray-200">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Email</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
className="rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Password</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
</label>
{error && <p className="text-sm text-red-600" role="alert">{error}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-blue-600 text-white py-2 font-medium hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" placeholder="admin@chapter.com" autoComplete="username" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="password" render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{form.formState.errors.root && (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
)}
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</Form>
);
}
export default function LoginPage() {
export default function ChapterLoginPage() {
return (
<div className="max-w-sm mx-auto mt-16">
<h1 className="text-2xl font-semibold mb-2">Sign in</h1>
<p className="text-sm text-gray-500 mb-6">TOWER administrative console</p>
<Suspense fallback={<div className="bg-white p-6 rounded-xl border border-gray-200 h-64" />}>
<PortalLoginShell
title="Chapter Portal"
subtitle="Sign in to manage your chapter's messages and groups."
theme="blue"
highlights={['Approve & route group messages', 'Configure hashtag and prefix rules', 'Review AI-generated digests']}
>
<Suspense>
<LoginForm />
</Suspense>
<p className="text-sm text-gray-500 mt-6 text-center">
New here?{' '}
<a href="/signup" className="text-blue-600 hover:underline">
Create a community
</a>
</p>
</div>
</PortalLoginShell>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { PortalLoginShell } from '../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../_components/ui/form';
import { Input } from '../_components/ui/input';
import { Button } from '../_components/ui/button';
import Link from 'next/link';
const phoneSchema = z.object({ phone: z.string().min(8, 'Enter your WhatsApp number') });
const otpSchema = z.object({ code: z.string().length(6, 'Enter the 6-digit code') });
type PhoneValues = z.infer<typeof phoneSchema>;
type OtpValues = z.infer<typeof otpSchema>;
export default function MemberLoginPage() {
const router = useRouter();
const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState('');
const [challengeId, setChallengeId] = useState('');
const phoneForm = useForm<PhoneValues>({ resolver: zodResolver(phoneSchema), defaultValues: { phone: '' } });
const otpForm = useForm<OtpValues>({ resolver: zodResolver(otpSchema), defaultValues: { code: '' } });
async function onPhone(values: PhoneValues) {
const res = await fetch('/api/auth/member/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: values.phone }),
});
const data = await res.json().catch(() => ({})) as { challengeId?: string; message?: string };
if (!res.ok) { phoneForm.setError('root', { message: data.message ?? 'Failed to send code' }); return; }
setPhone(values.phone);
setChallengeId(data.challengeId ?? '');
setStep('otp');
}
async function onOtp(values: OtpValues) {
const res = await fetch('/api/my/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengeId, phone, code: values.code }),
});
const data = await res.json().catch(() => ({})) as { message?: string };
if (!res.ok) { otpForm.setError('root', { message: data.message ?? 'Verification failed' }); return; }
router.push('/my');
}
return (
<PortalLoginShell
title="Member Portal"
subtitle={step === 'phone' ? 'Enter your WhatsApp number to receive a sign-in code.' : `We sent a 6-digit code to ${phone} on WhatsApp.`}
theme="emerald"
highlights={['Read your community digests', 'Manage privacy & consent', 'Discover events and members']}
footer={
step === 'phone'
? <Link href="/onboard" className="text-emerald-600 hover:underline">First time? Use your invite link</Link>
: undefined
}
>
{step === 'phone' ? (
<Form {...phoneForm}>
<form onSubmit={phoneForm.handleSubmit(onPhone)} className="space-y-4">
<FormField control={phoneForm.control} name="phone" render={({ field }) => (
<FormItem>
<FormLabel>WhatsApp number</FormLabel>
<FormControl><Input type="tel" placeholder="+91 98765 43210" autoFocus {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{phoneForm.formState.errors.root && (
<p className="text-sm text-destructive">{phoneForm.formState.errors.root.message}</p>
)}
<Button type="submit" className="w-full" disabled={phoneForm.formState.isSubmitting}>
{phoneForm.formState.isSubmitting ? 'Sending…' : 'Send code'}
</Button>
</form>
</Form>
) : (
<Form {...otpForm}>
<form onSubmit={otpForm.handleSubmit(onOtp)} className="space-y-4">
<FormField control={otpForm.control} name="code" render={({ field }) => (
<FormItem>
<FormLabel>6-digit code</FormLabel>
<FormControl>
<Input
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
className="text-center text-xl tracking-[0.5em] font-mono"
autoFocus
{...field}
onChange={(e) => field.onChange(e.target.value.replace(/\D/g, ''))}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
{otpForm.formState.errors.root && (
<p className="text-sm text-destructive">{otpForm.formState.errors.root.message}</p>
)}
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => { setStep('phone'); otpForm.reset(); }} className="flex-none">
Back
</Button>
<Button type="submit" className="flex-1" disabled={otpForm.formState.isSubmitting}>
{otpForm.formState.isSubmitting ? 'Verifying…' : 'Sign in'}
</Button>
</div>
<Button type="button" variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={() => void onPhone({ phone })}>
Resend code
</Button>
</form>
</Form>
)}
</PortalLoginShell>
);
}
-105
View File
@@ -1,105 +0,0 @@
import { apiFetch } from '../../_lib/api';
interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
async function fetchPending(): Promise<FetchResult<PendingMessage[]>> {
try {
const res = await apiFetch('/admin/messages/pending');
if (!res.ok) {
const body = await res.text().catch(() => '');
return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText };
}
return { ok: true, data: (await res.json()) as PendingMessage[] };
} catch (err) {
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
}
}
export default async function PendingMessagesPage() {
const result = await fetchPending();
const messages = result.ok ? result.data : [];
const error = !result.ok ? (result.status === 0 ? 'API unreachable' : `API ${result.status}: ${result.error}`) : null;
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Flagged messages waiting for an admin to approve. Approving forwards them to every active
route from the source group and indexes them in search.
</p>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Failed to load: {error}
</div>
)}
{!error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now. New flagged messages will appear here as they arrive.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow key={m.id} message={m} />
))}
</ul>
</div>
);
}
function PendingMessageRow({ message }: { message: PendingMessage }) {
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">
{new Date(message.createdAt).toLocaleString()}
</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span
key={t}
className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700"
>
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">
{message.content}
</div>
<form
action={`/api/messages/${message.id}/approve`}
method="post"
className="flex justify-end pt-1"
>
<button
type="submit"
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; forward
</button>
</form>
</li>
);
}
+23 -60
View File
@@ -1,71 +1,34 @@
import Link from 'next/link';
import { getMemberToken } from '../_lib/api';
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
import { redirect } from 'next/navigation';
import { MemberNav } from './member-nav';
const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/ask', label: 'Ask AI' },
{ href: '/my/knowledge', label: 'Knowledge' },
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },
{ href: '/my/circles', label: 'Circles' },
{ href: '/my/directory', label: 'Directory' },
{ href: '/my/memories', label: 'Memories' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];
interface MemberScope {
member: { displayName: string | null };
chapter: { id: string; slug: string; name: string };
organization: { id: string; slug: string; name: string } | null;
}
async function fetchScope(token: string): Promise<MemberScope | null> {
const res = await fetch(`${getApiBaseUrl()}/my/scope`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as MemberScope;
}
export default async function MemberLayout({ children }: { children: React.ReactNode }) {
const token = await getMemberToken();
if (!token) redirect('/onboard');
if (!token) redirect('/member-login');
const scope = await fetchScope(token);
return (
<div className="flex h-full -m-6 min-h-screen">
{/* Left sidebar */}
<nav className="w-[220px] shrink-0 bg-white border-r border-gray-200 flex flex-col p-4">
<span className="font-bold text-base mb-6 px-2">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
{item.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3">
<p className="px-3 text-xs text-gray-400 uppercase tracking-wide">Member portal</p>
</div>
</nav>
{/* Main content */}
<main className="flex-1 overflow-auto p-6 bg-gray-50">
{children}
<div className="flex h-screen overflow-hidden">
<MemberNav scope={scope} />
<main className="flex-1 overflow-auto bg-muted/30">
<div className="p-6 lg:p-8 max-w-6xl mx-auto w-full">{children}</div>
</main>
{/* Right activity rail */}
<aside className="w-[280px] shrink-0 bg-white border-l border-gray-200 p-4 hidden lg:block">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-4">Quick actions</p>
<div className="flex flex-col gap-2">
<Link
href="/my/settings"
className="text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md hover:bg-gray-50"
>
Privacy settings
</Link>
<form method="POST" action="/api/my/logout">
<button
type="submit"
className="w-full text-left text-sm text-red-500 hover:text-red-700 px-3 py-2 rounded-md hover:bg-red-50"
>
Sign out
</button>
</form>
</div>
</aside>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '../_lib/utils';
import { Button } from '../_components/ui/button';
import { ScopeCard } from '../_components/scope-card';
import { PORTAL_THEMES } from '../_components/portal-theme';
import {
LayoutDashboard, Newspaper, Calendar, HeartHandshake, Users2,
BookOpen, Sparkles, Images, Contact, Settings, LogOut,
} from 'lucide-react';
interface MemberScope {
member: { displayName: string | null };
chapter: { id: string; slug: string; name: string };
organization: { id: string; slug: string; name: string } | null;
}
const NAV = [
{ href: '/my', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/my/digest', label: 'Digest', icon: <Newspaper /> },
{ href: '/my/ask', label: 'Ask AI', icon: <Sparkles /> },
{ href: '/my/events', label: 'Events', icon: <Calendar /> },
{ href: '/my/seva', label: 'Seva & Points', icon: <HeartHandshake /> },
{ href: '/my/circles', label: 'Circles', icon: <Users2 /> },
{ href: '/my/directory', label: 'Directory', icon: <Contact /> },
{ href: '/my/knowledge', label: 'Knowledge', icon: <BookOpen /> },
{ href: '/my/memories', label: 'Memories', icon: <Images /> },
{ href: '/my/groups', label: 'My Groups', icon: <Users2 /> },
{ href: '/my/settings', label: 'Settings', icon: <Settings /> },
];
export function MemberNav({ scope }: { scope?: MemberScope | null }) {
const pathname = usePathname();
const t = PORTAL_THEMES.emerald;
async function logout() {
await fetch('/api/my/logout', { method: 'POST' });
window.location.href = '/member-login';
}
return (
<aside className="w-60 shrink-0 flex flex-col border-r bg-sidebar">
<div className="h-16 flex items-center gap-3 px-5 border-b">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm shadow-sm', t.gradient)}>
T
</div>
<div className="flex flex-col leading-none">
<span className="font-semibold text-sm tracking-tight">TOWER</span>
<span className="text-[11px] text-muted-foreground mt-0.5">Member</span>
</div>
</div>
{scope && (
<ScopeCard accent="text-emerald-600" organization={scope.organization} chapter={scope.chapter} />
)}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV.map((item) => {
const isActive = item.href === '/my' ? pathname === '/my' : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive ? cn(t.activeBg, t.activeText, 'font-medium') : 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{isActive && <span className={cn('absolute left-0 top-1/2 -translate-y-1/2 h-5 w-1 rounded-r-full', t.bar)} />}
<span className="size-4 shrink-0 [&_svg]:size-4">{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t">
<Button variant="ghost" size="sm" className="w-full justify-start text-muted-foreground" onClick={() => void logout()}>
<LogOut className="size-4" />
Sign out
</Button>
</div>
</aside>
);
}
+1 -3
View File
@@ -43,8 +43,6 @@ const SCOPE_META: ScopeMeta[] = [
},
];
const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
export function OnboardingForm({ token, groupName, tenantName, defaultScopes, defaultRetentionDays }: Props) {
const [step, setStep] = useState<Step>('welcome');
const [phone, setPhone] = useState('');
@@ -62,7 +60,7 @@ export function OnboardingForm({ token, groupName, tenantName, defaultScopes, de
setError(null);
setBusy(true);
try {
const res = await fetch(`${API_BASE}/public/auth/request-otp`, {
const res = await fetch('/api/onboard/request-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ onboardingToken: token, phone }),
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { LayoutDashboard, Building2, Shield } from 'lucide-react';
const NAV = [
{ href: '/org', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/org/tenants', label: 'Chapters', icon: <Building2 /> },
{ href: '/org/rules', label: 'Org Rules', icon: <Shield /> },
];
export default function OrgAuthedLayout({ children }: { children: React.ReactNode }) {
const { orgAdmin, loading, logout } = useOrgAdmin();
const router = useRouter();
useEffect(() => {
if (!loading && !orgAdmin) router.replace('/org/login');
}, [orgAdmin, loading, router]);
return (
<PortalShell
portalName="Organisation"
theme="violet"
navItems={NAV}
loading={loading}
authed={!!orgAdmin}
user={orgAdmin ? { email: orgAdmin.email, name: orgAdmin.name, role: orgAdmin.role, subtitle: orgAdmin.organization?.name } : undefined}
onLogout={() => { void logout(); router.replace('/org/login'); }}
>
{children}
</PortalShell>
);
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useOrgAdmin } from '../_lib/org-admin-context';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
export default function OrgDashboardPage() {
const { orgAdmin, loading } = useOrgAdmin();
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '../../_lib/org-admin-context';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
const MATCH_TYPES = ['HASHTAG', 'PREFIX', 'REACTION_EMOJI'];
const ACTIONS = ['FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT', 'P1'];
@@ -0,0 +1,137 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { orgAdmin, loading } = useOrgAdmin();
const router = useRouter();
const [tenant, setTenant] = useState<any>(null);
const [showAdminForm, setShowAdminForm] = useState(false);
const [adminForm, setAdminForm] = useState({ email: '', password: '', role: 'ADMIN' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (loading) return;
if (!orgAdmin) { router.replace('/org/login'); return; }
fetch(`/api/org/tenants/${id}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => setTenant(d))
.catch(() => {});
}, [orgAdmin, loading, router, id]);
async function handleCreateAdmin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSaving(true);
try {
const res = await fetch(`/api/org/tenants/${id}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(adminForm),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(Array.isArray(err.message) ? err.message.join(', ') : (err.message ?? 'Failed'));
}
setShowAdminForm(false);
setAdminForm({ email: '', password: '', role: 'ADMIN' });
const updated = await fetch(`/api/org/tenants/${id}`).then((r) => r.ok ? r.json() : null);
if (updated) setTenant(updated);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setSaving(false);
}
}
if (loading || !orgAdmin) return <p className="text-gray-500">Loading...</p>;
if (!tenant) return <p className="text-gray-400">Chapter not found.</p>;
return (
<div>
<div className="mb-6">
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-3"> Back</button>
<h1 className="text-2xl font-bold">{tenant.name}</h1>
<p className="text-sm text-gray-500">{tenant.slug}</p>
</div>
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ label: 'Messages', value: tenant._count?.messages },
{ label: 'Members', value: tenant._count?.towerUsers },
{ label: 'Groups', value: tenant._count?.groups },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border p-5">
<p className="text-2xl font-bold">{stat.value ?? '—'}</p>
<p className="text-sm text-gray-500">{stat.label}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl border overflow-hidden">
<div className="px-5 py-4 border-b flex items-center justify-between">
<h2 className="font-semibold">Chapter Admins</h2>
<button
onClick={() => setShowAdminForm(!showAdminForm)}
className="bg-blue-600 text-white rounded-lg px-3 py-1.5 text-sm font-medium hover:bg-blue-700"
>
Add Admin
</button>
</div>
{showAdminForm && (
<form onSubmit={(e) => void handleCreateAdmin(e)} className="p-5 border-b flex flex-col gap-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={adminForm.email} onChange={(e) => setAdminForm({ ...adminForm, email: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required />
</div>
<div>
<label className="block text-xs font-medium mb-1">Password</label>
<input type="password" value={adminForm.password} onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required />
</div>
<div>
<label className="block text-xs font-medium mb-1">Role</label>
<select value={adminForm.role} onChange={(e) => setAdminForm({ ...adminForm, role: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="VIEWER">VIEWER</option>
</select>
</div>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={saving} className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">{saving ? 'Saving...' : 'Create'}</button>
<button type="button" onClick={() => { setShowAdminForm(false); setError(''); }} className="border rounded-lg px-4 py-2 text-sm hover:bg-gray-50">Cancel</button>
</div>
</form>
)}
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Joined</th>
</tr>
</thead>
<tbody className="divide-y">
{(tenant.admins ?? []).map((a: any) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="px-4 py-3">{a.email}</td>
<td className="px-4 py-3">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-gray-100 text-gray-700">{a.role}</span>
</td>
<td className="px-4 py-3 text-gray-500">{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{tenant.admins?.length === 0 && <p className="p-4 text-gray-400">No admins yet.</p>}
</div>
</div>
);
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useOrgAdmin } from '../../_lib/org-admin-context';
import { useOrgAdmin } from '@/app/_lib/org-admin-context';
export default function OrgTenantsPage() {
const { orgAdmin, loading } = useOrgAdmin();
-3
View File
@@ -1,3 +0,0 @@
export default function OrgLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+48 -48
View File
@@ -1,67 +1,67 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useOrgAdmin } from '../../_lib/org-admin-context';
import { PortalLoginShell } from '../../_components/portal-login-shell';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../../_components/ui/form';
import { Input } from '../../_components/ui/input';
import { Button } from '../../_components/ui/button';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type FormValues = z.infer<typeof schema>;
export default function OrgLoginPage() {
const { login } = useOrgAdmin();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
async function onSubmit(values: FormValues) {
try {
await login(email, password);
await login(values.email, values.password);
router.replace('/org');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
form.setError('root', { message: err instanceof Error ? err.message : 'Login failed' });
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-xl border p-8 w-full max-w-sm">
<h1 className="text-2xl font-bold mb-1">TOWER</h1>
<p className="text-sm text-gray-500 mb-6">Organization portal</p>
<form onSubmit={(e) => void handleSubmit(e)} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm"
required
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
<PortalLoginShell
title="Organisation Portal"
subtitle="Manage your chapters, admins and org-wide rules."
theme="violet"
highlights={['Create and oversee chapters', 'Set org-wide routing rules', 'Provision chapter admins']}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" placeholder="admin@org.com" autoComplete="username" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="password" render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl><Input type="password" autoComplete="current-password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{form.formState.errors.root && (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
)}
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</div>
</div>
</Form>
</PortalLoginShell>
);
}
-74
View File
@@ -1,74 +0,0 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrgAdmin } from '../../../_lib/org-admin-context';
export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { orgAdmin, loading } = useOrgAdmin();
const router = useRouter();
const [tenant, setTenant] = useState<any>(null);
useEffect(() => {
if (loading) return;
if (!orgAdmin) { router.replace('/org/login'); return; }
fetch(`/api/org/tenants/${id}`)
.then((r) => r.ok ? r.json() : null)
.then((d) => setTenant(d))
.catch(() => {});
}, [orgAdmin, loading, router, id]);
if (loading || !orgAdmin) return <p className="text-gray-500">Loading...</p>;
if (!tenant) return <p className="text-gray-400">Chapter not found.</p>;
return (
<div>
<div className="mb-6">
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-3"> Back</button>
<h1 className="text-2xl font-bold">{tenant.name}</h1>
<p className="text-sm text-gray-500">{tenant.slug}</p>
</div>
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ label: 'Messages', value: tenant._count?.messages },
{ label: 'Members', value: tenant._count?.towerUsers },
{ label: 'Groups', value: tenant._count?.groups },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border p-5">
<p className="text-2xl font-bold">{stat.value ?? '—'}</p>
<p className="text-sm text-gray-500">{stat.label}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl border overflow-hidden">
<div className="px-5 py-4 border-b">
<h2 className="font-semibold">Chapter Admins</h2>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Joined</th>
</tr>
</thead>
<tbody className="divide-y">
{(tenant.admins ?? []).map((a: any) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="px-4 py-3">{a.email}</td>
<td className="px-4 py-3">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-gray-100 text-gray-700">{a.role}</span>
</td>
<td className="px-4 py-3 text-gray-500">{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{tenant.admins?.length === 0 && <p className="p-4 text-gray-400">No admins yet.</p>}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More