diff --git a/CLAUDE.md b/CLAUDE.md index 83dc009..a19a87e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,35 @@ No linter is configured in this project. Angular 18 single-page app for managing calendar events for the "Nachklang" organization. Uses Angular Material for UI, RxJS for async data, and reactive forms. No NgRx — state lives in component local variables. -**Environments:** -- Dev: `http://localhost:3000` (expects backend running locally) -- Prod: `https://api.nachklang.art` +**Environments** (`src/environments/`): `apiUrl` and `adminAppUrl`. +- Dev: `http://localhost:3000` (expects backend running locally) / `http://localhost:3002` +- Prod: `https://api.nachklang.art` / `https://admin.nachklang.art` + +## Authentication + +**This app has no login form and no accounts of its own.** Since the auth cutover +(`docs/calendar-auth-migration.md` in the API repo) it shares one identity with the tickets, +feedback and admin apps: accounts live in the admin app, and the session is an httpOnly +cookie on `.nachklang.art` that the *API's* host sets. Consequences that cannot be designed +around: + +- `withCredentials: true` is mandatory on every call (`api.service.ts` sets it once). Without + it the browser sends no cookie and the API answers 401. +- This app can never read or verify the session. It calls `GET /admin/me` and believes the + answer; the API's `requireAppAccess('calendar')` is the actual gate. +- 401 and 403 mean different things and must not be collapsed. 401 means "nobody is signed + in" and is the only one worth redirecting to the login page - redirecting on 403 produces a + loop where signing in succeeds and lands straight back on the refusal. See `failure` in + `admin.component.ts`. +- Sign-out ends the session for *all four* apps; there is only one. + +The dev server must be reachable at a port the API trusts. `ng serve` defaults to 4200, which +is in better-auth's dev `localhostOrigins` and in the admin app's +`NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`; another port fails sign-out and the return redirect, +not the sign-in. + +**The public calendar stays anonymous.** `GET /calendar/events/public/json` needs no session +at all, because nachklang.art reads it to show the next upcoming event. **Routing** (`app.routing.ts`): - `/` → `LandingpageComponent` @@ -27,8 +53,9 @@ Angular 18 single-page app for managing calendar events for the "Nachklang" orga - `**` → `NotfoundComponent` **Service layer** (`src/app/services/`): -- `api.service.ts` — all HTTP calls to the backend REST API; session credentials are passed as query params (`sessionId`, `sessionKey`) -- `utils.service.ts` — localStorage helpers for persisting session and user data +- `api.service.ts` — all HTTP calls to the backend REST API; carries the session cookie via `withCredentials`, holds no credential itself +- `admin-auth.service.ts` — where signing in happens: the admin app's login URL (with a `?redirect=` back here) and sign-out +- `utils.service.ts` — caches the signed-in user's display name for unsaved draft rows. Cosmetic only; the server takes the author from the session **Data models** (`src/app/models/`): `Event`, `User`, `Session` @@ -36,4 +63,10 @@ Angular 18 single-page app for managing calendar events for the "Nachklang" orga **Calendar-specific behavior:** Birthday calendar auto-sets recurrence to YEARLY. Events have a `status` field (`DRAFT` / `DELETED`). -**Session lifecycle:** `checkSession()` is called on `AdminComponent` init; on failure it redirects to `/`. +**Session lifecycle:** `AdminComponent` calls `me()` on init. 401 redirects to the admin app's +login carrying this URL as the return target; 403 (or a signed-in account without the +`calendar` permission) shows a refusal with Reload/Sign out; anything else shows "cannot be +reached". None of those three redirect, on purpose. + +**Dead since the cutover:** the unrouted `LoginComponent`. `src/app/models/session.ts` is +gone; there is no session type here any more, because this app never handles one. diff --git a/src/app/components/event/event.component.ts b/src/app/components/event/event.component.ts index da13ed8..5533cc1 100644 --- a/src/app/components/event/event.component.ts +++ b/src/app/components/event/event.component.ts @@ -3,7 +3,9 @@ import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {MatDialog} from '@angular/material/dialog'; import {Event} from '../../models/event'; +import {HttpErrorResponse} from '@angular/common/http'; import {ApiService} from '../../services/api.service'; +import {AdminAuthService} from '../../services/admin-auth.service'; import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component"; @Component({ @@ -71,20 +73,21 @@ export class EventComponent implements OnInit, OnDestroy { } if(this.event.eventId === undefined) { - this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { - console.log(res); - - if(res.eventId) { - this.event!.eventId = res.eventId; - } else { - this.showCreateError = true; - return; - } + this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({ + next: (res: any) => { + if(res.eventId) { + this.event!.eventId = res.eventId; + } else { + this.showCreateError = true; + } + }, + error: this.handleWriteError('The new event') }); } else { // Update existing event - this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { - console.log(res); + this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({ + next: () => {}, + error: this.handleWriteError('Your change') }); } } @@ -180,15 +183,48 @@ export class EventComponent implements OnInit, OnDestroy { let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`); if(deleteConfirmed && this.event) { - this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { - console.log(res); - if(res.message) { - this.deleteEvent.next(this.event!.eventId); - } + this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({ + next: (res: any) => { + if(res.message) { + this.deleteEvent.next(this.event!.eventId); + } + }, + error: this.handleWriteError('The deletion') }); } } + /** + * What to do when a write fails. + * + * Before the auth cutover none of the writes here had an error callback, so + * a failure was invisible: the row closed, nothing was saved, and the user + * had every reason to think it had been. A 401 is now a routine event - the + * session expires, or is ended from another app or another tab - so silence + * is no longer survivable. + * + * A 401 means the session is gone, and nothing on this page can be saved + * until it comes back, so it goes straight to the login carrying this page + * as the return target. Everything else says what happened and leaves the + * user where they are, with their edits still on screen. + */ + private handleWriteError(action: string): (error: HttpErrorResponse) => void { + return (error: HttpErrorResponse) => { + if (error.status === 401) { + window.alert(`Your session has expired, so ${action} was not saved. Signing you in again.`); + AdminAuthService.goToLogin(); + return; + } + + if (error.status === 403) { + window.alert(`${action} failed: this account no longer has access to the calendar.`); + return; + } + + window.alert(`${action} failed. Please try again. (${error.status || 'no response from the server'})`); + }; + } + triggerMove() { if(this.editActive) { window.alert('Please save your changes before moving the event to a different calendar.'); @@ -204,11 +240,13 @@ export class EventComponent implements OnInit, OnDestroy { movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => { // If popup is dismissed, undefined will be returned if(result) { - this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { - console.log(res); - // Uses the same interface as delete as from the calendar table perspective it is the same action - // as a delete - this.deleteEvent.next(result.eventId); + this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe({ + next: () => { + // Uses the same interface as delete as from the calendar table perspective it is the same action + // as a delete + this.deleteEvent.next(result.eventId); + }, + error: this.handleWriteError('The move') }); } }); diff --git a/src/app/models/session.ts b/src/app/models/session.ts deleted file mode 100644 index df8d9c5..0000000 --- a/src/app/models/session.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Session { - sessionId: number; - sessionKey: string; -} diff --git a/src/app/models/user.ts b/src/app/models/user.ts index 4216773..a00e56b 100644 --- a/src/app/models/user.ts +++ b/src/app/models/user.ts @@ -1,7 +1,14 @@ +/** + * The signed-in account, as returned by GET /admin/me. + * + * Replaces the old calendar-local user: there is no `userId` int and no + * `isActive` flag any more. A disabled account cannot reach this app at all - + * the API answers 403 before the handler runs - so "signed in" and "allowed" + * are the same state here, and `apps` says which apps they may open. + */ export interface User { - userId: number; - fullName: string; - passwordHash: string; + id: string; email: string; - isActive: boolean; + fullName: string; + apps: string[]; } diff --git a/src/app/pages/admin/admin.component.html b/src/app/pages/admin/admin.component.html index f411626..ccef8ab 100644 --- a/src/app/pages/admin/admin.component.html +++ b/src/app/pages/admin/admin.component.html @@ -1,27 +1,25 @@ -
Please log in:
- -If you dont' have an account yet, please use the following form to register:
- -Passwords have to use uppercase and lowercase letters, numbers and must have at least 12 characters!
-Passwords do not match!
- + +Signing you in…
+This account does not have access to the calendar.
+Ask an administrator to grant it in the admin app, then reload.
+ + +The administration service cannot be reached right now.
+ +