Compare commits
14 Commits
e61b0dfebd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73956fd1e3 | |||
| e598073dc7 | |||
| 5a0f7aa58f | |||
| a3798b3059 | |||
| 205418fc4e | |||
| d7b5988858 | |||
| f8b7afcc38 | |||
| 8d720278cb | |||
| 6bff55db64 | |||
| b26a635283 | |||
| 484ae7bf9d | |||
| 8a61908df1 | |||
| abb6799c3d | |||
| a1d2c6e6c2 |
@@ -7,3 +7,5 @@ coverage
|
||||
*.env.local
|
||||
.env.production
|
||||
sessions/
|
||||
*.tsbuildinfo
|
||||
.vercel
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.turbo
|
||||
.next
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.git
|
||||
apps/worker/sessions
|
||||
apps/api/sessions
|
||||
sessions
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 & 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 & 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 & 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;
|
||||
+94
-24
@@ -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">
|
||||
+3
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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)),
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export interface AuthAdmin {
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName?: string;
|
||||
organization?: { id: string; slug: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & forward
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+23
-60
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function OrgLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user