good forst commit
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user