feat: enhance admin page functionality with new components and hooks
- Added `AdminDutyList` and `ReassignSheet` components for improved duty management in the admin panel. - Introduced `useAdminPage` hook to encapsulate admin-related logic, including user and duty loading, and reassign functionality. - Updated `frontend.mdc` documentation to reflect new admin components and their usage. - Improved error handling for API responses, particularly for access denied scenarios. - Refactored admin page to utilize new components, streamlining the UI and enhancing maintainability.
This commit is contained in:
@@ -27,6 +27,7 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
|
||||
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||
| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet |
|
||||
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||
@@ -42,6 +43,7 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
|
||||
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
|
||||
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||
- **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,264 +1,37 @@
|
||||
/**
|
||||
* Admin page: list duties for the month and reassign duty to another user.
|
||||
* Visible only to admins (link shown on calendar when GET /api/admin/me returns is_admin).
|
||||
* Requires GET /api/admin/users and PATCH /api/admin/duties/:id (admin-only).
|
||||
* Logic and heavy UI live in components/admin (useAdminPage, AdminDutyList, ReassignSheet).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { backButton } from "@telegram-apps/sdk-react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
fetchDuties,
|
||||
fetchAdminMe,
|
||||
fetchAdminUsers,
|
||||
patchAdminDuty,
|
||||
AccessDeniedError,
|
||||
type UserForAdmin,
|
||||
} from "@/lib/api";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
localDateString,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from "@/components/ui/sheet";
|
||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||
import { LoadingState } from "@/components/states/LoadingState";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_WRAPPER_CLASS =
|
||||
"content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6";
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang })));
|
||||
const { t, monthName } = useTranslation();
|
||||
const admin = useAdminPage();
|
||||
|
||||
// Telegram BackButton: show on mount when in Mini App, navigate to calendar on click.
|
||||
useEffect(() => {
|
||||
if (isLocalhost) return;
|
||||
let offClick: (() => void) | undefined;
|
||||
try {
|
||||
if (backButton.mount.isAvailable()) {
|
||||
backButton.mount();
|
||||
}
|
||||
if (backButton.show.isAvailable()) {
|
||||
backButton.show();
|
||||
}
|
||||
if (backButton.onClick.isAvailable()) {
|
||||
offClick = backButton.onClick(() => router.push("/"));
|
||||
}
|
||||
} catch {
|
||||
// Non-Telegram environment; BackButton not available.
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
if (typeof offClick === "function") offClick();
|
||||
if (backButton.hide.isAvailable()) {
|
||||
backButton.hide();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors in non-Telegram environment.
|
||||
}
|
||||
};
|
||||
}, [isLocalhost, router]);
|
||||
|
||||
const [users, setUsers] = useState<UserForAdmin[]>([]);
|
||||
const [duties, setDuties] = useState<DutyWithUser[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [loadingDuties, setLoadingDuties] = useState(true);
|
||||
/** null = not yet checked, true = is admin, false = not admin (then adminAccessDenied is set). */
|
||||
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
|
||||
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
|
||||
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reassignError, setReassignError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const sentinelRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||
const from = localDateString(firstDayOfMonth(currentMonth));
|
||||
const to = localDateString(lastDayOfMonth(currentMonth));
|
||||
|
||||
// Check admin status first; only then load users and duties (avoids extra 403 for non-admins).
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw) return;
|
||||
setAdminCheckComplete(null);
|
||||
setAdminAccessDenied(false);
|
||||
fetchAdminMe(initDataRaw, lang)
|
||||
.then(({ is_admin }) => {
|
||||
if (!is_admin) {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminAccessDeniedDetail(null);
|
||||
setAdminCheckComplete(false);
|
||||
} else {
|
||||
setAdminCheckComplete(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminAccessDeniedDetail(null);
|
||||
setAdminCheckComplete(false);
|
||||
});
|
||||
}, [isAllowed, initDataRaw, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoadingUsers(true);
|
||||
fetchAdminUsers(initDataRaw, lang, controller.signal)
|
||||
.then((list) => {
|
||||
setUsers(list);
|
||||
})
|
||||
.catch((e) => {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
if (e instanceof AccessDeniedError) {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminAccessDeniedDetail(e.serverDetail ?? null);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingUsers(false));
|
||||
return () => controller.abort();
|
||||
}, [isAllowed, initDataRaw, lang, adminCheckComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoadingDuties(true);
|
||||
setError(null);
|
||||
fetchDuties(from, to, initDataRaw, lang, controller.signal)
|
||||
.then((list) => setDuties(list))
|
||||
.catch((e) => {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => setLoadingDuties(false));
|
||||
return () => controller.abort();
|
||||
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [from, to]);
|
||||
|
||||
const openReassign = useCallback((duty: DutyWithUser) => {
|
||||
setSelectedDuty(duty);
|
||||
setSelectedUserId(duty.user_id);
|
||||
setReassignError(null);
|
||||
}, []);
|
||||
|
||||
const closeReassign = useCallback(() => {
|
||||
setSelectedDuty(null);
|
||||
setSelectedUserId("");
|
||||
setReassignError(null);
|
||||
}, []);
|
||||
|
||||
const handleReassign = useCallback(() => {
|
||||
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
|
||||
if (selectedUserId === selectedDuty.user_id) {
|
||||
closeReassign();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setReassignError(null);
|
||||
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
|
||||
.then((updated) => {
|
||||
setDuties((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === updated.id
|
||||
? {
|
||||
...d,
|
||||
user_id: updated.user_id,
|
||||
full_name:
|
||||
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
|
||||
}
|
||||
: d
|
||||
)
|
||||
);
|
||||
setSuccessMessage(t("admin.reassign_success"));
|
||||
closeReassign();
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
})
|
||||
.catch((e) => {
|
||||
setReassignError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign]);
|
||||
|
||||
const now = new Date();
|
||||
const dutyOnly = duties
|
||||
.filter(
|
||||
(d) =>
|
||||
d.event_type === "duty" && new Date(d.end_at) > now
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
|
||||
/** Users with role_id 1 (user) or 2 (admin) shown in reassign dropdown. */
|
||||
const usersForSelect = users.filter(
|
||||
(u) => u.role_id === 1 || u.role_id === 2
|
||||
);
|
||||
|
||||
const visibleDuties = dutyOnly.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < dutyOnly.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const el = sentinelRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisibleCount((prev) =>
|
||||
Math.min(prev + PAGE_SIZE, dutyOnly.length)
|
||||
);
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: "200px", threshold: 0 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, dutyOnly.length]);
|
||||
|
||||
const loading = loadingUsers || loadingDuties;
|
||||
|
||||
if (!isAllowed) {
|
||||
if (!admin.isAllowed) {
|
||||
return (
|
||||
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||
<div className={PAGE_WRAPPER_CLASS}>
|
||||
<AccessDeniedScreen primaryAction="reload" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAllowed && initDataRaw && adminCheckComplete === null) {
|
||||
if (admin.adminCheckComplete === null) {
|
||||
return (
|
||||
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||
<div className={PAGE_WRAPPER_CLASS}>
|
||||
<div className="py-4 flex flex-col items-center gap-2">
|
||||
<LoadingState />
|
||||
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
|
||||
@@ -267,11 +40,13 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (adminAccessDenied) {
|
||||
if (admin.adminAccessDenied) {
|
||||
return (
|
||||
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||
<div className={PAGE_WRAPPER_CLASS}>
|
||||
<div className="flex flex-col gap-4 py-6">
|
||||
<p className="text-muted-foreground">{adminAccessDeniedDetail ?? t("admin.access_denied")}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">{t("admin.back_to_calendar")}</Link>
|
||||
</Button>
|
||||
@@ -281,170 +56,66 @@ export default function AdminPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||
<div className={PAGE_WRAPPER_CLASS}>
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-[var(--header-bg)] py-3">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("admin.title")} — {monthName(currentMonth.getMonth())} {currentMonth.getFullYear()}
|
||||
{t("admin.title")} — {monthName(admin.currentMonth.getMonth())}{" "}
|
||||
{admin.currentMonth.getFullYear()}
|
||||
</h1>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">{t("admin.back_to_calendar")}</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{successMessage && (
|
||||
{admin.successMessage && (
|
||||
<p className="mt-3 text-sm text-[var(--duty)]" role="status">
|
||||
{successMessage}
|
||||
{admin.successMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
{admin.loading && (
|
||||
<div className="py-4 flex flex-col items-center gap-2">
|
||||
<LoadingState />
|
||||
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<ErrorState message={error} onRetry={() => window.location.reload()} className="my-3" />
|
||||
{admin.error && !admin.loading && (
|
||||
<ErrorState
|
||||
message={admin.error}
|
||||
onRetry={() => window.location.reload()}
|
||||
className="my-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
{!admin.loading && !admin.error && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
||||
</p>
|
||||
{dutyOnly.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4">{t("admin.no_duties")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" aria-label={t("admin.list_aria")}>
|
||||
{visibleDuties.map((duty) => {
|
||||
const start = new Date(duty.start_at);
|
||||
const end = new Date(duty.end_at);
|
||||
const dateStr = localDateString(start);
|
||||
const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
return (
|
||||
<li key={duty.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openReassign(duty)}
|
||||
aria-label={t("admin.reassign_aria", {
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
name: duty.full_name,
|
||||
})}
|
||||
className="w-full rounded-lg border border-l-[3px] border-l-duty bg-surface px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--surface-hover)] focus-visible:outline-accent"
|
||||
>
|
||||
<span className="font-medium">{dateStr}</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{timeStr}</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
<span>{duty.full_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<li ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<AdminDutyList
|
||||
duties={admin.visibleDuties}
|
||||
hasMore={admin.hasMore}
|
||||
sentinelRef={admin.sentinelRef}
|
||||
onSelectDuty={admin.openReassign}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDuty !== null && (
|
||||
<Sheet open onOpenChange={(open) => !open && closeReassign()}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]"
|
||||
overlayClassName="backdrop-blur-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="relative px-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={closeReassign}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
<div
|
||||
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||
aria-hidden
|
||||
<ReassignSheet
|
||||
open={!admin.sheetExiting && admin.selectedDuty !== null}
|
||||
selectedDuty={admin.selectedDuty}
|
||||
selectedUserId={admin.selectedUserId}
|
||||
setSelectedUserId={admin.setSelectedUserId}
|
||||
users={admin.usersForSelect}
|
||||
saving={admin.saving}
|
||||
reassignError={admin.reassignError}
|
||||
onReassign={admin.handleReassign}
|
||||
onRequestClose={admin.requestCloseSheet}
|
||||
onCloseAnimationEnd={admin.closeReassign}
|
||||
t={t}
|
||||
/>
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle>{t("admin.reassign_duty")}</SheetTitle>
|
||||
<SheetDescription>{t("admin.select_user")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedDuty && (
|
||||
<div className="flex flex-col gap-4 pt-2 pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{localDateString(new Date(selectedDuty.start_at))}{" "}
|
||||
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
||||
</p>
|
||||
{usersForSelect.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("admin.no_users_for_assign")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="admin-user-select" className="text-sm font-medium">
|
||||
{t("admin.select_user")}
|
||||
</label>
|
||||
<select
|
||||
id="admin-user-select"
|
||||
value={selectedUserId === "" ? "" : String(selectedUserId)}
|
||||
onChange={(e) => setSelectedUserId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-accent"
|
||||
disabled={saving}
|
||||
>
|
||||
{usersForSelect.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.full_name}
|
||||
{u.username ? ` (@${u.username})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{reassignError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{reassignError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter className="flex-row justify-end gap-2">
|
||||
<Button
|
||||
onClick={handleReassign}
|
||||
disabled={
|
||||
saving ||
|
||||
selectedUserId === "" ||
|
||||
selectedUserId === selectedDuty?.user_id ||
|
||||
usersForSelect.length === 0
|
||||
}
|
||||
>
|
||||
{saving ? t("loading") : t("admin.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
66
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
66
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Admin duty list: visible duties with infinite-scroll sentinel. Presentational only.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { localDateString, formatHHMM } from "@/lib/date-utils";
|
||||
|
||||
export interface AdminDutyListProps {
|
||||
/** Duties to show (already sliced to visibleCount by parent). */
|
||||
duties: DutyWithUser[];
|
||||
/** Whether there are more items; when true, sentinel is rendered for intersection observer. */
|
||||
hasMore: boolean;
|
||||
/** Ref for the sentinel element (infinite scroll). */
|
||||
sentinelRef: React.RefObject<HTMLLIElement | null>;
|
||||
/** Called when user selects a duty to reassign. */
|
||||
onSelectDuty: (duty: DutyWithUser) => void;
|
||||
/** Translation function. */
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
export function AdminDutyList({
|
||||
duties,
|
||||
hasMore,
|
||||
sentinelRef,
|
||||
onSelectDuty,
|
||||
t,
|
||||
}: AdminDutyListProps) {
|
||||
if (duties.length === 0) {
|
||||
return <p className="text-muted-foreground py-4">{t("admin.no_duties")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1.5" aria-label={t("admin.list_aria")}>
|
||||
{duties.map((duty) => {
|
||||
const dateStr = localDateString(new Date(duty.start_at));
|
||||
const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
return (
|
||||
<li key={duty.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDuty(duty)}
|
||||
aria-label={t("admin.reassign_aria", {
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
name: duty.full_name,
|
||||
})}
|
||||
className="w-full rounded-lg border border-l-[3px] border-l-duty bg-surface px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5"
|
||||
>
|
||||
<span>
|
||||
<span className="font-medium">{dateStr}</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{timeStr}</span>
|
||||
</span>
|
||||
<span>{duty.full_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<li ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
158
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
158
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Bottom sheet for reassigning a duty to another user. Uses design tokens and safe area.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import type { UserForAdmin } from "@/lib/api";
|
||||
import { localDateString, formatHHMM } from "@/lib/date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
export interface ReassignSheetProps {
|
||||
/** Whether the sheet is open (and not in exiting state). */
|
||||
open: boolean;
|
||||
/** Selected duty to reassign; when null, content may still render with previous duty during close. */
|
||||
selectedDuty: DutyWithUser | null;
|
||||
/** Current selected user id for the select. */
|
||||
selectedUserId: number | "";
|
||||
/** Called when user changes selection. */
|
||||
setSelectedUserId: (value: number | "") => void;
|
||||
/** Users to show in the dropdown (role_id 1 or 2). */
|
||||
users: UserForAdmin[];
|
||||
/** Reassign request in progress. */
|
||||
saving: boolean;
|
||||
/** Error message from last reassign attempt. */
|
||||
reassignError: string | null;
|
||||
/** Called when user confirms reassign. */
|
||||
onReassign: () => void;
|
||||
/** Called when user requests close (start close animation). */
|
||||
onRequestClose: () => void;
|
||||
/** Called when close animation ends (clear state). */
|
||||
onCloseAnimationEnd: () => void;
|
||||
/** Translation function. */
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
export function ReassignSheet({
|
||||
open,
|
||||
selectedDuty,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
users,
|
||||
saving,
|
||||
reassignError,
|
||||
onReassign,
|
||||
onRequestClose,
|
||||
onCloseAnimationEnd,
|
||||
t,
|
||||
}: ReassignSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)] pb-[calc(24px+env(safe-area-inset-bottom,0px))]"
|
||||
overlayClassName="backdrop-blur-md"
|
||||
showCloseButton={false}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative px-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={onRequestClose}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
<div
|
||||
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||
aria-hidden
|
||||
/>
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle>{t("admin.reassign_duty")}</SheetTitle>
|
||||
<SheetDescription>{t("admin.select_user")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedDuty && (
|
||||
<div className="flex flex-col gap-4 pt-2 pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{localDateString(new Date(selectedDuty.start_at))}{" "}
|
||||
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
||||
</p>
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.no_users_for_assign")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="admin-user-select" className="text-sm font-medium">
|
||||
{t("admin.select_user")}
|
||||
</label>
|
||||
<select
|
||||
id="admin-user-select"
|
||||
value={selectedUserId === "" ? "" : String(selectedUserId)}
|
||||
onChange={(e) =>
|
||||
setSelectedUserId(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
className="rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-accent"
|
||||
disabled={saving}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.full_name}
|
||||
{u.username ? ` (@${u.username})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{reassignError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{reassignError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter className="flex-row justify-end gap-2">
|
||||
<Button
|
||||
onClick={onReassign}
|
||||
disabled={
|
||||
saving ||
|
||||
selectedUserId === "" ||
|
||||
selectedUserId === selectedDuty?.user_id ||
|
||||
users.length === 0
|
||||
}
|
||||
>
|
||||
{saving ? t("loading") : t("admin.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
9
webapp-next/src/components/admin/index.ts
Normal file
9
webapp-next/src/components/admin/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Admin feature: hook and presentational components for the admin page.
|
||||
*/
|
||||
|
||||
export { useAdminPage } from "./useAdminPage";
|
||||
export { AdminDutyList } from "./AdminDutyList";
|
||||
export { ReassignSheet } from "./ReassignSheet";
|
||||
export type { AdminDutyListProps } from "./AdminDutyList";
|
||||
export type { ReassignSheetProps } from "./ReassignSheet";
|
||||
258
webapp-next/src/components/admin/useAdminPage.ts
Normal file
258
webapp-next/src/components/admin/useAdminPage.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Admin page hook: BackButton, admin check, users/duties loading, reassign sheet state,
|
||||
* infinite scroll, and derived data. Used by the admin page for composition.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { backButton } from "@telegram-apps/sdk-react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
fetchDuties,
|
||||
fetchAdminMe,
|
||||
fetchAdminUsers,
|
||||
patchAdminDuty,
|
||||
AccessDeniedError,
|
||||
type UserForAdmin,
|
||||
} from "@/lib/api";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
localDateString,
|
||||
} from "@/lib/date-utils";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function useAdminPage() {
|
||||
const router = useRouter();
|
||||
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang })));
|
||||
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [users, setUsers] = useState<UserForAdmin[]>([]);
|
||||
const [duties, setDuties] = useState<DutyWithUser[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [loadingDuties, setLoadingDuties] = useState(true);
|
||||
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
|
||||
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
|
||||
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reassignError, setReassignError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [sheetExiting, setSheetExiting] = useState(false);
|
||||
const sentinelRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
const from = localDateString(firstDayOfMonth(currentMonth));
|
||||
const to = localDateString(lastDayOfMonth(currentMonth));
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalhost) return;
|
||||
let offClick: (() => void) | undefined;
|
||||
try {
|
||||
if (backButton.mount.isAvailable()) {
|
||||
backButton.mount();
|
||||
}
|
||||
if (backButton.show.isAvailable()) {
|
||||
backButton.show();
|
||||
}
|
||||
if (backButton.onClick.isAvailable()) {
|
||||
offClick = backButton.onClick(() => router.push("/"));
|
||||
}
|
||||
} catch {
|
||||
// Non-Telegram environment; BackButton not available.
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
if (typeof offClick === "function") offClick();
|
||||
if (backButton.hide.isAvailable()) {
|
||||
backButton.hide();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors in non-Telegram environment.
|
||||
}
|
||||
};
|
||||
}, [isLocalhost, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw) return;
|
||||
setAdminCheckComplete(null);
|
||||
setAdminAccessDenied(false);
|
||||
fetchAdminMe(initDataRaw, lang)
|
||||
.then(({ is_admin }) => {
|
||||
if (!is_admin) {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminAccessDeniedDetail(null);
|
||||
setAdminCheckComplete(false);
|
||||
} else {
|
||||
setAdminCheckComplete(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminAccessDeniedDetail(null);
|
||||
setAdminCheckComplete(false);
|
||||
});
|
||||
}, [isAllowed, initDataRaw, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingUsers(true);
|
||||
fetchAdminUsers(initDataRaw, lang, controller.signal)
|
||||
.then((list) => setUsers(list))
|
||||
.catch((e) => {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
if (e instanceof AccessDeniedError) {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminAccessDeniedDetail(e.serverDetail ?? null);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingUsers(false));
|
||||
return () => controller.abort();
|
||||
}, [isAllowed, initDataRaw, lang, adminCheckComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingDuties(true);
|
||||
setError(null);
|
||||
fetchDuties(from, to, initDataRaw, lang, controller.signal)
|
||||
.then((list) => setDuties(list))
|
||||
.catch((e) => {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => setLoadingDuties(false));
|
||||
return () => controller.abort();
|
||||
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [from, to]);
|
||||
|
||||
const closeReassign = useCallback(() => {
|
||||
setSelectedDuty(null);
|
||||
setSelectedUserId("");
|
||||
setReassignError(null);
|
||||
setSheetExiting(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetExiting) return;
|
||||
const fallback = window.setTimeout(() => {
|
||||
closeReassign();
|
||||
}, 320);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [sheetExiting, closeReassign]);
|
||||
|
||||
const openReassign = useCallback((duty: DutyWithUser) => {
|
||||
setSelectedDuty(duty);
|
||||
setSelectedUserId(duty.user_id);
|
||||
setReassignError(null);
|
||||
}, []);
|
||||
|
||||
const requestCloseSheet = useCallback(() => {
|
||||
setSheetExiting(true);
|
||||
}, []);
|
||||
|
||||
const handleReassign = useCallback(() => {
|
||||
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
|
||||
if (selectedUserId === selectedDuty.user_id) {
|
||||
closeReassign();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setReassignError(null);
|
||||
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
|
||||
.then((updated) => {
|
||||
setDuties((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === updated.id
|
||||
? {
|
||||
...d,
|
||||
user_id: updated.user_id,
|
||||
full_name:
|
||||
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
|
||||
}
|
||||
: d
|
||||
)
|
||||
);
|
||||
setSuccessMessage(t("admin.reassign_success"));
|
||||
requestCloseSheet();
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
})
|
||||
.catch((e) => {
|
||||
setReassignError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t]);
|
||||
|
||||
const now = new Date();
|
||||
const dutyOnly = duties
|
||||
.filter((d) => d.event_type === "duty" && new Date(d.end_at) > now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
|
||||
const usersForSelect = users.filter((u) => u.role_id === 1 || u.role_id === 2);
|
||||
const visibleDuties = dutyOnly.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < dutyOnly.length;
|
||||
const loading = loadingUsers || loadingDuties;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const el = sentinelRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, dutyOnly.length));
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: "200px", threshold: 0 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, dutyOnly.length]);
|
||||
|
||||
return {
|
||||
isAllowed,
|
||||
adminCheckComplete,
|
||||
adminAccessDenied,
|
||||
adminAccessDeniedDetail,
|
||||
error,
|
||||
loading,
|
||||
currentMonth,
|
||||
successMessage,
|
||||
dutyOnly,
|
||||
usersForSelect,
|
||||
visibleDuties,
|
||||
hasMore,
|
||||
sentinelRef,
|
||||
selectedDuty,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
saving,
|
||||
reassignError,
|
||||
sheetExiting,
|
||||
openReassign,
|
||||
requestCloseSheet,
|
||||
handleReassign,
|
||||
closeReassign,
|
||||
};
|
||||
}
|
||||
@@ -128,6 +128,27 @@ function buildFetchOptions(
|
||||
return { headers, signal: controller.signal, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse 403 response body for user-facing detail. Returns default i18n message if body is invalid.
|
||||
*/
|
||||
async function handle403Response(
|
||||
res: Response,
|
||||
acceptLang: ApiLang,
|
||||
defaultI18nKey: string
|
||||
): Promise<string> {
|
||||
let detail = translate(acceptLang, defaultI18nKey);
|
||||
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 */
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch duties for date range. Throws AccessDeniedError on 403.
|
||||
* Rethrows AbortError when the request is cancelled (e.g. stale load).
|
||||
@@ -148,17 +169,7 @@ export async function fetchDuties(
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
logger.warn("Access denied", from, to);
|
||||
let detail = translate(acceptLang, "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 */
|
||||
}
|
||||
const detail = await handle403Response(res, acceptLang, "access_denied");
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -197,17 +208,7 @@ export async function fetchCalendarEvents(
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
logger.warn("Access denied", from, to, "calendar-events");
|
||||
let detail = translate(acceptLang, "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 */
|
||||
}
|
||||
const detail = await handle403Response(res, acceptLang, "access_denied");
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) return [];
|
||||
@@ -273,17 +274,7 @@ export async function fetchAdminUsers(
|
||||
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 */
|
||||
}
|
||||
const detail = await handle403Response(res, acceptLang, "admin.access_denied");
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -338,17 +329,7 @@ export async function patchAdminDuty(
|
||||
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 */
|
||||
}
|
||||
const detail = await handle403Response(res, acceptLang, "admin.access_denied");
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user