From 7ae59fee2092ebe9cf54252a26ca49e4c9e688ed Mon Sep 17 00:00:00 2001
From: Ashwin Kumar Sivakumar
Date: Mon, 13 Jul 2026 18:24:44 +0530
Subject: [PATCH] fix: admin panel bugs found in QA audit
- 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 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
---
src/routes/admin/[...module].tsx | 58 ++++++++++------
src/routes/admin/credit.tsx | 67 ++++++++-----------
src/routes/admin/index.tsx | 9 +++
src/routes/admin/roles/index.tsx | 12 +++-
src/routes/admin/runtime-roles/index.tsx | 51 ++++++++++----
src/routes/admin/users/index.tsx | 85 ++++++++++++++++++------
6 files changed, 186 insertions(+), 96 deletions(-)
diff --git a/src/routes/admin/[...module].tsx b/src/routes/admin/[...module].tsx
index f452e76..0244be9 100644
--- a/src/routes/admin/[...module].tsx
+++ b/src/routes/admin/[...module].tsx
@@ -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 (
@@ -85,26 +88,39 @@ export default function LegacyModuleShellPage() {
Live legacy module embedded for exact design and functionality parity during migration.
-
-
+
+ This module isn’t available yet.
+
+ No legacy admin service is configured for this environment
+ (VITE_LEGACY_ADMIN_ORIGIN is unset), so “{moduleName()}” can’t be embedded here.
+
+
+ }
+ >
+
+
+
);
diff --git a/src/routes/admin/credit.tsx b/src/routes/admin/credit.tsx
index 4d6b307..1c49384 100644
--- a/src/routes/admin/credit.tsx
+++ b/src/routes/admin/credit.tsx
@@ -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 {
+ 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('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() {
-
);
}
diff --git a/src/routes/admin/index.tsx b/src/routes/admin/index.tsx
index 9c08a2b..1eefefc 100644
--- a/src/routes/admin/index.tsx
+++ b/src/routes/admin/index.tsx
@@ -413,6 +413,15 @@ export default function AdminHomePage() {
+
+
+ Failed to load dashboard metrics. Widgets below may show stale or missing data — try
+ refreshing the page.
+
+
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(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) => {
diff --git a/src/routes/admin/runtime-roles/index.tsx b/src/routes/admin/runtime-roles/index.tsx
index 41ec2d4..37f5a0c 100644
--- a/src/routes/admin/runtime-roles/index.tsx
+++ b/src/routes/admin/runtime-roles/index.tsx
@@ -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 {
+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 {
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() {
+
+
+ {loadError()}
+
+
+
+
+ {deleteError()}
+
+
+
{/* Stat cards */}
{([
{ 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 => (
@@ -158,7 +181,7 @@ export default function ExternalRolesPage() {
-
+
{() => (
@@ -173,7 +196,7 @@ export default function ExternalRolesPage() {
-
+
|
diff --git a/src/routes/admin/users/index.tsx b/src/routes/admin/users/index.tsx
index d0a1a58..d0f376b 100644
--- a/src/routes/admin/users/index.tsx
+++ b/src/routes/admin/users/index.tsx
@@ -91,19 +91,26 @@ export default function UsersManagementPage() {
const [filterOpen, setFilterOpen] = createSignal(false);
const [openMenuId, setOpenMenuId] = createSignal(null);
const [selectedUser, setSelectedUser] = createSignal(null);
+ const [loadError, setLoadError] = createSignal("");
+ const [actionError, setActionError] = createSignal("");
const currentUser = createMemo(() => selectedUser());
+ const authHeaders = (): Record => {
+ 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() {
+
+
+ {loadError()}
+
+
+
+
+ {actionError()}
+
+
+
{[
@@ -839,9 +882,13 @@ export default function UsersManagementPage() {
fallback={
|
- No users found
+
+ {loadError() ? "Unable to load users" : "No users found"}
+
- Try changing filters or search.
+ {loadError()
+ ? "There was a problem contacting the server. Try refreshing the page."
+ : "Try changing filters or search."}
|
|