fix: admin panel bugs found in QA audit
All checks were successful
build-and-release / build (push) Successful in 59s

- users: wire suspend/block buttons to PATCH /api/admin/users/:id/status
  (previously only mutated local state, reverted on refresh); add
  load/action error banners instead of silently emptying the table
- [...module]: stop rendering a dead localhost:9201 iframe for legacy
  modules when VITE_LEGACY_ADMIN_ORIGIN isn't configured (it never is
  in production); show a clear "not available" message instead
- dashboard: surface a banner when /api/admin/dashboard/metrics fails
  instead of silently showing "No Data" on every widget
- credit: fix stray extra closing </Show> tag that broke the whole
  file's JSX parse; restore missing API/authHeaders module helpers
  dropped in a previous refactor (AI Credits handlers referenced them
  but they were undefined); replace a dead, broken exportLedgerCsv/
  filteredLedger implementation with one matching actual call sites;
  fix activeTab type/tab keys so the Balance & Ledger and Platform
  Ledger tabs were actually reachable (they compared against 'balance'
  /'platform' but the tab buttons only ever set 'ledger')
- roles: surface errors when fetching a role's permissions for edit
  fails, instead of silently swallowing them
- runtime-roles: surface fetch/delete errors instead of silently
  swallowing them or presenting fallback sample data as if real

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-13 18:24:44 +05:30
parent 98a41d833a
commit 7ae59fee20
6 changed files with 186 additions and 96 deletions

View file

@ -1,5 +1,5 @@
import { A, useParams } from "@solidjs/router";
import { createMemo, lazy } from "solid-js";
import { Show, createMemo, lazy } from "solid-js";
const ApprovalManagementPage = lazy(() => import("./approval"));
const VerificationManagementPage = lazy(() => import("./verification"));
@ -77,6 +77,9 @@ export default function LegacyModuleShellPage() {
const moduleName = createMemo(() => toTitle(modulePath || "Management"));
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
const legacyOriginConfigured = createMemo(
() => Boolean(import.meta.env.VITE_LEGACY_ADMIN_ORIGIN) || import.meta.env.DEV
);
return (
<div>
@ -85,26 +88,39 @@ export default function LegacyModuleShellPage() {
Live legacy module embedded for exact design and functionality parity during migration.
</p>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="actions">
<A
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={legacyUrl()}
target="_blank"
>
Open Module In New Tab
</A>
</div>
<iframe
src={legacyUrl()}
title={`${moduleName()} (Legacy)`}
style={{
width: "100%",
height: "72vh",
border: "1px solid #e2e8f0",
"border-radius": "10px",
"margin-top": "10px",
}}
/>
<Show
when={legacyOriginConfigured()}
fallback={
<div class="p-6 text-sm text-gray-600">
<p class="font-medium text-gray-900">This module isnt available yet.</p>
<p class="mt-1">
No legacy admin service is configured for this environment
(VITE_LEGACY_ADMIN_ORIGIN is unset), so {moduleName()} cant be embedded here.
</p>
</div>
}
>
<div class="actions">
<A
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={legacyUrl()}
target="_blank"
>
Open Module In New Tab
</A>
</div>
<iframe
src={legacyUrl()}
title={`${moduleName()} (Legacy)`}
style={{
width: "100%",
height: "72vh",
border: "1px solid #e2e8f0",
"border-radius": "10px",
"margin-top": "10px",
}}
/>
</Show>
</section>
</div>
);

View file

@ -1,4 +1,4 @@
import { createMemo, createResource, createSignal, Show, For } from 'solid-js';
import { createResource, createSignal, Show, For } from 'solid-js';
import {
api,
AdminLedgerEntry,
@ -14,8 +14,22 @@ import {
type ActiveTab = 'balance' | 'adjust' | 'reconcile' | 'platform';
const API = '';
function authHeaders(): Record<string, string> {
const token =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export default function CreditPage() {
const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile' | 'ai_credits'>('ledger');
const [activeTab, setActiveTab] = createSignal<ActiveTab | 'ai_credits'>('balance');
// AI Credits sub-tab state
const [aiCreditsSubTab, setAiCreditsSubTab] = createSignal<'balance' | 'adjust' | 'reconcile'>('balance');
@ -158,10 +172,11 @@ export default function CreditPage() {
}
};
const tabs: { key: 'ledger' | 'adjust' | 'reconcile' | 'ai_credits'; label: string }[] = [
{ key: 'ledger', label: 'Balance & Ledger' },
const tabs: { key: ActiveTab | 'ai_credits'; label: string }[] = [
{ key: 'balance', label: 'Balance & Ledger' },
{ key: 'adjust', label: 'Reward / Deduct' },
{ key: 'reconcile', label: 'Reconcile' },
{ key: 'platform', label: 'Platform Ledger' },
{ key: 'ai_credits', label: 'AI Credits' },
];
@ -253,40 +268,15 @@ export default function CreditPage() {
}
};
const filteredLedger = createMemo(() => {
let data = ledger();
const q = ledgerSearch().toLowerCase().trim();
if (q) {
data = data.filter((entry) =>
String(entry.referenceId || '').toLowerCase().includes(q)
|| String(entry.transactionType || '').toLowerCase().includes(q)
);
}
if (ledgerTypeFilter() !== 'all') {
data = data.filter((entry) => entry.transactionType === ledgerTypeFilter());
}
const sorted = [...data];
sorted.sort((a, b) => {
const aCreated = new Date(a.createdAt || 0).getTime();
const bCreated = new Date(b.createdAt || 0).getTime();
const aAmt = Number(a.amount ?? 0);
const bAmt = Number(b.amount ?? 0);
if (ledgerSortBy() === 'oldest') return aCreated - bCreated;
if (ledgerSortBy() === 'amount_desc') return bAmt - aAmt;
if (ledgerSortBy() === 'amount_asc') return aAmt - bAmt;
return bCreated - aCreated;
});
return sorted;
});
const exportLedgerCsv = () => {
const headers = ['Type', 'Amount', 'Ref ID', 'Expires At', 'Date'];
const rows = filteredLedger().map((entry) => [
entry.transactionType,
`${entry.transactionType === 'ADD' ? '+' : '-'}${entry.amount ?? 0}`,
entry.referenceId || '—',
entry.expiresAt ? new Date(entry.expiresAt).toLocaleDateString() : '—',
entry.createdAt ? new Date(entry.createdAt).toLocaleString() : '—',
const exportLedgerCsv = (entries: AdminLedgerEntry[], filename: string) => {
const headers = ['Type', 'Amount', 'Balance After', 'Reason', 'Reference', 'Date'];
const rows = entries.map((entry) => [
entry.entry_type,
`${entry.amount > 0 ? '+' : ''}${entry.amount ?? 0}`,
entry.balance_after ?? '—',
entry.reason || '—',
entry.reference_id || '—',
entry.created_at ? new Date(entry.created_at).toLocaleString('en-IN') : '—',
]);
const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
@ -926,7 +916,6 @@ export default function CreditPage() {
</section>
</Show>
</div>
</Show>
);
}

View file

@ -413,6 +413,15 @@ export default function AdminHomePage() {
</p>
</div>
</Show>
<Show when={Boolean(metrics.error)}>
<div
class="rounded-2xl border border-[#FECACA] bg-[#FEF2F2] px-6 py-4 text-[13px] font-medium text-[#B91C1C] shadow-sm"
style="margin-bottom: 18px"
>
Failed to load dashboard metrics. Widgets below may show stale or missing data try
refreshing the page.
</div>
</Show>
<div
class="rounded-2xl border border-[#E5E7EB] bg-white px-6 py-5 shadow-sm md:px-8"
style="margin-bottom: 28px"

View file

@ -420,16 +420,22 @@ export default function RoleManagementPage() {
setFormTab("general");
setView("form");
setOpenMenuId(null);
// Fetch permission_keys for this role
setFormError("");
fetch(`${API}/api/admin/roles/${row.id}`)
.then((r) => r.json())
.then((r) => {
if (!r.ok) throw new Error("Failed to fetch role permissions");
return r.json();
})
.then((detail) => {
if (Array.isArray(detail?.permission_keys)) {
const keys = (detail.permission_keys as any[]).map((k) => String(k));
setSelectedPermissions(new Set<string>(keys));
}
})
.catch(() => {});
.catch((e) => {
console.error(e);
setFormError("Failed to load existing permissions for this role. They may be incomplete.");
});
};
const openDetail = async (row: RoleRecord) => {

View file

@ -22,10 +22,10 @@ const FALLBACK: ExternalRole[] = [
{ id: 'r5', roleKey: 'MAKEUP_ARTIST', displayName: 'Makeup Artist', vertical: 'Professional', enabledModules: ['Portfolio', 'Leads', 'Reviews'], onboardingSchemaId: 'schema-professional', isActive: false },
];
async function fetchExternalRoles(): Promise<ExternalRole[]> {
async function fetchExternalRoles(): Promise<{ roles: ExternalRole[]; error: string }> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) throw new Error();
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.roles ?? []))
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL');
@ -41,9 +41,13 @@ async function fetchExternalRoles(): Promise<ExternalRole[]> {
isActive: r.is_active !== false,
};
});
return list.length > 0 ? list : FALLBACK;
} catch {
return FALLBACK;
return { roles: list, error: '' };
} catch (e) {
console.error(e);
return {
roles: FALLBACK,
error: 'Failed to load external roles from the server. Showing sample data — changes here will not be saved.',
};
}
}
@ -57,30 +61,38 @@ function StatusBadge(props: { active: boolean }) {
}
export default function ExternalRolesPage() {
const [roles, { refetch }] = createResource(fetchExternalRoles);
const [result, { refetch }] = createResource(fetchExternalRoles);
const roles = () => result()?.roles ?? [];
const roleFetchLoading = () => result.loading;
const loadError = () => result()?.error ?? '';
const [search, setSearch] = createSignal('');
const [openMenu, setOpenMenu] = createSignal('');
const [deleting, setDeleting] = createSignal('');
const [deleteError, setDeleteError] = createSignal('');
const filtered = () => {
const q = search().toLowerCase();
return (roles() ?? []).filter(r =>
return roles().filter(r =>
!q || r.displayName.toLowerCase().includes(q) || r.roleKey.toLowerCase().includes(q) || r.vertical.toLowerCase().includes(q)
);
};
const stats = () => {
const list = roles() ?? [];
const list = roles();
return { total: list.length, active: list.filter(r => r.isActive).length, inactive: list.filter(r => !r.isActive).length };
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete external role "${name}"? This cannot be undone.`)) { setOpenMenu(''); return; }
setDeleting(id); setOpenMenu('');
setDeleting(id); setOpenMenu(''); setDeleteError('');
try {
await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
refetch();
} catch {}
} catch (e) {
console.error(e);
setDeleteError(`Failed to delete "${name}". Please try again.`);
}
finally { setDeleting(''); }
};
@ -103,13 +115,24 @@ export default function ExternalRolesPage() {
</A>
</div>
<Show when={loadError()}>
<div class="rounded-xl border border-[#FECACA] bg-[#FEF2F2] px-4 py-3 text-[13px] font-medium text-[#B91C1C]">
{loadError()}
</div>
</Show>
<Show when={deleteError()}>
<div class="rounded-xl border border-[#FECACA] bg-[#FEF2F2] px-4 py-3 text-[13px] font-medium text-[#B91C1C]">
{deleteError()}
</div>
</Show>
{/* Stat cards */}
<div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
{([
{ label: 'Total Roles', value: () => stats().total, Icon: Globe, bg: 'bg-[#FFF1EB]', color: 'text-[#FF5E13]' },
{ label: 'Active Roles', value: () => stats().active, Icon: ShieldCheck, bg: 'bg-[#ECFDF5]', color: 'text-[#059669]' },
{ label: 'Inactive Roles', value: () => stats().inactive, Icon: ShieldOff, bg: 'bg-[#FEF2F2]', color: 'text-[#DC2626]' },
{ label: 'Total Modules', value: () => (roles() ?? []).reduce((a, r) => a + r.enabledModules.length, 0), Icon: Layers, bg: 'bg-[#EFF6FF]', color: 'text-[#2563EB]' },
{ label: 'Total Modules', value: () => roles().reduce((a, r) => a + r.enabledModules.length, 0), Icon: Layers, bg: 'bg-[#EFF6FF]', color: 'text-[#2563EB]' },
] as const).map(s => (
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div class="flex items-start justify-between">
@ -158,7 +181,7 @@ export default function ExternalRolesPage() {
</thead>
<tbody class="divide-y divide-[#F3F4F6]">
<Show when={roles.loading}>
<Show when={roleFetchLoading()}>
<For each={[1, 2, 3]}>
{() => (
<tr class="animate-pulse">
@ -173,7 +196,7 @@ export default function ExternalRolesPage() {
</For>
</Show>
<Show when={!roles.loading && filtered().length === 0}>
<Show when={!roleFetchLoading() && filtered().length === 0}>
<tr>
<td colspan="6" class="px-5 py-16 text-center">
<div class="flex flex-col items-center gap-2">

View file

@ -91,19 +91,26 @@ export default function UsersManagementPage() {
const [filterOpen, setFilterOpen] = createSignal(false);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [selectedUser, setSelectedUser] = createSignal<ExternalUserRecord | null>(null);
const [loadError, setLoadError] = createSignal("");
const [actionError, setActionError] = createSignal("");
const currentUser = createMemo<ExternalUserRecord | null>(() => selectedUser());
const authHeaders = (): Record<string, string> => {
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
};
const load = async () => {
try {
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
setLoadError("");
const r = await fetch("/api/admin/users", {
headers: {
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...authHeaders(),
},
credentials: "include",
});
@ -149,6 +156,7 @@ export default function UsersManagementPage() {
} catch (e) {
console.error(e);
setRows([]);
setLoadError("Failed to load users. Please refresh or try again.");
}
};
@ -273,22 +281,46 @@ export default function UsersManagementPage() {
setOpenMenuId(null);
};
const toggleSuspend = (user: ExternalUserRecord) => {
const nextStatus = user.accountStatus === "SUSPENDED" ? "ACTIVE" : "SUSPENDED";
setRows((prev) =>
prev.map((r) => (r.id === user.id ? { ...r, accountStatus: nextStatus } : r))
);
if (selectedUser()?.id === user.id) setSelectedUser({ ...user, accountStatus: nextStatus });
const applyAccountStatus = async (user: ExternalUserRecord, nextStatus: string) => {
const previousStatus = user.accountStatus;
setActionError("");
setOpenMenuId(null);
setRows((prev) =>
prev.map((r) => (r.id === user.id ? { ...r, accountStatus: nextStatus as any } : r))
);
if (selectedUser()?.id === user.id)
setSelectedUser({ ...user, accountStatus: nextStatus as any });
try {
const r = await fetch(`/api/admin/users/${user.id}/status`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...authHeaders(),
},
credentials: "include",
body: JSON.stringify({ status: nextStatus }),
});
if (!r.ok) throw new Error("Failed to update user status");
} catch (e) {
console.error(e);
setRows((prev) =>
prev.map((r) => (r.id === user.id ? { ...r, accountStatus: previousStatus } : r))
);
if (selectedUser()?.id === user.id)
setSelectedUser({ ...user, accountStatus: previousStatus });
setActionError(`Failed to update status for ${user.name}. Please try again.`);
}
};
const toggleSuspend = (user: ExternalUserRecord) => {
void applyAccountStatus(user, user.accountStatus === "SUSPENDED" ? "ACTIVE" : "SUSPENDED");
};
const toggleBlock = (user: ExternalUserRecord) => {
const nextStatus = user.accountStatus === "BLOCKED" ? "ACTIVE" : "BLOCKED";
setRows((prev) =>
prev.map((r) => (r.id === user.id ? { ...r, accountStatus: nextStatus } : r))
);
if (selectedUser()?.id === user.id) setSelectedUser({ ...user, accountStatus: nextStatus });
setOpenMenuId(null);
void applyAccountStatus(user, user.accountStatus === "BLOCKED" ? "ACTIVE" : "BLOCKED");
};
return (
@ -301,6 +333,17 @@ export default function UsersManagementPage() {
</p>
</div>
<Show when={loadError()}>
<div style="margin-bottom:16px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;color:#B91C1C;padding:12px 16px;font-size:13px;font-weight:500">
{loadError()}
</div>
</Show>
<Show when={actionError()}>
<div style="margin-bottom:16px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;color:#B91C1C;padding:12px 16px;font-size:13px;font-weight:500">
{actionError()}
</div>
</Show>
<Show when={listTab() !== "view"}>
<div style="display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px">
{[
@ -839,9 +882,13 @@ export default function UsersManagementPage() {
fallback={
<tr>
<td colSpan={10} style="padding:32px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No users found</p>
<p style="font-size:15px;font-weight:600;color:#111827">
{loadError() ? "Unable to load users" : "No users found"}
</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">
Try changing filters or search.
{loadError()
? "There was a problem contacting the server. Try refreshing the page."
: "Try changing filters or search."}
</p>
</td>
</tr>