fix architecture and write new tests
This commit is contained in:
@@ -18,14 +18,10 @@ export default function AuthRedirectPage() {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('token')
|
||||
const next = resolveAdminNextPath(params.get('next'))
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
// Clear the token from the URL
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
// Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie.
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
|
||||
window.location.replace(next)
|
||||
}, [])
|
||||
|
||||
@@ -33,13 +33,11 @@ export default function AdminUsersPage() {
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
@@ -97,7 +95,8 @@ export default function AdminUsersPage() {
|
||||
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
||||
method: isEditing ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
@@ -28,9 +28,8 @@ export default function AdminAuditLogsPage() {
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -467,14 +467,13 @@ export default function AdminBillingPage() {
|
||||
)
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (res.headers.get('content-type')?.includes('application/pdf')) {
|
||||
|
||||
@@ -207,10 +207,6 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
|
||||
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('admin_token') ?? ''
|
||||
}
|
||||
|
||||
function toDateInput(value: string | null | undefined) {
|
||||
return value ? new Date(value).toISOString().slice(0, 10) : ''
|
||||
}
|
||||
@@ -324,11 +320,10 @@ export default function AdminCompanyDetailPage() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
|
||||
async function fetchData() {
|
||||
const headers = { Authorization: `Bearer ${getToken()}` }
|
||||
try {
|
||||
const [cRes, aRes] = await Promise.all([
|
||||
fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }),
|
||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }),
|
||||
fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { cache: 'no-store', credentials: 'include' }),
|
||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { cache: 'no-store', credentials: 'include' }),
|
||||
])
|
||||
const cJson = await cRes.json()
|
||||
const aJson = await aRes.json()
|
||||
@@ -339,7 +334,7 @@ export default function AdminCompanyDetailPage() {
|
||||
|
||||
const subId = cJson.data?.subscription?.id
|
||||
if (subId) {
|
||||
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' })
|
||||
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { cache: 'no-store', credentials: 'include' })
|
||||
const eJson = await eRes.json()
|
||||
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
|
||||
}
|
||||
@@ -365,7 +360,8 @@ export default function AdminCompanyDetailPage() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
company: {
|
||||
name: form.company.name,
|
||||
@@ -469,7 +465,8 @@ export default function AdminCompanyDetailPage() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ reason: subOverrideReason }),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -490,7 +487,8 @@ export default function AdminCompanyDetailPage() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -508,7 +506,7 @@ export default function AdminCompanyDetailPage() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Delete failed')
|
||||
|
||||
@@ -84,10 +84,9 @@ export default function AdminCompaniesPage() {
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
async function fetchCompanies() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -111,11 +110,11 @@ export default function AdminCompaniesPage() {
|
||||
|
||||
async function changeStatus(id: string, status: string) {
|
||||
setActioning(id)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
|
||||
@@ -25,8 +25,7 @@ interface CompanyContainer {
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||
return { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
@@ -57,7 +56,7 @@ function LogsModal({ companyId, companyName, onClose }: { companyId: string; com
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json()
|
||||
setLogs(json.data?.logs ?? '')
|
||||
} catch {
|
||||
@@ -126,7 +125,7 @@ export default function ContainersPage() {
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders() })
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||
@@ -151,7 +150,7 @@ export default function ContainersPage() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||
setProvisionResults(json.data?.results ?? [])
|
||||
@@ -172,7 +171,7 @@ export default function ContainersPage() {
|
||||
action === 'remove'
|
||||
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
||||
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders() })
|
||||
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
||||
|
||||
@@ -38,16 +38,30 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
} else {
|
||||
setReady(true)
|
||||
let cancelled = false
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1'}/admin/auth/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => {
|
||||
if (cancelled) return
|
||||
if (response.ok) {
|
||||
setReady(true)
|
||||
} else {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
void fetch('/admin/api/v1/admin/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||
}
|
||||
|
||||
|
||||
@@ -86,20 +86,15 @@ const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
|
||||
|
||||
const INPUT =
|
||||
'mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-950 outline-none focus:border-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
|
||||
const LABEL = 'text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
|
||||
|
||||
function getToken() {
|
||||
return typeof window === 'undefined' ? '' : localStorage.getItem('admin_token') ?? ''
|
||||
}
|
||||
'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-2 focus:ring-orange-500'
|
||||
const LABEL = 'text-xs font-medium uppercase tracking-wider text-zinc-500'
|
||||
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
@@ -148,13 +143,13 @@ function formFromItem(item: MenuItem): FormState {
|
||||
function chip(text: string, tone = 'default') {
|
||||
const toneClass =
|
||||
tone === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300'
|
||||
? 'bg-emerald-950/40 text-emerald-400'
|
||||
: tone === 'warn'
|
||||
? 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-300'
|
||||
: 'border-stone-200 bg-stone-50 text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
|
||||
? 'bg-orange-950/40 text-orange-400'
|
||||
: 'bg-zinc-800 text-zinc-300'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
@@ -347,8 +342,8 @@ export default function MenuManagementPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-8 py-10">
|
||||
<div className="rounded-3xl border border-stone-200/80 bg-white/80 p-8 text-sm text-stone-500 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/70 dark:text-slate-400">
|
||||
<div className="shell py-8">
|
||||
<div className="panel p-8 text-sm text-zinc-500">
|
||||
Loading menu management…
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,20 +351,20 @@ export default function MenuManagementPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 px-8 py-10 text-stone-900 dark:text-white">
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-8 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
||||
<div className="shell py-8 space-y-6 text-zinc-100">
|
||||
<section className="panel p-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-300">Platform Navigation</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-blue-950 dark:text-white">Menu Management</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-stone-500 dark:text-slate-400">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Menu Management</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm text-zinc-400">
|
||||
Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
className="rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-500"
|
||||
className="rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-400"
|
||||
>
|
||||
Create menu item
|
||||
</button>
|
||||
@@ -378,62 +373,62 @@ export default function MenuManagementPage() {
|
||||
|
||||
{(error || success) && (
|
||||
<section className="space-y-3">
|
||||
{error && <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-300">{error}</div>}
|
||||
{success && <div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-900/20 dark:text-emerald-300">{success}</div>}
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
{success && <div className="panel p-4 text-sm text-emerald-400">{success}</div>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Menu items</h2>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{items.length} configured items</p>
|
||||
<h2 className="px-5 pt-5 text-xl font-semibold text-zinc-100">Menu items</h2>
|
||||
<p className="px-5 pb-1 pt-1 text-sm text-zinc-400">{items.length} configured items</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 dark:border-blue-900">
|
||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Item</th>
|
||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Type</th>
|
||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Assignments</th>
|
||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Status</th>
|
||||
<th className="px-3 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Actions</th>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Item</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Assignments</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-stone-100 align-top last:border-b-0 dark:border-blue-900/60">
|
||||
<td className="px-3 py-4">
|
||||
<tr key={item.id} className="align-top transition-colors hover:bg-zinc-800/30">
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-blue-950 dark:text-white">{item.label}</span>
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.isRequired && chip('Required', 'warn')}
|
||||
{item.systemKey && chip(item.systemKey)}
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 dark:text-slate-400">
|
||||
<p className="text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
{item.parent ? ` • child of ${item.parent.label}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-xs text-stone-600 dark:text-slate-300">{item.itemType}</td>
|
||||
<td className="px-3 py-4">
|
||||
<td className="px-5 py-4 text-xs text-zinc-300">{item.itemType}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.subscriptionAssignments.length > 0
|
||||
? item.subscriptionAssignments.map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
|
||||
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.plan}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-xs text-stone-400 dark:text-slate-500">No plan assignments</span>}
|
||||
: <span className="text-xs text-zinc-500">No plan assignments</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.companyAssignments.slice(0, 3).map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
|
||||
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.company.name}
|
||||
</span>
|
||||
))}
|
||||
@@ -441,25 +436,25 @@ export default function MenuManagementPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.roleVisibilities.map((entry) => (
|
||||
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-[11px] font-medium text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300">
|
||||
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{entry.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<td className="px-5 py-4">
|
||||
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => startEdit(item)} className="rounded-full border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:border-orange-400 hover:text-orange-600 dark:border-blue-800 dark:text-slate-300">
|
||||
<button type="button" onClick={() => startEdit(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleStatus(item)} className="rounded-full border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:border-orange-400 hover:text-orange-600 dark:border-blue-800 dark:text-slate-300">
|
||||
<button type="button" onClick={() => toggleStatus(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
{item.isActive ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button type="button" onClick={() => removeItem(item)} className="rounded-full border border-red-200 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-900/60 dark:text-red-300 dark:hover:bg-red-900/20">
|
||||
<button type="button" onClick={() => removeItem(item)} className="rounded-lg border border-red-900/60 bg-red-950/20 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-900/30">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -471,16 +466,16 @@ export default function MenuManagementPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
||||
<div className="flex items-center justify-between">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
|
||||
</p>
|
||||
</div>
|
||||
{editingId && (
|
||||
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-600 hover:text-orange-500">
|
||||
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-400 hover:text-orange-300">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
@@ -537,18 +532,18 @@ export default function MenuManagementPage() {
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(e) => updateForm('isActive', e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.openInNewTab} onChange={(e) => updateForm('openInNewTab', e.target.checked)} />
|
||||
New tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 rounded-xl border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isRequired} onChange={(e) => updateForm('isRequired', e.target.checked)} />
|
||||
Required system item
|
||||
</label>
|
||||
@@ -557,7 +552,7 @@ export default function MenuManagementPage() {
|
||||
<p className={LABEL}>Role visibility</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{ROLES.map((role) => (
|
||||
<label key={role} className="flex items-center gap-2 rounded-full border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
||||
<label key={role} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.roles.includes(role)} onChange={() => toggleArrayValue('roles', role)} />
|
||||
{role}
|
||||
</label>
|
||||
@@ -569,7 +564,7 @@ export default function MenuManagementPage() {
|
||||
<p className={LABEL}>Subscription plans</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PLANS.map((plan) => (
|
||||
<label key={plan} className="flex items-center gap-2 rounded-full border border-stone-200 px-3 py-2 text-sm dark:border-blue-800">
|
||||
<label key={plan} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.plans.includes(plan)} onChange={() => toggleArrayValue('plans', plan)} />
|
||||
{plan}
|
||||
</label>
|
||||
@@ -579,14 +574,14 @@ export default function MenuManagementPage() {
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Company-specific assignments</p>
|
||||
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-stone-200 p-3 dark:border-blue-800">
|
||||
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-700 bg-zinc-900/30 p-3">
|
||||
{companies.map((company) => (
|
||||
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm hover:bg-stone-50 dark:hover:bg-[#07101e]">
|
||||
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm text-zinc-200 transition-colors hover:bg-zinc-800/40">
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
|
||||
<span>{company.name}</span>
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 dark:text-slate-500">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{company.subscription?.plan ?? 'No plan'} • {company.status}
|
||||
</span>
|
||||
</label>
|
||||
@@ -597,7 +592,7 @@ export default function MenuManagementPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
|
||||
className="w-full rounded-lg bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-orange-400 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Saving…' : editingId ? 'Update menu item' : 'Create menu item'}
|
||||
</button>
|
||||
@@ -606,11 +601,11 @@ export default function MenuManagementPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Preview company menu</h2>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Preview company menu</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Test the generated menu for a company and role before troubleshooting or saving follow-up changes.
|
||||
</p>
|
||||
</div>
|
||||
@@ -632,7 +627,7 @@ export default function MenuManagementPage() {
|
||||
type="button"
|
||||
onClick={runPreview}
|
||||
disabled={previewing || !previewCompanyId}
|
||||
className="rounded-full bg-blue-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-blue-900 disabled:opacity-60 dark:bg-orange-600 dark:hover:bg-orange-500"
|
||||
className="rounded-lg bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-orange-400 disabled:opacity-60"
|
||||
>
|
||||
{previewing ? 'Previewing…' : 'Run preview'}
|
||||
</button>
|
||||
@@ -640,25 +635,25 @@ export default function MenuManagementPage() {
|
||||
|
||||
{preview && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
|
||||
<p className="text-sm font-semibold text-blue-950 dark:text-white">{preview.company.name}</p>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
|
||||
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<p className="text-sm font-semibold text-zinc-100">{preview.company.name}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{preview.items.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-blue-950 dark:text-white">{item.label}</span>
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
|
||||
{chip(item.source === 'none' ? 'unassigned' : item.source)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-stone-600 dark:text-slate-300">
|
||||
<ul className="mt-3 space-y-1 text-sm text-zinc-300">
|
||||
{item.reasons.map((reason, index) => (
|
||||
<li key={`${item.id}-${index}`}>{reason}</li>
|
||||
))}
|
||||
@@ -670,22 +665,22 @@ export default function MenuManagementPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]/78">
|
||||
<h2 className="text-xl font-semibold text-blue-950 dark:text-white">Recent menu audit activity</h2>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
|
||||
<section className="panel p-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Recent menu audit activity</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Recent platform-side changes to menu items and assignments.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{auditLogs.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-stone-200 p-4 dark:border-blue-800">
|
||||
<div key={entry.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-blue-950 dark:text-white">{entry.action}</p>
|
||||
<span className="text-xs text-stone-400 dark:text-slate-500">
|
||||
<p className="text-sm font-semibold text-zinc-100">{entry.action}</p>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{entry.adminUser
|
||||
? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}`
|
||||
: 'Unknown admin'}
|
||||
|
||||
@@ -56,14 +56,13 @@ export default function AdminNotificationsPage() {
|
||||
function load(p: number) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterCompany) params.set('companyId', filterCompany)
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -48,9 +48,8 @@ export default function AdminDashboardPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -124,8 +124,7 @@ function emptyPlanFeatureForm(plan = PLANS[0]): PlanFeatureForm {
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function sortFeatures(list: PlanFeature[]) {
|
||||
@@ -315,7 +314,7 @@ function PlanFeaturesSection() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders() })
|
||||
fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders(), credentials: 'include' })
|
||||
.then(async (r) => {
|
||||
const json = await r.json()
|
||||
if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features')
|
||||
@@ -377,6 +376,7 @@ function PlanFeaturesSection() {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(result.payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -398,6 +398,7 @@ function PlanFeaturesSection() {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/features/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
setFeatures((prev) => prev.filter((feature) => feature.id !== id))
|
||||
@@ -712,7 +713,7 @@ function PromotionsSection() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders() })
|
||||
fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders(), credentials: 'include' })
|
||||
.then(async (r) => {
|
||||
const json = await r.json()
|
||||
if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions')
|
||||
@@ -795,6 +796,7 @@ function PromotionsSection() {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(result.payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -816,6 +818,7 @@ function PromotionsSection() {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ isActive: !promo.isActive }),
|
||||
})
|
||||
const json = await res.json()
|
||||
@@ -829,6 +832,7 @@ function PromotionsSection() {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
setPromotions((prev) => prev.filter((p) => p.id !== id))
|
||||
@@ -1005,6 +1009,7 @@ export default function PricingPage() {
|
||||
useEffect(() => {
|
||||
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -1037,6 +1042,7 @@ export default function PricingPage() {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ entries }),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
@@ -70,13 +70,11 @@ export default function AdminRentersPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchRenters() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
@@ -105,7 +103,7 @@ export default function AdminRentersPage() {
|
||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchRenters()
|
||||
|
||||
@@ -183,15 +183,14 @@ export default function AdminSiteConfigPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const [companiesRes, homepageRes] = await Promise.all([
|
||||
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
@@ -276,15 +275,13 @@ export default function AdminSiteConfigPage() {
|
||||
async function saveHomepage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ homepage }),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => ({
|
||||
redirect: vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })),
|
||||
}))
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
redirect: nextServer.redirect,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('admin favicon route', () => {
|
||||
it('redirects favicon requests to the generated icon route on the same origin', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves forwarded path base through the request URL origin only', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/admin/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ export default function AdminForgotPasswordPage() {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
|
||||
@@ -2,8 +2,8 @@ import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const requestHeaders = headers()
|
||||
export default async function AdminLoginPage() {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
requestHeaders.get('host'),
|
||||
|
||||
@@ -35,6 +35,7 @@ function AdminResetPasswordContent() {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${path}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import AdminRootPage from './page'
|
||||
|
||||
describe('admin public redirects', () => {
|
||||
it('sends the admin root to the login page', () => {
|
||||
expect(() => AdminRootPage()).toThrow('NEXT_REDIRECT:/login')
|
||||
expect(navigation.redirect).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/components/PublicHeader', () => ({ default: function MockAdminHeader() { return React.createElement('admin-header') } }))
|
||||
vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter() { return React.createElement('admin-footer') } }))
|
||||
|
||||
import PublicShell from './PublicShell'
|
||||
|
||||
function childTypes(node: React.ReactElement): string[] {
|
||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||
const type = child.type as any
|
||||
if (typeof type === 'string') return type
|
||||
return type.displayName ?? type.name ?? 'anonymous'
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin PublicShell', () => {
|
||||
it('always renders the admin public chrome around children', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section', { id: 'login' }) })
|
||||
|
||||
expect(childTypes(shell)).toEqual(['MockAdminHeader', 'div', 'MockAdminFooter'])
|
||||
})
|
||||
|
||||
it('uses a flex column root so footer placement stays stable', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section') })
|
||||
|
||||
expect(shell.props.className).toContain('flex')
|
||||
expect(shell.props.className).toContain('min-h-screen')
|
||||
expect(shell.props.className).toContain('flex-col')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('adminFetch', () => {
|
||||
it('requests through the server API base and returns the data envelope', async () => {
|
||||
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { companies: [] } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/companies', 'admin-token')).resolves.toEqual({ companies: [] })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://internal-api/api/v1/admin/companies', {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits authorization when no token is supplied', async () => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await adminFetch('/public')
|
||||
|
||||
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual({ cache: 'no-store', credentials: 'include' })
|
||||
})
|
||||
|
||||
it('throws the API message from failed responses', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, json: async () => ({ message: 'Admin only' }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/menu')).rejects.toThrow('Admin only')
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,10 @@ export const ADMIN_API_BASE =
|
||||
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1')
|
||||
|
||||
export async function adminFetch<T>(path: string, token?: string): Promise<T> {
|
||||
export async function adminFetch<T>(path: string, _token?: string): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl, resolveServerAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('admin app URL resolution', () => {
|
||||
it('keeps server fallback unchanged without a browser window', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin')).toBe('http://localhost:3002/admin')
|
||||
})
|
||||
|
||||
it('removes trailing slash for localhost browser fallbacks', () => {
|
||||
installWindow('127.0.0.1')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin/')).toBe('http://localhost:3002/admin')
|
||||
})
|
||||
|
||||
it('rewrites fallback origin to the current browser host in production', () => {
|
||||
installWindow('admin.rentaldrivego.ma')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin')).toBe('https://admin.rentaldrivego.ma/admin')
|
||||
})
|
||||
|
||||
it('resolves server app URLs from forwarded host and protocol', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', 'admin.example.com', 'https')).toBe('https://admin.example.com:3002/admin')
|
||||
})
|
||||
|
||||
it('falls back when host is missing or the fallback is not parseable', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
|
||||
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
if (request.headers.has('x-middleware-subrequest')) {
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user