Compare commits
12 Commits
abb6799c3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73956fd1e3 | |||
| e598073dc7 | |||
| 5a0f7aa58f | |||
| a3798b3059 | |||
| 205418fc4e | |||
| d7b5988858 | |||
| f8b7afcc38 | |||
| 8d720278cb | |||
| 6bff55db64 | |||
| b26a635283 | |||
| 484ae7bf9d | |||
| 8a61908df1 |
@@ -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,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
|
||||
// ============================================================================
|
||||
@@ -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');
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
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';
|
||||
|
||||
@@ -12,6 +13,7 @@ class CreateOrgAdminDto {
|
||||
@IsString() email!: string;
|
||||
@IsString() @IsOptional() name?: string;
|
||||
@IsString() @MinLength(8) password!: string;
|
||||
@IsEnum(OrgAdminRole) @IsOptional() role?: OrgAdminRole;
|
||||
}
|
||||
|
||||
class AssignTenantOrgDto {
|
||||
@@ -40,7 +42,7 @@ export class AdminOrgsController {
|
||||
|
||||
@Post('orgs/:id/admins')
|
||||
createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) {
|
||||
return this.service.createOrgAdmin(id, body.email, body.name, body.password);
|
||||
return this.service.createOrgAdmin(id, body.email, body.name, body.password, body.role);
|
||||
}
|
||||
|
||||
@Patch('tenants/:id/org')
|
||||
|
||||
@@ -77,7 +77,7 @@ export class SuperAdminService {
|
||||
return org;
|
||||
}
|
||||
|
||||
async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string) {
|
||||
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');
|
||||
|
||||
@@ -88,7 +88,7 @@ export class SuperAdminService {
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const admin = await this.prisma.orgAdmin.create({
|
||||
data: { organizationId: orgId, email, name, passwordHash },
|
||||
data: { organizationId: orgId, email, name, passwordHash, ...(role ? { role: role as any } : {}) },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
});
|
||||
return admin;
|
||||
|
||||
@@ -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', '/member-login'];
|
||||
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,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>
|
||||
);
|
||||
}
|
||||
|
||||
+101
-124
@@ -2,144 +2,121 @@
|
||||
|
||||
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 API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
|
||||
|
||||
type Step = 'phone' | 'code';
|
||||
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<Step>('phone');
|
||||
const [step, setStep] = useState<'phone' | 'otp'>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function requestOtp() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/public/auth/member-login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { challengeId?: string; message?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.message ?? 'Failed to send code');
|
||||
return;
|
||||
}
|
||||
setChallengeId(data.challengeId ?? '');
|
||||
setStep('code');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
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 verifyOtp() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/my/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ challengeId, phone, code }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.message ?? 'Verification failed');
|
||||
return;
|
||||
}
|
||||
router.push('/my');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm bg-white rounded-2xl border border-gray-200 shadow-sm p-7 space-y-6">
|
||||
<div>
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex items-center justify-center text-xl mb-4">🏠</div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Sign in to your portal</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{step === 'phone'
|
||||
? 'Enter your WhatsApp number and we\'ll send you a code.'
|
||||
: `We sent a 6-digit code to ${phone} on WhatsApp.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step === 'phone' && (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+91 98765 43210"
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestOtp}
|
||||
disabled={busy || phone.replace(/\D/g, '').length < 8}
|
||||
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{busy ? 'Sending…' : 'Send code'}
|
||||
</button>
|
||||
<p className="text-xs text-center text-gray-400">
|
||||
First time?{' '}
|
||||
<a href="/onboard" className="text-indigo-600 hover:underline">
|
||||
Use your invite link
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'code' && (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.3em] font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestOtp}
|
||||
disabled={busy}
|
||||
className="text-xs text-indigo-600 hover:underline disabled:opacity-50 w-full text-center"
|
||||
>
|
||||
Didn't get it? Resend code
|
||||
</button>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<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"
|
||||
onClick={() => { setStep('phone'); setCode(''); setError(null); }}
|
||||
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => { setStep('phone'); otpForm.reset(); }} className="flex-none">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={verifyOtp}
|
||||
disabled={busy || code.length < 6}
|
||||
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{busy ? 'Verifying…' : 'Sign in'}
|
||||
</button>
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={otpForm.formState.isSubmitting}>
|
||||
{otpForm.formState.isSubmitting ? 'Verifying…' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+22
-59
@@ -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('/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>
|
||||
);
|
||||
}
|
||||
+65
-18
@@ -1,25 +1,72 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Building2, Network, UserRound } from 'lucide-react';
|
||||
|
||||
const PORTALS = [
|
||||
{
|
||||
href: '/org/login',
|
||||
title: 'Organisation',
|
||||
description: 'Manage chapters, org-wide rules and admin access.',
|
||||
icon: Network,
|
||||
iconWrap: 'bg-violet-100 text-violet-600',
|
||||
ring: 'hover:ring-violet-200 hover:border-violet-300',
|
||||
},
|
||||
{
|
||||
href: '/login',
|
||||
title: 'Chapter',
|
||||
description: 'Approve messages, manage groups and routing rules.',
|
||||
icon: Building2,
|
||||
iconWrap: 'bg-blue-100 text-blue-600',
|
||||
ring: 'hover:ring-blue-200 hover:border-blue-300',
|
||||
},
|
||||
{
|
||||
href: '/member-login',
|
||||
title: 'Member',
|
||||
description: 'View digests, manage privacy and explore your community.',
|
||||
icon: UserRound,
|
||||
iconWrap: 'bg-emerald-100 text-emerald-600',
|
||||
ring: 'hover:ring-emerald-200 hover:border-emerald-300',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-xl">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Insignia TOWER</h1>
|
||||
<p className="text-gray-500">Community Knowledge Infrastructure Platform</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/search"
|
||||
className="flex-1 rounded-xl border border-gray-200 bg-white p-5 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<h2 className="font-semibold mb-1">Search</h2>
|
||||
<p className="text-sm text-gray-500">Full-text search of approved messages</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/groups"
|
||||
className="flex-1 rounded-xl border border-gray-200 bg-white p-5 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<h2 className="font-semibold mb-1">Groups</h2>
|
||||
<p className="text-sm text-gray-500">Manage groups and sync routes</p>
|
||||
</Link>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-6">
|
||||
<div className="w-full max-w-md space-y-10">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex size-12 rounded-2xl bg-gradient-to-br from-slate-700 to-slate-900 items-center justify-center text-white font-bold text-xl shadow-lg mx-auto">
|
||||
T
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Welcome to TOWER</h1>
|
||||
<p className="text-sm text-muted-foreground">Choose how you want to sign in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{PORTALS.map((p) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<Link
|
||||
key={p.href}
|
||||
href={p.href}
|
||||
className={`group flex items-center gap-4 bg-card rounded-2xl border p-4 shadow-sm ring-1 ring-transparent transition-all hover:shadow-md ${p.ring}`}
|
||||
>
|
||||
<div className={`size-11 rounded-xl flex items-center justify-center shrink-0 ${p.iconWrap}`}>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm">{p.title} Portal</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mt-0.5">{p.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground shrink-0 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Access is provisioned by your organisation admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../_lib/auth-context';
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
export function SignupForm({ redirect }: { redirect?: string }) {
|
||||
const router = useRouter();
|
||||
const { refresh } = useAuth();
|
||||
const [tenantName, setTenantName] = useState('');
|
||||
const [tenantSlug, setTenantSlug] = useState('');
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const computedSlug = slugify(tenantName);
|
||||
const effectiveSlug = slugTouched ? tenantSlug : computedSlug;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (effectiveSlug.length < 2) {
|
||||
setError('Tenant name must produce a valid slug (lowercase letters, digits, dashes)');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantName, tenantSlug: effectiveSlug, email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
const messages = Array.isArray((data as { message?: unknown }).message)
|
||||
? ((data as unknown as { message: string[] }).message.join(', '))
|
||||
: data.message;
|
||||
setError(messages ?? 'Signup failed');
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
router.push(redirect || '/');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Community / organization name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={tenantName}
|
||||
onChange={(e) => setTenantName(e.target.value)}
|
||||
placeholder="Delhi Traders"
|
||||
required
|
||||
className="mt-1 w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">URL slug</span>
|
||||
<input
|
||||
type="text"
|
||||
value={effectiveSlug}
|
||||
onChange={(e) => {
|
||||
setSlugTouched(true);
|
||||
setTenantSlug(slugify(e.target.value));
|
||||
}}
|
||||
placeholder="delhi"
|
||||
required
|
||||
className="mt-1 w-full border rounded px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
<span className="block text-xs text-gray-500 mt-1">
|
||||
Lowercase letters, digits, dashes. 2–40 chars.
|
||||
</span>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Your email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="mt-1 w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
minLength={8}
|
||||
required
|
||||
className="mt-1 w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="font-medium">Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
className="mt-1 w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating account…' : 'Create community'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
You'll be the first OWNER. You can invite others from settings later.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { SignupForm } from './SignupForm';
|
||||
|
||||
export default async function SignupPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ redirect?: string }>;
|
||||
}) {
|
||||
const { redirect } = await searchParams;
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="w-full max-w-md bg-white rounded shadow p-6">
|
||||
<h1 className="text-xl font-semibold mb-1">Create your TOWER community</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<SignupForm redirect={redirect} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-1
@@ -9,11 +9,30 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.17",
|
||||
"@radix-ui/react-avatar": "^1.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-separator": "^1.1.10",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-toast": "^1.2.17",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tower/types": "workspace:*",
|
||||
"@tower/ui": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user