feat: implement admin panel functionality in Mini App
- Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties. - Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data. - Updated documentation to include Mini App design guidelines and admin panel functionalities. - Enhanced tests for admin API to ensure proper access control and functionality. - Improved error handling and localization for admin actions.
This commit is contained in:
@@ -4,7 +4,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
|
||||
import {
|
||||
fetchDuties,
|
||||
fetchCalendarEvents,
|
||||
fetchAdminMe,
|
||||
fetchAdminUsers,
|
||||
patchAdminDuty,
|
||||
AccessDeniedError,
|
||||
} from "./api";
|
||||
|
||||
describe("fetchDuties", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAdminMe", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ is_admin: true }),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("returns is_admin true on 200", async () => {
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: true });
|
||||
});
|
||||
|
||||
it("returns is_admin false on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
} as Response);
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: false });
|
||||
});
|
||||
|
||||
it("returns is_admin false on non-200 and non-403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as Response);
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: false });
|
||||
});
|
||||
|
||||
it("returns is_admin false when response is not boolean", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ is_admin: "yes" }),
|
||||
} as Response);
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAdminUsers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const validUsers = [
|
||||
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
|
||||
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(validUsers),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("returns users array on 200", async () => {
|
||||
const result = await fetchAdminUsers("init-data", "en");
|
||||
expect(result).toEqual(validUsers);
|
||||
});
|
||||
|
||||
it("throws AccessDeniedError on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||
} as Response);
|
||||
await expect(fetchAdminUsers("init-data", "en")).rejects.toThrow(AccessDeniedError);
|
||||
await expect(fetchAdminUsers("init-data", "en")).rejects.toMatchObject({
|
||||
message: "ACCESS_DENIED",
|
||||
serverDetail: "Admin only",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters invalid items from response", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
validUsers[0],
|
||||
{ id: 2, full_name: "Bob", username: 123, role_id: 2 },
|
||||
{ id: 3 },
|
||||
]),
|
||||
} as Response);
|
||||
const result = await fetchAdminUsers("init-data", "en");
|
||||
expect(result).toEqual([validUsers[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchAdminDuty", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const updatedDuty = {
|
||||
id: 5,
|
||||
user_id: 2,
|
||||
start_at: "2025-03-01T09:00:00Z",
|
||||
end_at: "2025-03-01T18:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(updatedDuty),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("sends PATCH with user_id and returns updated duty", async () => {
|
||||
const result = await patchAdminDuty(5, 2, "init-data", "en");
|
||||
expect(result).toEqual(updatedDuty);
|
||||
const fetchCall = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[1]?.method).toBe("PATCH");
|
||||
expect(fetchCall[1]?.body).toBe(JSON.stringify({ user_id: 2 }));
|
||||
});
|
||||
|
||||
it("throws AccessDeniedError on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||
} as Response);
|
||||
await expect(
|
||||
patchAdminDuty(5, 2, "init-data", "en")
|
||||
).rejects.toThrow(AccessDeniedError);
|
||||
});
|
||||
|
||||
it("throws with server detail on 400", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ detail: "User not found" }),
|
||||
} as Response);
|
||||
await expect(
|
||||
patchAdminDuty(5, 999, "init-data", "en")
|
||||
).rejects.toThrow("User not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,22 @@ import { translate } from "@/i18n/messages";
|
||||
|
||||
type ApiLang = "ru" | "en";
|
||||
|
||||
/** User summary for admin dropdown (GET /api/admin/users). */
|
||||
export interface UserForAdmin {
|
||||
id: number;
|
||||
full_name: string;
|
||||
username: string | null;
|
||||
role_id: number | null;
|
||||
}
|
||||
|
||||
/** Minimal duty shape returned by PATCH /api/admin/duties/:id. */
|
||||
export interface DutyReassignResponse {
|
||||
id: number;
|
||||
user_id: number;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
}
|
||||
|
||||
/** Minimal runtime check for a single duty item (required fields). */
|
||||
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
@@ -207,3 +223,153 @@ export async function fetchCalendarEvents(
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch admin status for the current user (GET /api/admin/me).
|
||||
* Returns { is_admin: true } or { is_admin: false }. On 403, treat as not admin.
|
||||
*/
|
||||
export async function fetchAdminMe(
|
||||
initData: string,
|
||||
acceptLang: ApiLang
|
||||
): Promise<{ is_admin: boolean }> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/admin/me`;
|
||||
const opts = buildFetchOptions(initData, acceptLang);
|
||||
try {
|
||||
logger.debug("API request", "/api/admin/me");
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
return { is_admin: false };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { is_admin: false };
|
||||
}
|
||||
const data = (await res.json()) as { is_admin?: boolean };
|
||||
return {
|
||||
is_admin: typeof data?.is_admin === "boolean" ? data.is_admin : false,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.warn("API request failed", "/api/admin/me", e);
|
||||
return { is_admin: false };
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch users for admin dropdown (GET /api/admin/users). Admin only; throws AccessDeniedError on 403.
|
||||
*/
|
||||
export async function fetchAdminUsers(
|
||||
initData: string,
|
||||
acceptLang: ApiLang,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<UserForAdmin[]> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/admin/users`;
|
||||
const opts = buildFetchOptions(initData, acceptLang, signal);
|
||||
try {
|
||||
logger.debug("API request", "/api/admin/users");
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
let detail = translate(acceptLang, "admin.access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||
detail =
|
||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.filter(
|
||||
(x: unknown): x is UserForAdmin =>
|
||||
x != null &&
|
||||
typeof (x as UserForAdmin).id === "number" &&
|
||||
typeof (x as UserForAdmin).full_name === "string" &&
|
||||
((x as UserForAdmin).username === null ||
|
||||
typeof (x as UserForAdmin).username === "string") &&
|
||||
((x as UserForAdmin).role_id === null ||
|
||||
typeof (x as UserForAdmin).role_id === "number")
|
||||
);
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
|
||||
throw e;
|
||||
}
|
||||
logger.error("API request failed", "/api/admin/users", e);
|
||||
throw e;
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a duty to another user (PATCH /api/admin/duties/:id). Admin only.
|
||||
* Returns updated duty (id, user_id, start_at, end_at). Throws on 403/404/400.
|
||||
*/
|
||||
export async function patchAdminDuty(
|
||||
dutyId: number,
|
||||
userId: number,
|
||||
initData: string,
|
||||
acceptLang: ApiLang
|
||||
): Promise<DutyReassignResponse> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/admin/duties/${dutyId}`;
|
||||
const opts = buildFetchOptions(initData, acceptLang);
|
||||
try {
|
||||
logger.debug("API request", "PATCH", url, { user_id: userId });
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...opts.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
let detail = translate(acceptLang, "admin.access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||
detail =
|
||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
data && typeof (data as { detail?: string }).detail === "string"
|
||||
? (data as { detail: string }).detail
|
||||
: translate(acceptLang, "error_generic");
|
||||
throw new Error(msg);
|
||||
}
|
||||
const out = data as DutyReassignResponse;
|
||||
if (
|
||||
typeof out?.id !== "number" ||
|
||||
typeof out?.user_id !== "number" ||
|
||||
typeof out?.start_at !== "string" ||
|
||||
typeof out?.end_at !== "string"
|
||||
) {
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user