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:
2026-03-06 09:57:26 +03:00
parent 68b1884b73
commit c390a4dd6e
28 changed files with 2045 additions and 15 deletions

View File

@@ -50,4 +50,15 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`. - **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states. - **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
## Design guideline
When adding or changing UI in the Mini App, **follow the [Mini App design guideline](../../docs/miniapp-design.md)**:
- Use only design tokens from `globals.css` and Tailwind/shadcn aliases (no hardcoded colours).
- Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals.
- Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.).
- Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations.
Use the checklist in the design doc when introducing new screens or components.
Consider these rules when changing the Mini App or adding frontend features. Consider these rules when changing the Mini App or adding frontend features.

View File

@@ -24,6 +24,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
| Duty-schedule parser | `duty_teller/importers/` | | Duty-schedule parser | `duty_teller/importers/` |
| Config (env vars) | `duty_teller/config.py` | | Config (env vars) | `duty_teller/config.py` |
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) | | Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
| Admin panel (Mini App) | `webapp-next/src/app/admin/page.tsx`; API: `GET /api/admin/me`, `GET /api/admin/users`, `PATCH /api/admin/duties/:id` |
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) | | Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
## Running and testing ## Running and testing
@@ -35,6 +36,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
## Documentation ## Documentation
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md). - User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
- [Mini App design guideline](docs/miniapp-design.md) — Theme, layout, safe areas, component patterns, accessibility for webapp-next.
- Configuration reference: [docs/configuration.md](docs/configuration.md). - Configuration reference: [docs/configuration.md](docs/configuration.md).
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`. - Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
@@ -48,4 +50,5 @@ Docstrings and code comments must be in English (Google-style docstrings). UI st
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets. - **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once. - **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side. - **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
- **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases from it, apply layout/safe-area/accessibility rules, and use the checklist for new screens or components.
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers. - **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.

View File

@@ -18,6 +18,9 @@ High-level architecture of Duty Teller: components, data flow, and package relat
- **Miniapp → API** - **Miniapp → API**
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`. Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
- **Admin panel (Mini App)**
Admins see an "Admin" link on the calendar (when `GET /api/admin/me` returns `is_admin: true`). The admin page at `/app/admin` lists duties for the current month and allows reassigning a duty to another user. It uses `GET /api/admin/users` (admin-only) for the user dropdown and `PATCH /api/admin/duties/:id` with `{ user_id }` to reassign. All admin endpoints require valid initData; `/users` and PATCH `/duties` additionally require the user to have the admin role (`require_admin_telegram_id`). PATCH error messages (e.g. duty not found, user not found) use the request `Accept-Language` header for i18n. The reassign dropdown shows only users with role `user` or `admin` (role_id 1 or 2 per migration 007).
- **Import** - **Import**
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()``DutyScheduleResult``duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`). Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()``DutyScheduleResult``duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).

View File

@@ -6,6 +6,7 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
- [Configuration](configuration.md) — Environment variables (types, defaults, examples). - [Configuration](configuration.md) — Environment variables (types, defaults, examples).
- [Architecture](architecture.md) — Components, data flow, package relationships. - [Architecture](architecture.md) — Components, data flow, package relationships.
- [Mini App design](miniapp-design.md) — Design guideline for the Telegram Mini App (webapp-next): theme, layout, components, accessibility.
- [Import format](import-format.md) — Duty-schedule JSON format and example. - [Import format](import-format.md) — Duty-schedule JSON format and example.
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations. - [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config). - [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).

217
docs/miniapp-design.md Normal file
View File

@@ -0,0 +1,217 @@
# Mini App Design Guideline
This document defines the design rules for the Duty Teller Mini App (Next.js frontend in `webapp-next/`). It aligns with [Telegrams official Mini App design guidelines](https://core.telegram.org/bots/webapps#designing-mini-apps) (Color Schemes and Design Guidelines) and codifies the current implementation (calendar, duty list, admin) as the reference for new screens and components.
---
## 1. Telegram design principles
Telegrams guidelines state:
- **Mobile-first:** All elements must be responsive and designed for mobile first.
- **Consistency:** Interactive elements should match the style, behaviour, and intent of existing Telegram UI components.
- **Performance:** Animations should be smooth, ideally 60fps.
- **Accessibility:** Inputs and images must have labels.
- **Theme:** The app must use the dynamic theme-based colors provided by the API (Day/Night and custom themes).
- **Safe areas:** The interface must respect the **safe area** and **content safe area** so content does not overlap system or Telegram UI, especially in fullscreen.
- **Android:** On Android, use the extra User-Agent data (e.g. performance class) and reduce animations and effects on low-performance devices for smooth operation.
**Color schemes:** Mini Apps receive the users current **theme** in real time. Use the `ThemeParams` object and the CSS variables Telegram exposes (e.g. `--tg-theme-bg-color`, `--tg-theme-text-color`) so the UI adapts when the user switches Day/Night or custom themes.
---
## 2. Theme and colors
### 2.1 Sources
Theme is resolved in this order:
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the inline script in `webapp-next/src/app/layout.tsx`).
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via `use-telegram-theme.ts` and `TelegramProvider`.
The inline script in the layout maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The hook sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
### 2.2 Mapping (ThemeParams → app tokens)
In `webapp-next/src/app/globals.css`, `:root` and `[data-theme="light"]` / `[data-theme="dark"]` map Telegrams ThemeParams to internal design tokens:
| Telegram ThemeParam (CSS var) | App token / usage |
|--------------------------------------|-------------------|
| `--tg-theme-bg-color` | `--bg` (background) |
| `--tg-theme-secondary-bg-color` | `--surface` (cards, panels) |
| `--tg-theme-text-color` | `--text` (primary text) |
| `--tg-theme-hint-color`, `--tg-theme-subtitle-text-color` | `--muted` (secondary text) |
| `--tg-theme-link-color` | `--accent` (links, secondary actions) |
| `--tg-theme-header-bg-color` | `--header-bg` |
| `--tg-theme-section-bg-color` | `--card` (sections) |
| `--tg-theme-section-header-text-color` | `--section-header` |
| `--tg-theme-section-separator-color` | `--border` |
| `--tg-theme-button-color` | `--primary` |
| `--tg-theme-button-text-color` | `--primary-foreground` |
| `--tg-theme-destructive-text-color` | `--error` |
| `--tg-theme-accent-text-color` | `--accent-text` |
Tailwind/shadcn semantic tokens are wired to these: `--background``--bg`, `--foreground``--text`, `--primary`, `--secondary``--surface`, etc.
### 2.3 Domain colors
Duty-specific semantics (fixed per theme, not from ThemeParams):
- `--duty` — duty shift (e.g. green).
- `--unavailable` — unavailable (e.g. amber).
- `--vacation` — vacation (e.g. blue).
- `--today` — “today” highlight; tied to `--tg-theme-accent-text-color` / `--tg-theme-link-color`.
Use Tailwind classes such as `bg-duty`, `border-l-unavailable`, `text-today`, `bg-today`, etc.
### 2.4 Derived tokens (color-mix)
Prefer these instead of ad-hoc color mixes:
- `--surface-hover`, `--surface-hover-10` — hover states for surfaces.
- `--surface-today-tint`, `--surface-muted-tint` — subtle tints.
- `--today-hover`, `--today-border`, `--today-border-selected`, `--today-gradient-end` — today state.
- `--muted-fade`, `--shadow-card`, etc.
Defined in `globals.css`; use via `var(--surface-hover)` in classes (e.g. `hover:bg-[var(--surface-hover)]`).
### 2.5 Rule for new work
Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-muted`, `bg-surface`, `text-accent`, `border-l-duty`, `bg-today`, etc.). Do not hardcode hex or RGB in new components or screens.
---
## 3. Layout and safe areas
### 3.1 Width
- **Token:** `--max-width-app: 420px` (in `@theme` in `globals.css`).
- **Usage:** Page wrapper uses `max-w-[var(--max-width-app)]` (e.g. in `page.tsx`, `CalendarPage.tsx`, `admin/page.tsx`). Content is centred with `mx-auto`.
### 3.2 Height
- **Viewport:** Prefer `min-h-[var(--tg-viewport-stable-height,100vh)]` for the main content area so the Mini App fills the visible height correctly when expanded/collapsed. Fallback `100vh` when not in Telegram.
- **Body:** In `globals.css`, `body` already has `min-height: var(--tg-viewport-stable-height, 100vh)` and `background: var(--bg)`.
### 3.3 Safe area and content safe area
- **Class `.content-safe`** (in `globals.css`): Applies:
- `padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0))`
- `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0))`
- Use `.content-safe` on the **root container of each page** so content is not covered by the Telegram header or bottom bar (Bot API 8.0+).
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
### 3.4 Sheets and modals
Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.:
`pb-[calc(24px+env(safe-area-inset-bottom,0px))]`
See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
---
## 4. Typography and spacing
### 4.1 Font
- **Family:** `system-ui, -apple-system, sans-serif` (set in `globals.css` and Tailwind theme).
### 4.2 Patterns from the calendar and duty list
| Element | Classes / tokens |
|--------|-------------------|
| Month title | `text-[1.1rem]` / `sm:text-[1.25rem]`, `font-semibold` |
| Year (above month) | `text-xs`, `text-muted` |
| Nav buttons (prev/next month) | `size-10`, `rounded-[10px]` |
| Calendar day cell | `text-[0.85rem]`, `rounded-lg`, `p-1` |
| Duty timeline card | `px-2.5 py-2`, `rounded-lg` |
### 4.3 Page and block spacing
- Page container: `px-3 pb-6` in addition to `.content-safe`.
- Between sections: `mb-3`, `mb-4` as appropriate.
- Grids: `gap-1` for tight layouts (e.g. calendar grid), larger gaps where needed.
---
## 5. Component patterns
### 5.1 Buttons
- **Primary:** Use the default Button variant: `bg-primary text-primary-foreground` (from `webapp-next/src/components/ui/button.tsx`).
- **Secondary icon buttons** (e.g. calendar nav):
`bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95`
with `size-10` and `rounded-[10px]`.
- Keep focus visible (e.g. `focus-visible:outline-accent` or ring); do not remove outline without a visible replacement.
### 5.2 Cards
- **Background:** `bg-surface` or `bg-card` (both resolve to theme tokens).
- **Borders:** `border`, `--border` (section separator color).
- **Emphasis:** `var(--shadow-card)` for highlighted cards (e.g. current duty).
- **Left stripe by type:** `border-l-[3px]` with:
- `border-l-duty`, `border-l-unavailable`, `border-l-vacation` for event types;
- `border-l-today` for “current duty” (see `.border-l-today` in `globals.css`).
### 5.3 Calendar grid
- **Structure:** 7 columns × 6 rows; use `role="grid"` on the container and `role="gridcell"` on each cell.
- **Layout:** `min-h-[var(--calendar-grid-min-height)]`, cells `aspect-square` with `min-h-8`, `rounded-lg`, `gap-1`.
- **Today:** `bg-today text-[var(--bg)]`; hover `hover:bg-[var(--today-hover)]`.
- **Other month:** `opacity-40`, `pointer-events-none`, `bg-[var(--surface-muted-tint)]`.
### 5.4 Timeline list (duties)
- **Dates:** Horizontal line and vertical tick from shared CSS in `globals.css`:
`.duty-timeline-date`, `.duty-timeline-date--today` (with `::before` / `::after`).
- **Cards:** Same card rules as above; `border-l-[3px]` + type class; optional flip card for contacts (see `DutyTimelineCard.tsx`).
---
## 6. Motion and performance
### 6.1 Timing
- **Tokens:** `--transition-fast: 0.15s`, `--transition-normal: 0.25s`, `--ease-out: cubic-bezier(0.32, 0.72, 0, 1)`.
- Use these for transitions and short animations so behaviour is consistent and predictable.
### 6.2 Reduced motion
- **Rule:** `@media (prefers-reduced-motion: reduce)` in `globals.css` shortens animation and transition durations globally. New animations should remain optional or short so they degrade gracefully when reduced.
### 6.3 Android low-performance devices
- **Detection:** `webapp-next/src/lib/telegram-android-perf.ts` reads Telegrams User-Agent and sets `data-perf="low"` on the document root when the device performance class is LOW.
- **CSS:** `[data-perf="low"] *` in `globals.css` minimizes animation/transition duration. Avoid adding heavy or long animations without considering this; prefer simple or no animation on low-end devices.
---
## 7. Accessibility
- **Focus:** Use `focus-visible:outline-accent` (or equivalent ring) on interactive elements; do not remove focus outline without a visible alternative.
- **Calendar:** Use `role="grid"` and `role="gridcell"`, `aria-label` on nav buttons (e.g. “Previous month”), and a composite `aria-label` on each day cell (date + event types). See `webapp-next/src/components/calendar/CalendarDay.tsx`.
- **Images and inputs:** Always provide labels (per Telegrams guidelines and WCAG).
---
## 8. Telegram integration
- **Header and background:** On init (layout script and `use-telegram-theme.ts`), call:
- `setBackgroundColor('bg_color')`
- `setHeaderColor('bg_color')`
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
- **Surface contrast:** When `--surface` equals `--bg` (e.g. some iOS OLED themes), `fixSurfaceContrast()` in `use-telegram-theme.ts` adjusts `--surface` using ThemeParams or a light color-mix so cards and panels remain visible.
---
## 9. Checklist for new screens and components
Use this for review when adding or changing UI:
- [ ] Use only design tokens from `globals.css` and Tailwind/shadcn aliases; no hardcoded colours.
- [ ] Page wrapper has `.content-safe`, `max-w-[var(--max-width-app)]`, and appropriate min-height (viewport-stable-height or `min-h-screen` with fallback).
- [ ] Buttons and cards follow the patterns above (variants, surfaces, border-l by type).
- [ ] Safe area is respected for bottom padding and for sheets/modals.
- [ ] Interactive elements and grids/lists have appropriate `aria-label`s and roles.
- [ ] New animations respect `prefers-reduced-motion` and `data-perf="low"` (short or minimal on low-end Android).

View File

@@ -6,7 +6,7 @@ from datetime import date, timedelta
import duty_teller.config as config import duty_teller.config as config
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -14,19 +14,34 @@ from sqlalchemy.orm import Session
from duty_teller.api.calendar_ics import get_calendar_events from duty_teller.api.calendar_ics import get_calendar_events
from duty_teller.api.dependencies import ( from duty_teller.api.dependencies import (
_lang_from_accept_language,
fetch_duties_response, fetch_duties_response,
get_authenticated_telegram_id_dep,
get_db_session, get_db_session,
get_validated_dates, get_validated_dates,
require_admin_telegram_id,
require_miniapp_username, require_miniapp_username,
) )
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
from duty_teller.cache import ics_calendar_cache from duty_teller.cache import invalidate_duty_related_caches, ics_calendar_cache
from duty_teller.db.repository import ( from duty_teller.db.repository import (
get_duties, get_duties,
get_duties_for_user, get_duties_for_user,
get_duty_by_id,
get_user_by_calendar_token, get_user_by_calendar_token,
get_users_for_admin,
is_admin_for_telegram_user,
update_duty_user,
) )
from duty_teller.db.schemas import CalendarEvent, DutyWithUser from duty_teller.db.models import User
from duty_teller.db.schemas import (
AdminDutyReassignBody,
CalendarEvent,
DutyInDb,
DutyWithUser,
UserForAdmin,
)
from duty_teller.i18n import t
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -283,6 +298,98 @@ def get_personal_calendar_ical(
) )
# --- Admin API (initData + admin role required for GET /users and PATCH /duties) ---
@app.get(
"/api/admin/me",
summary="Check admin status",
description=(
"Returns is_admin for the authenticated Mini App user. "
"Requires valid initData (same as /api/duties)."
),
)
def admin_me(
_username: str = Depends(require_miniapp_username),
telegram_id: int = Depends(get_authenticated_telegram_id_dep),
session: Session = Depends(get_db_session),
) -> dict:
"""Return { is_admin: true } or { is_admin: false } for the current user."""
is_admin = is_admin_for_telegram_user(session, telegram_id)
return {"is_admin": is_admin}
@app.get(
"/api/admin/users",
response_model=list[UserForAdmin],
summary="List users for admin dropdown",
description="Returns id, full_name, username for all users. Admin only.",
)
def admin_list_users(
_admin_telegram_id: int = Depends(require_admin_telegram_id),
session: Session = Depends(get_db_session),
) -> list[UserForAdmin]:
"""Return all users ordered by full_name for admin reassign dropdown."""
users = get_users_for_admin(session)
return [
UserForAdmin(
id=u.id,
full_name=u.full_name,
username=u.username,
role_id=u.role_id,
)
for u in users
]
@app.patch(
"/api/admin/duties/{duty_id}",
response_model=DutyInDb,
summary="Reassign duty to another user",
description="Update duty's user_id. Admin only. Invalidates ICS and pin caches.",
)
def admin_reassign_duty(
duty_id: int,
body: AdminDutyReassignBody,
request: Request,
_admin_telegram_id: int = Depends(require_admin_telegram_id),
session: Session = Depends(get_db_session),
) -> DutyInDb:
"""Reassign duty to another user; return updated duty or 404/400 with i18n detail."""
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
if duty_id <= 0 or body.user_id <= 0:
raise HTTPException(
status_code=400,
detail=t(lang, "api.bad_request"),
)
duty = get_duty_by_id(session, duty_id)
if duty is None:
raise HTTPException(
status_code=404,
detail=t(lang, "admin.duty_not_found"),
)
if session.get(User, body.user_id) is None:
raise HTTPException(
status_code=400,
detail=t(lang, "admin.user_not_found"),
)
updated = update_duty_user(
session, duty_id, body.user_id, commit=True
)
if updated is None:
raise HTTPException(
status_code=404,
detail=t(lang, "admin.duty_not_found"),
)
invalidate_duty_related_caches()
return DutyInDb(
id=updated.id,
user_id=updated.user_id,
start_at=updated.start_at,
end_at=updated.end_at,
)
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out" webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
if webapp_path.is_dir(): if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp") app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")

View File

@@ -12,6 +12,7 @@ from duty_teller.db.repository import (
get_duties, get_duties,
get_user_by_telegram_id, get_user_by_telegram_id,
can_access_miniapp_for_telegram_user, can_access_miniapp_for_telegram_user,
is_admin_for_telegram_user,
) )
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
from duty_teller.db.session import session_scope from duty_teller.db.session import session_scope
@@ -159,6 +160,103 @@ def get_authenticated_username(
return username or (user.full_name or "") or f"id:{telegram_user_id}" return username or (user.full_name or "") or f"id:{telegram_user_id}"
def get_authenticated_telegram_id(
request: Request,
x_telegram_init_data: str | None,
session: Session,
) -> int:
"""Return Telegram user id for the authenticated miniapp user; 0 if skip-auth.
Same validation as get_authenticated_username. Used to check is_admin.
Args:
request: FastAPI request (for Accept-Language in error messages).
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
session: DB session.
Returns:
telegram_user_id (int). When MINI_APP_SKIP_AUTH, returns 0 (no real user).
Raises:
HTTPException: 403 if initData missing/invalid or user not in allowlist.
"""
if config.MINI_APP_SKIP_AUTH:
return 0
init_data = (x_telegram_init_data or "").strip()
if not init_data:
log.warning("no X-Telegram-Init-Data header")
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
init_data, config.BOT_TOKEN, max_age_seconds=max_age
)
if auth_reason != "ok":
log.warning("initData validation failed: %s", auth_reason)
raise HTTPException(
status_code=403, detail=_auth_error_detail(auth_reason, lang)
)
if telegram_user_id is None:
log.warning("initData valid but telegram_user_id missing")
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
user = get_user_by_telegram_id(session, telegram_user_id)
if not user:
log.warning(
"user not in DB (username=%s, telegram_id=%s)",
username,
telegram_user_id,
)
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
failed_phone = config.normalize_phone(user.phone) if user.phone else None
log.warning(
"access denied (username=%s, telegram_id=%s, phone=%s)",
username,
telegram_user_id,
failed_phone or "",
)
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
return telegram_user_id
def get_authenticated_telegram_id_dep(
request: Request,
x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
session: Session = Depends(get_db_session),
) -> int:
"""FastAPI dependency: return telegram_user_id for authenticated miniapp user (0 if skip-auth)."""
return get_authenticated_telegram_id(request, x_telegram_init_data, session)
def require_admin_telegram_id(
request: Request,
x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
session: Session = Depends(get_db_session),
) -> int:
"""FastAPI dependency: require valid miniapp auth and admin role; return telegram_user_id.
When MINI_APP_SKIP_AUTH is True, admin routes are disabled (403).
Raises:
HTTPException: 403 if initData missing/invalid, user not in allowlist, or not admin.
"""
if config.MINI_APP_SKIP_AUTH:
log.warning("Admin routes disabled when MINI_APP_SKIP_AUTH is set")
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
telegram_user_id = get_authenticated_telegram_id(
request, x_telegram_init_data, session
)
if not is_admin_for_telegram_user(session, telegram_user_id):
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
return telegram_user_id
def fetch_duties_response( def fetch_duties_response(
session: Session, from_date: str, to_date: str session: Session, from_date: str, to_date: str
) -> list[DutyWithUser]: ) -> list[DutyWithUser]:

View File

@@ -4,6 +4,7 @@ from duty_teller.db.models import Base, User, Duty, Role
from duty_teller.db.schemas import ( from duty_teller.db.schemas import (
UserCreate, UserCreate,
UserInDb, UserInDb,
UserForAdmin,
DutyCreate, DutyCreate,
DutyInDb, DutyInDb,
DutyWithUser, DutyWithUser,
@@ -16,11 +17,14 @@ from duty_teller.db.session import (
) )
from duty_teller.db.repository import ( from duty_teller.db.repository import (
delete_duties_in_range, delete_duties_in_range,
get_duties,
get_duty_by_id,
get_or_create_user, get_or_create_user,
get_or_create_user_by_full_name, get_or_create_user_by_full_name,
get_duties, get_users_for_admin,
insert_duty, insert_duty,
set_user_phone, set_user_phone,
update_duty_user,
update_user_display_name, update_user_display_name,
) )
@@ -31,6 +35,7 @@ __all__ = [
"Role", "Role",
"UserCreate", "UserCreate",
"UserInDb", "UserInDb",
"UserForAdmin",
"DutyCreate", "DutyCreate",
"DutyInDb", "DutyInDb",
"DutyWithUser", "DutyWithUser",
@@ -39,11 +44,14 @@ __all__ = [
"get_session", "get_session",
"session_scope", "session_scope",
"delete_duties_in_range", "delete_duties_in_range",
"get_duties",
"get_duty_by_id",
"get_or_create_user", "get_or_create_user",
"get_or_create_user_by_full_name", "get_or_create_user_by_full_name",
"get_duties", "get_users_for_admin",
"insert_duty", "insert_duty",
"set_user_phone", "set_user_phone",
"update_duty_user",
"update_user_display_name", "update_user_display_name",
"init_db", "init_db",
] ]

View File

@@ -322,6 +322,61 @@ def delete_duties_in_range(
return count return count
def get_duty_by_id(session: Session, duty_id: int) -> Duty | None:
"""Return duty by primary key.
Args:
session: DB session.
duty_id: Duty id (duties.id).
Returns:
Duty or None if not found.
"""
return session.get(Duty, duty_id)
def update_duty_user(
session: Session,
duty_id: int,
new_user_id: int,
*,
commit: bool = True,
) -> Duty | None:
"""Update the assigned user of a duty.
Args:
session: DB session.
duty_id: Duty id (duties.id).
new_user_id: New user id (users.id).
commit: If True, commit immediately. If False, caller commits.
Returns:
Updated Duty or None if duty not found.
"""
duty = session.get(Duty, duty_id)
if duty is None:
return None
duty.user_id = new_user_id
if commit:
session.commit()
session.refresh(duty)
else:
session.flush()
return duty
def get_users_for_admin(session: Session) -> list[User]:
"""Return all users ordered by full_name for admin dropdown (id, full_name, username).
Args:
session: DB session.
Returns:
List of User instances ordered by full_name.
"""
return session.query(User).order_by(User.full_name).all()
def get_duties( def get_duties(
session: Session, session: Session,
from_date: str, from_date: str,

View File

@@ -69,6 +69,21 @@ class DutyWithUser(DutyInDb):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class UserForAdmin(BaseModel):
"""User summary for admin dropdown: id, full_name, username, role_id."""
id: int
full_name: str
username: str | None = None
role_id: int | None = None
class AdminDutyReassignBody(BaseModel):
"""Request body for PATCH /api/admin/duties/:id — reassign duty to another user."""
user_id: int
class CalendarEvent(BaseModel): class CalendarEvent(BaseModel):
"""External calendar event (e.g. holiday) for a single day.""" """External calendar event (e.g. holiday) for a single day."""

View File

@@ -88,6 +88,7 @@ MESSAGES: dict[str, dict[str, str]] = {
), ),
"api.auth_invalid": "Invalid auth data", "api.auth_invalid": "Invalid auth data",
"api.access_denied": "Access denied", "api.access_denied": "Access denied",
"api.bad_request": "Bad request",
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format", "dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
"dates.from_after_to": "from date must not be after to", "dates.from_after_to": "from date must not be after to",
"dates.range_too_large": "Date range is too large. Request a shorter period.", "dates.range_too_large": "Date range is too large. Request a shorter period.",
@@ -98,6 +99,9 @@ MESSAGES: dict[str, dict[str, str]] = {
"current_duty.shift": "Shift", "current_duty.shift": "Shift",
"current_duty.remaining": "Remaining: {hours}h {minutes}min", "current_duty.remaining": "Remaining: {hours}h {minutes}min",
"current_duty.back": "Back to calendar", "current_duty.back": "Back to calendar",
"admin.duty_not_found": "Duty not found",
"admin.user_not_found": "User not found",
"admin.reassign_success": "Duty reassigned successfully",
}, },
"ru": { "ru": {
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.", "start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
@@ -174,6 +178,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"из которого открыт календарь (тот же бот, что в меню).", "из которого открыт календарь (тот же бот, что в меню).",
"api.auth_invalid": "Неверные данные авторизации", "api.auth_invalid": "Неверные данные авторизации",
"api.access_denied": "Доступ запрещён", "api.access_denied": "Доступ запрещён",
"api.bad_request": "Неверный запрос",
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD", "dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
"dates.from_after_to": "Дата from не должна быть позже to", "dates.from_after_to": "Дата from не должна быть позже to",
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.", "dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
@@ -184,5 +189,8 @@ MESSAGES: dict[str, dict[str, str]] = {
"current_duty.shift": "Смена", "current_duty.shift": "Смена",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин", "current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.back": "Назад к календарю", "current_duty.back": "Назад к календарю",
"admin.duty_not_found": "Дежурство не найдено",
"admin.user_not_found": "Пользователь не найден",
"admin.reassign_success": "Дежурство успешно переназначено",
}, },
} }

360
tests/test_admin_api.py Normal file
View File

@@ -0,0 +1,360 @@
"""Tests for admin API: GET /api/admin/me, GET /api/admin/users, PATCH /api/admin/duties/:id."""
from unittest.mock import ANY, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from duty_teller.api.app import app
@pytest.fixture
def client():
return TestClient(app)
# --- GET /api/admin/me ---
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_admin_me_skip_auth_returns_is_admin_false(client):
"""With MINI_APP_SKIP_AUTH, GET /api/admin/me returns is_admin: false (no real user)."""
r = client.get("/api/admin/me")
assert r.status_code == 200
assert r.json() == {"is_admin": False}
@patch("duty_teller.api.app.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_me_returns_is_admin_true_when_admin(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
):
"""When user is admin, GET /api/admin/me returns is_admin: true."""
from types import SimpleNamespace
mock_validate.return_value = (100, "user", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
r = client.get("/api/admin/me", headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"})
assert r.status_code == 200
assert r.json() == {"is_admin": True}
@patch("duty_teller.api.app.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_me_returns_is_admin_false_when_not_admin(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
):
"""When user is not admin, GET /api/admin/me returns is_admin: false."""
from types import SimpleNamespace
mock_validate.return_value = (200, "user", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="User", username="user")
mock_can_access.return_value = True
mock_is_admin.return_value = False
r = client.get("/api/admin/me", headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A200%7D&hash=x"})
assert r.status_code == 200
assert r.json() == {"is_admin": False}
# --- GET /api/admin/users ---
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_users_403_without_init_data(client):
"""GET /api/admin/users without initData returns 403."""
r = client.get("/api/admin/users")
assert r.status_code == 403
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_users_403_when_not_admin(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
):
"""GET /api/admin/users when not admin returns 403 with admin_only message."""
from types import SimpleNamespace
mock_validate.return_value = (100, "u", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="U", username="u")
mock_can_access.return_value = True
mock_is_admin.return_value = False # not admin
r = client.get(
"/api/admin/users",
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"},
)
assert r.status_code == 403
detail = r.json()["detail"]
assert "admin" in detail.lower() or "администратор" in detail or "only" in detail
@patch("duty_teller.api.app.get_users_for_admin")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_users_200_returns_list(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, mock_get_users, client
):
"""GET /api/admin/users returns list of id, full_name, username, role_id."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_users.return_value = [
SimpleNamespace(id=1, full_name="Alice", username="alice", role_id=1),
SimpleNamespace(id=2, full_name="Bob", username=None, role_id=2),
]
r = client.get(
"/api/admin/users",
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
)
assert r.status_code == 200
data = r.json()
assert len(data) == 2
assert data[0]["id"] == 1
assert data[0]["full_name"] == "Alice"
assert data[0]["username"] == "alice"
assert data[0]["role_id"] == 1
assert data[1]["id"] == 2
assert data[1]["full_name"] == "Bob"
assert data[1]["username"] is None
assert data[1]["role_id"] == 2
# --- PATCH /api/admin/duties/:id ---
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_403_without_auth(client):
"""PATCH /api/admin/duties/1 without auth returns 403."""
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
)
assert r.status_code == 403
@patch("duty_teller.api.app.require_admin_telegram_id")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_403_when_not_admin(mock_require_admin, client):
"""PATCH /api/admin/duties/1 when not admin returns 403."""
from fastapi import HTTPException
from duty_teller.i18n import t
mock_require_admin.side_effect = HTTPException(
status_code=403, detail=t("en", "import.admin_only")
)
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
headers={"X-Telegram-Init-Data": "x"},
)
assert r.status_code == 403
@patch("duty_teller.api.app.invalidate_duty_related_caches")
@patch("duty_teller.api.app.update_duty_user")
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_404_when_duty_missing(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
mock_update,
mock_invalidate,
client,
):
"""PATCH /api/admin/duties/999 returns 404 when duty not found."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_duty.return_value = None
r = client.patch(
"/api/admin/duties/999",
json={"user_id": 2},
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
)
assert r.status_code == 404
mock_update.assert_not_called()
mock_invalidate.assert_not_called()
@patch("duty_teller.api.app.invalidate_duty_related_caches")
@patch("duty_teller.api.app.update_duty_user")
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_400_when_user_not_found(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
mock_update,
mock_invalidate,
client,
):
"""PATCH /api/admin/duties/1 returns 400 when user_id does not exist."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_duty.return_value = SimpleNamespace(
id=1, user_id=10, start_at="2026-01-15T09:00:00Z", end_at="2026-01-15T18:00:00Z"
)
mock_session = MagicMock()
mock_session.get.return_value = None # User not found
with patch("duty_teller.api.app.get_db_session") as mock_db:
mock_db.return_value.__enter__.return_value = mock_session
mock_db.return_value.__exit__.return_value = None
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 999},
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
)
assert r.status_code == 400
mock_update.assert_not_called()
mock_invalidate.assert_not_called()
@patch("duty_teller.api.app.invalidate_duty_related_caches")
@patch("duty_teller.api.app.update_duty_user")
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_200_updates_and_invalidates(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
mock_update_duty_user,
mock_invalidate,
client,
):
"""PATCH /api/admin/duties/1 with valid body returns 200 and invalidates caches."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
duty = SimpleNamespace(
id=1,
user_id=10,
start_at="2026-01-15T09:00:00Z",
end_at="2026-01-15T18:00:00Z",
)
updated_duty = SimpleNamespace(
id=1,
user_id=2,
start_at="2026-01-15T09:00:00Z",
end_at="2026-01-15T18:00:00Z",
)
mock_get_duty.return_value = duty
mock_update_duty_user.return_value = updated_duty
mock_session = MagicMock()
mock_session.get.return_value = SimpleNamespace(id=2) # User exists
with patch("duty_teller.api.app.get_db_session") as mock_db:
mock_db.return_value.__enter__.return_value = mock_session
mock_db.return_value.__exit__.return_value = None
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
)
assert r.status_code == 200
data = r.json()
assert data["id"] == 1
assert data["user_id"] == 2
assert data["start_at"] == "2026-01-15T09:00:00Z"
assert data["end_at"] == "2026-01-15T18:00:00Z"
mock_update_duty_user.assert_called_once_with(ANY, 1, 2, commit=True)
mock_invalidate.assert_called_once()
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_admin_users_403_when_skip_auth(client):
"""GET /api/admin/users with MINI_APP_SKIP_AUTH returns 403 (admin routes disabled)."""
r = client.get("/api/admin/users")
assert r.status_code == 403
detail = r.json()["detail"]
assert "admin" in detail.lower() or "администратор" in detail
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_admin_reassign_403_when_skip_auth(client):
"""PATCH /api/admin/duties/1 with MINI_APP_SKIP_AUTH returns 403."""
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
)
assert r.status_code == 403
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_404_uses_accept_language_for_detail(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
client,
):
"""PATCH with Accept-Language: ru returns 404 detail in Russian."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_duty.return_value = None
with patch("duty_teller.api.app._lang_from_accept_language") as mock_lang:
mock_lang.return_value = "ru"
r = client.patch(
"/api/admin/duties/999",
json={"user_id": 2},
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x",
"Accept-Language": "ru",
},
)
assert r.status_code == 404
assert r.json()["detail"] == "Дежурство не найдено"

View File

@@ -9,9 +9,12 @@ from duty_teller.db.repository import (
delete_duties_in_range, delete_duties_in_range,
get_duties, get_duties,
get_duties_for_user, get_duties_for_user,
get_duty_by_id,
get_or_create_user, get_or_create_user,
get_or_create_user_by_full_name, get_or_create_user_by_full_name,
get_users_for_admin,
insert_duty, insert_duty,
update_duty_user,
update_user_display_name, update_user_display_name,
) )
@@ -217,6 +220,52 @@ def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session):
assert u2.username == "new_username" assert u2.username == "new_username"
def test_get_duty_by_id_returns_duty(session, user_a):
"""get_duty_by_id returns the duty when it exists."""
duty = insert_duty(
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
)
found = get_duty_by_id(session, duty.id)
assert found is not None
assert found.id == duty.id
assert found.user_id == user_a.id
assert found.start_at == "2026-02-01T09:00:00Z"
def test_get_duty_by_id_returns_none_when_missing(session):
"""get_duty_by_id returns None for non-existent id."""
assert get_duty_by_id(session, 99999) is None
def test_update_duty_user_changes_user(session, user_a):
"""update_duty_user updates user_id and returns the duty."""
user_b = get_or_create_user_by_full_name(session, "User B")
duty = insert_duty(
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
)
updated = update_duty_user(session, duty.id, user_b.id, commit=True)
assert updated is not None
assert updated.id == duty.id
assert updated.user_id == user_b.id
session.refresh(duty)
assert duty.user_id == user_b.id
def test_update_duty_user_returns_none_when_duty_missing(session, user_a):
"""update_duty_user returns None when duty does not exist."""
assert update_duty_user(session, 99999, user_a.id, commit=True) is None
def test_get_users_for_admin_returns_all_ordered_by_full_name(session, user_a):
"""get_users_for_admin returns all users ordered by full_name."""
get_or_create_user_by_full_name(session, "Alice")
get_or_create_user_by_full_name(session, "Борис")
users = get_users_for_admin(session)
assert len(users) >= 3
full_names = [u.full_name for u in users]
assert full_names == sorted(full_names)
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session): def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name.""" """update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
get_or_create_user( get_or_create_user(

View File

@@ -0,0 +1,246 @@
/**
* Component tests for admin page: render, access denied, duty list, reassign sheet.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import AdminPage from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
const mockUseTelegramAuth = vi.mocked(
await import("@/hooks/use-telegram-auth").then((m) => m.useTelegramAuth)
);
const sampleUsers = [
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
];
const sampleDuties: Array<{
id: number;
user_id: number;
start_at: string;
end_at: string;
full_name: string;
event_type: string;
phone: string | null;
username: string | null;
}> = [
{
id: 10,
user_id: 1,
start_at: "2030-01-15T09:00:00Z",
end_at: "2030-01-15T18:00:00Z",
full_name: "Alice",
event_type: "duty",
phone: null,
username: "alice",
},
];
function mockFetchForAdmin(
users: Array<{ id: number; full_name: string; username: string | null; role_id?: number }> = sampleUsers,
duties = sampleDuties,
options?: { adminMe?: { is_admin: boolean } }
) {
const adminMe = options?.adminMe ?? { is_admin: true };
vi.stubGlobal(
"fetch",
vi.fn((url: string, init?: RequestInit) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(adminMe),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(users),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(duties),
} as Response);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
})
);
}
describe("AdminPage", () => {
beforeEach(() => {
resetAppStore();
useAppStore.getState().setCurrentMonth(new Date(2025, 0, 1));
mockUseTelegramAuth.mockReturnValue({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
});
});
it("shows admin title and back link when allowed and data loaded", async () => {
mockFetchForAdmin();
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument();
});
expect(screen.getByRole("link", { name: /back to calendar|назад к календарю/i })).toBeInTheDocument();
});
it("shows access denied when fetchAdminMe returns is_admin false", async () => {
mockFetchForAdmin(sampleUsers, [], { adminMe: { is_admin: false } });
render(<AdminPage />);
await waitFor(
() => {
expect(
screen.getByText(/Access only for administrators|Доступ только для администраторов/i)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it("shows access denied message when GET /api/admin/users returns 403", async () => {
vi.stubGlobal(
"fetch",
vi.fn((url: string) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
} as Response);
}
return Promise.reject(new Error(`Unexpected: ${url}`));
})
);
render(<AdminPage />);
await waitFor(
() => {
expect(
screen.getByText(/Admin only|Access only for administrators|Доступ только для администраторов/i)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it("shows duty row and opens reassign sheet on click", async () => {
mockFetchForAdmin();
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
const dutyButton = screen.getByRole("button", { name: /Alice/ });
fireEvent.click(dutyButton);
await waitFor(() => {
expect(
screen.getByLabelText(/select user|выберите пользователя/i)
).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
});
it("shows no users message in sheet when usersForSelect is empty", async () => {
mockFetchForAdmin([], sampleDuties);
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
await waitFor(() => {
expect(
screen.getByText(/No users available for assignment|Нет пользователей для назначения/i)
).toBeInTheDocument();
});
});
it("shows success message after successful reassign", async () => {
mockFetchForAdmin();
vi.stubGlobal(
"fetch",
vi.fn((url: string, init?: RequestInit) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(sampleUsers),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(sampleDuties),
} as Response);
}
if (url.includes("/api/admin/duties/") && init?.method === "PATCH") {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 10,
user_id: 2,
start_at: "2030-01-15T09:00:00Z",
end_at: "2030-01-15T18:00:00Z",
}),
} as Response);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
})
);
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
await waitFor(() => {
expect(screen.getByLabelText(/select user|выберите пользователя/i)).toBeInTheDocument();
});
const select = screen.getByLabelText(/select user|выберите пользователя/i);
fireEvent.change(select, { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
await waitFor(() => {
expect(
screen.getByText(/Duty reassigned|Дежурство переназначено/i)
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,450 @@
/**
* 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).
*/
"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 { 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;
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();
// 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) {
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">
<AccessDeniedScreen primaryAction="reload" />
</div>
);
}
if (isAllowed && initDataRaw && 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="py-4 flex flex-col items-center gap-2">
<LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div>
</div>
);
}
if (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="flex flex-col gap-4 py-6">
<p className="text-muted-foreground">{adminAccessDeniedDetail ?? t("admin.access_denied")}</p>
<Button asChild variant="outline">
<Link href="/">{t("admin.back_to_calendar")}</Link>
</Button>
</div>
</div>
);
}
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">
<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()}
</h1>
<Button asChild variant="ghost" size="sm">
<Link href="/">{t("admin.back_to_calendar")}</Link>
</Button>
</header>
{successMessage && (
<p className="mt-3 text-sm text-[var(--duty)]" role="status">
{successMessage}
</p>
)}
{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" />
)}
{!loading && !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>
)}
</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
/>
<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>
);
}

View File

@@ -31,7 +31,7 @@ export default function GlobalError({
/> />
</head> </head>
<body className="antialiased"> <body className="antialiased">
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"> <div className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{translate(lang, "error_boundary.message")} {translate(lang, "error_boundary.message")}
</h1> </h1>

View File

@@ -11,7 +11,7 @@ import { useTranslation } from "@/i18n/use-translation";
export default function NotFound() { export default function NotFound() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"> <div className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1> <h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
<p className="text-muted-foreground">{t("not_found.description")}</p> <p className="text-muted-foreground">{t("not_found.description")}</p>
<Link <Link

View File

@@ -12,6 +12,8 @@ import { useTelegramTheme } from "@/hooks/use-telegram-theme";
import { useTelegramAuth } from "@/hooks/use-telegram-auth"; import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init"; import { useAppInit } from "@/hooks/use-app-init";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready"; import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { fetchAdminMe } from "@/lib/api";
import { getLang } from "@/i18n/messages";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView"; import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage"; import { CalendarPage } from "@/components/CalendarPage";
@@ -24,6 +26,15 @@ export default function Home() {
useAppInit({ isAllowed, startParam }); useAppInit({ isAllowed, startParam });
const setIsAdmin = useAppStore((s) => s.setIsAdmin);
useEffect(() => {
if (!isAllowed || !initDataRaw) {
setIsAdmin(false);
return;
}
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
}, [isAllowed, initDataRaw, setIsAdmin]);
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } = const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
useAppStore( useAppStore(
useShallow((s: AppState) => ({ useShallow((s: AppState) => ({
@@ -50,7 +61,7 @@ export default function Home() {
const content = accessDenied ? ( const content = accessDenied ? (
<AccessDeniedScreen primaryAction="reload" /> <AccessDeniedScreen primaryAction="reload" />
) : currentView === "currentDuty" ? ( ) : currentView === "currentDuty" ? (
<div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"> <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">
<CurrentDutyView <CurrentDutyView
onBack={handleBackFromCurrentDuty} onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"} openedFromPin={startParam === "duty"}

View File

@@ -6,12 +6,14 @@
"use client"; "use client";
import { useRef, useState, useEffect, useCallback } from "react"; import { useRef, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data"; import { useMonthData } from "@/hooks/use-month-data";
import { useSwipe } from "@/hooks/use-swipe"; import { useSwipe } from "@/hooks/use-swipe";
import { useStickyScroll } from "@/hooks/use-sticky-scroll"; import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh"; import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { useTranslation } from "@/i18n/use-translation";
import { CalendarHeader } from "@/components/calendar/CalendarHeader"; import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid"; import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList"; import { DutyList } from "@/components/duty/DutyList";
@@ -53,6 +55,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
duties, duties,
calendarEvents, calendarEvents,
selectedDay, selectedDay,
isAdmin,
nextMonth, nextMonth,
prevMonth, prevMonth,
setCurrentMonth, setCurrentMonth,
@@ -68,6 +71,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
duties: s.duties, duties: s.duties,
calendarEvents: s.calendarEvents, calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay, selectedDay: s.selectedDay,
isAdmin: s.isAdmin,
nextMonth: s.nextMonth, nextMonth: s.nextMonth,
prevMonth: s.prevMonth, prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth, setCurrentMonth: s.setCurrentMonth,
@@ -76,6 +80,8 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
})) }))
); );
const { t } = useTranslation();
const { retry } = useMonthData({ const { retry } = useMonthData({
initDataRaw, initDataRaw,
enabled: isAllowed, enabled: isAllowed,
@@ -133,7 +139,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
}, [loading, accessDenied, setAppContentReady]); }, [loading, accessDenied, setAppContentReady]);
return ( return (
<div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"> <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 <div
ref={calendarStickyRef} ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2" className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
@@ -143,6 +149,16 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
disabled={navDisabled} disabled={navDisabled}
onPrevMonth={handlePrevMonth} onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth} onNextMonth={handleNextMonth}
trailingContent={
isAdmin ? (
<Link
href="/admin"
className="text-sm text-accent hover:underline focus-visible:outline-accent rounded"
>
{t("admin.link")}
</Link>
) : undefined
}
/> />
<CalendarGrid <CalendarGrid
currentMonth={currentMonth} currentMonth={currentMonth}

View File

@@ -5,6 +5,7 @@
"use client"; "use client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -20,6 +21,8 @@ export interface CalendarHeaderProps {
disabled?: boolean; disabled?: boolean;
onPrevMonth: () => void; onPrevMonth: () => void;
onNextMonth: () => void; onNextMonth: () => void;
/** Optional content shown above the nav row (e.g. Admin link). */
trailingContent?: ReactNode;
className?: string; className?: string;
} }
@@ -28,6 +31,7 @@ export function CalendarHeader({
disabled = false, disabled = false,
onPrevMonth, onPrevMonth,
onNextMonth, onNextMonth,
trailingContent,
className, className,
}: CalendarHeaderProps) { }: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation(); const { t, monthName, weekdayLabels } = useTranslation();
@@ -37,6 +41,9 @@ export function CalendarHeader({
return ( return (
<header className={cn("flex flex-col", className)}> <header className={cn("flex flex-col", className)}>
{trailingContent != null && (
<div className="flex justify-end mb-1 min-h-[1.5rem]">{trailingContent}</div>
)}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<Button <Button
type="button" type="button"

View File

@@ -204,7 +204,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
<SheetContent <SheetContent
side="bottom" side="bottom"
className={cn( className={cn(
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]", "rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]",
className className
)} )}
overlayClassName="backdrop-blur-md" overlayClassName="backdrop-blur-md"

View File

@@ -60,7 +60,7 @@ export function AccessDeniedScreen({
return ( return (
<div <div
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground" className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
role="alert" role="alert"
> >
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">

View File

@@ -94,7 +94,7 @@ function SheetContent({
side === "top" && side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" && side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", "inset-x-0 bottom-0 h-auto border-t pb-[calc(24px+var(--tg-viewport-content-safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className className
)} )}
{...props} {...props}

View File

@@ -42,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[var(--shadow-card)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className className
)} )}
{...props} {...props}

View File

@@ -82,6 +82,21 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"not_found.open_calendar": "Open calendar", "not_found.open_calendar": "Open calendar",
"access_denied.hint": "Open the app again from Telegram.", "access_denied.hint": "Open the app again from Telegram.",
"access_denied.reload": "Reload", "access_denied.reload": "Reload",
"admin.link": "Admin",
"admin.title": "Admin",
"admin.reassign_duty": "Reassign duty",
"admin.select_user": "Select user",
"admin.reassign_success": "Duty reassigned",
"admin.access_denied": "Access only for administrators.",
"admin.duty_not_found": "Duty not found",
"admin.user_not_found": "User not found",
"admin.back_to_calendar": "Back to calendar",
"admin.loading_users": "Loading users…",
"admin.no_duties": "No duties this month.",
"admin.no_users_for_assign": "No users available for assignment.",
"admin.save": "Save",
"admin.list_aria": "List of duties to reassign",
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
}, },
ru: { ru: {
"app.title": "Календарь дежурств", "app.title": "Календарь дежурств",
@@ -159,6 +174,21 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"not_found.open_calendar": "Открыть календарь", "not_found.open_calendar": "Открыть календарь",
"access_denied.hint": "Откройте приложение снова из Telegram.", "access_denied.hint": "Откройте приложение снова из Telegram.",
"access_denied.reload": "Обновить", "access_denied.reload": "Обновить",
"admin.link": "Админка",
"admin.title": "Админка",
"admin.reassign_duty": "Переназначить дежурного",
"admin.select_user": "Выберите пользователя",
"admin.reassign_success": "Дежурство переназначено",
"admin.access_denied": "Доступ только для администраторов.",
"admin.duty_not_found": "Дежурство не найдено",
"admin.user_not_found": "Пользователь не найден",
"admin.back_to_calendar": "Назад к календарю",
"admin.loading_users": "Загрузка пользователей…",
"admin.no_duties": "В этом месяце дежурств нет.",
"admin.no_users_for_assign": "Нет пользователей для назначения.",
"admin.save": "Сохранить",
"admin.list_aria": "Список дежурств для перераспределения",
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
}, },
}; };

View File

@@ -4,7 +4,14 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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", () => { describe("fetchDuties", () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
).rejects.toMatchObject({ name: "AbortError" }); ).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");
});
});

View File

@@ -11,6 +11,22 @@ import { translate } from "@/i18n/messages";
type ApiLang = "ru" | "en"; 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). */ /** Minimal runtime check for a single duty item (required fields). */
function isDutyWithUser(x: unknown): x is DutyWithUser { function isDutyWithUser(x: unknown): x is DutyWithUser {
if (!x || typeof x !== "object") return false; if (!x || typeof x !== "object") return false;
@@ -207,3 +223,153 @@ export async function fetchCalendarEvents(
opts.cleanup(); 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();
}
}

View File

@@ -30,6 +30,8 @@ export interface AppState {
selectedDay: string | null; selectedDay: string | null;
/** True when the first visible screen has finished loading; used to hide content until ready(). */ /** True when the first visible screen has finished loading; used to hide content until ready(). */
appContentReady: boolean; appContentReady: boolean;
/** True when GET /api/admin/me returned is_admin: true; used to show Admin link. */
isAdmin: boolean;
setCurrentMonth: (d: Date) => void; setCurrentMonth: (d: Date) => void;
nextMonth: () => void; nextMonth: () => void;
@@ -44,8 +46,9 @@ export interface AppState {
setCurrentView: (v: CurrentView) => void; setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void; setSelectedDay: (key: string | null) => void;
setAppContentReady: (v: boolean) => void; setAppContentReady: (v: boolean) => void;
setIsAdmin: (v: boolean) => void;
/** Batch multiple state updates into a single re-render. */ /** Batch multiple state updates into a single re-render. */
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady">>) => void; batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady" | "isAdmin">>) => void;
} }
const now = new Date(); const now = new Date();
@@ -71,6 +74,7 @@ export const useAppStore = create<AppState>((set) => ({
currentView: getInitialView(), currentView: getInitialView(),
selectedDay: null, selectedDay: null,
appContentReady: false, appContentReady: false,
isAdmin: false,
setCurrentMonth: (d) => set({ currentMonth: d }), setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () => nextMonth: () =>
@@ -91,5 +95,6 @@ export const useAppStore = create<AppState>((set) => ({
setCurrentView: (v) => set({ currentView: v }), setCurrentView: (v) => set({ currentView: v }),
setSelectedDay: (key) => set({ selectedDay: key }), setSelectedDay: (key) => set({ selectedDay: key }),
setAppContentReady: (v) => set({ appContentReady: v }), setAppContentReady: (v) => set({ appContentReady: v }),
setIsAdmin: (v) => set({ isAdmin: v }),
batchUpdate: (partial) => set(partial), batchUpdate: (partial) => set(partial),
})); }));