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' }; /*