Sign in through the admin app instead of this one #20

Merged
Paddy merged 3 commits from feature/admin-auth-cutover into master 2026-09-06 21:13:12 +00:00
11 changed files with 316 additions and 279 deletions
+39 -6
View File
@@ -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. 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:** **Environments** (`src/environments/`): `apiUrl` and `adminAppUrl`.
- Dev: `http://localhost:3000` (expects backend running locally) - Dev: `http://localhost:3000` (expects backend running locally) / `http://localhost:3002`
- Prod: `https://api.nachklang.art` - 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`): **Routing** (`app.routing.ts`):
- `/``LandingpageComponent` - `/``LandingpageComponent`
@@ -27,8 +53,9 @@ Angular 18 single-page app for managing calendar events for the "Nachklang" orga
- `**``NotfoundComponent` - `**``NotfoundComponent`
**Service layer** (`src/app/services/`): **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`) - `api.service.ts` — all HTTP calls to the backend REST API; carries the session cookie via `withCredentials`, holds no credential itself
- `utils.service.ts`localStorage helpers for persisting session and user data - `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` **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`). **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.
+48 -10
View File
@@ -3,7 +3,9 @@ import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {Event} from '../../models/event'; import {Event} from '../../models/event';
import {HttpErrorResponse} from '@angular/common/http';
import {ApiService} from '../../services/api.service'; import {ApiService} from '../../services/api.service';
import {AdminAuthService} from '../../services/admin-auth.service';
import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component"; import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component";
@Component({ @Component({
@@ -71,20 +73,21 @@ export class EventComponent implements OnInit, OnDestroy {
} }
if(this.event.eventId === undefined) { if(this.event.eventId === undefined) {
this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: (res: any) => {
if(res.eventId) { if(res.eventId) {
this.event!.eventId = res.eventId; this.event!.eventId = res.eventId;
} else { } else {
this.showCreateError = true; this.showCreateError = true;
return;
} }
},
error: this.handleWriteError('The new event')
}); });
} else { } else {
// Update existing event // Update existing event
this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); 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.`); let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`);
if(deleteConfirmed && this.event) { if(deleteConfirmed && this.event) {
this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: (res: any) => {
if(res.message) { if(res.message) {
this.deleteEvent.next(this.event!.eventId); 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() { triggerMove() {
if(this.editActive) { if(this.editActive) {
window.alert('Please save your changes before moving the event to a different calendar.'); 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 => { movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => {
// If popup is dismissed, undefined will be returned // If popup is dismissed, undefined will be returned
if(result) { if(result) {
this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe((res: any) => { this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe({
console.log(res); next: () => {
// Uses the same interface as delete as from the calendar table perspective it is the same action // Uses the same interface as delete as from the calendar table perspective it is the same action
// as a delete // as a delete
this.deleteEvent.next(result.eventId); this.deleteEvent.next(result.eventId);
},
error: this.handleWriteError('The move')
}); });
} }
}); });
-4
View File
@@ -1,4 +0,0 @@
export interface Session {
sessionId: number;
sessionKey: string;
}
+11 -4
View File
@@ -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 { export interface User {
userId: number; id: string;
fullName: string;
passwordHash: string;
email: string; email: string;
isActive: boolean; fullName: string;
apps: string[];
} }
+20 -22
View File
@@ -1,27 +1,25 @@
<div *ngIf="!isLoggedIn" class="form-container"> <!--
<p>Please log in:</p> No sign-in form and no registration form: accounts live in the admin app and
<label for="email">Your &#64;nachklang.art email: </label> the session is one cookie shared by all four apps. What is left here is a
<input id="email" type="text" aria-label="Your Email" [(ngModel)]="email"><br> link to the right place, plus the two states that must not turn into a
<label for="password">Password: </label> redirect loop - see `failure` in the component.
<input id="password" type="password" aria-label="Password" (keyup.enter)="login()" [(ngModel)]="password"><br> -->
<button (click)="login()">Login</button> <div *ngIf="!isLoggedIn && failure === null" class="form-container">
<br><br> <p>Signing you in&hellip;</p>
<p>If you dont' have an account yet, please use the following form to register:</p> <button (click)="signIn()">Go to sign-in</button>
<label for="name">Your full name: </label>
<input id="name" type="text" aria-label="Your Name" [(ngModel)]="name"><br>
<label for="registerEmail">Your &#64;nachklang.art email: </label>
<input id="registerEmail" type="text" aria-label="Your Email" [(ngModel)]="registerEmail"><br>
<label for="registerPassword">Password: </label>
<input id="registerPassword" type="password" aria-label="Password" [(ngModel)]="registerPassword"><br>
<label for="registerPasswordConfirm">Confirm password: </label>
<input id="registerPasswordConfirm" type="password" aria-label="Password" (keyup.enter)="register()" [(ngModel)]="registerPasswordConfirm"><br>
<p *ngIf="!checkPasswordPolicy()">Passwords have to use uppercase and lowercase letters, numbers and must have at least 12 characters!</p>
<p *ngIf="!checkPasswordsMatch()">Passwords do not match!</p>
<button (click)="register()">Register</button>
</div> </div>
<div *ngIf="isLoggedIn"> <div *ngIf="failure === 'denied'" class="form-container">
<p>This account does not have access to the calendar.</p>
<p>Ask an administrator to grant it in the admin app, then reload.</p>
<button (click)="reload()">Reload</button>
<button (click)="logout()">Sign out</button>
</div>
<div *ngIf="failure === 'unavailable'" class="form-container">
<p>The administration service cannot be reached right now.</p>
<button (click)="reload()">Reload</button>
</div>
<div *ngIf="isLoggedIn && failure === null">
<span>Logged in as {{getUserName()}}</span> <span>Logged in as {{getUserName()}}</span>
<span *ngIf="checkUserInactive()">&nbsp;(inactive)</span>
<span>&nbsp;</span> <span>&nbsp;</span>
<button (click)="logout()">Logout</button> <button (click)="logout()">Logout</button>
<span>&nbsp; | &nbsp;</span> <span>&nbsp; | &nbsp;</span>
+55 -71
View File
@@ -1,10 +1,11 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {Subject} from 'rxjs'; import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
import {HttpErrorResponse} from '@angular/common/http';
import {ApiService} from '../../services/api.service'; import {ApiService} from '../../services/api.service';
import {UtilsService} from '../../services/utils.service'; import {UtilsService} from '../../services/utils.service';
import {AdminAuthService} from '../../services/admin-auth.service';
import {Event} from '../../models/event'; import {Event} from '../../models/event';
import {Session} from '../../models/session';
import {User} from '../../models/user'; import {User} from '../../models/user';
@Component({ @Component({
@@ -19,15 +20,20 @@ export class AdminComponent implements OnInit, OnDestroy {
isLoggedIn: boolean = false; isLoggedIn: boolean = false;
events: Event[] = []; events: Event[] = [];
selectedCalendar: string = ''; selectedCalendar: string = '';
password: string = '';
name: string = ''; name: string = '';
email: string = '';
eventFilter: string = 'future'; // Default value for filter eventFilter: string = 'future'; // Default value for filter
eventSorting: string = 'start_asc'; // Default value for sorting eventSorting: string = 'start_asc'; // Default value for sorting
isActive: boolean = false;
registerEmail: string = ''; /**
registerPassword: string = ''; * Why the page is not showing events, when it is not.
registerPasswordConfirm: string = ''; *
* '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( constructor(
private api: ApiService private api: ApiService
@@ -35,17 +41,30 @@ export class AdminComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
if (UtilsService.getSessionInfoFromLocalStorage().sessionId !== -1) { this.api.me().pipe(takeUntil(this.destroy$)).subscribe({
this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).pipe(takeUntil(this.destroy$)).subscribe((user: User) => { next: (user: User) => {
if(user.userId != null && user.userId !== -1) {
this.isLoggedIn = true; this.isLoggedIn = true;
this.name = user.fullName; this.name = user.fullName;
this.isActive = user.isActive; 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(); this.getEvents();
},
error: (error: HttpErrorResponse) => {
if (error.status === 401) {
AdminAuthService.goToLogin();
return;
}
this.failure = error.status === 403 ? 'denied' : 'unavailable';
} }
}); });
} }
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
@@ -59,7 +78,8 @@ export class AdminComponent implements OnInit, OnDestroy {
return; return;
} }
this.api.getEvents(this.selectedCalendar).pipe(takeUntil(this.destroy$)).subscribe((events: Event[]): void => { this.api.getEvents(this.selectedCalendar).pipe(takeUntil(this.destroy$)).subscribe({
next: (events: Event[]): void => {
for (let event of events) { for (let event of events) {
if (event.status !== 'DELETED') { if (event.status !== 'DELETED') {
this.events.push({ this.events.push({
@@ -73,6 +93,18 @@ export class AdminComponent implements OnInit, OnDestroy {
} }
this.filterEvents(); this.filterEvents();
this.sortEvents(); this.sortEvents();
},
// Without this a failed load is indistinguishable from an empty
// calendar - which is exactly what the old bundle looks like against
// the post-cutover API, and how a deploy in progress gets mistaken for
// lost data.
error: (error: HttpErrorResponse): void => {
if (error.status === 401) {
AdminAuthService.goToLogin();
return;
}
this.failure = error.status === 403 ? 'denied' : 'unavailable';
}
}); });
} }
@@ -156,68 +188,20 @@ export class AdminComponent implements OnInit, OnDestroy {
}); });
} }
login(): void { /**
this.api.login(this.email, this.password).pipe(takeUntil(this.destroy$)).subscribe((session: Session): void => { * There is no sign-in form here any more, and no account creation: accounts
if(session.sessionId != null && session.sessionId !== -1) { * exist only by invitation from the admin app. Both are one redirect.
UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey); */
signIn(): void {
// Get user info AdminAuthService.goToLogin();
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);
});
} }
logout(): void { logout(): void {
UtilsService.clearSessionInfo(); UtilsService.clearName();
this.isLoggedIn = false; void AdminAuthService.signOut();
} }
checkUserInactive(): boolean { reload(): void {
return !this.isActive; window.location.reload();
}
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;
} }
} }
+57
View File
@@ -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<void> {
// 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;
}
}
+31 -112
View File
@@ -1,152 +1,71 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {HttpClient, HttpParams} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import {Event} from '../models/event'; import {Event} from '../models/event';
import {UtilsService} from './utils.service';
import {environment} from './../../environments/environment'; import {environment} from './../../environments/environment';
import {Session} from '../models/session';
import {User} from '../models/user'; 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({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ApiService { export class ApiService {
apiUrl = environment.apiUrl + '/calendar/events/'; 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( constructor(
private http: HttpClient private http: HttpClient
) { ) {
} }
register(email: string, fullName: string, password: string): Observable<Session> { /**
try { * Who is signed in, across all four apps. 401 means "nobody" and 403 means
let registerEvent: any = { * "signed in, but this account may not use the calendar" - the caller has to
"email": email, * tell those apart, because only the first one is worth a trip to the login
"fullName": fullName, * page.
"password": password */
}; me(): Observable<User> {
return this.http.get<User>(environment.apiUrl + '/admin/me', this.withSession);
return this.http.post<Session>(this.userApiUrl + 'register', registerEvent);
} catch (exception) {
console.log('Error fetching events from API');
}
return new Observable<Session>();
}
login(email: string, password: string): Observable<Session> {
try {
let loginEvent: any = {
"email": email,
"password": password
};
return this.http.post<Session>(this.userApiUrl + 'login', loginEvent);
} catch (exception) {
console.log('Error fetching events from API');
}
return new Observable<Session>();
}
checkSession(session: Session): Observable<User> {
try {
return this.http.post<User>(this.userApiUrl + 'checkSessionValid', session);
} catch (exception) {
console.log('Error fetching events from API');
}
return new Observable<User>();
} }
getEvents(calendar: string): Observable<Event[]> { getEvents(calendar: string): Observable<Event[]> {
try { return this.http.get<Event[]>(this.apiUrl + calendar + '/json', this.withSession);
let session = UtilsService.getSessionInfoFromLocalStorage();
let params = new HttpParams();
params = params.append('sessionId', session.sessionId);
params = params.append('sessionKey', session.sessionKey);
return this.http.get<Event[]>((this.apiUrl + calendar + '/json'), {params});
} catch (exception) {
console.log('Error fetching events from API');
}
return new Observable<Event[]>();
} }
updateEvent(event: Event): Observable<any> { updateEvent(event: Event): Observable<any> {
try { return this.http.put(this.apiUrl + event.eventId, event, this.withSession);
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<any>();
} }
createEvent(event: Event): Observable<any> { createEvent(event: Event): Observable<any> {
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 // Automatically set birthdays to recurring
if (event.calendarId === 5) { if (event.calendarId === 5) {
event.repeatFrequency = 'YEARLY'; event.repeatFrequency = 'YEARLY';
} }
let createEvent: any = event; return this.http.post(this.apiUrl, event, this.withSession);
return this.http.post(this.apiUrl, createEvent, {params});
} catch (exception) {
console.log('Error creating event');
}
return new Observable<any>();
} }
deleteEvent(event: Event): Observable<any> { deleteEvent(event: Event): Observable<any> {
try { return this.http.delete(this.apiUrl + event.eventId, {
let session = UtilsService.getSessionInfoFromLocalStorage(); headers: {'Content-Type': 'application/json'},
body: event,
let params = new HttpParams(); withCredentials: true
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<any>();
} }
moveEvent(event: Event): Observable<any> { moveEvent(event: Event): Observable<any> {
try { return this.http.put(this.apiUrl + 'move/' + event.eventId, event, this.withSession);
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<any>();
} }
} }
+15 -16
View File
@@ -1,5 +1,4 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {Session} from '../models/session';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -9,24 +8,24 @@ export class UtilsService {
constructor() { 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 { static getNameFromLocalStorage(): string {
return localStorage.getItem('name') ?? ''; return localStorage.getItem('name') ?? '';
} }
static saveSessionInfoToLocalStorage(sessionId: number, sessionKey: string): void { static clearName(): void {
localStorage.setItem('sessionId', sessionId.toString()); localStorage.removeItem('name');
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', '');
} }
} }
+2 -1
View File
@@ -1,4 +1,5 @@
export const environment = { export const environment = {
production: true, production: true,
apiUrl: 'https://api.nachklang.art' apiUrl: 'https://api.nachklang.art',
adminAppUrl: 'https://admin.nachklang.art'
}; };
+6 -1
View File
@@ -4,7 +4,12 @@
export const environment = { export const environment = {
production: false, 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'
}; };
/* /*