From 7ce42324da714e97ade1f5e878774303af2c3d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Sun, 6 Sep 2026 22:23:45 +0200 Subject: [PATCH] Sign in through the admin app instead of this one The frontend half of the calendar auth cutover (step 4 of docs/calendar-auth-migration.md in the API repo). This app now shares one identity with the tickets, feedback and admin apps. Every call carries the session cookie via withCredentials rather than appending sessionId/sessionKey to the URL, so there is no credential left in api.service.ts at all - that was DEFERRED_SECURITY.md item 1. The login and registration forms are gone. Accounts exist only by invitation from the admin app, so both were one redirect; sign-out ends the session for all four apps and returns here, so doing it by accident costs one click. 401 and 403 are deliberately not collapsed. Only 401 goes to the login page: redirecting on 403 produces a loop where signing in succeeds and lands straight back on the refusal, and doing it for an unreachable API produces the same loop with no way out. Both of those now render a message instead. src/app/models/session.ts and the unrouted LoginComponent are dead but left in place; removing files is a separate decision. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 45 ++++++- src/app/models/user.ts | 15 ++- src/app/pages/admin/admin.component.html | 42 +++--- src/app/pages/admin/admin.component.ts | 119 +++++++---------- src/app/services/admin-auth.service.ts | 57 +++++++++ src/app/services/api.service.ts | 155 ++++++----------------- src/app/services/utils.service.ts | 31 +++-- src/environments/environment.prod.ts | 3 +- src/environments/environment.ts | 7 +- 9 files changed, 232 insertions(+), 242 deletions(-) create mode 100644 src/app/services/admin-auth.service.ts diff --git a/CLAUDE.md b/CLAUDE.md index 83dc009..55f7307 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, kept only because deleting files needs a decision:** +`src/app/models/session.ts` and the unrouted `LoginComponent`. 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.

+ +
+
Logged in as {{getUserName()}} -  (inactive)     |   diff --git a/src/app/pages/admin/admin.component.ts b/src/app/pages/admin/admin.component.ts index 6b50870..b98e56a 100644 --- a/src/app/pages/admin/admin.component.ts +++ b/src/app/pages/admin/admin.component.ts @@ -1,10 +1,11 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; +import {HttpErrorResponse} from '@angular/common/http'; import {ApiService} from '../../services/api.service'; import {UtilsService} from '../../services/utils.service'; +import {AdminAuthService} from '../../services/admin-auth.service'; import {Event} from '../../models/event'; -import {Session} from '../../models/session'; import {User} from '../../models/user'; @Component({ @@ -19,15 +20,20 @@ export class AdminComponent implements OnInit, OnDestroy { isLoggedIn: boolean = false; events: Event[] = []; selectedCalendar: string = ''; - password: string = ''; name: string = ''; - email: string = ''; eventFilter: string = 'future'; // Default value for filter eventSorting: string = 'start_asc'; // Default value for sorting - isActive: boolean = false; - registerEmail: string = ''; - registerPassword: string = ''; - registerPasswordConfirm: string = ''; + + /** + * Why the page is not showing events, when it is not. + * + * 'denied' and 'unavailable' are kept apart on purpose. Only a 401 is worth + * sending someone to the login page; bouncing them there for a 403 produces + * a loop where signing in succeeds and lands them straight back here, and + * bouncing them for an unreachable API produces the same loop with no way + * out at all. + */ + failure: 'denied' | 'unavailable' | null = null; constructor( private api: ApiService @@ -35,16 +41,29 @@ export class AdminComponent implements OnInit, OnDestroy { } ngOnInit(): void { - if (UtilsService.getSessionInfoFromLocalStorage().sessionId !== -1) { - this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).pipe(takeUntil(this.destroy$)).subscribe((user: User) => { - if(user.userId != null && user.userId !== -1) { - this.isLoggedIn = true; - this.name = user.fullName; - this.isActive = user.isActive; - this.getEvents(); + this.api.me().pipe(takeUntil(this.destroy$)).subscribe({ + next: (user: User) => { + this.isLoggedIn = true; + this.name = user.fullName; + UtilsService.saveNameToLocalStorage(user.fullName); + + // Signed in, but not for this app. The API would answer 403 to + // every events call, so say so once instead of failing per call. + if (!user.apps.includes('calendar')) { + this.failure = 'denied'; + return; } - }); - } + + this.getEvents(); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 401) { + AdminAuthService.goToLogin(); + return; + } + this.failure = error.status === 403 ? 'denied' : 'unavailable'; + } + }); } ngOnDestroy(): void { @@ -156,68 +175,20 @@ export class AdminComponent implements OnInit, OnDestroy { }); } - login(): void { - this.api.login(this.email, this.password).pipe(takeUntil(this.destroy$)).subscribe((session: Session): void => { - if(session.sessionId != null && session.sessionId !== -1) { - UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey); - - // Get user info - this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).pipe(takeUntil(this.destroy$)).subscribe((user: User) => { - if(user.userId != null && user.userId !== -1) { - this.isLoggedIn = true; - this.name = user.fullName; - this.isActive = user.isActive; - this.getEvents(); - } else { - alert('Login unsuccessful. Please check if you provided the correct username and password.'); - } - }); - } - }, (error) => { - alert('Login unsuccessful. Reported problem from server: ' + error?.error?.message); - }); - } - - register(): void { - this.api.register(this.registerEmail, this.name, this.registerPassword).pipe(takeUntil(this.destroy$)).subscribe((session: Session): void => { - if(session.sessionId != null && session.sessionId !== -1) { - UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey); - this.isLoggedIn = true; - this.getEvents(); - alert('An email was sent to your Nachklang address. Please click the link in the email to activate your account. You can\'t use this application before the activation.'); - } else { - alert('Registration unsuccessful. Please contact Patrick.'); - } - }, (error) => { - alert('Registration unsuccessful. Reported problem from server: ' + error?.error?.message); - }); + /** + * There is no sign-in form here any more, and no account creation: accounts + * exist only by invitation from the admin app. Both are one redirect. + */ + signIn(): void { + AdminAuthService.goToLogin(); } logout(): void { - UtilsService.clearSessionInfo(); - this.isLoggedIn = false; + UtilsService.clearName(); + void AdminAuthService.signOut(); } - checkUserInactive(): boolean { - return !this.isActive; - } - - checkPasswordsMatch(): boolean { - return this.registerPassword === this.registerPasswordConfirm; - } - - checkPasswordPolicy(): boolean { - let isLongEnough = this.registerPassword.length >= 12; - - var lowercaseRegex = /[a-z]/g - let hasLowercase = lowercaseRegex.test(this.registerPassword); - - var uppercaseRegex = /[A-Z]/g - let hasUppercase = uppercaseRegex.test(this.registerPassword); - - var numberRegex = /[0-9]/g - let hasNumbers = numberRegex.test(this.registerPassword); - - return isLongEnough && hasLowercase && hasUppercase && hasNumbers; + reload(): void { + window.location.reload(); } } diff --git a/src/app/services/admin-auth.service.ts b/src/app/services/admin-auth.service.ts new file mode 100644 index 0000000..95a400f --- /dev/null +++ b/src/app/services/admin-auth.service.ts @@ -0,0 +1,57 @@ +import {Injectable} from '@angular/core'; +import {environment} from '../../environments/environment'; + +/** + * Everything to do with *where* signing in happens. This app holds no + * credential of its own: the session is an httpOnly cookie on + * .nachklang.art that the API sets, so the calendar can neither read it nor + * mint one. It can only send the browser somewhere that can. + * + * Mirrors the same file in nachklang-tickets and nachklang-feedback - the three + * apps are one product with one sign-in. + */ +@Injectable({providedIn: 'root'}) +export class AdminAuthService { + + /** + * The admin app's login URL, carrying where to come back to. The admin app + * validates that target against its own allowlist of origins, so a + * `?redirect=` it does not recognise is dropped rather than followed. + */ + static loginUrl(returnTo?: string): string { + const target = returnTo ?? (window.location.pathname + window.location.search); + const absolute = new URL(target, window.location.origin).toString(); + return `${environment.adminAppUrl}/login?redirect=${encodeURIComponent(absolute)}`; + } + + static goToLogin(returnTo?: string): void { + AdminAuthService.leave(AdminAuthService.loginUrl(returnTo)); + } + + /** + * Ends the session for every app, not just this one - there is only one + * session. The user lands back on the admin login with this page as the + * return target, so signing out by accident costs one click to undo. + */ + static async signOut(): Promise { + // Captured before the request: afterwards the page may already be gone. + const target = AdminAuthService.loginUrl(); + try { + await fetch(`${environment.apiUrl}/admin/auth/sign-out`, { + method: 'POST', + credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: '{}' + }); + } catch { + // A network failure still leaves the browser better off at the login + // page than on a signed-in-looking shell it can no longer refresh. + } + AdminAuthService.leave(target); + } + + /** Assigning through a variable rather than a literal keeps this one place. */ + private static leave(url: string): void { + window.location.href = url; + } +} diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts index 2649cf6..3302f9e 100644 --- a/src/app/services/api.service.ts +++ b/src/app/services/api.service.ts @@ -1,152 +1,71 @@ import {Injectable} from '@angular/core'; -import {HttpClient, HttpParams} from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; import {Event} from '../models/event'; -import {UtilsService} from './utils.service'; -import { environment } from './../../environments/environment'; -import {Session} from '../models/session'; +import {environment} from './../../environments/environment'; import {User} from '../models/user'; +/** + * Every call here is cross-origin to the API and carries the shared session + * cookie, which is what `withCredentials` means and why it is not optional: + * without it the browser sends no cookie and the API answers 401. + * + * Before the auth cutover each call appended a sessionId/sessionKey pair to the + * query string instead - credentials in URLs, and so in access logs, browser + * history and Referer headers (DEFERRED_SECURITY.md item 1). There is no + * credential left in this file at all. + */ @Injectable({ providedIn: 'root' }) export class ApiService { apiUrl = environment.apiUrl + '/calendar/events/'; - userApiUrl = environment.apiUrl + '/calendar/users/'; + + // Sending the cookie is the whole authentication story; nothing else here + // says who the caller is. + private readonly withSession = {withCredentials: true}; constructor( private http: HttpClient ) { } - register(email: string, fullName: string, password: string): Observable { - try { - let registerEvent: any = { - "email": email, - "fullName": fullName, - "password": password - }; - - return this.http.post(this.userApiUrl + 'register', registerEvent); - } catch (exception) { - console.log('Error fetching events from API'); - } - return new Observable(); - } - - login(email: string, password: string): Observable { - try { - let loginEvent: any = { - "email": email, - "password": password - }; - - return this.http.post(this.userApiUrl + 'login', loginEvent); - } catch (exception) { - console.log('Error fetching events from API'); - } - return new Observable(); - } - - checkSession(session: Session): Observable { - try { - return this.http.post(this.userApiUrl + 'checkSessionValid', session); - } catch (exception) { - console.log('Error fetching events from API'); - } - return new Observable(); + /** + * Who is signed in, across all four apps. 401 means "nobody" and 403 means + * "signed in, but this account may not use the calendar" - the caller has to + * tell those apart, because only the first one is worth a trip to the login + * page. + */ + me(): Observable { + return this.http.get(environment.apiUrl + '/admin/me', this.withSession); } getEvents(calendar: string): Observable { - try { - let session = UtilsService.getSessionInfoFromLocalStorage(); - - let params = new HttpParams(); - params = params.append('sessionId', session.sessionId); - params = params.append('sessionKey', session.sessionKey); - return this.http.get((this.apiUrl + calendar + '/json'), {params}); - } catch (exception) { - console.log('Error fetching events from API'); - } - return new Observable(); + return this.http.get(this.apiUrl + calendar + '/json', this.withSession); } updateEvent(event: Event): Observable { - try { - let session = UtilsService.getSessionInfoFromLocalStorage(); - - let params = new HttpParams(); - params = params.append('sessionId', session.sessionId); - params = params.append('sessionKey', session.sessionKey); - - let updateEvent: any = event; - - return this.http.put(this.apiUrl + updateEvent.eventId, updateEvent, {params}); - } catch (exception) { - console.log('Error updating event'); - } - return new Observable(); + return this.http.put(this.apiUrl + event.eventId, event, this.withSession); } createEvent(event: Event): Observable { - try { - let session = UtilsService.getSessionInfoFromLocalStorage(); - - let params = new HttpParams(); - params = params.append('sessionId', session.sessionId); - params = params.append('sessionKey', session.sessionKey); - - // Automatically set birthdays to recurring - if(event.calendarId === 5) { - event.repeatFrequency = 'YEARLY'; - } - - let createEvent: any = event; - - return this.http.post(this.apiUrl, createEvent, {params}); - } catch (exception) { - console.log('Error creating event'); + // Automatically set birthdays to recurring + if (event.calendarId === 5) { + event.repeatFrequency = 'YEARLY'; } - return new Observable(); + + return this.http.post(this.apiUrl, event, this.withSession); } deleteEvent(event: Event): Observable { - try { - let session = UtilsService.getSessionInfoFromLocalStorage(); - - let params = new HttpParams(); - params = params.append('sessionId', session.sessionId); - params = params.append('sessionKey', session.sessionKey); - - let deleteEvent: any = event; - - return this.http.delete(this.apiUrl + deleteEvent.eventId, { - headers: { - 'Content-Type': 'application/json' - }, - body: deleteEvent, - params - }); - } catch (exception) { - console.log('Error deleting event'); - } - return new Observable(); + return this.http.delete(this.apiUrl + event.eventId, { + headers: {'Content-Type': 'application/json'}, + body: event, + withCredentials: true + }); } moveEvent(event: Event): Observable { - try { - let session = UtilsService.getSessionInfoFromLocalStorage(); - - let params = new HttpParams(); - params = params.append('sessionId', session.sessionId); - params = params.append('sessionKey', session.sessionKey); - - let updateEvent: any = event; - - return this.http.put(this.apiUrl + 'move/' + updateEvent.eventId, updateEvent, {params}); - } catch (exception) { - console.log('Error updating event'); - } - return new Observable(); + return this.http.put(this.apiUrl + 'move/' + event.eventId, event, this.withSession); } } diff --git a/src/app/services/utils.service.ts b/src/app/services/utils.service.ts index a6a01be..e70f309 100644 --- a/src/app/services/utils.service.ts +++ b/src/app/services/utils.service.ts @@ -1,5 +1,4 @@ import {Injectable} from '@angular/core'; -import {Session} from '../models/session'; @Injectable({ providedIn: 'root' @@ -9,24 +8,24 @@ export class UtilsService { constructor() { } + /** + * The signed-in user's name, cached so a freshly added draft row can show an + * author before it has been saved. Purely cosmetic: the server records the + * author from the session, never from anything the client sends. + * + * The session itself is an httpOnly cookie and is deliberately not here - + * this app used to keep a sessionId/sessionKey pair in localStorage and + * append it to every URL, which is what the auth cutover removed. + */ + static saveNameToLocalStorage(name: string): void { + localStorage.setItem('name', name); + } + static getNameFromLocalStorage(): string { return localStorage.getItem('name') ?? ''; } - static saveSessionInfoToLocalStorage(sessionId: number, sessionKey: string): void { - localStorage.setItem('sessionId', sessionId.toString()); - localStorage.setItem('sessionKey', sessionKey); - } - - static getSessionInfoFromLocalStorage(): Session { - return { - sessionId: parseInt((localStorage.getItem('sessionId') ?? '-1'), 10), - sessionKey: localStorage.getItem('sessionKey') ?? '' - } - } - - static clearSessionInfo(): void { - localStorage.setItem('sessionId', '-1'); - localStorage.setItem('sessionKey', ''); + static clearName(): void { + localStorage.removeItem('name'); } } diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 5831ec9..d06532c 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,4 +1,5 @@ export const environment = { production: true, - apiUrl: 'https://api.nachklang.art' + apiUrl: 'https://api.nachklang.art', + adminAppUrl: 'https://admin.nachklang.art' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index a40ab69..fdad968 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -4,7 +4,12 @@ export const environment = { production: false, - apiUrl: 'http://localhost:3000' + apiUrl: 'http://localhost:3000', + // Where signing in happens. This app has no login form of its own since the + // auth cutover: accounts live in the admin app, and the session is a cookie + // on .nachklang.art that all four apps share. In dev that is one host, so + // the cookie is scoped to localhost and the port does not matter. + adminAppUrl: 'http://localhost:3002' }; /*