Skip to content
Merged
146 changes: 58 additions & 88 deletions src/actions/admin-management.ts
Original file line number Diff line number Diff line change
@@ -1,135 +1,105 @@
"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 };
}

function fail(message: string): AdminManagementResult {
return { ok: false, message };
}

export async function getAdminAccounts(): Promise<ManagedAdminAccount[]> {
return MOCK_ADMIN_ACCOUNTS;
async function callAdminApi(
request: () => Promise<unknown>,
successMessage: string,
): Promise<AdminManagementResult> {
try {
await request();
return ok(successMessage);
} catch (err) {
return fail(toApiError(err).message);
}
}

export async function getAdminAccounts(
params: AdminAccountsQueryParams = {},
): Promise<AdminAccountsPage> {
const res = await authApi.get<GetAdminAccountsResponse>("/admin/admins", {
params,
});
// Response is double-wrapped: ApiEnvelope.data = { status, data: { items, ... } }
return unwrapData(res).data;
}

export async function inviteAdmin(
input: InviteAdminInput,
): Promise<AdminManagementResult> {
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.");
}

addMockAdmin(email);

return success(`Invite sent to ${email}.`);
return callAdminApi(
() => authApi.post("/admin/admins/invite", { email: input.email }),
`Invite sent to ${input.email}.`,
);
}

export async function resetAdminPassword(
id: string,
): Promise<AdminManagementResult> {
const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === id);

if (!admin) {
return fail("Admin account not found.");
}

return success(`Password reset for ${admin.name}.`);
return callAdminApi(
() => authApi.post(`/admin/admins/${id}/reset-password`),
"Password reset email sent.",
);
}

export async function changeAdminEmail(
input: ChangeAdminEmailInput,
): Promise<AdminManagementResult> {
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.");
}

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}.`);
return callAdminApi(
() =>
authApi.patch(`/admin/admins/${input.id}/email`, {
email: input.email,
}),
"Email updated successfully.",
);
}

export async function changeAdminRole(
input: ChangeAdminRoleInput,
): Promise<AdminManagementResult> {
const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === input.id);

if (!admin) {
return fail("Admin account not found.");
}

updateMockAdminRole(input.id, input.role);

return success(`Role updated for ${admin.name}.`);
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<AdminManagementResult> {
const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === id);

if (!admin) {
return fail("Admin account not found.");
}

updateMockAdminStatus(id, "Deactivated");

return success("Account deactivated.");
return callAdminApi(
() => authApi.patch(`/admin/admins/${id}/deactivate`),
"Account deactivated.",
);
}

export async function reactivateAdminAccount(
id: string,
): Promise<AdminManagementResult> {
const admin = MOCK_ADMIN_ACCOUNTS.find((account) => account.id === id);

if (!admin) {
return fail("Admin account not found.");
}

updateMockAdminStatus(id, "Active");

return success("Account reactivated.");
return callAdminApi(
() => authApi.patch(`/admin/admins/${id}/reactivate`),
"Account reactivated.",
);
}
32 changes: 23 additions & 9 deletions src/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,11 +16,23 @@ import {
} from "@/lib/api/cookies";
import { unwrapData } from "./utils";

export async function login(body: LoginInput): Promise<LoginResponseData> {
const res = await publicApi.post<ApiEnvelope<LoginResponseData>>(
"/admin/auth/login",
body,
);
export type LoginResult =
| { success: true; data: LoginResponseData }
| { success: false; error: string };

export async function login(body: LoginInput): Promise<LoginResult> {
let res: Awaited<
ReturnType<typeof publicApi.post<ApiEnvelope<LoginResponseData>>>
>;

try {
res = await publicApi.post<ApiEnvelope<LoginResponseData>>(
"/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().
Expand All @@ -28,14 +41,15 @@ export async function login(body: LoginInput): Promise<LoginResponseData> {
.filter((c): c is NonNullable<typeof c> => 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<RefreshResponseData> {
Expand Down
31 changes: 20 additions & 11 deletions src/actions/engagement.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
"use server";

import type { ApiEnvelope } from "@/types/api";
import type {
EngagementStats,
MinorAssessmentUptakeData,
Nested,
RetakeDropoffData,
} from "@/types/api/engagement";

import {
getMockMinorAssessmentUptake,
MOCK_ENGAGEMENT_STATS,
MOCK_RETAKE_DROPOFF,
} from "@/mocks/engagement";

// TODO: replace mock bodies with real API calls once endpoints are available.
import { authApi } from "@/lib/api/clients";
import { unwrapData } from "./utils";

export async function getEngagementStats(): Promise<EngagementStats> {
return MOCK_ENGAGEMENT_STATS;
const res = await authApi.get<ApiEnvelope<Nested<EngagementStats>>>(
"/admin/engagement/stats",
);
return unwrapData(res).data;
}

export async function getRetakeDropoff(): Promise<RetakeDropoffData> {
return MOCK_RETAKE_DROPOFF;
const res = await authApi.get<ApiEnvelope<Nested<RetakeDropoffData>>>(
"/admin/engagement/retake-dropoff",
);
return unwrapData(res).data;
}

export async function getMinorAssessmentUptake(
track = "all",
): Promise<MinorAssessmentUptakeData> {
return getMockMinorAssessmentUptake(track);
const params = track && track !== "all" ? { track } : undefined;
const res = await authApi.get<ApiEnvelope<Nested<MinorAssessmentUptakeData>>>(
"/admin/engagement/minor-uptake",
{ params },
);
return unwrapData(res).data;
}
Loading
Loading