From 2ac2b7857e2d17c2835a41739afc14081d33ca99 Mon Sep 17 00:00:00 2001 From: Ayomikun Date: Sat, 4 Jul 2026 21:33:47 +0100 Subject: [PATCH 1/4] feat: setup admin management flow with backend endpoints --- src/actions/admin-management.ts | 132 +++++++----------- src/actions/auth.ts | 32 +++-- .../admin-management/admin-accounts-table.tsx | 4 +- src/components/admin-management/columns.tsx | 26 ++-- src/components/auth/admin-login-form.tsx | 46 +++--- src/hooks/api/keys.ts | 3 + src/hooks/api/use-admin-management.ts | 10 +- src/mocks/admin-management.ts | 24 ++-- src/types/api/admin-management.ts | 35 ++++- 9 files changed, 172 insertions(+), 140 deletions(-) diff --git a/src/actions/admin-management.ts b/src/actions/admin-management.ts index a3e2bf4..e8d7625 100644 --- a/src/actions/admin-management.ts +++ b/src/actions/admin-management.ts @@ -1,33 +1,19 @@ "use server"; -import { - addMockAdmin, - MOCK_ADMIN_ACCOUNTS, - updateMockAdminEmail, - updateMockAdminRole, - updateMockAdminStatus, -} from "@/mocks/admin-management"; import type { + AdminAccountsPage, + AdminAccountsQueryParams, AdminManagementResult, ChangeAdminEmailInput, ChangeAdminRoleInput, + GetAdminAccountsResponse, InviteAdminInput, - ManagedAdminAccount, } from "@/types/api/admin-management"; +import { authApi } from "@/lib/api/clients"; +import { toApiError } from "@/lib/api/errors"; +import { unwrapData } from "./utils"; -function isValidEmail(email: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -function adminExists(email: string, ignoreId?: string): boolean { - return MOCK_ADMIN_ACCOUNTS.some( - (admin) => - admin.email.toLowerCase() === email.toLowerCase() && - admin.id !== ignoreId, - ); -} - -function success(message: string): AdminManagementResult { +function ok(message: string): AdminManagementResult { return { ok: true, message }; } @@ -35,101 +21,83 @@ function fail(message: string): AdminManagementResult { return { ok: false, message }; } -export async function getAdminAccounts(): Promise { - return MOCK_ADMIN_ACCOUNTS; +export async function getAdminAccounts( + params: AdminAccountsQueryParams = {}, +): Promise { + const res = await authApi.get("/admin/admins", { + params, + }); + // Response is double-wrapped: ApiEnvelope.data = { status, data: { items, ... } } + return unwrapData(res).data; } export async function inviteAdmin( input: InviteAdminInput, ): Promise { - const email = input.email.trim().toLowerCase(); - - if (!isValidEmail(email)) { - return fail("Enter a valid email address."); - } - - if (adminExists(email)) { - return fail("An admin account already exists with this email."); + try { + await authApi.post("/admin/admins/invite", { email: input.email }); + return ok(`Invite sent to ${input.email}.`); + } catch (err) { + return fail(toApiError(err).message); } - - addMockAdmin(email); - - return success(`Invite sent to ${email}.`); } export async function resetAdminPassword( id: string, ): Promise { - const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === id); - - if (!admin) { - return fail("Admin account not found."); + try { + await authApi.post(`/admin/admins/${id}/reset-password`); + return ok("Password reset email sent."); + } catch (err) { + return fail(toApiError(err).message); } - - return success(`Password reset for ${admin.name}.`); } export async function changeAdminEmail( input: ChangeAdminEmailInput, ): Promise { - const email = input.email.trim().toLowerCase(); - const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === input.id); - - if (!admin) { - return fail("Admin account not found."); - } - - if (!isValidEmail(email)) { - return fail("Enter a valid email address."); + try { + await authApi.patch(`/admin/admins/${input.id}/email`, { + email: input.email, + }); + return ok("Email updated successfully."); + } catch (err) { + return fail(toApiError(err).message); } - - if (adminExists(email, input.id)) { - return fail("An admin account already exists with this email."); - } - - updateMockAdminEmail(input.id, email); - - return success(`Email updated for ${admin.name}.`); } export async function changeAdminRole( input: ChangeAdminRoleInput, ): Promise { - const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === input.id); - - if (!admin) { - return fail("Admin account not found."); + try { + await authApi.patch(`/admin/admins/${input.id}/role`, { + role: input.role, + confirm_downgrade: input.confirm_downgrade, + }); + return ok("Role updated successfully."); + } catch (err) { + return fail(toApiError(err).message); } - - updateMockAdminRole(input.id, input.role); - - return success(`Role updated for ${admin.name}.`); } export async function deactivateAdminAccount( id: string, ): Promise { - const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === id); - - if (!admin) { - return fail("Admin account not found."); + try { + await authApi.patch(`/admin/admins/${id}/deactivate`); + return ok("Account deactivated."); + } catch (err) { + return fail(toApiError(err).message); } - - updateMockAdminStatus(id, "Deactivated"); - - return success("Account deactivated."); } export async function reactivateAdminAccount( id: string, ): Promise { - const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === id); - - if (!admin) { - return fail("Admin account not found."); + try { + await authApi.patch(`/admin/admins/${id}/reactivate`); + return ok("Account reactivated."); + } catch (err) { + return fail(toApiError(err).message); } - - updateMockAdminStatus(id, "Active"); - - return success("Account reactivated."); } diff --git a/src/actions/auth.ts b/src/actions/auth.ts index e166a85..9b7420d 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -7,6 +7,7 @@ import type { RefreshResponseData, } from "@/types/api/auth"; import { publicApi } from "@/lib/api/clients"; +import { toApiError } from "@/lib/api/errors"; import { getServerCookieHeader, parseSetCookieHeader, @@ -15,11 +16,23 @@ import { } from "@/lib/api/cookies"; import { unwrapData } from "./utils"; -export async function login(body: LoginInput): Promise { - const res = await publicApi.post>( - "/admin/auth/login", - body, - ); +export type LoginResult = + | { success: true; data: LoginResponseData } + | { success: false; error: string }; + +export async function login(body: LoginInput): Promise { + let res: Awaited< + ReturnType>> + >; + + try { + res = await publicApi.post>( + "/admin/auth/login", + body, + ); + } catch (err) { + return { success: false, error: toApiError(err).message }; + } // Forward API auth cookies to the browser so subsequent server-action API // calls can read and proxy them via getServerCookieHeader(). @@ -28,14 +41,15 @@ export async function login(body: LoginInput): Promise { .filter((c): c is NonNullable => c != null); if (cookies.length === 0) { - throw new Error( - "Authentication failed: no session cookies returned by the API.", - ); + return { + success: false, + error: "Authentication failed: no session cookies returned by the API.", + }; } await persistServerCookies(cookies); - return unwrapData(res); + return { success: true, data: unwrapData(res) }; } export async function refreshTokens(): Promise { diff --git a/src/components/admin-management/admin-accounts-table.tsx b/src/components/admin-management/admin-accounts-table.tsx index 4505731..7e83c72 100644 --- a/src/components/admin-management/admin-accounts-table.tsx +++ b/src/components/admin-management/admin-accounts-table.tsx @@ -65,7 +65,8 @@ export function AdminAccountsTable() { const [emailError, setEmailError] = React.useState(""); const queryClient = useQueryClient(); - const { data: admins = [], isLoading } = useAdminAccounts(); + const { data, isLoading } = useAdminAccounts(); + const admins = data?.items ?? []; async function handleResult(result: { ok: boolean; message: string }) { if (!result.ok) { @@ -189,6 +190,7 @@ export function AdminAccountsTable() { changeRoleMutation.mutate({ id: selectedAdmin.id, role: selectedRole, + confirm_downgrade: isDowngradingSuperAdmin, }); } diff --git a/src/components/admin-management/columns.tsx b/src/components/admin-management/columns.tsx index 9f85c14..258d55c 100644 --- a/src/components/admin-management/columns.tsx +++ b/src/components/admin-management/columns.tsx @@ -25,7 +25,10 @@ import type { AdminAccountStatus, ManagedAdminAccount, } from "@/types/api/admin-management"; -import { ADMIN_ROLE_LABELS } from "@/types/api/admin-management"; +import { + ADMIN_ROLE_LABELS, + ADMIN_STATUS_LABELS, +} from "@/types/api/admin-management"; import type { AdminRole } from "@/types/api/auth"; const roleVariantMap: Record = { @@ -35,9 +38,9 @@ const roleVariantMap: Record = { }; const statusVariantMap: Record = { - Active: "success", - "Pending Setup": "warning", - Deactivated: "error", + active: "success", + pending_setup: "warning", + deactivated: "error", }; type AdminColumnsParams = { @@ -81,12 +84,11 @@ export function getAdminAccountColumns({ accessorKey: "lastLogin", header: "Last Login", enableSorting: true, - cell: ({ row }) => - row.original.lastLogin ? ( - format(new Date(row.original.lastLogin), "MMM d, yyyy · h:mm a") - ) : ( - - ), + cell: ({ row }) => { + const date = row.original.last_login; + if (!date) return ; + return format(new Date(date), "MMM d, yyyy · h:mm a"); + }, }, { accessorKey: "status", @@ -94,7 +96,7 @@ export function getAdminAccountColumns({ enableSorting: true, cell: ({ row }) => ( ), @@ -105,7 +107,7 @@ export function getAdminAccountColumns({ enableSorting: false, cell: ({ row }) => { const admin = row.original; - const isDeactivated = admin.status === "Deactivated"; + const isDeactivated = admin.status === "deactivated"; return (
diff --git a/src/components/auth/admin-login-form.tsx b/src/components/auth/admin-login-form.tsx index 4eaa1cb..64edc27 100644 --- a/src/components/auth/admin-login-form.tsx +++ b/src/components/auth/admin-login-form.tsx @@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; -import { login } from "@/actions/auth"; +import { login, type LoginResult } from "@/actions/auth"; import { FormInput } from "@/components/custom/form-input"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; @@ -38,32 +38,42 @@ export function AdminLoginForm() { async function onSubmit(values: AdminLoginFormValues) { setFormError(null); - let user: Awaited>["user"]; + let result: LoginResult; try { - const data = await login({ + result = await login({ email: values.email, password: values.password, }); - user = data.user; - } catch (err) { + } catch { + setFormError("Something went wrong. Please try again."); + return; + } + + if (!result.success) { + setFormError(result.error); + return; + } + + const { user } = result.data; + + let signInResult: Awaited>; + try { + signInResult = await signIn("credentials", { + sessionUser: "true", + userId: user.id, + email: user.email, + name: user.fullname, + image: user.avatar_url ?? undefined, + role: user.admin_tier ?? user.role, + redirect: false, + }); + } catch { setFormError( - err instanceof Error - ? err.message - : "Something went wrong. Please try again.", + "Signed in with the API, but couldn't start your session. Try again.", ); return; } - const signInResult = await signIn("credentials", { - sessionUser: "true", - userId: user.id, - email: user.email, - name: user.fullname, - image: user.avatar_url ?? undefined, - role: user.admin_tier ?? user.role, - redirect: false, - }); - if (signInResult?.error) { setFormError( "Signed in with the API, but couldn't start your session. Try again.", diff --git a/src/hooks/api/keys.ts b/src/hooks/api/keys.ts index 94c1bb6..d7dad80 100644 --- a/src/hooks/api/keys.ts +++ b/src/hooks/api/keys.ts @@ -1,3 +1,4 @@ +import type { AdminAccountsQueryParams } from "@/types/api/admin-management"; import type { VoidedAttemptsQueryParams } from "@/types/api/integrity"; import type { EmployersQueryParams } from "@/types/api/employers"; @@ -104,6 +105,8 @@ export const engagementKeys = { export const adminManagementKeys = { all: ["admin-management"] as const, accounts: () => [...adminManagementKeys.all, "accounts"] as const, + list: (params?: AdminAccountsQueryParams) => + [...adminManagementKeys.accounts(), params ?? {}] as const, }; export const accountKeys = { diff --git a/src/hooks/api/use-admin-management.ts b/src/hooks/api/use-admin-management.ts index cd398b3..bd39d09 100644 --- a/src/hooks/api/use-admin-management.ts +++ b/src/hooks/api/use-admin-management.ts @@ -1,13 +1,15 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { getAdminAccounts } from "@/actions/admin-management"; +import type { AdminAccountsQueryParams } from "@/types/api/admin-management"; import { adminManagementKeys } from "./keys"; -export function useAdminAccounts() { +export function useAdminAccounts(params: AdminAccountsQueryParams = {}) { return useQuery({ - queryKey: adminManagementKeys.accounts(), - queryFn: getAdminAccounts, + queryKey: adminManagementKeys.list(params), + queryFn: () => getAdminAccounts(params), + placeholderData: keepPreviousData, }); } diff --git a/src/mocks/admin-management.ts b/src/mocks/admin-management.ts index efd9c14..1a6c288 100644 --- a/src/mocks/admin-management.ts +++ b/src/mocks/admin-management.ts @@ -10,40 +10,40 @@ export let MOCK_ADMIN_ACCOUNTS: ManagedAdminAccount[] = [ name: "Super Admin", email: "super@skillbridge.test", role: "super_admin", - lastLogin: "2026-06-27T09:30:00.000Z", - status: "Active", + last_login: "2026-06-27T09:30:00.000Z", + status: "active", }, { id: "mock-admin", name: "Platform Admin", email: "admin@skillbridge.test", role: "admin", - lastLogin: "2026-06-26T16:12:00.000Z", - status: "Active", + last_login: "2026-06-26T16:12:00.000Z", + status: "active", }, { id: "mock-reviewer", name: "Question Reviewer", email: "reviewer@skillbridge.test", role: "reviewer", - lastLogin: "2026-06-25T13:45:00.000Z", - status: "Active", + last_login: "2026-06-25T13:45:00.000Z", + status: "active", }, { id: "mock-pending-admin", name: "Pending Admin", email: "pending@skillbridge.test", role: "admin", - lastLogin: null, - status: "Pending Setup", + last_login: null, + status: "pending_setup", }, { id: "mock-deactivated-admin", name: "Deactivated Admin", email: "deactivated@skillbridge.test", role: "admin", - lastLogin: "2026-05-30T10:18:00.000Z", - status: "Deactivated", + last_login: "2026-05-30T10:18:00.000Z", + status: "deactivated", }, ]; @@ -53,8 +53,8 @@ export function addMockAdmin(email: string): ManagedAdminAccount { name: "Pending Admin", email, role: "admin", - lastLogin: null, - status: "Pending Setup", + last_login: null, + status: "pending_setup", }; MOCK_ADMIN_ACCOUNTS = [newAdmin, ...MOCK_ADMIN_ACCOUNTS]; diff --git a/src/types/api/admin-management.ts b/src/types/api/admin-management.ts index 2da89db..0f8ed13 100644 --- a/src/types/api/admin-management.ts +++ b/src/types/api/admin-management.ts @@ -1,16 +1,46 @@ +import type { ApiEnvelope } from "./common"; import type { AdminRole } from "@/types/api/auth"; -export type AdminAccountStatus = "Active" | "Pending Setup" | "Deactivated"; +export type AdminAccountStatus = "active" | "pending_setup" | "deactivated"; + +export const ADMIN_STATUS_LABELS: Record = { + active: "Active", + pending_setup: "Pending Setup", + deactivated: "Deactivated", +}; export type ManagedAdminAccount = { id: string; name: string; email: string; role: AdminRole; - lastLogin: string | null; + last_login: string | null; status: AdminAccountStatus; }; +export type AdminAccountsQueryParams = { + page?: number; + limit?: number; + status?: AdminAccountStatus; + search?: string; +}; + +export type AdminAccountsPage = { + items: ManagedAdminAccount[]; + total: number; + page: number; + limit: number; + totalPages: number; +}; + +/** The GET /admin/admins response is double-wrapped: data.data.items */ +type AdminAccountsApiData = { + status: string; + data: AdminAccountsPage; +}; + +export type GetAdminAccountsResponse = ApiEnvelope; + export type InviteAdminInput = { email: string; }; @@ -23,6 +53,7 @@ export type ChangeAdminEmailInput = { export type ChangeAdminRoleInput = { id: string; role: AdminRole; + confirm_downgrade?: boolean; }; export type AdminManagementResult = { From 8bbf68217d88079659ac337c4722e98315264c5c Mon Sep 17 00:00:00 2001 From: Ayomikun Date: Sat, 4 Jul 2026 21:57:22 +0100 Subject: [PATCH 2/4] feat: add separation of concerns for admin accounts table - fix coderabbit errors --- .../admin-management/admin-accounts-table.tsx | 370 ++---------------- .../admin-management/admin-action-dialogs.tsx | 269 +++++++++++++ src/components/admin-management/columns.tsx | 2 +- src/hooks/api/use-admin-management.ts | 118 +++++- 4 files changed, 428 insertions(+), 331 deletions(-) create mode 100644 src/components/admin-management/admin-action-dialogs.tsx diff --git a/src/components/admin-management/admin-accounts-table.tsx b/src/components/admin-management/admin-accounts-table.tsx index 7e83c72..a38b5eb 100644 --- a/src/components/admin-management/admin-accounts-table.tsx +++ b/src/components/admin-management/admin-accounts-table.tsx @@ -1,144 +1,36 @@ "use client"; import * as React from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - changeAdminEmail, - changeAdminRole, - deactivateAdminAccount, - reactivateAdminAccount, - resetAdminPassword, -} from "@/actions/admin-management"; import { DataTable } from "@/components/shared/data-table"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { adminManagementKeys } from "@/hooks/api/keys"; import { useAdminAccounts } from "@/hooks/api/use-admin-management"; -import type { - ChangeAdminEmailInput, - ChangeAdminRoleInput, - ManagedAdminAccount, -} from "@/types/api/admin-management"; -import { ADMIN_ROLE_LABELS, ADMIN_ROLES } from "@/types/api/admin-management"; -import type { AdminRole } from "@/types/api/auth"; +import type { ManagedAdminAccount } from "@/types/api/admin-management"; +import { + ChangeEmailDialog, + ChangeRoleDialog, + ResetPasswordDialog, + ToggleActivationDialog, +} from "./admin-action-dialogs"; import { getAdminAccountColumns } from "./columns"; type ActionType = "reset" | "email" | "role" | "deactivate" | "reactivate"; -function isValidEmail(email: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - export function AdminAccountsTable() { const [selectedAdmin, setSelectedAdmin] = React.useState(null); const [actionType, setActionType] = React.useState(null); - const [newEmail, setNewEmail] = React.useState(""); - const [selectedRole, setSelectedRole] = React.useState("admin"); - const [emailError, setEmailError] = React.useState(""); - const queryClient = useQueryClient(); - const { data, isLoading } = useAdminAccounts(); + const { data, isLoading, isError } = useAdminAccounts(); const admins = data?.items ?? []; - async function handleResult(result: { ok: boolean; message: string }) { - if (!result.ok) { - toast.error(result.message); - return; - } - - toast.success(result.message); - closeActionDialog(); - - await queryClient.invalidateQueries({ - queryKey: adminManagementKeys.accounts(), - }); - } - - const resetPasswordMutation = useMutation({ - mutationFn: resetAdminPassword, - onSuccess: handleResult, - onError: () => toast.error("Reset failed. Try again."), - }); - - const changeEmailMutation = useMutation({ - mutationFn: (input: ChangeAdminEmailInput) => changeAdminEmail(input), - onSuccess: async (result) => { - if (!result.ok) { - setEmailError(result.message); - toast.error(result.message); - return; - } - - await handleResult(result); - }, - onError: () => toast.error("Email update failed. Try again."), - }); - - const changeRoleMutation = useMutation({ - mutationFn: (input: ChangeAdminRoleInput) => changeAdminRole(input), - onSuccess: handleResult, - onError: () => toast.error("Role update failed. Try again."), - }); - - const deactivateMutation = useMutation({ - mutationFn: deactivateAdminAccount, - onSuccess: handleResult, - onError: () => toast.error("Deactivation failed. Try again."), - }); - - const reactivateMutation = useMutation({ - mutationFn: reactivateAdminAccount, - onSuccess: handleResult, - onError: () => toast.error("Reactivation failed. Try again."), - }); - function openActionDialog(admin: ManagedAdminAccount, type: ActionType) { setSelectedAdmin(admin); setActionType(type); - setEmailError(""); - - if (type === "email") { - setNewEmail(admin.email); - } - - if (type === "role") { - setSelectedRole(admin.role); - } } function closeActionDialog() { setSelectedAdmin(null); setActionType(null); - setNewEmail(""); - setEmailError(""); } const columns = React.useMemo( @@ -153,226 +45,50 @@ export function AdminAccountsTable() { [], ); - const isResetOpen = actionType === "reset" && !!selectedAdmin; - const isEmailOpen = actionType === "email" && !!selectedAdmin; - const isRoleOpen = actionType === "role" && !!selectedAdmin; - const isDeactivateOpen = actionType === "deactivate" && !!selectedAdmin; - const isReactivateOpen = actionType === "reactivate" && !!selectedAdmin; - - const isDowngradingSuperAdmin = - selectedAdmin?.role === "super_admin" && selectedRole !== "super_admin"; - - function submitEmailChange(event: React.FormEvent) { - event.preventDefault(); - - if (!selectedAdmin) return; - - const trimmedEmail = newEmail.trim().toLowerCase(); - - if (!isValidEmail(trimmedEmail)) { - setEmailError("Enter a valid email address."); - return; - } - - setEmailError(""); - - changeEmailMutation.mutate({ - id: selectedAdmin.id, - email: trimmedEmail, - }); - } - - function submitRoleChange(event: React.FormEvent) { - event.preventDefault(); - - if (!selectedAdmin) return; - - changeRoleMutation.mutate({ - id: selectedAdmin.id, - role: selectedRole, - confirm_downgrade: isDowngradingSuperAdmin, - }); - } - return ( <> - - - - Reset password? - - Reset password for {selectedAdmin?.name}? They will need to set a - new password before logging in again. - - - - - Cancel - - selectedAdmin && resetPasswordMutation.mutate(selectedAdmin.id) - } - > - Reset Password - - - - - - - - - Change Email - - Update the email address for {selectedAdmin?.name}. - - - -
-
- - -
- -
- - { - setNewEmail(event.target.value); - setEmailError(""); - }} - /> - - {emailError &&

{emailError}

} -
- -
- - -
-
-
-
- - - - - Change Role - - Change the dashboard access level for {selectedAdmin?.name}. - - - -
-
- - -
- - {isDowngradingSuperAdmin && ( -
- Downgrading this Super Admin will remove access to Payments and - Admin Management immediately. -
- )} - -
- - -
-
-
-
- - - - - Deactivate account? - - Deactivate {selectedAdmin?.name}'s account? They will no - longer be able to log in. - - - - - Cancel - - selectedAdmin && deactivateMutation.mutate(selectedAdmin.id) - } - > - Deactivate - - - - - - - - - Reactivate account? - - Reactivate {selectedAdmin?.name}'s account? They will be able - to log in again. - - - - - Cancel - - selectedAdmin && reactivateMutation.mutate(selectedAdmin.id) - } - > - Reactivate - - - - + + + + + ); } diff --git a/src/components/admin-management/admin-action-dialogs.tsx b/src/components/admin-management/admin-action-dialogs.tsx new file mode 100644 index 0000000..3d4b54a --- /dev/null +++ b/src/components/admin-management/admin-action-dialogs.tsx @@ -0,0 +1,269 @@ +"use client"; + +import * as React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + useChangeAdminEmail, + useChangeAdminRole, + useDeactivateAdmin, + useReactivateAdmin, + useResetAdminPassword, +} from "@/hooks/api/use-admin-management"; +import type { ManagedAdminAccount } from "@/types/api/admin-management"; +import { ADMIN_ROLE_LABELS, ADMIN_ROLES } from "@/types/api/admin-management"; +import type { AdminRole } from "@/types/api/auth"; + +type DialogProps = { + admin: ManagedAdminAccount | null; + open: boolean; + onClose: () => void; +}; + +export function ResetPasswordDialog({ admin, open, onClose }: DialogProps) { + const mutation = useResetAdminPassword(onClose); + + return ( + !isOpen && onClose()}> + + + Reset password? + + Reset password for {admin?.name}? They will need to set a new + password before logging in again. + + + + Cancel + admin && mutation.mutate(admin.id)}> + Reset Password + + + + + ); +} + +export function ChangeEmailDialog({ admin, open, onClose }: DialogProps) { + const [newEmail, setNewEmail] = React.useState(admin?.email ?? ""); + const mutation = useChangeAdminEmail(onClose); + + const serverError = mutation.data?.ok === false ? mutation.data.message : ""; + + return ( + { + if (!isOpen) { + onClose(); + mutation.reset(); + } + }} + > + + + Change Email + + Update the email address for {admin?.name}. + + + +
{ + e.preventDefault(); + if (admin) + mutation.mutate({ + id: admin.id, + email: newEmail.trim().toLowerCase(), + }); + }} + className="flex flex-col gap-4" + > +
+ + +
+ +
+ + setNewEmail(e.target.value)} + /> + {serverError &&

{serverError}

} +
+ +
+ + +
+
+
+
+ ); +} + +export function ChangeRoleDialog({ admin, open, onClose }: DialogProps) { + const [selectedRole, setSelectedRole] = React.useState( + admin?.role ?? "admin", + ); + const mutation = useChangeAdminRole(onClose); + + const isDowngradingSuperAdmin = + admin?.role === "super_admin" && selectedRole !== "super_admin"; + + return ( + { + if (!isOpen) { + onClose(); + mutation.reset(); + } + }} + > + + + Change Role + + Change the dashboard access level for {admin?.name}. + + + +
{ + e.preventDefault(); + if (admin) + mutation.mutate({ + id: admin.id, + role: selectedRole, + confirm_downgrade: isDowngradingSuperAdmin, + }); + }} + className="flex flex-col gap-4" + > +
+ + +
+ + {isDowngradingSuperAdmin && ( +
+ Downgrading this Super Admin will remove access to Payments and + Admin Management immediately. +
+ )} + +
+ + +
+
+
+
+ ); +} + +const toggleConfig = { + deactivate: { + title: "Deactivate account?", + description: (name: string) => + `Deactivate ${name}'s account? They will no longer be able to log in.`, + action: "Deactivate", + actionClass: "bg-error text-white hover:bg-error/90", + }, + reactivate: { + title: "Reactivate account?", + description: (name: string) => + `Reactivate ${name}'s account? They will be able to log in again.`, + action: "Reactivate", + actionClass: undefined, + }, +} as const; + +type ToggleActivationDialogProps = DialogProps & { + mode: "deactivate" | "reactivate"; +}; + +export function ToggleActivationDialog({ + admin, + open, + onClose, + mode, +}: ToggleActivationDialogProps) { + const deactivate = useDeactivateAdmin(onClose); + const reactivate = useReactivateAdmin(onClose); + const mutation = mode === "deactivate" ? deactivate : reactivate; + const { title, description, action, actionClass } = toggleConfig[mode]; + + return ( + !isOpen && onClose()}> + + + {title} + + {admin?.name ? description(admin.name) : null} + + + + Cancel + admin && mutation.mutate(admin.id)} + > + {action} + + + + + ); +} diff --git a/src/components/admin-management/columns.tsx b/src/components/admin-management/columns.tsx index 258d55c..9ae3d18 100644 --- a/src/components/admin-management/columns.tsx +++ b/src/components/admin-management/columns.tsx @@ -81,7 +81,7 @@ export function getAdminAccountColumns({ ), }, { - accessorKey: "lastLogin", + accessorKey: "last_login", header: "Last Login", enableSorting: true, cell: ({ row }) => { diff --git a/src/hooks/api/use-admin-management.ts b/src/hooks/api/use-admin-management.ts index bd39d09..8c14db7 100644 --- a/src/hooks/api/use-admin-management.ts +++ b/src/hooks/api/use-admin-management.ts @@ -1,9 +1,26 @@ "use client"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { toast } from "sonner"; -import { getAdminAccounts } from "@/actions/admin-management"; -import type { AdminAccountsQueryParams } from "@/types/api/admin-management"; +import { + changeAdminEmail, + changeAdminRole, + deactivateAdminAccount, + getAdminAccounts, + reactivateAdminAccount, + resetAdminPassword, +} from "@/actions/admin-management"; +import type { + AdminAccountsQueryParams, + ChangeAdminEmailInput, + ChangeAdminRoleInput, +} from "@/types/api/admin-management"; import { adminManagementKeys } from "./keys"; export function useAdminAccounts(params: AdminAccountsQueryParams = {}) { @@ -13,3 +30,98 @@ export function useAdminAccounts(params: AdminAccountsQueryParams = {}) { placeholderData: keepPreviousData, }); } + +export function useResetAdminPassword(onSuccess?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: resetAdminPassword, + onSuccess: async (result) => { + if (!result.ok) { + toast.error(result.message); + return; + } + toast.success(result.message); + onSuccess?.(); + await queryClient.invalidateQueries({ + queryKey: adminManagementKeys.accounts(), + }); + }, + onError: () => toast.error("Password reset failed. Try again."), + }); +} + +export function useChangeAdminEmail(onSuccess?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: ChangeAdminEmailInput) => changeAdminEmail(input), + onSuccess: async (result) => { + if (!result.ok) { + toast.error(result.message); + return; + } + toast.success(result.message); + onSuccess?.(); + await queryClient.invalidateQueries({ + queryKey: adminManagementKeys.accounts(), + }); + }, + onError: () => toast.error("Email update failed. Try again."), + }); +} + +export function useChangeAdminRole(onSuccess?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: ChangeAdminRoleInput) => changeAdminRole(input), + onSuccess: async (result) => { + if (!result.ok) { + toast.error(result.message); + return; + } + toast.success(result.message); + onSuccess?.(); + await queryClient.invalidateQueries({ + queryKey: adminManagementKeys.accounts(), + }); + }, + onError: () => toast.error("Role update failed. Try again."), + }); +} + +export function useDeactivateAdmin(onSuccess?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deactivateAdminAccount, + onSuccess: async (result) => { + if (!result.ok) { + toast.error(result.message); + return; + } + toast.success(result.message); + onSuccess?.(); + await queryClient.invalidateQueries({ + queryKey: adminManagementKeys.accounts(), + }); + }, + onError: () => toast.error("Deactivation failed. Try again."), + }); +} + +export function useReactivateAdmin(onSuccess?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: reactivateAdminAccount, + onSuccess: async (result) => { + if (!result.ok) { + toast.error(result.message); + return; + } + toast.success(result.message); + onSuccess?.(); + await queryClient.invalidateQueries({ + queryKey: adminManagementKeys.accounts(), + }); + }, + onError: () => toast.error("Reactivation failed. Try again."), + }); +} From e17bee045ee871b7aedcfa7a4f22dd1890bc1f2f Mon Sep 17 00:00:00 2001 From: Ayomikun Date: Wed, 8 Jul 2026 17:59:18 +0100 Subject: [PATCH 3/4] fix: separation of concerns --- .../admin-management/admin-accounts-table.tsx | 2 +- .../admin-management/admin-action-dialogs.tsx | 57 ------------- .../admin-toggle-activation-modal.tsx | 79 +++++++++++++++++++ 3 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 src/components/admin-management/admin-toggle-activation-modal.tsx diff --git a/src/components/admin-management/admin-accounts-table.tsx b/src/components/admin-management/admin-accounts-table.tsx index a38b5eb..5c3f698 100644 --- a/src/components/admin-management/admin-accounts-table.tsx +++ b/src/components/admin-management/admin-accounts-table.tsx @@ -9,9 +9,9 @@ import { ChangeEmailDialog, ChangeRoleDialog, ResetPasswordDialog, - ToggleActivationDialog, } from "./admin-action-dialogs"; import { getAdminAccountColumns } from "./columns"; +import { ToggleActivationDialog } from "./admin-toggle-activation-modal"; type ActionType = "reset" | "email" | "role" | "deactivate" | "reactivate"; diff --git a/src/components/admin-management/admin-action-dialogs.tsx b/src/components/admin-management/admin-action-dialogs.tsx index 3d4b54a..6c0e3d6 100644 --- a/src/components/admin-management/admin-action-dialogs.tsx +++ b/src/components/admin-management/admin-action-dialogs.tsx @@ -31,8 +31,6 @@ import { import { useChangeAdminEmail, useChangeAdminRole, - useDeactivateAdmin, - useReactivateAdmin, useResetAdminPassword, } from "@/hooks/api/use-admin-management"; import type { ManagedAdminAccount } from "@/types/api/admin-management"; @@ -212,58 +210,3 @@ export function ChangeRoleDialog({ admin, open, onClose }: DialogProps) { ); } - -const toggleConfig = { - deactivate: { - title: "Deactivate account?", - description: (name: string) => - `Deactivate ${name}'s account? They will no longer be able to log in.`, - action: "Deactivate", - actionClass: "bg-error text-white hover:bg-error/90", - }, - reactivate: { - title: "Reactivate account?", - description: (name: string) => - `Reactivate ${name}'s account? They will be able to log in again.`, - action: "Reactivate", - actionClass: undefined, - }, -} as const; - -type ToggleActivationDialogProps = DialogProps & { - mode: "deactivate" | "reactivate"; -}; - -export function ToggleActivationDialog({ - admin, - open, - onClose, - mode, -}: ToggleActivationDialogProps) { - const deactivate = useDeactivateAdmin(onClose); - const reactivate = useReactivateAdmin(onClose); - const mutation = mode === "deactivate" ? deactivate : reactivate; - const { title, description, action, actionClass } = toggleConfig[mode]; - - return ( - !isOpen && onClose()}> - - - {title} - - {admin?.name ? description(admin.name) : null} - - - - Cancel - admin && mutation.mutate(admin.id)} - > - {action} - - - - - ); -} diff --git a/src/components/admin-management/admin-toggle-activation-modal.tsx b/src/components/admin-management/admin-toggle-activation-modal.tsx new file mode 100644 index 0000000..12c1ebd --- /dev/null +++ b/src/components/admin-management/admin-toggle-activation-modal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +import { + useDeactivateAdmin, + useReactivateAdmin, +} from "@/hooks/api/use-admin-management"; +import type { ManagedAdminAccount } from "@/types/api/admin-management"; + +type DialogProps = { + admin: ManagedAdminAccount | null; + open: boolean; + onClose: () => void; +}; + +const toggleConfig = { + deactivate: { + title: "Deactivate account?", + description: (name: string) => + `Deactivate ${name}'s account? They will no longer be able to log in.`, + action: "Deactivate", + actionClass: "bg-error text-white hover:bg-error/90", + }, + reactivate: { + title: "Reactivate account?", + description: (name: string) => + `Reactivate ${name}'s account? They will be able to log in again.`, + action: "Reactivate", + actionClass: undefined, + }, +} as const; + +type ToggleActivationDialogProps = DialogProps & { + mode: "deactivate" | "reactivate"; +}; + +export function ToggleActivationDialog({ + admin, + open, + onClose, + mode, +}: ToggleActivationDialogProps) { + const deactivate = useDeactivateAdmin(onClose); + const reactivate = useReactivateAdmin(onClose); + const mutation = mode === "deactivate" ? deactivate : reactivate; + const { title, description, action, actionClass } = toggleConfig[mode]; + + return ( + !isOpen && onClose()}> + + + {title} + + {admin?.name ? description(admin.name) : null} + + + + Cancel + admin && mutation.mutate(admin.id)} + > + {action} + + + + + ); +} From a384e28e56d3e4d6e7e981bd940066666c92871e Mon Sep 17 00:00:00 2001 From: Lisan al Gaib Date: Thu, 9 Jul 2026 11:39:33 +0100 Subject: [PATCH 4/4] fix: address CodeRabbit review feedback on admin management PR - Extract shared callAdminApi/createAdminMutation helpers to remove duplicated try/catch and toast/invalidation boilerplate. - Keep the reset password dialog open until the mutation resolves instead of letting AlertDialogAction auto-close it. - Require the new email field to block empty submissions client-side. --- src/actions/admin-management.ts | 84 +++++------ .../admin-management/admin-action-dialogs.tsx | 15 +- src/hooks/api/use-admin-management.ts | 135 ++++++------------ 3 files changed, 97 insertions(+), 137 deletions(-) diff --git a/src/actions/admin-management.ts b/src/actions/admin-management.ts index e8d7625..1f880d6 100644 --- a/src/actions/admin-management.ts +++ b/src/actions/admin-management.ts @@ -21,6 +21,18 @@ function fail(message: string): AdminManagementResult { return { ok: false, message }; } +async function callAdminApi( + request: () => Promise, + successMessage: string, +): Promise { + try { + await request(); + return ok(successMessage); + } catch (err) { + return fail(toApiError(err).message); + } +} + export async function getAdminAccounts( params: AdminAccountsQueryParams = {}, ): Promise { @@ -34,70 +46,60 @@ export async function getAdminAccounts( export async function inviteAdmin( input: InviteAdminInput, ): Promise { - try { - await authApi.post("/admin/admins/invite", { email: input.email }); - return ok(`Invite sent to ${input.email}.`); - } catch (err) { - return fail(toApiError(err).message); - } + return callAdminApi( + () => authApi.post("/admin/admins/invite", { email: input.email }), + `Invite sent to ${input.email}.`, + ); } export async function resetAdminPassword( id: string, ): Promise { - try { - await authApi.post(`/admin/admins/${id}/reset-password`); - return ok("Password reset email sent."); - } catch (err) { - return fail(toApiError(err).message); - } + return callAdminApi( + () => authApi.post(`/admin/admins/${id}/reset-password`), + "Password reset email sent.", + ); } export async function changeAdminEmail( input: ChangeAdminEmailInput, ): Promise { - try { - await authApi.patch(`/admin/admins/${input.id}/email`, { - email: input.email, - }); - return ok("Email updated successfully."); - } catch (err) { - return fail(toApiError(err).message); - } + return callAdminApi( + () => + authApi.patch(`/admin/admins/${input.id}/email`, { + email: input.email, + }), + "Email updated successfully.", + ); } export async function changeAdminRole( input: ChangeAdminRoleInput, ): Promise { - try { - await authApi.patch(`/admin/admins/${input.id}/role`, { - role: input.role, - confirm_downgrade: input.confirm_downgrade, - }); - return ok("Role updated successfully."); - } catch (err) { - return fail(toApiError(err).message); - } + return callAdminApi( + () => + authApi.patch(`/admin/admins/${input.id}/role`, { + role: input.role, + confirm_downgrade: input.confirm_downgrade, + }), + "Role updated successfully.", + ); } export async function deactivateAdminAccount( id: string, ): Promise { - try { - await authApi.patch(`/admin/admins/${id}/deactivate`); - return ok("Account deactivated."); - } catch (err) { - return fail(toApiError(err).message); - } + return callAdminApi( + () => authApi.patch(`/admin/admins/${id}/deactivate`), + "Account deactivated.", + ); } export async function reactivateAdminAccount( id: string, ): Promise { - try { - await authApi.patch(`/admin/admins/${id}/reactivate`); - return ok("Account reactivated."); - } catch (err) { - return fail(toApiError(err).message); - } + return callAdminApi( + () => authApi.patch(`/admin/admins/${id}/reactivate`), + "Account reactivated.", + ); } diff --git a/src/components/admin-management/admin-action-dialogs.tsx b/src/components/admin-management/admin-action-dialogs.tsx index 6c0e3d6..67f2a28 100644 --- a/src/components/admin-management/admin-action-dialogs.tsx +++ b/src/components/admin-management/admin-action-dialogs.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { AlertDialog, - AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -57,10 +56,15 @@ export function ResetPasswordDialog({ admin, open, onClose }: DialogProps) { - Cancel - admin && mutation.mutate(admin.id)}> - Reset Password - + + Cancel + + @@ -112,6 +116,7 @@ export function ChangeEmailDialog({ admin, open, onClose }: DialogProps) { setNewEmail(e.target.value)} /> diff --git a/src/hooks/api/use-admin-management.ts b/src/hooks/api/use-admin-management.ts index 8c14db7..3464671 100644 --- a/src/hooks/api/use-admin-management.ts +++ b/src/hooks/api/use-admin-management.ts @@ -18,8 +18,7 @@ import { } from "@/actions/admin-management"; import type { AdminAccountsQueryParams, - ChangeAdminEmailInput, - ChangeAdminRoleInput, + AdminManagementResult, } from "@/types/api/admin-management"; import { adminManagementKeys } from "./keys"; @@ -31,97 +30,51 @@ export function useAdminAccounts(params: AdminAccountsQueryParams = {}) { }); } -export function useResetAdminPassword(onSuccess?: () => void) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: resetAdminPassword, - onSuccess: async (result) => { - if (!result.ok) { - toast.error(result.message); - return; - } - toast.success(result.message); - onSuccess?.(); - await queryClient.invalidateQueries({ - queryKey: adminManagementKeys.accounts(), - }); - }, - onError: () => toast.error("Password reset failed. Try again."), - }); +function createAdminMutation( + mutationFn: (input: TInput) => Promise, + errorMessage: string, +) { + return function useAdminMutation(onSuccess?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: async (result) => { + if (!result.ok) { + toast.error(result.message); + return; + } + toast.success(result.message); + onSuccess?.(); + await queryClient.invalidateQueries({ + queryKey: adminManagementKeys.accounts(), + }); + }, + onError: () => toast.error(errorMessage), + }); + }; } -export function useChangeAdminEmail(onSuccess?: () => void) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input: ChangeAdminEmailInput) => changeAdminEmail(input), - onSuccess: async (result) => { - if (!result.ok) { - toast.error(result.message); - return; - } - toast.success(result.message); - onSuccess?.(); - await queryClient.invalidateQueries({ - queryKey: adminManagementKeys.accounts(), - }); - }, - onError: () => toast.error("Email update failed. Try again."), - }); -} +export const useResetAdminPassword = createAdminMutation( + resetAdminPassword, + "Password reset failed. Try again.", +); -export function useChangeAdminRole(onSuccess?: () => void) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input: ChangeAdminRoleInput) => changeAdminRole(input), - onSuccess: async (result) => { - if (!result.ok) { - toast.error(result.message); - return; - } - toast.success(result.message); - onSuccess?.(); - await queryClient.invalidateQueries({ - queryKey: adminManagementKeys.accounts(), - }); - }, - onError: () => toast.error("Role update failed. Try again."), - }); -} +export const useChangeAdminEmail = createAdminMutation( + changeAdminEmail, + "Email update failed. Try again.", +); -export function useDeactivateAdmin(onSuccess?: () => void) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deactivateAdminAccount, - onSuccess: async (result) => { - if (!result.ok) { - toast.error(result.message); - return; - } - toast.success(result.message); - onSuccess?.(); - await queryClient.invalidateQueries({ - queryKey: adminManagementKeys.accounts(), - }); - }, - onError: () => toast.error("Deactivation failed. Try again."), - }); -} +export const useChangeAdminRole = createAdminMutation( + changeAdminRole, + "Role update failed. Try again.", +); -export function useReactivateAdmin(onSuccess?: () => void) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: reactivateAdminAccount, - onSuccess: async (result) => { - if (!result.ok) { - toast.error(result.message); - return; - } - toast.success(result.message); - onSuccess?.(); - await queryClient.invalidateQueries({ - queryKey: adminManagementKeys.accounts(), - }); - }, - onError: () => toast.error("Reactivation failed. Try again."), - }); -} +export const useDeactivateAdmin = createAdminMutation( + deactivateAdminAccount, + "Deactivation failed. Try again.", +); + +export const useReactivateAdmin = createAdminMutation( + reactivateAdminAccount, + "Reactivation failed. Try again.", +);