Technical details —Admin Dashboard
Dive into how the internal dashboard executes: CRA bootstrap, React Router + ProtectedRoute, hooksuseAuthnot context, layerapiClientaxios wrapper, React Query class, 3-step withdrawal flow, and other "real code is different from documentation" points. Read the following overview report03-admin.html.
Stack & holistic thinking
Before reading the code, understand the six characteristics that determine how this app is organized. This is an internal business tool (operations, finance) — different from the player-oriented frontend kiosk.
CRA + CRACO, alias @/
craco start / craco build. Webpack alias @ → src/. Shared package @kioskgaming/ui + @kioskgaming/page-loading. Dev default port CRA3000(no more hardcode 3002).
Navigate using the real URL
In contrast to kiosk frontend (state machine in-memory), admin usesReact Router v6with real URL route:/dashboard, /users/:id, /withdrawal-requests… Each wrapped routeProtectedRoute.
axios + React Query, no manual fetching
A singletonapiClientwrap axios (interceptor token + 401). All server reads/writes go through the internal React Query v3 hookhooks/useApi.ts — cache, refetch, mutation.
useAuthis Context (AuthProvider)
App wrap<AuthProvider>— State auth synchronizes throughout the app.logout()clear React Query cache. Permissions fromresolveUserPermissions()(API objects orROLE_PERMISSIONS map).
UI = modal-per-action
Each sensitive action (approve / reject / complete / ban / update status) has a separate modal + confirmation. Repeat pattern:useState for selected*+ flagshow*Modal.
Tailwind 3 + shadcn style theme
Havetailwind.config.jsmap to internal HSL variableindex.css (--primary, --card…). Already defined.darkButThere is no button to turn on dark mode.
Newbies should open:src/App.tsx(route map) →components/auth/ProtectedRoute.tsx + hooks/useAuth.ts(gatekeeping & decentralization) →services/api.ts(how to call backend) →hooks/useApi.ts(React Query) → a feature, for examplecomponents/pages/withdrawalRequest/. These 5 files are ~70% of the system.
Directory structuresrc/
Organized by "feature folder": each business is an internal foldercomponents/pages/withindex.tsxcoordinate + child Lists/Details/Modals. Import usedrelative path(no alias@/).
*_API.mdIn each page folder (egwithdrawalRequest/WITHDRAWAL_REQUEST_API.md, users/USER_MANAGEMENT_API.md) has accompanying API documentation. Convenient to look up, butAlways compare with real codebecause of some endpoints in the document/constantsmisaligned (see Traps section).
Bootstrap & provider tree
What happens when the browser opens the app. Other kiosk frontend, here there isQueryClientProviderall inclusive andBrowserRoutercoordinate by URL.
ReactDOM.createRoot(#root).render(<StrictMode><App/></StrictMode>). StrictMode is on → in dev each effect runs twice.
QueryClientProvider (retry 1, refetchOnWindowFocus:false) → BrowserRouter → <Routes> + <Toaster>of react-hot-toast (top-right corner).
Each route is privateProtectedRoute. It callsuseAuth()→ readlocalStorage. DuringisLoadingdisplay<LoadingPage>"Verifying...".
Have a valid token → render<Layout>(Sidebar + Header + main) wraps the page content. No tokens →<Navigate to="/login">.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } }, }); return ( <QueryClientProvider client={queryClient}> <Router> <Routes> <Route path="/login" element={<LoginPage/>} /> <Route path="/users" element={ <ProtectedRoute requiredPermission="userManagement"> <Layout><UsersManagement/></Layout> </ProtectedRoute>} /> // … catch-all → Navigate to="/dashboard" </Routes> <Toaster position="top-right" /> </Router> </QueryClientProvider> )
Route table &ProtectedRoute
All routes declared flat inApp.tsx(no nested layout routes, no lazy loading). Each private route is wrapped in 2 layers:ProtectedRoute(guard) +Layout (khung UI).
| URL | Component | requiredPermission | Note |
|---|---|---|---|
/login | LoginPage | public | 2-step login, redirect if already logged in |
/ | Navigate | — | Wrap Layout, redirect→ /dashboard |
/dashboard | Dashboard | — (just login) | 6 stat card + 4 panel |
/users | UsersManagement | userManagement | List + filter |
/users/:id | UserDetailPage | userManagement | Details + wallet + ban/status |
/transactions | TransactionsManagement | transactionManagement | Every underlying transaction |
/withdrawal-requests | WithdrawalManagement | withdrawalManagement | Browse withdrawals in 3 steps |
/tickets | TicketManagement | ticketManagement | Customer support |
/wallets | JSX inline placeholder | walletManagement | "Under development..." right in App.tsx |
/admins | JSX inline placeholder | adminManagement | "Under development..." right in App.tsx |
* | Navigate | — | Catch-all → /dashboard |
if (isLoading) return <LoadingPage message="Verifying..." />; if (!isAuthenticated) return <Navigate to="/login" state={{ from: location }} replace />; if (requiredRole && !hasRole(requiredRole)) return <Navigate to="/dashboard" replace />; if (requiredPermission && !hasPermission(requiredPermission)) return <Navigate to="/dashboard" replace />; return <>{children}</>;
NAVIGATION_ITEMSdifferentLayout.tsxHardcode is correct5sidebar items (Dashboard, Users, Transactions, Withdrawal Requests, Support Tickets) —Are notthere are Wallets/Admins. Whileconstants.NAVIGATION_ITEMSlist7entry and also states the withdrawal path is/withdrawals(what a route/withdrawal-requests). This constant doesnot usedto render the sidebar.
Auth & RBAC — AuthProvider + permissions.ts
useAuthis React Context viaAuthProvider(wrapped inApp.tsx). Permissions resolve frompermissionObjectsAPI orROLE_PERMISSIONSmap with 5 main roles.
2-step login (send code → login)
useSendEmailCode() → POST /api/auth/send-code { email, password }. Success → move forwardcode, turn on the 60s countdown.
useLogin() → POST /api/auth/login { email, password, emailCode }.
onSuccessbelong touseLoginWrite straightadmin_auth_token & admin_user_dataGo to localStorage (not throughuseAuth.login()).
navigate(from). Khi ProtectedRoute mount, useAuthRead localStorage again → see logged in.
Decentralization (RBAC) — hardcode by role
if (!authState.user) return false; // Admin has ALL rights if (user.profile?.role === 'admin') return true; // Finance only has 4 financial rights if (user.profile?.role === 'finance') { const financePermissions = [ 'walletManagement', 'transactionManagement', 'withdrawalManagement', 'financeManagement', ]; return financePermissions.includes(permission); } return false; // other roles → no rights
| Permission | admin | finance | Other |
|---|---|---|---|
userManagement | ✅ | ❌ | ❌ |
walletManagement | ✅ | ✅ | ❌ |
transactionManagement | ✅ | ✅ | ❌ |
withdrawalManagement | ✅ | ✅ | ❌ |
financeManagement | ✅ | ✅ | ❌ |
ticketManagement | ✅ | ❌ | ❌ |
adminManagement | ✅ | ❌ | ❌ |
(1) AuthProvidersync state throughout the app;logout()callqueryClient.clear(). (2) RBAC client-only — backend authorize JWT. (3) Roles: system_admin, super_admin, platform_admin, finance_admin, support_admin— no longer just admin/finance.
apiClient— singleton wrapped axios
A classApiClientmade availableaxios.create()with base URL + 10s timeout, attach 2 interceptors and expose ~25 methods corresponding to each backend endpoint. Export 1 instanceapiClientshared throughout the app.
- Base URL:
API_BASE_URL = 'http://localhost:3001/api'— hardcode inconstants, all paths include a prefix/api - Request interceptor:read
localStorage.getItem('admin_auth_token')→ addAuthorization: Bearer <token> - Response interceptor:If
401→ deleteadmin_auth_token+admin_user_data→window.location.href = '/login'(hard redirect) - Do not refresh tokens:401 means logging out, there is no refresh mechanism like kiosk frontend
- Dense log:Each method has many
console.logrequest/response/error (debug left)
this.client = axios.create({ baseURL: API_BASE_URL, timeout: 10000 }); this.client.interceptors.request.use((config) => { const token = localStorage.getItem('admin_auth_token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); this.client.interceptors.response.use(r => r, (error) => { if (error.response?.status === 401) { localStorage.removeItem('admin_auth_token'); window.location.href = '/login'; } return Promise.reject(error); });
Endpoint maps (all under prefix/api)
| Group | Method apiClient | HTTP & real endpoint |
|---|---|---|
| Auth | sendEmailCode, login | POST /auth/send-code, /auth/login |
| Dashboard | getDashboardStats | GET /admin/dashboard/stats |
| Users | getUsers, getUserDetails, banUser, unbanUser, updateUserStatus | GET /admin/users, /admin/users/:id · POST …/ban, …/unban · PATCH …/status |
| Wallet | getUserWallet, getWalletTransactions | GET /admin/users/:id/wallet, …/wallet/transactions |
| Transactions | getTransactions, getTransactionStats, getTransactionDetail, updateTransactionStatus | GET /admin/transactions, …/stats, …/:id · PATCH …/:id/status |
| Withdrawals | getWithdrawals, approve/reject/complete | GET /payment/admin/withdrawals/pending · POST /payment/admin/withdrawals/:id/{approve,reject,complete} |
| Tickets | getTickets, getTicket, replyTicket, updateTicketStatus, markTicketDone, getSupportStats, getCategories | GET /support/admin/tickets, /support/tickets/:num, /support/admin/stats, /support/categories · POST …/reply · PATCH …/:num, …/:num/done |
constants.ENDPOINTSoutdated — don't trust itObject ENDPOINTS in constants/index.ts impossible api.tsuse (api.ts write path literal). Many values are skewed:ENDPOINTS.USERS = '/users'but really/admin/users; ENDPOINTS.TRANSACTIONS = '/payment/transactions'but really/admin/transactions. When adding an endpoint, edit it directly inapi.ts.
React Query class —hooks/useApi.ts
Component Are notcallapiClientdirect. They use internal React Query hooksuseApi.ts: useQueryfor reading,useMutationfor recording. Mutation automatically invalidates/refetchs related queries and fires a toast.
useDashboardStats— staleTime 5′, refetchInterval 30suseUsers(filters),useUserDetails(id)—keepPreviousDatauseTransactions,useTransactionStats,useTransactionDetailuseWithdrawals(filters)— staleTime 2′useTickets,useTicket,useSupportStats,useCategories
useSendEmailCode,useLogin(toast + ghi localStorage)useBanUser,useUnbanUser,useUpdateUserStatususeApproveWithdrawal,useRejectWithdrawal,useCompleteWithdrawaluseReplyTicket,useUpdateTicketStatus,useMarkTicketDoneuseUpdateTransactionStatus
export const useUsers = (filters) => useQuery(['users', filters], () => apiClient.getUsers(filters), { keepPreviousData: true, staleTime: 5 * 60 * 1000 }); export const useBanUser = () => { const qc = useQueryClient(); return useMutation(({ userId, data }) => apiClient.banUser(userId, data), { onSuccess: () => { qc.invalidateQueries('users'); qc.invalidateQueries('userDetails'); toast.success(SUCCESS_MESSAGES.USER_BANNED); }, onError: (e) => toast.error(parseErrorMessage(e)), }); };
The user/transaction hook uses the old React Query v3 syntax (invalidateQueries('users')+ toast in hook). The withdrawal/ticket hook uses the new object syntax (invalidateQueries({ queryKey: [...] })) andAre nottoast in hook — page handles refetching itself (egWithdrawalManagementtransmitonSuccesscallrefetch()). When adding hooks, follow the style of the feature being edited for consistency.
Type system & "envelope" API
The entire style is withintypes/index.ts(~700 lines). Most responses follow the standard "envelope".{ success, message, statusCode, timestamp, data }. This is where many "data shape traps" reside.
export interface ApiResponse<T> { success: boolean; message?: string; data: T; statusCode?: number; timestamp?: string; } export interface User { id: string; email: string; status: 'active'|'inactive'|'suspended'; banned: boolean; profile: Record<string, any>; // role is in profile.role } // ⚠ NO envelope — only flat: export interface WithdrawalsResponse { withdrawals: Withdrawal[]; pagination: Pagination; }
rolelies withinprofile
Permission to read throughuser.profile?.role. ButUser.profiledeclareRecord<string, any>with caption "API returns {} empty object" → needs backend data setroleIn the right place, RBAC will run.
DashboardStatsDeclare 2 times
Interface DashboardStatsbe definedcoincide(2 identical blocks) intypes/index.ts. Harmless because they're similar, but it's a sign of copy-paste that needs to be cleaned up.
WithdrawalsResponsevs usageType of withdrawal response declaration is{ withdrawals, pagination }(flat), butwithdrawalRequest/index.tsxread againwithdrawalsData?.data?.withdrawalsand…?.data?.pagination— which means expecting more classesdata. BecauseuseWithdrawalsNo strict generic constraints so TS doesn't catch errors; The actual run must follow the returned JSON backend. When connecting the API, check the console.log response to see if the class is presentdataor not.
Withdrawal approval — 3 steps
The most important business, sample example for the entire "list + modal-per-action" architecture.index.tsxkeep state & coordination;WithdrawalListshow + filter; 3 modals execute each action.
| Step | Hook | HTTP & endpoint | Status |
|---|---|---|---|
| See list | useWithdrawals(filters) | GET /payment/admin/withdrawals/pending | pending |
| Approve | useApproveWithdrawal | POST /payment/admin/withdrawals/:id/approve | approved |
| Complete | useCompleteWithdrawal | POST /payment/admin/withdrawals/:id/complete | completed |
| Reject | useRejectWithdrawal | POST /payment/admin/withdrawals/:id/reject | rejected |
const [selectedWithdrawal, setSelectedWithdrawal] = useState<WithdrawalRequest | null>(null); const [showApproveModal, setShowApproveModal] = useState(false); const handleApproveWithdrawal = (w) => { setSelectedWithdrawal(w); setShowApproveModal(true); }; // onSuccess of the modal → refetch() to update the list const handleWithdrawalUpdate = () => refetch();
Filter ofWithdrawalList
Theo status (pending/approved/completed/rejected), withdrawalType(bank_transfer / bitcoin_transfer), anddateFrom/dateTo. Change filter → resetpage = 1. Default statelimit: 20.
2 types of withdrawals
Bank: bankCode, accountNumber, accountName, routingNumber. Bitcoin:wallet address. Finance team transfers money/crypto manually and then clicks Complete.
dateFrom/dateTonot in the type filterWithdrawalFiltersjust declarestatus, page, limit, userId, withdrawalType — Are notHavedateFrom/dateTo, umbrellaindex.tsxinitialize andWithdrawalListset these 2 fields. This is a harmless type deviation at compile time (due to the use of spread) but can easily cause confusion. If you need to filter standard dates, add 2 fields to the type.
User management & wallet
Same list + modal template.users/index.tsxholdfiltersand transmitted downUserList; UserDetailPageCombine user information + wallet + transaction history.
UserList
Search by email, filterstatus & banned, sort by created_at/email/status/banned, pagination. Click row →navigate('/users/:id'). Each row has a Ban & Update Status opens the modal.
UserDetailPage
Show info +stats (totalDeposits, totalWithdrawals, walletBalance, totalTransactions), WalletSection(manual credit/debit) andWalletTransactionTable(wallet history with filter + pagination).
| Action | Modal | Hook | Endpoint |
|---|---|---|---|
| Ban account | BanUserModal | useBanUser | POST /admin/users/:id/ban { reason? } |
| Unblock | BanUserModal(unban mode) | useUnbanUser | POST /admin/users/:id/unban |
| Change status | UpdateUserStatusModal | useUpdateUserStatus | PATCH /admin/users/:id/status |
| Deposit/deduct wallet | WalletSection | (via apiClientwallet) | credit/debit (amount + reason + description) |
The response you returnedbanned_at, ban_reason(and recording backendbanned_by). Modal asks to enter a reason.status(active/inactive/suspended) is separate from flagsbanned— suspended is lighter than you.
Customer Support & transaction monitoring
The remaining two modules follow the same philosophy. Other points:tickets/index.tsxuse "master-detail" in the same page (select ticket → showTicketDetails) instead of URL navigation.
TicketListfilter by status: pending / in_progress / resolved / closed / doneTicketDetailsDisplay thread reply (distinguish between admin/user)ReplyTicketModal— with flagisInternal(internal notes)UpdateTicketStatusModal+markTicketDone(ghi resolvedAt/By)- The list is taken from the past
useCategories()
TransactionListfilter type/status/method/date + sortTransactionDetails— raw payload (paymentResult,linkMePayRequest)UpdateTransactionStatusModal— pending/completed/failed/cancelleduseTransactionStats— overview + byType + byMethod- Transaction ID isnumber (
getTransactionDetail(id: number))
User usesroute (/users/:id→ separate page). Tickets & Withdrawal usedinternal state(modal/panel). When adding features, choose the appropriate template: need deep-link/refresh to keep context → route; Quick action in list → modal.
Tailwind 3 & shadcn style theme
Unlike kiosk frontend (Tailwind v4 does not have config), admins use Tailwind 3tailwind.config.js+ PostCSS. Map color to HSL variable declared inindex.cssin shadcn/ui style.
@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --primary: 221.2 83.2% 53.3%; /* blue */ --card: 0 0% 100%; --border: 214.3 31.8% 91.4%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; /* …defined but not used yet */ } } // tailwind.config.js → colors: { primary: 'hsl(var(--primary))', card: 'hsl(var(--card))' … }
Component UI
Setui/Write yourself in shadcn style:Card, Badge, Button (variant default/outline/ghost), Input, Table, Loading, Menu. Merge class withcn() (twMerge(clsx())).
Icon & toast
Icon lucide-react. Notificationreact-hot-toast (<Toaster>In App, style according to card/border variable). Dashboard chart usedrecharts.
Layout
Layout= Sidebar (fixed, collapsed mobile) + Header + main scroll. Page title inferred fromlocation.pathname in getPageTitle().
Layout.tsxprints a red line"Debug: ADMIN / NOT ADMIN"right below the user name in the footer sidebar. Along with seriesconsole.log in api.ts/useApi.ts/useAuth.ts, this is the debug code that needs to be cleaned up before production.
Config & environment variable
Configuration is centralizedconstants/index.ts. Worth noting: the backend base URL is brokenhardcode, does not read environment variablesREACT_APP_*as CRA standard.
| Constant | Value | Meaning |
|---|---|---|
API_BASE_URL | process.env.REACT_APP_API_BASE_URL | Defaulthttp://localhost:3001/api— read from env viaconfig/env.ts |
PORT | 3000 (CRA default) | Dev server via craco start |
STORAGE_KEYS | admin_auth_token, admin_user_data, admin_theme | LocalStorage key |
PAGINATION_DEFAULTS | { page:1, limit:10 } | Default is constant (but pages set it themselveslimit:20) |
CURRENCY | { symbol:'$', code:'USD' } | formatCurrency render by en-US/USD |
BecauseENV.API_BASE_URLreadREACT_APP_API_BASE_URL, create.envfor other environments. Turnstile:REACT_APP_TURNSTILE_ENABLED + REACT_APP_TURNSTILE_SITE_KEYwhen the backend enables CAPTCHA login.
Testing & build
The test infrastructure is CRA's default (Jest + react-scripts) but the repoThere are no test files yet. Build/dev also follows CRA (Webpack), different from turbopack of kiosk frontend.
What's up?
10 unit test files (*.test.ts(x)) for utils, forgot-password, USD wallet mappers. @testing-library/react in deps. Playwright E2E: tests/visual/, scripts test:e2e, test:visual.
Scripts
npm start (craco, port 3000), npm run build (+ SPA fallback postbuild), npm test, npm run test:e2e (Playwright).
npm start # PORT=3002 react-scripts start → http://localhost:3002 npm run build # react-scripts build (Webpack) → build/ npm test # react-scripts test (Jest, no tests yet) npm run eject # extract CRA config (not recommended)
CRA react-scripts buildwillfailwhen there is a compile error (unlike Next withignoreBuildErrors). Ask for a favortsconfig strict, type errors are caught when building — but errorsany/spread (see type section) still gets through.
Cookbook for new devs
Common tasks follow project conventions.
- Create folders
src/components/pages/reports/index.tsx(feature folder). - More
<Route>inApp.tsx, wrap<ProtectedRoute requiredPermission="...">+<Layout>. - More
SidebarItemrespectively inlayout/Layout.tsx(remember: sidebar is hardcode, not generated from constants). - If there are new permissions, add an internal branch
useAuth.hasPermission.
- Add methods to the class
ApiClientinservices/api.ts(usethis.client.get/post/patch, path literal has prefix/api). - Declare the request/response interface in
types/index.ts(by envelope{ success, data }). - Create internal hooks
hooks/useApi.ts:useQuery(read) oruseMutation(write) + invalidate related query. - Component calls hook; error reported
toast.error(parseErrorMessage(e)).
- Create
RefundModal.tsxin feature folder (getisOpen,onClose,onSuccess, entity is selected). - Trong
index.tsx: add stateselected*+showRefundModal+ handler set both. - Modal called
useRefund();onSuccessclose modal +refetch()/invalidate. - Add a modal open button in the List component.
- Fix
API_BASE_URLinsrc/constants/index.ts(or refactor toprocess.env.REACT_APP_API_URL+ create.env). - Restart the dev server (CRA only reads env at start).
- Check Network/console: every request goes through an interceptor with a Bearer token attached.
Common traps (read before fixing)
| Trap | Details & how to handle |
|---|---|
useAuthis Context | AuthProviderwrap app;logout()clear query cache. Don't expect independent hooks like the old report. |
| Backend URL via env | REACT_APP_API_BASE_URL in config/env.ts- create.env for staging/prod. |
Sidebar ≠ NAVIGATION_ITEMS | Sidebar hardcodes 5 items inLayout.tsx; constant 7 entries with wrong withdrawal path (/withdrawals vs /withdrawal-requests). Add item to edit Layout. |
Deviateddatain withdrawals | Type WithdrawalsResponseFlat but readable code?.data?.withdrawals. Stick to real JSON when connecting to the API. |
| RBAC is only on the client | The backend must authorize itself according to JWT. Roleoperation/platformnot yet inhasPermission. |
| Backend URL hardcode | API_BASE_URLpermanentlocalhost:3001/api, Not available.env. Changing the environment requires modifying the code + rebuilding. |
| Default currency USD | formatCurrencyrender USD, transaction can be in VND. Checkcurrencyfrom data. |
| No testing yet | npm test"No tests found" message; Lack of Testing Library. Install before writing test components. |
| Debug remaining | Line "Debug: ADMIN/NOT ADMIN" in sidebar + manyconsole.login api/hooks. Clean up before production. |
| StrictMode runs the effect twice (dev) | In dev, effect/log runs doubleReact.StrictMode. Normal, not a bug. |