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