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;
}