Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08cb668bab |
@@ -1,69 +1,90 @@
|
||||
# Implementation Plan: Room Editor Refactor (Core & Logic)
|
||||
|
||||
**Objective:** Standardize the room layout data model, align backend/frontend types, and refactor the core editor logic for robustness and grid-based precision.
|
||||
**Objective:** Fix the pixel vs. grid-unit mismatch in stored room data, and robustify the editor for professional room planning.
|
||||
|
||||
**Background:**
|
||||
The current room implementation suffers from naming inconsistencies (`type` vs `kind`) and coordinate system mismatches (pixels vs grid units). The editor logic in `RoomCanvas.svelte` is basic and needs to be more robust to support professional room planning.
|
||||
**Background:**
|
||||
The editor (`RoomCanvas.svelte`) already stores and renders in grid units (1 unit = 40 px). However, the demo seed (`demo_seed.sql`) was written with raw pixel values (e.g. `width: 200`), causing demo Room A to render broken (200 grid units = 8000 px). Any room created via the editor since launch is correct; any room predating the editor's grid-unit switch (or the demo room) is broken. A one-time data migration is the first priority.
|
||||
|
||||
**Note on the `type`/`kind` field:** `backend/src/models.rs:81` already bridges this with `#[serde(rename = "type")] pub kind: String`. The wire format is `type` and `frontend/src/lib/types.ts:33` already uses `type`. **No rename is needed.** If the Rust internal name is ever changed to `type`, a raw identifier (`r#type`) is required since `type` is reserved.
|
||||
|
||||
**Note on backend validation:** `backend/src/routes/rooms.rs:18–69` already implements `validate_layout` with empty-layout check, unique IDs, allowed types (`seat`, `table`, `gap`, `door`), unique seat labels, and non-negative geometry. Tests at lines 184–322 cover all of it. **Task 2 below replaces the previously planned duplicate work.**
|
||||
|
||||
**Note on `SeatMap.svelte`:** This plan does **not** touch `SeatMap.svelte`. Its retirement and replacement with a dynamic renderer is handled by the sibling visualization plan. Any `LayoutElement` contract change made here must be cross-checked against `backend/src/routes/checkin.rs:53,194` (which deserialises it) and `frontend/src/lib/types.ts:86` (`CheckinInfo.layout`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model & Type Alignment
|
||||
## 1. Data Migration & Seed Fix
|
||||
|
||||
### Task 1: Standardize LayoutElement Naming
|
||||
### Task 1: Pixel → Grid-Unit Migration
|
||||
**Files to Modify:**
|
||||
- `backend/src/models.rs`
|
||||
- `frontend/src/lib/types.ts`
|
||||
- `backend/migrations/003_normalize_room_layout_units.sql` *(create)*
|
||||
- `backend/demo/demo_seed.sql`
|
||||
- `backend/src/routes/rooms.rs` (update tests that assert large numeric coordinates)
|
||||
|
||||
**Changes:**
|
||||
- Unified field name `type` (using `#[serde(rename = "type")]` if necessary in Rust, or changing it consistently).
|
||||
- Standardize coordinate units: All `x`, `y`, `width`, `height` values in the database will represent **grid units** (e.g., 1 unit = 40px) rather than raw pixels.
|
||||
- Update `demo_seed.sql` to use these normalized grid units.
|
||||
- Write `003_normalize_room_layout_units.sql`. For each row in `rooms`, parse `layout_json`; if any element has `x`, `y`, `width`, or `height` > 50, divide all four by 40 and update the row. This heuristic is safe because grid-unit values are small integers/half-steps (max ~30), while pixel values are large (typically 80–800).
|
||||
- Update `demo_seed.sql:16–41` to use grid units (e.g. `width: 200` → `width: 5`). The 24 elements in demo Room A need to be re-measured in grid units.
|
||||
- Update any integration tests in `rooms.rs` that rely on large pixel-scale layout values.
|
||||
|
||||
### Task 2: Backend Validation
|
||||
### Task 2: Backend Validation (Scope Reduction)
|
||||
**Files to Modify:**
|
||||
- `backend/src/routes/rooms.rs`
|
||||
|
||||
**Changes:**
|
||||
- Add validation logic to `POST /api/admin/rooms` and `PUT /api/admin/rooms/:id/layout`.
|
||||
- Ensure all elements have unique IDs.
|
||||
- Validate that `type` is one of the allowed strings (`seat`, `table`, `door`, `gap`).
|
||||
**Changes (additive only — do not duplicate existing logic):**
|
||||
- Add upper-bound validation: `x` and `y` must be < a `MAX_CANVAS` constant (e.g. 100 grid units). Reject elements that fall off the canvas.
|
||||
- Add grid-step validation: `x`, `y`, `width`, `height` must be multiples of 0.5 (i.e. `(value * 2) % 1 == 0`). Apply post-migration so existing data has already been normalised.
|
||||
- Add a test for each new validator.
|
||||
|
||||
---
|
||||
|
||||
## 2. Editor Core Refactor
|
||||
|
||||
### Task 3: RoomCanvas Logic Overhaul
|
||||
### Task 3: RoomCanvas State & Behaviour
|
||||
**Files to Modify:**
|
||||
- `frontend/src/lib/RoomCanvas.svelte`
|
||||
|
||||
**Current state (188 lines):**
|
||||
- Drag: `draggingId / startX / startY` only (lines 26–28). No resize state or handles exist.
|
||||
- Snap: lines 47–48 snap to 0.25 grid units (`Math.round(.../10)*10/40`). This is partially correct but the increment should be configurable (0.5 default).
|
||||
- Rendering: already multiplies by `GRID_SIZE = 40` (line 85). Unit separation is mostly correct.
|
||||
- Bug: `onmousemove` / `onmouseup` are bound on the SVG only (lines 69–71). Releasing the cursor outside the SVG strands the drag. Move these listeners to `window` for the duration of a drag.
|
||||
|
||||
**Changes:**
|
||||
- **Grid Snap:** Implement mandatory snap-to-grid (0.5 or 1.0 unit increments) during dragging and resizing.
|
||||
- **State Management:** Refactor internal dragging state to be cleaner and more predictable.
|
||||
- **Selection:** Improve the selection highlight and event propagation.
|
||||
- **Unit Separation:** Ensure the component strictly thinks in grid units, with the rendering layer handling the pixel scaling.
|
||||
- **Build resize from scratch.** Add resize handles (e.g. bottom-right corner hit area) per element. Track `resizingId`, `resizeStartX`, `resizeStartY`, `resizeStartW`, `resizeStartH` as drag state. Snap resize delta to 0.5 increments.
|
||||
- **Fix drag escape.** Bind `mousemove`/`mouseup` to `window` when dragging begins; remove them on drop.
|
||||
- **Snap increment.** Change snap to 0.5 grid units (from 0.25). Accept an optional `snapStep` prop (default `0.5`) for the snap-toggle feature below.
|
||||
|
||||
### Task 4: Editor UI Improvements
|
||||
**Files to Modify:**
|
||||
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
|
||||
|
||||
**Changes:**
|
||||
- Add a "Snap to Grid" toggle.
|
||||
- Add numeric inputs for precise coordinate editing (X, Y, W, H).
|
||||
- Implement "duplicate element" functionality.
|
||||
- Better error handling and visual feedback during saving.
|
||||
**What already exists (do not re-add):**
|
||||
- Width/height inputs with `step="0.5"` (lines 90–97)
|
||||
- Label input (line 87)
|
||||
- Add seat/table/door buttons (lines 64–66)
|
||||
- Delete button (line 101)
|
||||
|
||||
**What to add:**
|
||||
- **X/Y numeric inputs** (with `step="0.5"`) for precise coordinate editing of the selected element, bound to its `x` and `y` fields.
|
||||
- **"+ Gap" button** alongside the existing add buttons. `gap` is accepted by `validate_layout` but is currently unreachable from the UI.
|
||||
- **"Snap to Grid" toggle.** Bind to a boolean state; pass as `snapStep={snapEnabled ? 0.5 : 0}` to `RoomCanvas`.
|
||||
- **"Duplicate element" button.** Copies the selected element with a new UUID and offsets it by 1 grid unit.
|
||||
- **Surface save errors.** `saveLayout` (lines 27–29) currently only `console.error`. Display an inline error message in the UI.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verification
|
||||
|
||||
### Automated Tests:
|
||||
- `backend/src/routes/rooms.rs`: Add unit tests for layout validation.
|
||||
- `frontend/tests/rooms.spec.ts`: Create a new Playwright test for room editing (creating elements, dragging, snapping, and saving).
|
||||
- `backend/src/routes/rooms.rs`: Tests for the new upper-bound and grid-step validators.
|
||||
- `backend/migrations/`: Verify migration 003 runs cleanly on the test DB (use `sqlx migrate run`).
|
||||
- `frontend/tests/rooms.spec.ts` *(new)*: Playwright test — create a room, add table and two seats via the UI, drag a seat (verify snap), save and reload, assert coordinates are preserved.
|
||||
|
||||
### Manual Verification:
|
||||
1. Create a new room.
|
||||
2. Add a table and two seats.
|
||||
3. Verify that dragging snaps to the grid.
|
||||
4. Save and reload to ensure coordinates are preserved exactly.
|
||||
5. Inspect the SQLite database to confirm coordinates are stored as small grid units (e.g., `2.5`) instead of large pixel values.
|
||||
1. `make seed-demo` — reseed with the fixed `demo_seed.sql`.
|
||||
2. Open `Admin → Rooms → Room A` in the editor. All elements must appear at sensible grid positions (not far off-screen).
|
||||
3. Drag an element: verify it snaps to 0.5-unit increments.
|
||||
4. Resize an element: verify handles appear and snap correctly.
|
||||
5. Add a Gap element and verify it can be placed and saved.
|
||||
6. Inspect the SQLite DB directly: `SELECT layout_json FROM rooms LIMIT 1`. All element coordinates must be small numbers (≤ 30), not pixel values (≥ 80).
|
||||
7. Save and reload: verify coordinates are exactly preserved (no rounding drift).
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
# Implementation Plan: Room Editor Refactor (Unified Visualization)
|
||||
|
||||
**Objective:** Replace hardcoded seat maps with a unified, dynamic, and high-fidelity room visualization system that works across Admin and Student views.
|
||||
|
||||
**Background:**
|
||||
Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and Student Check-ins, while using a dynamic `RoomCanvas.svelte` for editing. This leads to data mismatches and prevents users from using custom room layouts. This plan unifies the visualization layer.
|
||||
**Objective:** Replace the broken hardcoded `SeatMap.svelte` with a unified, dynamic room renderer that works across Admin Live View and Student Check-in.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unified Visualization Component
|
||||
## Pre-flight: Existing Bugs This Work Fixes
|
||||
|
||||
### Task 1: Create `DynamicRoomView.svelte`
|
||||
**Files to Create/Modify:**
|
||||
- `frontend/src/lib/components/DynamicRoomView.svelte`
|
||||
- (Optionally) Merge into `frontend/src/lib/RoomCanvas.svelte`
|
||||
Acknowledge these before starting — do not assume current behaviour is correct.
|
||||
|
||||
1. **Student check-in seat selection is silently broken in production.** `frontend/src/lib/components/SeatMap.svelte:33–58` uses hardcoded seat IDs (`T1-1`…`T4-5`). These IDs do not exist in any room's `layout_json`, so `POST /api/checkin` is rejected by `backend/src/routes/checkin.rs:200–207` (`"invalid seat"`) for every seat click. Migrating to the dynamic renderer is the fix.
|
||||
2. **Admin live view shows no occupancy data.** `frontend/src/routes/admin/live/[slotId]/+page.svelte:161` calls `<SeatMap variant="tutor" scale={0.78} />` with no `assignments` / `students` props — so even though `attendances` is loaded, nothing renders in the seat map.
|
||||
3. **Check-in response is mistyped on the frontend.** `s/[code]/+page.svelte:82` treats the response of `POST /api/checkin` as an `Attendance` object, but the backend returns `{"ok": true}` (`checkin.rs:159–281`). Currently masked because `loadInfo()` is re-called immediately after. Fix this type error while migrating.
|
||||
|
||||
**Note on other consumers of `SeatMap`:** A grep of `frontend/src/` found `SeatMap` is used only in the two routes described below. There are no dashboard, print-view, or mobile-only consumers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Extend `RoomCanvas` (Decision: Don't Fork)
|
||||
|
||||
### Task 1: Add Read-Only / Interactive Modes to `RoomCanvas.svelte`
|
||||
**Files to Modify:**
|
||||
- `frontend/src/lib/RoomCanvas.svelte`
|
||||
|
||||
**Decision rationale:** `RoomCanvas.svelte:14–24` already accepts `occupiedSeatIds`, `mySeatId`, `studentNames`, `selectedId`, `onElementClick`, and `editable`. Creating a separate `DynamicRoomView.svelte` would duplicate the SVG/element rendering logic and risk divergence. **Extend `RoomCanvas` instead.**
|
||||
|
||||
**Changes:**
|
||||
- Create a read-only/interactive component that renders SVG layouts based on the `LayoutElement[]` data.
|
||||
- **High Fidelity:** Implement the aesthetic details from the design handoff (rounded tables, specific seat styling, label positioning).
|
||||
- **Responsive Scaling:** Implement an `autoScale` or `viewBox` based system so the room fills the available width on mobile and desktop without breaking coordinates.
|
||||
- **Interaction Modes:**
|
||||
- `mode="checkin"`: Seats are clickable for students.
|
||||
- `mode="notes"`: Seats are clickable for tutors to open note editors.
|
||||
- `mode="display"`: Read-only view for dashboard/monitoring.
|
||||
- Add a `clickable: boolean` prop (default `false`) — enables `onElementClick` in read-only mode without enabling edit handles. This maps to the `checkin` and `notes` use cases.
|
||||
- Remove the hardcoded `width="800" height="600"` (line 65). Replace with a `viewBox` computed from element extents (or a configured canvas size), `preserveAspectRatio="xMidYMid meet"`, and a CSS `width: 100%; height: auto` so the SVG scales responsively to its container. Verify this does not break the editor (pass a fixed `style="width:800px"` wrapper in the editor route).
|
||||
- Add high-fidelity styling per the design handoff: rounded tables (`rx`/`ry` on rect), specific seat styling (circle or rounded-rect), label positioning (centred on table, below seat icon), seat-state colours (vacant / occupied / mine).
|
||||
|
||||
**Prop summary after changes:**
|
||||
|
||||
| Prop | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `elements` | `LayoutElement[]` | Layout data |
|
||||
| `editable` | `boolean` | Enables drag, resize, add, delete |
|
||||
| `clickable` | `boolean` | Enables `onElementClick` in read-only mode |
|
||||
| `occupiedSeatIds` | `string[]` | Seats to style as occupied |
|
||||
| `mySeatId` | `string \| null` | Seat to style as "mine" |
|
||||
| `studentNames` | `Record<string,string>` | Labels overlaid on occupied seats |
|
||||
| `selectedId` | `string \| null` | Currently selected element (editor) |
|
||||
| `onElementClick` | `(id: string) => void` | Click callback |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,37 +49,67 @@ Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and
|
||||
### Task 2: Replace `SeatMap` in Admin Live View
|
||||
**Files to Modify:**
|
||||
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
|
||||
- `backend/src/routes/attendance.rs` *(extend API response)*
|
||||
|
||||
**Changes:**
|
||||
- Replace `SeatMap` with `DynamicRoomView`.
|
||||
- Connect the `onSeatClick` event to the note-taking and manual attendance logic.
|
||||
- Ensure attendance data (who sits where) is correctly overlaid on the dynamic layout.
|
||||
**Pre-step — extend the backend API response (required):**
|
||||
`attendance.rs:62–105` (`get_session_attendance`) returns `SessionAttendance` but does not include room layouts. The page has no way to know the layout. Options:
|
||||
- **(Recommended)** Extend `SessionAttendance` in `backend/src/models.rs` and `attendance.rs` to include each slot's `layout: Option<Vec<LayoutElement>>` keyed per slot. This avoids an N+1 fetch.
|
||||
- Alternative: have the page call `api.admin.rooms.get(slot.room_id)` after loading the slot. Simpler but adds a round-trip.
|
||||
|
||||
**Frontend changes:**
|
||||
- Replace `<SeatMap variant="tutor" scale={0.78} />` at line 161 with `<RoomCanvas elements={slot.layout ?? []} clickable={true} occupiedSeatIds={...} studentNames={...} onElementClick={handleSeatClick} />`.
|
||||
- `occupiedSeatIds`: derive from `attendances.map(a => a.seat_id).filter(Boolean)`.
|
||||
- `studentNames`: derive from `attendances` as `{ [seat_id]: student.name }`.
|
||||
- **Seat → student mapping (new logic required):** The existing note-editor is driven by `selectedStudentId` and `toggleAttendance` takes `studentId`. The new `onElementClick(seatId)` must look up `attendances.find(a => a.seat_id === seatId)?.student_id` to populate `selectedStudentId`. Add this mapping in `handleSeatClick`.
|
||||
|
||||
### Task 3: Replace `SeatMap` in Student Check-in
|
||||
**Files to Modify:**
|
||||
- `frontend/src/routes/s/[code]/+page.svelte`
|
||||
|
||||
**Changes:**
|
||||
- Replace `SeatMap` with `DynamicRoomView`.
|
||||
- Connect seat selection to the `POST /api/checkin` API.
|
||||
- Ensure the "current seat" (mySeatId) is visually highlighted in the dynamic view.
|
||||
**There are 4 call sites, not 1.** `SeatMap` is called at lines 210, 248, 316, and 368 (phone + desktop × seat-pick step + confirmed step). All four need to be replaced.
|
||||
|
||||
**Layout data is already on the wire.** `GET /api/checkin/:code` returns `CheckinInfo.layout: LayoutElement[] | null` (populated by `checkin.rs:53–68` and typed in `types.ts:84–88`), but `s/[code]/+page.svelte:43–58` discards `res.layout`. Read and store it: `let layout = $state<LayoutElement[]>([])` and assign `layout = res.layout ?? []`.
|
||||
|
||||
**Per call site:**
|
||||
- Lines 210, 316 (seat-pick step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={true} occupiedSeatIds={occupiedSeatIds} mySeatId={null} onElementClick={selectSeat} />`.
|
||||
- Lines 248, 368 (confirmed step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={false} occupiedSeatIds={occupiedSeatIds} mySeatId={myAttendance?.seat_id ?? null} />`.
|
||||
|
||||
**Derived state to add:**
|
||||
```ts
|
||||
const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[]);
|
||||
```
|
||||
|
||||
**Fix the response-typing bug** (Pre-flight item 3): `api.ts` types `checkin.post` as `Promise<Attendance>` but the backend returns `{ok: true}`. Change the return type to `Promise<{ok: boolean}>` and update `s/[code]/+page.svelte:82` accordingly.
|
||||
|
||||
### Task 4: Deprecate `SeatMap.svelte`
|
||||
**Files to Modify:**
|
||||
- Delete `frontend/src/lib/components/SeatMap.svelte` once integration is verified.
|
||||
**Files to Delete:**
|
||||
- `frontend/src/lib/components/SeatMap.svelte`
|
||||
|
||||
**Hard ordering — do not delete until all of the following are true:**
|
||||
1. All 6 call sites are migrated (1 in admin live view + 4 in `s/[code]`).
|
||||
2. `grep -rn "SeatMap" frontend/src/` returns zero results.
|
||||
3. All Playwright tests pass (see Task 5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Verification
|
||||
|
||||
### Automated Tests:
|
||||
- `frontend/tests/checkin-dynamic.spec.ts`: E2E test to verify student check-in on a **custom-created** room layout.
|
||||
- `frontend/tests/admin-live-dynamic.spec.ts`: E2E test to verify that tutors can see students on a **custom-created** room layout and click them to leave notes.
|
||||
### Recommended Ordering
|
||||
1. Extend the backend API (Task 2 pre-step) — unblocks frontend.
|
||||
2. Extend `RoomCanvas` with `clickable`, responsive `viewBox`, and high-fidelity styling (Task 1).
|
||||
3. Migrate `s/[code]` (Task 3) — backend already returns layout; this is the quickest win and immediately unblocks the broken check-in.
|
||||
4. Migrate admin live view (Task 2) — needs new backend data and the seat→student mapping.
|
||||
5. Run Playwright tests.
|
||||
6. Delete `SeatMap.svelte` (Task 4).
|
||||
|
||||
### Manual Verification:
|
||||
1. Create a non-standard room layout in the Admin Editor (e.g., a "U" shape).
|
||||
### Automated Tests
|
||||
- `frontend/tests/checkin-dynamic.spec.ts` *(new)*: E2E test — create a custom (non-square) room layout via the API, create a session + slot using it, open the student check-in link, verify the layout renders (not a blank grid), click a seat, verify `POST /api/checkin` succeeds and the seat turns green. Mirror the seat IDs already used in `backend/src/routes/checkin.rs:290–653` (`s1`, `s2`).
|
||||
- `frontend/tests/admin-live-dynamic.spec.ts` *(new)*: E2E test — using the same custom room, manually add attendance for a student on seat `s1`, open the tutor live view, verify the student's name appears on the correct seat.
|
||||
|
||||
### Manual Verification
|
||||
1. Create a U-shaped room layout in `Admin → Rooms`.
|
||||
2. Create a session and slot using this room.
|
||||
3. Open the Student Check-in link on a mobile device (browser simulation).
|
||||
4. Verify the "U" shape is rendered correctly and scaled to fit the screen.
|
||||
5. Check in as a student and verify the seat turns green.
|
||||
6. Open the Tutor Live View on a desktop and verify the same student is visible on the same seat in the "U" shape.
|
||||
3. Open the student check-in link on a mobile viewport. Verify the U-shape is rendered and scaled to fit.
|
||||
4. Check in as a student by clicking a seat. Verify the seat turns green.
|
||||
5. Open the tutor Live View on desktop. Verify the same student appears on the correct seat in the U-shape.
|
||||
6. Click the occupied seat. Verify the note-editor opens for that student.
|
||||
|
||||
@@ -7,6 +7,8 @@ import { auth } from './auth.svelte';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
let isRefreshing = false;
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
...init,
|
||||
@@ -18,6 +20,20 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
|
||||
if (res.status === 401 && browser) {
|
||||
if (!isRefreshing && path !== '/auth/refresh') {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const refreshed = await fetch(BASE + '/auth/refresh', { method: 'POST', credentials: 'include' });
|
||||
if (refreshed.ok) {
|
||||
isRefreshing = false;
|
||||
return request<T>(path, init);
|
||||
}
|
||||
} catch (_e) {
|
||||
// refresh failed, fall through to logout
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
auth.logout();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ export const auth = {
|
||||
async init() {
|
||||
if (!browser || _initialized) return;
|
||||
try {
|
||||
const res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
|
||||
let res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const refreshed = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
|
||||
if (refreshed.ok) {
|
||||
res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
|
||||
}
|
||||
}
|
||||
if (res.ok) {
|
||||
const me = await res.json();
|
||||
_isSuperadmin = me.is_superadmin;
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
|
||||
let course = $state<Course | null>(null);
|
||||
|
||||
const isLoginRoute = $derived($page.url.pathname === '/admin/login');
|
||||
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
if (isLoginRoute) return;
|
||||
if (!auth.authenticated) {
|
||||
goto('/admin/login');
|
||||
return;
|
||||
@@ -27,7 +30,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (auth.initialized && !auth.authenticated) goto('/admin/login');
|
||||
if (auth.initialized && !auth.authenticated && !isLoginRoute) goto('/admin/login');
|
||||
});
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
@@ -42,6 +45,8 @@
|
||||
>
|
||||
{@render children()}
|
||||
</TutorShell>
|
||||
{:else if isLoginRoute}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
|
||||
{/if}
|
||||
|
||||
20
frontend/tests/auth.spec.ts
Normal file
20
frontend/tests/auth.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Login page accessibility', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('renders login form without auth cookies (regression: redirect trap)', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await expect(page.locator('#email')).toBeVisible();
|
||||
await expect(page.locator('#password')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
await expect(page.locator('text=Willkommen zurück')).toBeVisible();
|
||||
await expect(page.locator('text=Redirecting to login')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('unauthenticated /admin redirects to login form', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForURL(/\/admin\/login/);
|
||||
await expect(page.locator('#email')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user