good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
+61
View File
@@ -0,0 +1,61 @@
import { cookies } from 'next/headers';
export const TOKEN_COOKIE = 'tower_token';
export const MEMBER_COOKIE = 'tower_member_token';
const MEMBER_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
export function getApiBaseUrl(): string {
return process.env['API_URL'] ?? 'http://localhost:3001';
}
export async function getToken(): Promise<string | undefined> {
const store = await cookies();
return store.get(TOKEN_COOKIE)?.value;
}
export async function getMemberToken(): Promise<string | undefined> {
const store = await cookies();
return store.get(MEMBER_COOKIE)?.value;
}
function withAuthHeader(headers: Headers, token: string | undefined): void {
headers.set('Accept', 'application/json');
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
}
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getToken();
const headers = new Headers(init.headers);
withAuthHeader(headers, token);
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return fetch(`${getApiBaseUrl()}${path}`, { ...init, headers, cache: 'no-store' });
}
export async function memberApiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getMemberToken();
const headers = new Headers(init.headers);
withAuthHeader(headers, token);
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return fetch(`${getApiBaseUrl()}${path}`, { ...init, headers, cache: 'no-store' });
}
export function buildMemberCookie(token: string): string {
return `${MEMBER_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${MEMBER_MAX_AGE_SECONDS}`;
}
export function clearMemberCookie(): string {
return `${MEMBER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
export function jsonResponse(body: unknown, status = 200, extraHeaders: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...extraHeaders },
});
}
+79
View File
@@ -0,0 +1,79 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { AuthProvider, useAuth } from './auth-context';
function Probe() {
const { admin, loading, error, logout } = useAuth();
return (
<div>
<div data-testid="loading">{String(loading)}</div>
<div data-testid="admin">{admin?.email ?? 'null'}</div>
<div data-testid="error">{error ?? 'null'}</div>
<button type="button" onClick={() => void logout()}>logout</button>
</div>
);
}
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('AuthProvider', () => {
it('exposes a loading state then sets admin from /api/auth/me', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ admin: { id: 'a-1', email: 'me@x.com', role: 'OWNER', tenantId: 't1', tenantSlug: 'default', tenantName: 'Default' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('admin')).toHaveTextContent('me@x.com'));
expect(screen.getByTestId('loading')).toHaveTextContent('false');
});
it('sets admin to null on 401', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ message: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
);
render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('admin')).toHaveTextContent('null'));
});
it('calls POST /api/auth/logout when logout is invoked', async () => {
fetchSpy
.mockResolvedValueOnce(
new Response(JSON.stringify({ admin: { id: 'a-1', email: 'me@x.com' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }));
render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('admin')).toHaveTextContent('me@x.com'));
await act(async () => {
screen.getByText('logout').click();
});
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ method: 'POST' })));
expect(screen.getByTestId('admin')).toHaveTextContent('null');
});
});
+74
View File
@@ -0,0 +1,74 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
export interface AuthAdmin {
id: string;
email: string;
name?: string | null;
role: 'OWNER' | 'ADMIN' | 'VIEWER';
tenantId: string;
tenantSlug: string;
tenantName?: string;
}
interface AuthState {
admin: AuthAdmin | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [admin, setAdmin] = useState<AuthAdmin | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (res.status === 401) {
setAdmin(null);
return;
}
if (!res.ok) {
setError('Unable to verify session');
setAdmin(null);
return;
}
const data = await res.json();
setAdmin(data.admin ?? null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Network error');
setAdmin(null);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
setAdmin(null);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<AuthContext.Provider value={{ admin, loading, error, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
return ctx;
}
+146
View File
@@ -0,0 +1,146 @@
'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: '/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' },
];
const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
const ADMIN_PATHS = ['/admin'];
const MEMBER_PATHS = ['/my'];
export function Sidebar() {
const { admin, loading, logout } = useAuth();
const { admin: superAdmin, logout: superLogout } = useSuperAdmin();
const pathname = usePathname();
const router = useRouter();
const [pendingCount, setPendingCount] = useState<number | null>(null);
useEffect(() => {
fetch('/api/messages/pending/count')
.then((r) => r.ok ? r.json() : null)
.then((data) => setPendingCount(data?.count ?? null))
.catch(() => setPendingCount(null));
}, []);
useEffect(() => {
if (loading) return;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return;
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) return;
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) return;
if (!admin) {
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}
}, [loading, admin, pathname, router]);
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4">
<span className="font-bold text-base">TOWER</span>
</nav>
);
}
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER Admin</span>
<div className="flex flex-col gap-1 flex-1">
{SUPER_ADMIN_LINKS.map((link) => (
<Link key={link.href} href={link.href} className="rounded px-3 py-2 text-sm hover:bg-gray-100">
{link.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{superAdmin?.email}</div>
<div className="uppercase tracking-wide">Super Admin</div>
</div>
<button
type="button"
onClick={() => void superLogout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
</nav>
);
}
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
return (
<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">
<Link href="/my" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Profile
</Link>
<Link href="/my/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Groups
</Link>
<Link href="/my/settings" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Settings
</Link>
</div>
<div className="border-t border-gray-200 pt-3 mt-3 text-xs text-gray-500 px-3">
Member portal
</div>
</nav>
);
}
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>
);
}
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
interface SuperAdmin {
id: string;
email: string;
name: string | null;
}
interface SuperAdminState {
admin: SuperAdmin | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const SuperAdminContext = createContext<SuperAdminState | null>(null);
export function SuperAdminProvider({ children }: { children: React.ReactNode }) {
const [admin, setAdmin] = useState<SuperAdmin | null>(null);
const [loading, setLoading] = useState(true);
const checkSession = useCallback(async () => {
try {
const res = await fetch('/api/auth/super/me', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAdmin(data);
} else {
setAdmin(null);
}
} catch {
setAdmin(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void checkSession(); }, [checkSession]);
const login = useCallback(async (email: string, password: string) => {
const res = await fetch('/api/auth/super/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Login failed' }));
throw new Error(err.message ?? 'Login failed');
}
const data = await res.json();
setAdmin(data.superAdmin);
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/super/logout', { method: 'POST', credentials: 'include' });
setAdmin(null);
}, []);
return (
<SuperAdminContext.Provider value={{ admin, loading, login, logout }}>
{children}
</SuperAdminContext.Provider>
);
}
export function useSuperAdmin(): SuperAdminState {
const ctx = useContext(SuperAdminContext);
if (!ctx) throw new Error('useSuperAdmin must be used within <SuperAdminProvider>');
return ctx;
}
-100
View File
@@ -1,100 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { AccountCard } from './AccountCard';
const activeAccount = {
id: 'acc_1',
jid: '111@s.whatsapp.net',
displayName: 'My Account',
status: 'ACTIVE',
platform: 'whatsapp',
};
const disconnectedAccount = {
id: 'acc_2',
jid: '222@s.whatsapp.net',
displayName: null,
status: 'DISCONNECTED',
platform: 'whatsapp',
};
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('AccountCard', () => {
it('shows displayName and Connected badge when ACTIVE', () => {
render(<AccountCard account={activeAccount} />);
expect(screen.getByText('My Account')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('falls back to jid when displayName is null', () => {
render(<AccountCard account={disconnectedAccount} />);
expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument();
});
it('shows Awaiting scan badge when DISCONNECTED', () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountCard account={disconnectedAccount} />);
expect(screen.getByText('Awaiting scan')).toBeInTheDocument();
});
it('does not fetch QR when account is ACTIVE', () => {
render(<AccountCard account={activeAccount} />);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountCard account={disconnectedAccount} />);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'),
);
});
it('shows QR image when qrDataUrl is returned', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountCard account={disconnectedAccount} />);
await waitFor(() => {
expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument();
});
expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute(
'src',
'data:image/png;base64,abc123',
);
});
it('shows waiting message when DISCONNECTED but no QR yet', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountCard account={disconnectedAccount} />);
await waitFor(() => {
expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument();
});
});
});
-72
View File
@@ -1,72 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
interface Account {
id: string;
jid: string;
displayName: string | null;
status: string;
platform: string;
}
export function AccountCard({ account }: { account: Account }) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const isDisconnected = account.status === 'DISCONNECTED';
useEffect(() => {
if (!isDisconnected) {
setQrDataUrl(null);
return;
}
async function fetchQr() {
try {
const res = await fetch(`/api/accounts/${account.id}/qr`);
if (!res.ok) return;
const data = await res.json();
setQrDataUrl(data.qrDataUrl ?? null);
} catch {
// ignore fetch errors (e.g. network issues)
}
}
fetchQr();
const interval = setInterval(fetchQr, 5000);
return () => clearInterval(interval);
}, [account.id, isDisconnected]);
return (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-2">
<div>
<p className="font-medium">{account.displayName ?? account.jid}</p>
{account.displayName && (
<p className="text-xs text-gray-500">{account.jid}</p>
)}
</div>
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${
account.status === 'ACTIVE'
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'}
</span>
</div>
{isDisconnected && qrDataUrl && (
<div className="mt-3">
<p className="text-sm text-gray-600 mb-2">
Open WhatsApp Linked Devices Link a Device scan below
</p>
<img src={qrDataUrl} alt="WhatsApp QR Code" className="w-48 h-48" />
</div>
)}
{isDisconnected && !qrDataUrl && (
<p className="text-sm text-gray-500 mt-2">Waiting for QR code from worker...</p>
)}
</div>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AccountsList } from './AccountsList';
const mockAccounts = [
{ id: 'acc_1', jid: '111@s.whatsapp.net', displayName: 'Account One', status: 'ACTIVE', platform: 'whatsapp' },
{ id: 'acc_2', jid: '222@s.whatsapp.net', displayName: null, status: 'DISCONNECTED', platform: 'whatsapp' },
];
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('AccountsList', () => {
it('renders an AccountCard for each initial account', () => {
render(<AccountsList initialAccounts={mockAccounts} />);
expect(screen.getByText('Account One')).toBeInTheDocument();
expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument();
});
it('shows empty state when no accounts', () => {
render(<AccountsList initialAccounts={[]} />);
expect(screen.getByText(/no accounts yet/i)).toBeInTheDocument();
});
it('renders Add Account button', () => {
render(<AccountsList initialAccounts={[]} />);
expect(screen.getByRole('button', { name: /add account/i })).toBeInTheDocument();
});
it('renders display name input', () => {
render(<AccountsList initialAccounts={[]} />);
expect(screen.getByPlaceholderText(/display name/i)).toBeInTheDocument();
});
it('calls POST /api/accounts when Add Account is clicked', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: null, status: 'ACTIVE', platform: 'whatsapp' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountsList initialAccounts={[]} />);
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith('/api/accounts', expect.objectContaining({ method: 'POST' })),
);
});
it('adds new account to list after successful POST', async () => {
const newAccount = { id: 'acc_new', jid: 'pending_x@placeholder', displayName: 'New Device', status: 'ACTIVE', platform: 'whatsapp' };
fetchSpy.mockResolvedValue(
new Response(JSON.stringify(newAccount), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountsList initialAccounts={[]} />);
const input = screen.getByPlaceholderText(/display name/i);
fireEvent.change(input, { target: { value: 'New Device' } });
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() => expect(screen.getByText('New Device')).toBeInTheDocument());
});
it('sends displayName in POST body when entered', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: 'Test Name', status: 'ACTIVE', platform: 'whatsapp' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountsList initialAccounts={[]} />);
const input = screen.getByPlaceholderText(/display name/i);
fireEvent.change(input, { target: { value: 'Test Name' } });
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/accounts',
expect.objectContaining({
body: JSON.stringify({ displayName: 'Test Name' }),
}),
),
);
});
it('clears the input after successful account creation', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: null, status: 'ACTIVE', platform: 'whatsapp' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountsList initialAccounts={[]} />);
const input = screen.getByPlaceholderText(/display name/i) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'My Device' } });
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() => expect(input.value).toBe(''));
});
});
-60
View File
@@ -1,60 +0,0 @@
'use client';
import { useState } from 'react';
import { AccountCard } from './AccountCard';
interface Account {
id: string;
jid: string;
displayName: string | null;
status: string;
platform: string;
}
export function AccountsList({ initialAccounts }: { initialAccounts: Account[] }) {
const [accounts, setAccounts] = useState<Account[]>(initialAccounts);
const [displayName, setDisplayName] = useState('');
async function handleAdd() {
try {
const res = await fetch('/api/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: displayName || undefined }),
});
if (!res.ok) return;
const account: Account = await res.json();
setAccounts((prev) => [...prev, account]);
setDisplayName('');
} catch {}
}
return (
<div>
<div className="flex gap-2 mb-6">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Display name (optional)"
className="flex-1 border rounded px-3 py-2 text-sm"
/>
<button
onClick={handleAdd}
className="bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700"
>
Add Account
</button>
</div>
{accounts.length === 0 ? (
<p className="text-gray-500">No accounts yet. Add one above to get started.</p>
) : (
<div className="flex flex-col gap-3">
{accounts.map((a) => (
<AccountCard key={a.id} account={a} />
))}
</div>
)}
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { AccountsList } from './AccountsList';
interface Account {
id: string;
jid: string;
displayName: string | null;
status: string;
platform: string;
}
export default async function AccountsPage() {
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
let accounts: Account[] = [];
try {
const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' });
if (res.ok) accounts = await res.json();
} catch {}
return (
<div className="max-w-2xl">
<h1 className="text-xl font-semibold mb-6">Accounts</h1>
<AccountsList initialAccounts={accounts} />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_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 [initiating, setInitiating] = useState(false);
const [pairingInfo, setPairingInfo] = useState<{ token: string; expiresAt: string } | null>(null);
async function load() {
const res = await fetch('/api/admin/bots');
if (res.ok) setBots(await res.json());
}
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
void load();
}, [admin, loading, router]);
async function initiateBot() {
setInitiating(true);
try {
const res = await fetch('/api/admin/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (res.ok) {
const data = await res.json();
setPairingInfo(data);
}
} finally {
setInitiating(false);
void load();
}
}
async function removeBot(id: string) {
if (!confirm('Remove this bot? Only possible if no tenants are assigned.')) return;
const res = await fetch(`/api/admin/bots/${id}`, { method: 'DELETE' });
if (res.ok) {
void load();
} else {
const err = await res.json();
alert(err.message ?? 'Failed to remove bot');
}
}
function getQrUrl() {
if (!pairingInfo) return null;
return `/api/admin/bots/qr/${pairingInfo.token}`;
}
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Bot Pool</h1>
<button
onClick={initiateBot}
disabled={initiating}
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
{initiating ? 'Creating...' : 'Add Bot'}
</button>
</div>
{pairingInfo && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
<p className="text-sm font-medium mb-2">New bot created scan QR to pair</p>
<p className="text-xs text-gray-600 mb-2">Expires: {pairingInfo.expiresAt}</p>
<a
href={getQrUrl() ?? '#'}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 text-sm underline"
>
View QR Code
</a>
</div>
)}
<div className="bg-white rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<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">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>
))}
</tbody>
</table>
{bots.length === 0 && <p className="p-4 text-gray-400">No bots in the pool.</p>}
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { FormEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSuperAdmin } from '../../_lib/super-admin-context';
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);
try {
await login(email, password);
router.replace('/admin');
} catch (err: any) {
setError(err.message ?? 'Login failed');
} finally {
setBusy(false);
}
}
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>
);
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../_lib/super-admin-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function AdminDashboard() {
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const [tenants, setTenants] = useState<any[]>([]);
const [bots, setBots] = useState<any[]>([]);
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
fetch('/api/admin/tenants').then(r => r.ok && r.json()).then(d => setTenants(d ?? [])).catch(() => {});
fetch('/api/admin/bots').then(r => r.ok && r.json()).then(d => setBots(d ?? [])).catch(() => {});
}, [admin, loading, router]);
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
const totalTenants = tenants.length;
const activeTenants = tenants.filter((t: any) => t.isActive).length;
const totalBots = bots.length;
const totalMessages = tenants.reduce((s: number, t: any) => s + (t.stats?.messages ?? 0), 0);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Admin Dashboard</h1>
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Tenants</div>
<div className="text-2xl font-bold mt-1">{totalTenants}</div>
<div className="text-xs text-gray-400">{activeTenants} active</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Bot Accounts</div>
<div className="text-2xl font-bold mt-1">{totalBots}</div>
<div className="text-xs text-gray-400">in pool</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Messages</div>
<div className="text-2xl font-bold mt-1">{totalMessages}</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Avg tenants/bot</div>
<div className="text-2xl font-bold mt-1">{totalBots ? Math.round(totalTenants / totalBots) : 0}</div>
</div>
</div>
<div className="flex gap-4">
<Link href="/admin/tenants" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Tenants</Link>
<Link href="/admin/bots" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Bots</Link>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../../_lib/super-admin-context';
import { useRouter, useParams } from 'next/navigation';
export default function TenantDetailPage() {
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const params = useParams();
const [tenant, setTenant] = useState<any>(null);
const [busy, setBusy] = useState(false);
async function load() {
const res = await fetch(`/api/admin/tenants/${params.id}`);
if (res.ok) setTenant(await res.json());
}
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
void load();
}, [admin, loading, router, params.id]);
async function toggleActive() {
setBusy(true);
await fetch(`/api/admin/tenants/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !tenant.isActive }),
});
await load();
setBusy(false);
}
async function togglePaused() {
setBusy(true);
await fetch(`/api/admin/tenants/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isForwardingPaused: !tenant.isForwardingPaused }),
});
await load();
setBusy(false);
}
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
if (!tenant) return <p className="text-gray-500">Loading tenant...</p>;
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold mb-2">{tenant.name}</h1>
<p className="text-gray-500 text-sm mb-6">Slug: {tenant.slug}</p>
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase">Status</div>
<button
onClick={toggleActive}
disabled={busy}
className={`mt-1 text-sm font-medium px-3 py-1 rounded-full ${tenant.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
>
{tenant.isActive ? 'Active' : 'Inactive'}
</button>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase">Forwarding</div>
<button
onClick={togglePaused}
disabled={busy}
className={`mt-1 text-sm font-medium px-3 py-1 rounded-full ${tenant.isForwardingPaused ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
>
{tenant.isForwardingPaused ? 'Paused' : 'Active'}
</button>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase">Groups</div>
<div className="text-xl font-bold mt-1">{tenant.stats?.groups ?? 0}</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase">Messages</div>
<div className="text-xl font-bold mt-1">{tenant.stats?.messages ?? 0}</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase">Rules</div>
<div className="text-xl font-bold mt-1">{tenant.stats?.rules ?? 0}</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-xs text-gray-500 uppercase">Routes</div>
<div className="text-xl font-bold mt-1">{tenant.stats?.routes ?? 0}</div>
</div>
</div>
{tenant.bot && (
<div className="bg-white rounded-xl border p-4 mb-6">
<h2 className="font-semibold text-sm mb-2">Assigned Bot</h2>
<div className="text-xs space-y-1">
<p><span className="text-gray-500">JID:</span> <span className="font-mono">{tenant.bot.jid}</span></p>
<p><span className="text-gray-500">Name:</span> {tenant.bot.displayName ?? '—'}</p>
<p><span className="text-gray-500">Status:</span> {tenant.bot.status}</p>
<p><span className="text-gray-500">Linked:</span> {tenant.bot.linkedSince}</p>
</div>
</div>
)}
{tenant.admins && tenant.admins.length > 0 && (
<div className="bg-white rounded-xl border p-4">
<h2 className="font-semibold text-sm mb-2">Admins</h2>
<div className="text-xs space-y-1">
{tenant.admins.map((a: any) => (
<p key={a.id}>{a.email} <span className="uppercase">{a.role}</span></p>
))}
</div>
</div>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function TenantsPage() {
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const [tenants, setTenants] = useState<any[]>([]);
async function load() {
const res = await fetch('/api/admin/tenants');
if (res.ok) setTenants(await res.json());
}
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
void load();
}, [admin, loading, router]);
async function toggleActive(id: string, current: boolean) {
await fetch(`/api/admin/tenants/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !current }),
});
void load();
}
async function togglePaused(id: string, current: boolean) {
await fetch(`/api/admin/tenants/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isForwardingPaused: !current }),
});
void load();
}
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Tenants</h1>
<div className="bg-white rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Slug</th>
<th className="px-4 py-3 font-medium">Bot</th>
<th className="px-4 py-3 font-medium">Groups</th>
<th className="px-4 py-3 font-medium">Messages</th>
<th className="px-4 py-3 font-medium">Active</th>
<th className="px-4 py-3 font-medium">Paused</th>
</tr>
</thead>
<tbody className="divide-y">
{tenants.map((t: any) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/admin/tenants/${t.id}`} className="text-blue-600 hover:underline font-medium">
{t.name}
</Link>
</td>
<td className="px-4 py-3 text-gray-500">{t.slug}</td>
<td className="px-4 py-3 text-xs">
{t.bot ? <span className="font-mono">{t.bot.jid?.slice(0, 20)}...</span> : <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3">{t.stats.groups}</td>
<td className="px-4 py-3">{t.stats.messages}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleActive(t.id, t.isActive)}
className={`text-xs font-medium px-2 py-1 rounded-full ${t.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
>
{t.isActive ? 'Active' : 'Inactive'}
</button>
</td>
<td className="px-4 py-3">
<button
onClick={() => togglePaused(t.id, t.isForwardingPaused)}
className={`text-xs font-medium px-2 py-1 rounded-full ${t.isForwardingPaused ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}
>
{t.isForwardingPaused ? 'Paused' : 'Active'}
</button>
</td>
</tr>
))}
</tbody>
</table>
{tenants.length === 0 && <p className="p-4 text-gray-400">No tenants yet.</p>}
</div>
</div>
);
}
@@ -1,7 +0,0 @@
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const res = await fetch(`${API_URL}/accounts/${id}/qr`, { cache: 'no-store' });
return Response.json(await res.json(), { status: res.status });
}
-16
View File
@@ -1,16 +0,0 @@
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
export async function GET() {
const res = await fetch(`${API_URL}/accounts`, { cache: 'no-store' });
return Response.json(await res.json(), { status: res.status });
}
export async function POST(req: Request) {
const body = await req.json();
const res = await fetch(`${API_URL}/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return Response.json(await res.json(), { status: res.status });
}
@@ -0,0 +1,21 @@
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const body = await req.json().catch(() => ({}));
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/bots/${id}/assign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+18
View File
@@ -0,0 +1,18 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/bots/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,17 @@
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }): Promise<Response> {
const { token } = await params;
const tokenCookie = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/bots/qr/${token}`, {
headers: {
Accept: 'application/json',
...(tokenCookie ? { Authorization: `Bearer ${tokenCookie}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
+33
View File
@@ -0,0 +1,33 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/bots`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/bots/initiate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
@@ -0,0 +1,35 @@
import { getApiBaseUrl, 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 token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/tenants/${id}`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const body = await req.json().catch(() => ({}));
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/tenants/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+16
View File
@@ -0,0 +1,16 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/admin/tenants`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
+19
View File
@@ -0,0 +1,19 @@
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/login`, {
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: 'Login 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 });
}
+12
View File
@@ -0,0 +1,12 @@
import { TOKEN_COOKIE, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(): Promise<Response> {
const cookieValue = `${TOKEN_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
return new Response(null, { status: 204, headers: { 'Set-Cookie': cookieValue } });
}
export function GET(): Response {
return jsonResponse({ message: 'Use POST to log out' }, 405);
}
+9
View File
@@ -0,0 +1,9 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/auth/me');
const body = await res.json();
return jsonResponse(body, res.status);
}
+19
View File
@@ -0,0 +1,19 @@
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 });
}
@@ -0,0 +1,19 @@
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 res = await fetch(`${getApiBaseUrl()}/auth/super/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await res.json();
const headers: Record<string, string> = {};
if (res.ok && payload.token) {
headers['Set-Cookie'] = `tower_super_token=${payload.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`;
}
return jsonResponse(payload, res.status, headers);
}
@@ -0,0 +1,9 @@
import { jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(): Promise<Response> {
return jsonResponse({ ok: true }, 200, {
'Set-Cookie': 'tower_super_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
});
}
+16
View File
@@ -0,0 +1,16 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/auth/super/me`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
+13
View File
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/bot/${id}`, { method: 'DELETE' });
const payload = await res.json().catch(() => ({}));
return jsonResponse(payload, res.status);
}
+15
View File
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await apiFetch('/admin/bot/attach', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
}).catch(() => null);
if (!res) return jsonResponse({ message: 'Upstream unavailable' }, 502);
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { getApiBaseUrl, getToken, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const token = await getToken();
const res = await fetch(`${getApiBaseUrl()}/admin/bot/initiate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+13
View File
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
): Promise<Response> {
const { token } = await params;
const res = await apiFetch(`/admin/bot/qr/${token}`);
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+17
View File
@@ -0,0 +1,17 @@
import { getApiBaseUrl, getToken, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(): Promise<Response> {
const token = await getToken();
const res = await fetch(`${getApiBaseUrl()}/admin/bot/reveal`, {
method: 'POST',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+10
View File
@@ -0,0 +1,10 @@
import { apiFetch, jsonResponse } from '../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/bot').catch(() => null);
if (!res) return jsonResponse({ message: 'Upstream unavailable' }, 502);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,17 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await req.json();
const res = await apiFetch(`/admin/groups/${id}/share`, {
method: 'POST',
body: JSON.stringify(body),
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
@@ -0,0 +1,14 @@
import { apiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; targetTenantId: string }> },
): Promise<Response> {
const { id, targetTenantId } = await params;
const res = await apiFetch(`/admin/groups/${id}/share/${targetTenantId}`, { 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,12 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(req: Request): Promise<Response> {
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');
if (!token) return new Response(JSON.stringify({ message: 'token required' }), { status: 400 });
const res = await apiFetch(`/admin/groups/claim-token-info?token=${encodeURIComponent(token)}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await apiFetch('/admin/groups/claim-with-token', {
method: 'POST',
body: JSON.stringify(body),
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
@@ -0,0 +1,9 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/groups/shared-by-me');
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/groups/shared');
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,19 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST' });
const body = await res.text();
let payload: unknown = body;
try {
payload = JSON.parse(body);
} catch {
/* keep as text */
}
return jsonResponse(payload, res.status);
}
+13
View File
@@ -0,0 +1,13 @@
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}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,9 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/messages/pending/count');
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,9 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(_req: Request): Promise<Response> {
const res = await apiFetch('/admin/messages/pending');
const body = await res.json();
return jsonResponse(body, res.status);
}
+15
View File
@@ -0,0 +1,15 @@
import { clearMemberCookie, jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function DELETE(): Promise<Response> {
const res = await memberApiFetch('/my/account', { method: 'DELETE' });
if (res.status === 204) {
return new Response(null, {
status: 204,
headers: { 'Set-Cookie': clearMemberCookie() },
});
}
const body = await res.json().catch(() => ({}));
return jsonResponse(body, res.status, { 'Set-Cookie': clearMemberCookie() });
}
+13
View File
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } 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 memberApiFetch(`/my/groups/${id}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/groups');
const body = await res.json();
return jsonResponse(body, res.status);
}
+7
View File
@@ -0,0 +1,7 @@
import { clearMemberCookie } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(): Promise<Response> {
return new Response(null, { status: 204, headers: { 'Set-Cookie': clearMemberCookie() } });
}
+10
View File
@@ -0,0 +1,10 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await memberApiFetch('/my/opt-in', { method: 'POST', body: JSON.stringify(body) });
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+10
View File
@@ -0,0 +1,10 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await memberApiFetch('/my/opt-out', { method: 'POST', body: JSON.stringify(body) });
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/profile');
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,30 @@
import { buildMemberCookie, getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const upstream = await fetch(`${getApiBaseUrl()}/public/auth/verify-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = (await upstream.json().catch(() => ({}))) as {
memberToken?: string;
user?: { id: string; tenantId: string; jid: string; displayName: string | null };
consent?: { scopes: string[]; retentionDays: number; policyVersion: string };
message?: string;
};
if (!upstream.ok) {
return jsonResponse(payload, upstream.status);
}
if (!payload.memberToken) {
return jsonResponse({ message: 'Upstream response missing memberToken' }, 502);
}
return jsonResponse(
{ user: payload.user, consent: payload.consent },
200,
{ 'Set-Cookie': buildMemberCookie(payload.memberToken) },
);
}
+7 -4
View File
@@ -1,11 +1,14 @@
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
import { apiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
): Promise<Response> {
const { id } = await params;
const res = await fetch(`${API_URL}/routes/${id}`, { method: 'DELETE' });
const res = await apiFetch(`/routes/${id}`, { method: 'DELETE' });
if (res.status === 204) return new Response(null, { status: 204 });
return Response.json(await res.json(), { status: res.status });
const body = await res.text();
return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
+13
View File
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await apiFetch('/routes/batch', {
method: 'POST',
body: JSON.stringify(body),
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+13 -10
View File
@@ -1,19 +1,22 @@
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
import { apiFetch, jsonResponse } from '../../_lib/api';
export async function GET(req: Request) {
export const dynamic = 'force-dynamic';
export async function GET(req: Request): Promise<Response> {
const { searchParams } = new URL(req.url);
const url = new URL(`${API_URL}/routes`);
searchParams.forEach((v, k) => url.searchParams.set(k, v));
const res = await fetch(url, { cache: 'no-store' });
return Response.json(await res.json(), { status: res.status });
const query = searchParams.toString();
const path = query ? `/routes?${query}` : '/routes';
const res = await apiFetch(path);
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(req: Request) {
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await fetch(`${API_URL}/routes`, {
const res = await apiFetch('/routes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return Response.json(await res.json(), { status: res.status });
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+28
View File
@@ -0,0 +1,28 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PUT(
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await req.json();
const res = await apiFetch(`/admin/rules/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/rules/${id}`, { 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' } });
}
+19
View File
@@ -0,0 +1,19 @@
import { apiFetch, jsonResponse } from '../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/rules');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(req: Request): Promise<Response> {
const body = await req.json();
const res = await apiFetch('/admin/rules', {
method: 'POST',
body: JSON.stringify(body),
});
const payload = await res.json();
return jsonResponse(payload, res.status);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
interface TokenInfo {
groupName: string;
expiresAt: string;
isConsumed: boolean;
isExpired: boolean;
}
function ClaimGroupContent() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [info, setInfo] = useState<TokenInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError('No claim token provided');
return;
}
fetch(`/api/groups/claim-token-info?token=${encodeURIComponent(token)}`)
.then((r) => r.json().catch(() => null))
.then((data) => {
if (data?.groupName) setInfo(data);
else setError('Invalid or expired claim link');
})
.catch(() => setError('Failed to load claim info'));
}, [token]);
async function handleClaim() {
if (!token) return;
setBusy(true);
setError(null);
try {
const res = await fetch('/api/groups/claim-with-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (res.status === 401) {
router.push(`/signup?redirect=/claim-group?token=${encodeURIComponent(token)}`);
return;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.message ?? 'Claim failed');
return;
}
setSuccess(true);
} finally {
setBusy(false);
}
}
if (error && !info) {
return (
<div className="max-w-md mx-auto mt-16 text-center">
<h1 className="text-xl font-semibold mb-4">Claim Group</h1>
<p className="text-red-600 text-sm">{error}</p>
</div>
);
}
if (success) {
return (
<div className="max-w-md mx-auto mt-16 text-center">
<h1 className="text-xl font-semibold mb-4">Group Claimed!</h1>
<p className="text-green-600 text-sm mb-4">{info?.groupName ?? 'Group'} has been added to your tenant.</p>
<a href="/groups" className="text-blue-600 hover:underline text-sm">Go to Groups</a>
</div>
);
}
return (
<div className="max-w-md mx-auto mt-16">
<h1 className="text-xl font-semibold mb-4">Claim Group</h1>
{info && (
<div className="border border-gray-200 rounded-lg p-4 mb-4">
<p className="text-sm mb-1">
<span className="text-gray-500">Group:</span>{' '}
<span className="font-medium">{info.groupName}</span>
</p>
<p className="text-sm text-gray-500">
Expires: {new Date(info.expiresAt).toLocaleDateString()}
</p>
{info.isConsumed && (
<p className="text-sm text-amber-600 mt-2">This link has already been used.</p>
)}
{info.isExpired && (
<p className="text-sm text-red-600 mt-2">This link has expired.</p>
)}
</div>
)}
{error && (
<p className="text-red-600 text-sm mb-4 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
)}
<button
onClick={handleClaim}
disabled={busy || !info || info.isConsumed || info.isExpired}
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Claiming…' : 'Claim this group'}
</button>
</div>
);
}
export default function ClaimGroupPage() {
return (
<Suspense fallback={<div className="max-w-md mx-auto mt-16 text-center"><p className="text-gray-500 text-sm">Loading</p></div>}>
<ClaimGroupContent />
</Suspense>
);
}
+28
View File
@@ -0,0 +1,28 @@
'use client';
import Link from 'next/link';
interface Props {
current: 'mine' | 'shared' | 'all';
counts: { mine: number; shared: number };
}
export function GroupsTabs({ current, counts }: Props) {
const tabs: { key: Props['current']; label: string; href: string }[] = [
{ key: 'mine', label: `My Groups (${counts.mine})`, href: '/groups' },
{ key: 'shared', label: `Shared with me (${counts.shared})`, href: '/groups?tab=shared' },
];
return (
<div className="flex border-b border-gray-200">
{tabs.map((t) => (
<Link key={t.key} href={t.href}
className={`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
current === t.key
? 'border-blue-600 text-blue-700 font-medium'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}>
{t.label}
</Link>
))}
</div>
);
}
+47 -15
View File
@@ -2,8 +2,9 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RouteManager } from './RouteManager';
const groups = [
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp' },
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp' },
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', isActive: true },
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', isActive: true },
{ id: 'grp_3', name: 'Gamma', platform: 'whatsapp', isActive: true },
];
const routes = [
@@ -27,46 +28,77 @@ afterEach(() => {
});
describe('RouteManager', () => {
it('renders existing routes with source → target names', () => {
it('renders routes grouped by source group', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Beta')).toBeInTheDocument();
});
it('renders two group select dropdowns for adding a route', () => {
it('shows source dropdown and target checkboxes', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
expect(screen.getAllByRole('combobox')).toHaveLength(2);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
it('calls POST /api/routes when Add route is submitted', async () => {
it('shows target checkboxes when a source is selected', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } });
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
});
it('calls POST /api/routes/batch with selected targetIds', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }),
JSON.stringify([
{ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } },
{ id: 'rt_new2', sourceGroupId: 'grp_1', targetGroupId: 'grp_3', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Gamma' } },
]),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<RouteManager groups={groups} initialRoutes={[]} />);
const [sourceSelect, targetSelect] = screen.getAllByRole('combobox');
fireEvent.change(sourceSelect, { target: { value: 'grp_1' } });
fireEvent.change(targetSelect, { target: { value: 'grp_2' } });
fireEvent.click(screen.getByRole('button', { name: /add route/i }));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } });
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
fireEvent.click(screen.getByRole('button', { name: /create 2 routes/i }));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
'/api/routes',
expect.objectContaining({ method: 'POST' }),
'/api/routes/batch',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ sourceGroupId: 'grp_1', targetGroupIds: ['grp_2', 'grp_3'] }),
}),
);
});
});
it('shows error message on 409 conflict', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ message: 'Routes already exist for: Beta' }),
{ status: 409, headers: { 'Content-Type': 'application/json' } },
),
);
render(<RouteManager groups={groups} initialRoutes={routes} />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } });
fireEvent.click(screen.getAllByRole('checkbox')[0]);
fireEvent.click(screen.getByRole('button', { name: /create 1 route/i }));
await waitFor(() => {
expect(screen.getByText('Routes already exist for: Beta')).toBeInTheDocument();
});
});
it('shows a delete button for each existing route', () => {
render(<RouteManager groups={groups} initialRoutes={routes} />);
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
const deleteBtn = screen.getByRole('button', { name: /delete route to beta/i });
expect(deleteBtn).toBeInTheDocument();
});
it('calls DELETE /api/routes/:id when a route is deleted', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
render(<RouteManager groups={groups} initialRoutes={routes} />);
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
fireEvent.click(screen.getByRole('button', { name: /delete route to beta/i }));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
'/api/routes/rt_1',
+112 -56
View File
@@ -6,6 +6,7 @@ interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
interface Route {
@@ -25,23 +26,51 @@ export function RouteManager({
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetId, setTargetId] = useState('');
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function addRoute() {
if (!sourceId || !targetId) return;
// 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', {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }),
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (!res.ok) return;
const created: Route = await res.json();
setRoutes((prev) => [created, ...prev]);
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('');
setTargetId('');
setTargetIds(new Set());
} finally {
setBusy(false);
}
@@ -52,6 +81,8 @@ export function RouteManager({
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>
@@ -59,24 +90,26 @@ export function RouteManager({
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<ul className="flex flex-col gap-2">
{routes.map((route) => (
<li
key={route.id}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3"
>
<span className="text-sm">
<span className="font-medium">{route.sourceGroup.name}</span>
<span className="mx-2 text-gray-400"></span>
<span className="font-medium">{route.targetGroup.name}</span>
</span>
<button
onClick={() => deleteRoute(route.id)}
className="text-xs text-red-500 hover:underline"
aria-label="Delete route"
>
Delete
</button>
<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>
@@ -84,37 +117,60 @@ export function RouteManager({
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add route</h2>
<div className="flex gap-2 items-center flex-wrap">
<select
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Source group"
>
<option value="">Source group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400"></span>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Target group"
>
<option value="">Target group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<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={addRoute}
disabled={!sourceId || !targetId || busy}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
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"
>
Add route
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
</button>
</div>
</section>
+78 -13
View File
@@ -1,9 +1,19 @@
import { RouteManager } from './RouteManager';
import { GroupsTabs } from './GroupsTabs';
import { apiFetch } from '../_lib/api';
interface Group {
id: string;
name: string;
platform: string;
platformId: string;
isActive: boolean;
accountId: string | null;
tenantId: string | null;
}
interface SharedGroup extends Group {
sharedByTenantName: string;
}
interface Route {
@@ -14,27 +24,82 @@ interface Route {
targetGroup: { name: string };
}
async function fetchJson<T>(url: string): Promise<T | null> {
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
async function fetchJson<T>(path: string): Promise<FetchResult<T>> {
let res: Response;
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return null;
return res.json();
} catch {
return null;
res = await apiFetch(path);
} catch (err) {
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
}
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 T };
}
export default async function GroupsPage() {
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
const [groups, routes] = await Promise.all([
fetchJson<Group[]>(`${apiUrl}/groups`),
fetchJson<Route[]>(`${apiUrl}/routes`),
export default async function GroupsPage({
searchParams,
}: {
searchParams: Promise<{ tab?: 'mine' | 'shared' }>;
}) {
const { tab: rawTab } = await searchParams;
const tab: 'mine' | 'shared' = rawTab === 'shared' ? 'shared' : 'mine';
const [groupsR, sharedR, routesR] = await Promise.all([
fetchJson<Group[]>('/groups'),
tab === 'shared' ? fetchJson<SharedGroup[]>('/groups/shared') : Promise.resolve(null),
fetchJson<Route[]>('/routes'),
]);
const groups = groupsR?.ok ? groupsR.data : [];
const shared = sharedR && sharedR.ok ? sharedR.data : [];
const routes = routesR?.ok ? routesR.data : [];
const errors = [groupsR, sharedR, routesR]
.filter((r): r is { ok: false; status: number; error: string } => !!r && !r.ok && r.status !== 401)
.map((e) => `${e.status === 0 ? 'API unreachable' : `API ${e.status}`}: ${e.error}`);
return (
<div className="max-w-2xl">
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-6">Groups &amp; Routes</h1>
<RouteManager groups={groups ?? []} initialRoutes={routes ?? []} />
{errors.length > 0 && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<div className="font-medium mb-1">Failed to load some data</div>
<ul className="list-disc pl-5 space-y-0.5">
{errors.map((e) => <li key={e}>{e}</li>)}
</ul>
</div>
)}
<GroupsTabs current={tab} counts={{ mine: groups.length, shared: shared.length }} />
{tab === 'shared' && (
<div className="mt-4">
<h2 className="text-sm font-medium mb-2">Shared with me</h2>
<p className="text-xs text-gray-500 mb-3">
Groups other tenants have shared with you. You can use them as TARGET groups in your routes.
</p>
{shared.length === 0 ? (
<p className="text-sm text-gray-400 italic">No groups shared with you yet.</p>
) : (
<ul className="space-y-2">
{shared.map((g) => (
<li key={g.id} className="border border-gray-200 rounded bg-white px-4 py-3 flex items-center justify-between">
<div>
<span className="text-sm font-medium">{g.name}</span>
{!g.isActive && <span className="ml-2 text-xs text-red-500 font-medium">(Bot removed)</span>}
<span className="text-xs text-gray-400 ml-2">shared by {g.sharedByTenantName}</span>
</div>
</li>
))}
</ul>
)}
</div>
)}
{tab === 'mine' && (
<div className="mt-4">
<RouteManager groups={groups} initialRoutes={routes} />
</div>
)}
</div>
);
}
+9 -13
View File
@@ -1,6 +1,9 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
import { AuthProvider } from './_lib/auth-context';
import { SuperAdminProvider } from './_lib/super-admin-context';
import { Sidebar } from './_lib/sidebar';
export const metadata: Metadata = {
title: 'Insignia TOWER',
@@ -11,19 +14,12 @@ 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">
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col gap-1">
<span className="font-bold text-base mb-4">TOWER</span>
<Link href="/search" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Search
</Link>
<Link href="/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Groups &amp; Routes
</Link>
<Link href="/accounts" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Accounts
</Link>
</nav>
<main className="flex-1 overflow-auto p-6">{children}</main>
<AuthProvider>
<SuperAdminProvider>
<Sidebar />
<main className="flex-1 overflow-auto p-6">{children}</main>
</SuperAdminProvider>
</AuthProvider>
</body>
</html>
);
+88
View File
@@ -0,0 +1,88 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import LoginPage from './page';
import { AuthProvider } from '../_lib/auth-context';
import { SuperAdminProvider } from '../_lib/super-admin-context';
const pushMock = jest.fn();
const refreshMock = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: pushMock, refresh: refreshMock, replace: jest.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
function authResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
const UNAUTHORIZED = () => Promise.resolve(new Response('Unauthorized', { status: 401 }));
function mockFetch(responses: Record<string, (url: string, init?: RequestInit) => Response | Promise<Response>>) {
fetchSpy.mockImplementation((url: string, init?: RequestInit) => {
const handler = responses[url];
if (handler) return handler(url, init);
return responses['default']?.(url, init) ?? UNAUTHORIZED();
});
}
beforeEach(() => {
jest.clearAllMocks();
fetchSpy = jest.spyOn(global, 'fetch');
mockFetch({ default: UNAUTHORIZED });
});
afterEach(() => {
jest.restoreAllMocks();
});
function renderWithProvider(ui: React.ReactElement) {
return render(
<AuthProvider>
<SuperAdminProvider>{ui}</SuperAdminProvider>
</AuthProvider>,
);
}
describe('LoginPage', () => {
it('renders email and password fields and a submit button', async () => {
renderWithProvider(<LoginPage />);
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument();
expect(await screen.findByLabelText(/password/i)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('posts credentials to /api/auth/login and redirects on success', async () => {
mockFetch({
'/api/auth/login': () => authResponse({ admin: { id: 'a-1', email: 'me@x.com' } }),
'/api/auth/me': () => authResponse({ admin: { id: 'a-1', email: 'me@x.com', role: 'OWNER', tenantId: 't1', tenantSlug: 'default', tenantName: 'Default' } }),
});
renderWithProvider(<LoginPage />);
await screen.findByLabelText(/email/i);
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'me@x.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/auth/login',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'me@x.com', password: 'secret' }),
}),
),
);
expect(pushMock).toHaveBeenCalledWith('/');
});
it('shows an error message on 401', async () => {
mockFetch({
'/api/auth/login': () => authResponse({ message: 'Invalid email or password' }, 401),
});
renderWithProvider(<LoginPage />);
await screen.findByLabelText(/email/i);
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'me@x.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } });
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/invalid email or password/i);
expect(pushMock).not.toHaveBeenCalled();
});
});
+113
View File
@@ -0,0 +1,113 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { useAuth } from '../_lib/auth-context';
import { useSuperAdmin } from '../_lib/super-admin-context';
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);
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);
}
}
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>
);
}
export default function LoginPage() {
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" />}>
<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>
);
}
+158
View File
@@ -0,0 +1,158 @@
import Link from 'next/link';
import { apiFetch } from '../../_lib/api';
interface MessageDetail {
id: string;
tenantId: string;
platform: string;
platformMsgId: string;
sourceGroupId: string;
sourceGroup: {
id: string;
name: string;
platformId: string;
claimStatus: string;
isActive: boolean;
createdAt: string;
} | null;
senderJid: string;
senderName: string | null;
senderTowerUser: {
id: string;
jid: string;
phoneHash: string;
displayName: string | null;
createdAt: string;
} | null;
content: string;
mediaUrl: string | null;
tags: string[];
status: string;
createdAt: string;
updatedAt: string;
approval: {
id: string;
adminId: string;
decision: string;
notes: string | null;
decidedAt: string;
} | null;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[160px_1fr] gap-2 text-sm py-2 border-b border-gray-100">
<span className="text-gray-500 font-medium">{label}</span>
<span className="text-gray-900 break-words">{children ?? <span className="text-gray-300"></span>}</span>
</div>
);
}
export default async function MessageDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let msg: MessageDetail | null = null;
let error: string | null = null;
try {
const res = await apiFetch(`/admin/messages/${id}`);
if (res.ok) {
msg = await res.json();
} else {
error = `API returned ${res.status}`;
}
} catch {
error = 'Failed to load message';
}
if (error || !msg) {
return (
<div className="max-w-2xl">
<Link href="/search" className="text-sm text-blue-600 hover:underline mb-4 inline-block">&larr; Back to search</Link>
<p className="text-red-600 text-sm">{error ?? 'Message not found'}</p>
</div>
);
}
return (
<div className="max-w-2xl">
<Link href="/search" className="text-sm text-blue-600 hover:underline mb-4 inline-block">&larr; Back to search</Link>
<h1 className="text-xl font-semibold mb-6">Message Detail</h1>
<div className="border border-gray-200 rounded-lg p-4 mb-6">
<p className="text-sm whitespace-pre-wrap bg-gray-50 rounded p-3 mb-2">{msg.content}</p>
<div className="flex flex-wrap gap-1">
{msg.tags.map((tag) => (
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">{tag}</span>
))}
</div>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Metadata</h2>
<Field label="Status">
<span className={`font-medium ${msg.status === 'APPROVED' ? 'text-green-600' : msg.status === 'REJECTED' ? 'text-red-600' : 'text-amber-600'}`}>
{msg.status}
</span>
</Field>
<Field label="Message ID">{msg.id}</Field>
<Field label="Platform">{msg.platform}</Field>
<Field label="Platform Msg ID">{msg.platformMsgId}</Field>
<Field label="Created">{new Date(msg.createdAt).toLocaleString()}</Field>
<Field label="Updated">{new Date(msg.updatedAt).toLocaleString()}</Field>
<div className="mt-4 pt-2 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">Sender</h3>
<Field label="JID">{msg.senderJid}</Field>
<Field label="Name">{msg.senderName}</Field>
{msg.senderTowerUser && (
<>
<Field label="Display Name">{msg.senderTowerUser.displayName}</Field>
<Field label="Phone Hash">{msg.senderTowerUser.phoneHash}</Field>
<Field label="Tower User ID">{msg.senderTowerUser.id}</Field>
</>
)}
</div>
<div className="mt-4 pt-2 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">Source Group</h3>
{msg.sourceGroup ? (
<>
<Field label="Name">{msg.sourceGroup.name}</Field>
<Field label="Platform ID">{msg.sourceGroup.platformId}</Field>
<Field label="Claim Status">{msg.sourceGroup.claimStatus}</Field>
<Field label="Active">{msg.sourceGroup.isActive ? 'Yes' : 'No'}</Field>
</>
) : (
<Field label="Group">(deleted)</Field>
)}
</div>
{msg.approval && (
<div className="mt-4 pt-2 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">Approval</h3>
<Field label="Decision">
<span className={`font-medium ${msg.approval.decision === 'APPROVED' ? 'text-green-600' : 'text-red-600'}`}>
{msg.approval.decision}
</span>
</Field>
<Field label="Admin ID">{msg.approval.adminId}</Field>
<Field label="Decided At">{new Date(msg.approval.decidedAt).toLocaleString()}</Field>
<Field label="Notes">{msg.approval.notes}</Field>
</div>
)}
{msg.mediaUrl && (
<div className="mt-4 pt-2 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-500 uppercase mb-2">Media</h3>
<Field label="Media URL">{msg.mediaUrl}</Field>
</div>
)}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { apiFetch } from '../../_lib/api';
interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
async function fetchPending(): Promise<FetchResult<PendingMessage[]>> {
try {
const res = await apiFetch('/admin/messages/pending');
if (!res.ok) {
const body = await res.text().catch(() => '');
return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText };
}
return { ok: true, data: (await res.json()) as PendingMessage[] };
} catch (err) {
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
}
}
export default async function PendingMessagesPage() {
const result = await fetchPending();
const messages = result.ok ? result.data : [];
const error = !result.ok ? (result.status === 0 ? 'API unreachable' : `API ${result.status}: ${result.error}`) : null;
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Flagged messages waiting for an admin to approve. Approving forwards them to every active
route from the source group and indexes them in search.
</p>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Failed to load: {error}
</div>
)}
{!error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now. New flagged messages will appear here as they arrive.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow key={m.id} message={m} />
))}
</ul>
</div>
);
}
function PendingMessageRow({ message }: { message: PendingMessage }) {
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">
{new Date(message.createdAt).toLocaleString()}
</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span
key={t}
className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700"
>
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">
{message.content}
</div>
<form
action={`/api/messages/${message.id}/approve`}
method="post"
className="flex justify-end pt-1"
>
<button
type="submit"
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; forward
</button>
</form>
</li>
);
}
@@ -0,0 +1,44 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
export function GroupOptOutButton({ groupId, groupName }: { groupId: string; groupName: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleClick() {
if (!window.confirm(`Revoke consent for "${groupName}"? Your messages will no longer be archived from this group.`)) {
return;
}
setError(null);
startTransition(async () => {
const res = await fetch('/api/my/opt-out', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ groupId }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
setError(body.message ?? 'Opt-out failed');
return;
}
router.refresh();
});
}
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={handleClick}
disabled={pending}
className="rounded border border-red-200 px-3 py-1 text-xs text-red-700 hover:bg-red-50 disabled:opacity-50"
>
{pending ? 'Revoking…' : 'Revoke consent'}
</button>
{error && <span className="text-xs text-red-600">{error}</span>}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { getApiBaseUrl, getMemberToken } from '../../../_lib/api';
import Link from 'next/link';
interface MemberGroupSummary {
id: string;
name: string;
tenantId: string;
scopes: string[];
retentionDays: number;
policyVersion: string;
consentStatus: 'GRANTED' | 'REVOKED' | 'PENDING';
joinedAt: string;
}
async function fetchGroup(token: string, id: string): Promise<MemberGroupSummary | null> {
const res = await fetch(`${getApiBaseUrl()}/my/groups/${id}`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as MemberGroupSummary;
}
export default async function MyGroupDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const token = await getMemberToken();
if (!token) {
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-yellow-200 bg-yellow-50">
<h1 className="text-lg font-semibold text-yellow-800">Sign in required</h1>
<p className="text-sm text-yellow-700">Complete onboarding to view group details.</p>
</div>
);
}
const group = await fetchGroup(token, id);
if (!group) {
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
<h1 className="text-lg font-semibold text-red-800">Group not found</h1>
<p className="text-sm text-red-700">
You aren&apos;t a member of this group, or it has been removed.
</p>
<Link href="/my/groups" className="text-sm text-blue-600 underline mt-3 inline-block">
Back to your groups
</Link>
</div>
);
}
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white">
<Link href="/my/groups" className="text-xs text-gray-500 underline">
All groups
</Link>
<h1 className="text-xl font-semibold mt-2 mb-4">{group.name}</h1>
<dl className="text-sm space-y-2">
<div>
<dt className="inline font-medium">Status: </dt>
<dd className="inline">{group.consentStatus}</dd>
</div>
<div>
<dt className="inline font-medium">Scopes: </dt>
<dd className="inline">{group.scopes.join(', ')}</dd>
</div>
<div>
<dt className="inline font-medium">Retention: </dt>
<dd className="inline">{group.retentionDays} days</dd>
</div>
<div>
<dt className="inline font-medium">Policy version: </dt>
<dd className="inline">{group.policyVersion}</dd>
</div>
<div>
<dt className="inline font-medium">Joined: </dt>
<dd className="inline">{new Date(group.joinedAt).toLocaleString()}</dd>
</div>
</dl>
<p className="mt-4 text-xs text-gray-500">
Use the &quot;Revoke consent&quot; button on{' '}
<Link href="/my/groups" className="underline">
your groups list
</Link>{' '}
to opt out of this group.
</p>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { getApiBaseUrl, getMemberToken } from '../../_lib/api';
import Link from 'next/link';
import { GroupOptOutButton } from './GroupOptOutButton';
interface MemberGroupSummary {
id: string;
name: string;
tenantId: string;
scopes: string[];
retentionDays: number;
policyVersion: string;
consentStatus: 'GRANTED' | 'REVOKED' | 'PENDING';
joinedAt: string;
}
async function fetchGroups(token: string): Promise<MemberGroupSummary[] | null> {
const res = await fetch(`${getApiBaseUrl()}/my/groups`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as MemberGroupSummary[];
}
export default async function MyGroupsPage() {
const token = await getMemberToken();
if (!token) {
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-yellow-200 bg-yellow-50">
<h1 className="text-lg font-semibold text-yellow-800">Sign in required</h1>
<p className="text-sm text-yellow-700">Complete onboarding to view your groups.</p>
</div>
);
}
const groups = await fetchGroups(token);
if (!groups) {
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
<h1 className="text-lg font-semibold text-red-800">Couldn&apos;t load your groups</h1>
<p className="text-sm text-red-700">Your session may have expired. Please try signing out and back in.</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto mt-12 p-6">
<h1 className="text-xl font-semibold mb-4">Your groups</h1>
{groups.length === 0 ? (
<p className="text-sm text-gray-500">
You haven&apos;t joined any TOWER-managed groups yet.
</p>
) : (
<ul className="flex flex-col gap-3">
{groups.map((g) => (
<li key={g.id} className="rounded-xl border border-gray-200 bg-white p-4">
<div className="flex items-start justify-between gap-4">
<div>
<Link href={`/my/groups/${g.id}`} className="text-sm font-medium text-blue-600 underline">
{g.name}
</Link>
<div className="mt-1 text-xs text-gray-500">
Status:{' '}
<span
className={
g.consentStatus === 'GRANTED'
? 'text-green-700'
: g.consentStatus === 'REVOKED'
? 'text-red-700'
: 'text-yellow-700'
}
>
{g.consentStatus}
</span>
{' · '}Retention {g.retentionDays}d · Policy {g.policyVersion}
</div>
<div className="mt-1 text-xs text-gray-500">
Scopes: {g.scopes.join(', ')}
</div>
</div>
{g.consentStatus === 'GRANTED' && <GroupOptOutButton groupId={g.id} groupName={g.name} />}
</div>
</li>
))}
</ul>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
import Link from 'next/link';
interface MemberProfile {
id: string;
tenantId: string;
jid: string;
displayName: string | null;
createdAt: string;
}
async function fetchProfile(token: string): Promise<MemberProfile | null> {
const res = await fetch(`${getApiBaseUrl()}/my/profile`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as MemberProfile;
}
export default async function MyPage() {
const token = await getMemberToken();
if (!token) {
return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-yellow-200 bg-yellow-50">
<h1 className="text-lg font-semibold text-yellow-800">Member portal</h1>
<p className="text-sm text-yellow-700">
No member session. Complete onboarding via the link a group admin sent you.
</p>
</div>
);
}
const profile = await fetchProfile(token);
if (!profile) {
return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
<h1 className="text-lg font-semibold text-red-800">Couldn&apos;t load your account</h1>
<p className="text-sm text-red-700">
Your session may have expired. Please complete onboarding again.
</p>
<form method="POST" action="/api/my/logout" className="mt-4">
<button type="submit" className="text-sm text-blue-600 underline">
Sign out
</button>
</form>
</div>
);
}
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white">
<h1 className="text-xl font-semibold mb-4">Your account</h1>
<dl className="text-sm space-y-1 mb-6">
<div>
<dt className="inline font-medium">Display name: </dt>
<dd className="inline">{profile.displayName ?? '—'}</dd>
</div>
<div>
<dt className="inline font-medium">JID: </dt>
<dd className="inline">{profile.jid}</dd>
</div>
<div>
<dt className="inline font-medium">Joined: </dt>
<dd className="inline">{new Date(profile.createdAt).toLocaleDateString()}</dd>
</div>
</dl>
<div className="flex flex-col gap-2 text-sm">
<Link href="/my/groups" className="text-blue-600 underline">Manage your groups </Link>
<Link href="/my/settings" className="text-blue-600 underline">Privacy &amp; account settings </Link>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
export function DeleteAccountButton() {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleClick() {
if (
!window.confirm(
'Permanently delete your TOWER account? This cannot be undone.',
)
) {
return;
}
setError(null);
startTransition(async () => {
const res = await fetch('/api/my/account', { method: 'DELETE' });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
setError(body.message ?? 'Delete failed');
return;
}
router.replace('/');
});
}
return (
<div className="flex flex-col items-start gap-1">
<button
type="button"
onClick={handleClick}
disabled={pending}
className="rounded border border-red-300 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 disabled:opacity-50"
>
{pending ? 'Deleting…' : 'Delete my account'}
</button>
{error && <span className="text-xs text-red-700">{error}</span>}
</div>
);
}
@@ -0,0 +1,24 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
export function MemberLogoutButton() {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
await fetch('/api/my/logout', { method: 'POST' });
router.replace('/');
})
}
className="rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
>
{pending ? 'Signing out…' : 'Sign out'}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { getMemberToken } from '../../_lib/api';
import { DeleteAccountButton } from './DeleteAccountButton';
import { MemberLogoutButton } from './MemberLogoutButton';
export default async function MySettingsPage() {
const token = await getMemberToken();
if (!token) {
return (
<div className="max-w-2xl mx-auto mt-12 p-6 rounded-lg border border-yellow-200 bg-yellow-50">
<h1 className="text-lg font-semibold text-yellow-800">Sign in required</h1>
<p className="text-sm text-yellow-700">Complete onboarding to manage your account.</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto mt-12 p-6 flex flex-col gap-6">
<section className="rounded-xl border border-gray-200 bg-white p-5">
<h2 className="text-base font-semibold mb-2">Session</h2>
<p className="text-sm text-gray-600 mb-3">
Sign out of your member portal. Your consent records are kept until you delete your account.
</p>
<MemberLogoutButton />
</section>
<section className="rounded-xl border border-red-200 bg-red-50 p-5">
<h2 className="text-base font-semibold text-red-800 mb-2">Delete your account</h2>
<p className="text-sm text-red-700 mb-3">
This permanently deletes your TOWER user record, all consent records, opt-out history, and
sessions. The messages themselves stay in their original groups. This cannot be undone.
</p>
<DeleteAccountButton />
</section>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useState } from 'react';
interface Props {
token: string;
defaultScopes: string[];
defaultRetentionDays: number;
}
type Step = 'phone' | 'code' | 'done';
export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: Props) {
const [step, setStep] = useState<Step>('phone');
const [phone, setPhone] = useState('');
const [challengeId, setChallengeId] = useState<string | null>(null);
const [code, setCode] = useState('');
const [retentionDays, setRetentionDays] = useState(defaultRetentionDays);
const [scopes, setScopes] = useState<string[]>(defaultScopes);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function requestOtp() {
setError(null);
setBusy(true);
try {
const res = await fetch(`${process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'}/public/auth/request-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ onboardingToken: token, phone }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
setError(body.message ?? 'Failed to send code');
return;
}
const data = (await res.json()) as { challengeId: string };
setChallengeId(data.challengeId);
setStep('code');
} finally {
setBusy(false);
}
}
async function verifyOtp() {
setError(null);
setBusy(true);
try {
const res = await fetch('/api/onboard/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
onboardingToken: token,
challengeId,
phone,
code,
scopes,
retentionDays,
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
setError(body.message ?? 'Verification failed');
return;
}
setStep('done');
} finally {
setBusy(false);
}
}
if (step === 'done') {
return (
<div>
<p className="text-sm text-green-700 mb-4">You&apos;re verified. Your session is set.</p>
<a href="/my" className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm">
Go to your portal
</a>
</div>
);
}
if (step === 'phone') {
return (
<div className="space-y-3">
<label className="block text-sm">
<span className="font-medium">Your WhatsApp phone number</span>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 234 567 8900"
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={requestOtp}
disabled={busy || phone.length < 6}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
Send verification code
</button>
<p className="text-xs text-gray-500">
We&apos;ll DM a 6-digit code to your WhatsApp.
</p>
</div>
);
}
return (
<div className="space-y-3">
<label className="block text-sm">
<span className="font-medium">Verification code</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
className="mt-1 w-full border rounded px-3 py-2"
/>
</label>
<fieldset className="text-sm">
<legend className="font-medium mb-1">Scopes</legend>
{['INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY'].map((s) => (
<label key={s} className="block">
<input
type="checkbox"
checked={scopes.includes(s)}
onChange={(e) =>
setScopes((prev) => (e.target.checked ? [...prev, s] : prev.filter((x) => x !== s)))
}
/>{' '}
{s}
</label>
))}
</fieldset>
<label className="block text-sm">
<span className="font-medium">Retention (days)</span>
<input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value))}
className="mt-1 w-32 border rounded px-3 py-2"
/>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="button"
onClick={verifyOtp}
disabled={busy || code.length < 6}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
Verify and join
</button>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { OnboardingForm } from './OnboardingForm';
interface PublicOnboardInfo {
groupName: string;
tenantName: string;
policyVersion: string;
defaultScopes: string[];
defaultRetentionDays: number;
}
export default async function OnboardPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
if (!token) {
return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
<h1 className="text-lg font-semibold text-red-800">Invalid link</h1>
<p className="text-sm text-red-700">This onboarding link is missing the token parameter.</p>
</div>
);
}
let info: PublicOnboardInfo | null = null;
let error: string | null = null;
try {
const res = await fetch(
`${process.env['API_URL'] ?? 'http://localhost:3001'}/public/onboard/${encodeURIComponent(token)}`,
{ headers: { Accept: 'application/json' }, cache: 'no-store' },
);
if (res.ok) {
info = (await res.json()) as PublicOnboardInfo;
} else {
const body = (await res.json().catch(() => ({}))) as { message?: string };
error = body.message ?? `Onboarding link rejected (${res.status})`;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Network error';
}
if (error || !info) {
return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-red-200 bg-red-50">
<h1 className="text-lg font-semibold text-red-800">Cannot start onboarding</h1>
<p className="text-sm text-red-700">{error ?? 'Unknown error'}</p>
</div>
);
}
return (
<div className="max-w-md mx-auto mt-12 p-6 rounded-lg border border-gray-200 bg-white">
<h1 className="text-xl font-semibold mb-2">Join {info.groupName}</h1>
<p className="text-sm text-gray-600 mb-1">Managed by {info.tenantName}</p>
<p className="text-xs text-gray-500 mb-4">
Policy version: {info.policyVersion} · Default retention: {info.defaultRetentionDays} days
</p>
<OnboardingForm
token={token}
defaultScopes={info.defaultScopes}
defaultRetentionDays={info.defaultRetentionDays}
/>
</div>
);
}
+23 -19
View File
@@ -1,3 +1,6 @@
import Link from 'next/link';
import { apiFetch } from '../_lib/api';
interface MeiliHit {
id: string;
content: string;
@@ -52,20 +55,25 @@ export function SearchResults({
</p>
<ul className="flex flex-col gap-3">
{hits.map((hit) => (
<li key={hit.id} className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-sm">{hit.content}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{hit.senderName}</span>
<span>·</span>
<span>{hit.sourceGroupName}</span>
<span>·</span>
<span>{new Date(hit.approvedAt).toLocaleDateString()}</span>
{hit.tags.map((tag) => (
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-blue-600">
{tag}
</span>
))}
</div>
<li key={hit.id}>
<Link
href={`/messages/${hit.id}`}
className="block rounded-xl border border-gray-200 bg-white p-4 hover:border-blue-300 hover:shadow-sm transition-colors"
>
<p className="text-sm">{hit.content}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{hit.senderName}</span>
<span>·</span>
<span>{hit.sourceGroupName}</span>
<span>·</span>
<span>{new Date(hit.approvedAt).toLocaleDateString()}</span>
{hit.tags.map((tag) => (
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-blue-600">
{tag}
</span>
))}
</div>
</Link>
</li>
))}
</ul>
@@ -81,14 +89,10 @@ export default async function SearchPage({
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const { q = '', page = '1' } = await searchParams;
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
const url = new URL(`${apiUrl}/search`);
url.searchParams.set('q', q);
url.searchParams.set('page', page);
let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q };
try {
const res = await fetch(url, { cache: 'no-store' });
const res = await apiFetch(`/search?q=${encodeURIComponent(q)}&page=${page}`);
if (res.ok) data = await res.json();
} catch {
// API unavailable — render empty results
@@ -0,0 +1,87 @@
'use client';
import { useState } from 'react';
type Status = 'ACTIVE' | 'DISCONNECTED' | 'BANNED' | 'PAIRING';
interface BotSummary {
id: string;
jid: string | null;
displayName: string | null;
status: Status;
}
export function BotSettingsCard({ initial }: { initial: { bot: BotSummary | null; shared: boolean; sharedBotId?: string } }) {
const [bot, setBot] = useState<BotSummary | null>(initial.bot);
const [revealedJid, setRevealedJid] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function onReveal() {
setError(null);
const res = await fetch('/api/bot/reveal', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const data = (await res.json()) as { jid: string };
setRevealedJid(data.jid);
} else {
setError('Reveal failed');
}
}
if (!bot) {
return (
<div className="rounded-lg border border-gray-200 p-6 bg-white">
<h2 className="text-lg font-medium mb-2">Bot</h2>
<p className="text-sm text-gray-600">
No bot assigned yet. Contact your platform administrator to get one assigned.
</p>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 p-6 bg-white">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-medium">Bot</h2>
<span
className={`text-xs px-2 py-1 rounded ${
bot.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: bot.status === 'PAIRING'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{bot.status}
</span>
</div>
<dl className="text-sm space-y-1 mb-4">
<div>
<dt className="inline font-medium">Number: </dt>
<dd className="inline">
{revealedJid ?? (bot.jid ? '••••••••••' : '—')}
{!revealedJid && bot.status === 'ACTIVE' && (
<button
type="button"
onClick={onReveal}
className="ml-2 text-xs text-blue-600 underline"
>
Reveal
</button>
)}
</dd>
</div>
<div>
<dt className="inline font-medium">Display name: </dt>
<dd className="inline">{bot.displayName ?? '—'}</dd>
</div>
</dl>
<p className="text-xs text-gray-400">
Bot is assigned by the platform administrator. Contact support to change or remove it.
</p>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { BotSettingsCard } from './BotSettingsCard';
import { apiFetch } from '../../_lib/api';
interface BotSummary {
id: string;
platform: string;
jid: string | null;
displayName: string | null;
status: 'ACTIVE' | 'DISCONNECTED' | 'BANNED' | 'PAIRING';
isBot: boolean;
createdAt: string;
updatedAt: string;
}
interface BotState {
bot: BotSummary | null;
shared: boolean;
}
export default async function BotSettingsPage() {
let state: BotState = { bot: null, shared: false };
try {
const res = await apiFetch('/admin/bot');
if (res.ok) {
state = (await res.json()) as BotState;
}
} catch {
state = { bot: null, shared: false };
}
return (
<div className="max-w-2xl">
<h1 className="text-xl font-semibold mb-6">Bot Settings</h1>
<p className="text-sm text-gray-600 mb-4">
TOWER runs on a dedicated WhatsApp number. Adding the bot to a group makes it eligible for
claim by any tenant admin. The bot number is hidden from members and the public web.
</p>
<BotSettingsCard initial={state} />
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
'use client';
import { useState } from 'react';
interface RuleData {
id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
priority: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
const MATCH_TYPE_LABELS: Record<string, string> = {
HASHTAG: 'Hashtag',
PREFIX: 'Prefix',
REACTION_EMOJI: 'Reaction Emoji',
};
const ACTION_LABELS: Record<string, string> = {
FLAG: 'Flag (Pending)',
AUTO_APPROVE: 'Auto-approve',
SKIP: 'Skip (Silent Drop)',
REJECT: 'Reject (Visible)',
};
export function RuleManager({ initial }: { initial: RuleData[] }) {
const [rules, setRules] = useState<RuleData[]>(initial);
const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG');
const [matchValue, setMatchValue] = useState('');
const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG');
const [priority, setPriority] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function addRule() {
if (!matchValue.trim()) return;
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 }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
setError(err.message ?? 'Failed to create rule');
return;
}
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);
}
}
async function toggleRule(rule: RuleData) {
const res = await fetch(`/api/rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !rule.isActive }),
});
if (res.ok) {
const updated: RuleData = await res.json();
setRules((prev) => prev.map((r) => (r.id === rule.id ? updated : r)));
}
}
async function deleteRule(id: string) {
const res = await fetch(`/api/rules/${id}`, { method: 'DELETE' });
if (res.ok) setRules((prev) => prev.filter((r) => r.id !== id));
}
return (
<div className="flex flex-col gap-6">
<section className="border border-gray-200 rounded-lg p-4">
<h2 className="text-base font-semibold mb-3">Add Rule</h2>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Type</label>
<select
value={matchType}
onChange={(e) => setMatchType(e.target.value as any)}
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>
</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"
/>
</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)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value="FLAG">Flag (Pending)</option>
<option value="AUTO_APPROVE">Auto-approve</option>
<option value="SKIP">Skip (Silent Drop)</option>
<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
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
/>
</div>
<button
type="button"
onClick={() => void addRule()}
disabled={busy || !matchValue.trim()}
className="bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700 disabled:opacity-40"
>
{busy ? 'Adding...' : 'Add Rule'}
</button>
</div>
{error && <p className="text-red-600 text-sm mt-2">{error}</p>}
</section>
<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>
) : (
<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">Action</th>
<th className="pb-2 pr-3">Priority</th>
<th className="pb-2 pr-3">Active</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<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">{ACTION_LABELS[rule.action] ?? rule.action}</td>
<td className="py-2 pr-3">{rule.priority}</td>
<td className="py-2 pr-3">
<button
type="button"
onClick={() => void toggleRule(rule)}
className={`text-xs rounded px-2 py-0.5 ${rule.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}
>
{rule.isActive ? 'ON' : 'OFF'}
</button>
</td>
<td className="py-2">
<button
type="button"
onClick={() => void deleteRule(rule.id)}
className="text-red-600 hover:text-red-800 text-xs"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { RuleManager } from './RuleManager';
import { apiFetch } from '../../_lib/api';
interface RuleData {
id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
priority: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export default async function RulesSettingsPage() {
let rules: RuleData[] = [];
try {
const res = await apiFetch('/admin/rules');
if (res.ok) {
rules = (await res.json()) as RuleData[];
}
} catch {
rules = [];
}
return (
<div className="max-w-2xl">
<h1 className="text-xl font-semibold mb-6">Rules Engine</h1>
<p className="text-sm text-gray-600 mb-4">
Configure which hashtags, prefixes, and reaction emojis trigger message processing
and what action TOWER should take. Rules are matched in priority order.
</p>
<RuleManager initial={rules} />
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
'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. 240 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&apos;ll be the first OWNER. You can invite others from settings later.
</p>
</form>
);
}
+24
View File
@@ -0,0 +1,24 @@
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>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
File diff suppressed because one or more lines are too long