Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d2dc2901 | |||
| 5d7221b87b | |||
| 7ce42324da | |||
| ded89d54f3 | |||
|
6400616335
|
|||
| 55748260d0 |
@@ -0,0 +1,72 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Dev server at http://localhost:4200/ (live reload)
|
||||
npm run build # Production build
|
||||
npm run watch # Build with watch mode (development config)
|
||||
npm test # Run unit tests (Jasmine/Karma in Chrome)
|
||||
```
|
||||
|
||||
No linter is configured in this project.
|
||||
|
||||
## Architecture
|
||||
|
||||
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** (`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`
|
||||
- `/admin` → `AdminComponent` (main calendar management page)
|
||||
- `**` → `NotfoundComponent`
|
||||
|
||||
**Service layer** (`src/app/services/`):
|
||||
- `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`
|
||||
|
||||
**Admin page** (`src/app/pages/admin/`) is the core of the app. It owns events state and passes it down to `EventsTableComponent` via `@Input()`. Key features: multi-calendar support (public, members, choir, management, birthdays), event filtering (future/past/all), sorting, and an event-move dialog (`EventMovePopupComponent` via Angular Material Dialog).
|
||||
|
||||
**Calendar-specific behavior:** Birthday calendar auto-sets recurrence to YEARLY. Events have a `status` field (`DRAFT` / `DELETED`).
|
||||
|
||||
**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.
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComponentRef, NgModule} from '@angular/core';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
|
||||
import {AppComponent} from './app.component';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {Component, Inject} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||
import {Event} from "../../models/event";
|
||||
import {log} from "@angular-devkit/build-angular/src/builders/ssr-dev-server";
|
||||
|
||||
@Component({
|
||||
selector: 'app-event-move-popup',
|
||||
@@ -10,7 +9,7 @@ import {log} from "@angular-devkit/build-angular/src/builders/ssr-dev-server";
|
||||
})
|
||||
export class EventMovePopupComponent {
|
||||
selectedCalendar = 'public';
|
||||
calendars = ['', 'public', 'members', 'management', 'choir'];
|
||||
calendars = ['', 'public', 'members', 'management', 'choir', 'birthdays'];
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<EventMovePopupComponent>,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
|
||||
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
|
||||
import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {Event} from '../../models/event';
|
||||
import {HttpErrorResponse} from '@angular/common/http';
|
||||
import {ApiService} from '../../services/api.service';
|
||||
import {AdminAuthService} from '../../services/admin-auth.service';
|
||||
import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.component";
|
||||
|
||||
@Component({
|
||||
@@ -9,7 +13,9 @@ import {EventMovePopupComponent} from "../event-move-popup/event-move-popup.comp
|
||||
templateUrl: './event.component.html',
|
||||
styleUrls: ['./event.component.css']
|
||||
})
|
||||
export class EventComponent implements OnInit {
|
||||
export class EventComponent implements OnInit, OnDestroy {
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@Input() event: Event | undefined;
|
||||
@Input() editActive: boolean = false;
|
||||
@@ -38,6 +44,11 @@ export class EventComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
toggleEdit() {
|
||||
if (this.editActive && this.event !== undefined) {
|
||||
// Prevent save if endDateTime is before startDateTime
|
||||
@@ -62,20 +73,21 @@ export class EventComponent implements OnInit {
|
||||
}
|
||||
|
||||
if(this.event.eventId === undefined) {
|
||||
this.api.createEvent(this.event).subscribe((res: any) => {
|
||||
console.log(res);
|
||||
|
||||
if(res.eventId) {
|
||||
this.event!.eventId = res.eventId;
|
||||
} else {
|
||||
this.showCreateError = true;
|
||||
return;
|
||||
}
|
||||
this.api.createEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (res: any) => {
|
||||
if(res.eventId) {
|
||||
this.event!.eventId = res.eventId;
|
||||
} else {
|
||||
this.showCreateError = true;
|
||||
}
|
||||
},
|
||||
error: this.handleWriteError('The new event')
|
||||
});
|
||||
} else {
|
||||
// Update existing event
|
||||
this.api.updateEvent(this.event).subscribe((res: any) => {
|
||||
console.log(res);
|
||||
this.api.updateEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: () => {},
|
||||
error: this.handleWriteError('Your change')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -171,15 +183,48 @@ export class EventComponent implements OnInit {
|
||||
let deleteConfirmed = window.confirm(`Are you sure you want to delete "${this.event!.name}"? This action cannot be undone.`);
|
||||
|
||||
if(deleteConfirmed && this.event) {
|
||||
this.api.deleteEvent(this.event).subscribe((res: any) => {
|
||||
console.log(res);
|
||||
if(res.message) {
|
||||
this.deleteEvent.next(this.event!.eventId);
|
||||
}
|
||||
this.api.deleteEvent(this.event).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (res: any) => {
|
||||
if(res.message) {
|
||||
this.deleteEvent.next(this.event!.eventId);
|
||||
}
|
||||
},
|
||||
error: this.handleWriteError('The deletion')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do when a write fails.
|
||||
*
|
||||
* Before the auth cutover none of the writes here had an error callback, so
|
||||
* a failure was invisible: the row closed, nothing was saved, and the user
|
||||
* had every reason to think it had been. A 401 is now a routine event - the
|
||||
* session expires, or is ended from another app or another tab - so silence
|
||||
* is no longer survivable.
|
||||
*
|
||||
* A 401 means the session is gone, and nothing on this page can be saved
|
||||
* until it comes back, so it goes straight to the login carrying this page
|
||||
* as the return target. Everything else says what happened and leaves the
|
||||
* user where they are, with their edits still on screen.
|
||||
*/
|
||||
private handleWriteError(action: string): (error: HttpErrorResponse) => void {
|
||||
return (error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
window.alert(`Your session has expired, so ${action} was not saved. Signing you in again.`);
|
||||
AdminAuthService.goToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.status === 403) {
|
||||
window.alert(`${action} failed: this account no longer has access to the calendar.`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(`${action} failed. Please try again. (${error.status || 'no response from the server'})`);
|
||||
};
|
||||
}
|
||||
|
||||
triggerMove() {
|
||||
if(this.editActive) {
|
||||
window.alert('Please save your changes before moving the event to a different calendar.');
|
||||
@@ -192,14 +237,16 @@ export class EventComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
|
||||
movePopup.afterClosed().subscribe(result => {
|
||||
movePopup.afterClosed().pipe(takeUntil(this.destroy$)).subscribe(result => {
|
||||
// If popup is dismissed, undefined will be returned
|
||||
if(result) {
|
||||
this.api.moveEvent(result).subscribe((res: any) => {
|
||||
console.log(res);
|
||||
// Uses the same interface as delete as from the calendar table perspective it is the same action
|
||||
// as a delete
|
||||
this.deleteEvent.next(result.eventId);
|
||||
this.api.moveEvent(result).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: () => {
|
||||
// Uses the same interface as delete as from the calendar table perspective it is the same action
|
||||
// as a delete
|
||||
this.deleteEvent.next(result.eventId);
|
||||
},
|
||||
error: this.handleWriteError('The move')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
<th>Move</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
<tr app-event *ngFor="let event of events" [event]="event" [editActive]="event.eventId === undefined" (deleteEvent)="deleteEvent($event)"></tr>
|
||||
<tr app-event *ngFor="let event of events; trackBy: trackByEventId" [event]="event" [editActive]="event.eventId === undefined" (deleteEvent)="deleteEvent($event)"></tr>
|
||||
</table>
|
||||
<button *ngIf="selectedCalendar !== -1" (click)="addEvent()">Add Event</button>
|
||||
|
||||
@@ -29,10 +29,10 @@ export class EventsTableComponent implements OnInit {
|
||||
}
|
||||
|
||||
deleteEvent(id: number) {
|
||||
this.events.forEach(event => {
|
||||
if(event.eventId === id) {
|
||||
this.events.splice(this.events.indexOf(event), 1);
|
||||
}
|
||||
})
|
||||
this.events = this.events.filter(event => event.eventId !== id);
|
||||
}
|
||||
|
||||
trackByEventId(index: number, event: Event): number {
|
||||
return event.eventId ?? index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface Session {
|
||||
sessionId: number;
|
||||
sessionKey: string;
|
||||
}
|
||||
+11
-4
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
<div *ngIf="!isLoggedIn" class="form-container">
|
||||
<p>Please log in:</p>
|
||||
<label for="email">Your @nachklang.art email: </label>
|
||||
<input id="email" type="text" aria-label="Your Email" [(ngModel)]="email"><br>
|
||||
<label for="password">Password: </label>
|
||||
<input id="password" type="password" aria-label="Password" (keyup.enter)="login()" [(ngModel)]="password"><br>
|
||||
<button (click)="login()">Login</button>
|
||||
<br><br>
|
||||
<p>If you dont' have an account yet, please use the following form to register:</p>
|
||||
<label for="name">Your full name: </label>
|
||||
<input id="name" type="text" aria-label="Your Name" [(ngModel)]="name"><br>
|
||||
<label for="registerEmail">Your @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>
|
||||
<!--
|
||||
No sign-in form and no registration form: accounts live in the admin app and
|
||||
the session is one cookie shared by all four apps. What is left here is a
|
||||
link to the right place, plus the two states that must not turn into a
|
||||
redirect loop - see `failure` in the component.
|
||||
-->
|
||||
<div *ngIf="!isLoggedIn && failure === null" class="form-container">
|
||||
<p>Signing you in…</p>
|
||||
<button (click)="signIn()">Go to sign-in</button>
|
||||
</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 *ngIf="checkUserInactive()"> (inactive)</span>
|
||||
<span> </span>
|
||||
<button (click)="logout()">Logout</button>
|
||||
<span> | </span>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
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({
|
||||
@@ -10,20 +13,27 @@ import {User} from '../../models/user';
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrls: ['./admin.component.css']
|
||||
})
|
||||
export class AdminComponent implements OnInit {
|
||||
export class AdminComponent implements OnInit, OnDestroy {
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
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
|
||||
@@ -31,16 +41,34 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (UtilsService.getSessionInfoFromLocalStorage().sessionId !== -1) {
|
||||
this.api.checkSession(UtilsService.getSessionInfoFromLocalStorage()).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 {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
getEvents(): void {
|
||||
@@ -50,20 +78,33 @@ export class AdminComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.getEvents(this.selectedCalendar).subscribe((events: Event[]): void => {
|
||||
for (let event of events) {
|
||||
if(event.status !== 'DELETED') {
|
||||
this.events.push({
|
||||
...event,
|
||||
startDateTime: new Date(event.startDateTime),
|
||||
endDateTime: new Date(event.endDateTime),
|
||||
createdDate: new Date(event.createdDate),
|
||||
lastModifiedDate: new Date(event.lastModifiedDate)
|
||||
});
|
||||
this.api.getEvents(this.selectedCalendar).pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (events: Event[]): void => {
|
||||
for (let event of events) {
|
||||
if (event.status !== 'DELETED') {
|
||||
this.events.push({
|
||||
...event,
|
||||
startDateTime: new Date(event.startDateTime),
|
||||
endDateTime: new Date(event.endDateTime),
|
||||
createdDate: new Date(event.createdDate),
|
||||
lastModifiedDate: new Date(event.lastModifiedDate)
|
||||
});
|
||||
}
|
||||
}
|
||||
this.filterEvents();
|
||||
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';
|
||||
}
|
||||
this.filterEvents();
|
||||
this.sortEvents();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,6 +160,7 @@ export class AdminComponent implements OnInit {
|
||||
dayOnlyDate.setFullYear(date.getFullYear());
|
||||
dayOnlyDate.setMonth(date.getMonth());
|
||||
dayOnlyDate.setDate(date.getDate());
|
||||
dayOnlyDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return dayOnlyDate;
|
||||
}
|
||||
@@ -146,68 +188,20 @@ export class AdminComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
login(): void {
|
||||
this.api.login(this.email, this.password).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()).subscribe((user: User) => {
|
||||
if(user.userId != null && user.userId !== -1) {
|
||||
this.isLoggedIn = true;
|
||||
this.name = user.fullName;
|
||||
this.isActive = user.isActive;
|
||||
this.getEvents();
|
||||
} else {
|
||||
confirm('Login unsuccessful. Please check if you provided the correct username and password.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, (error) => {
|
||||
confirm('Login unsuccessful. Reported problem from server: ' + error.error.message);
|
||||
});
|
||||
}
|
||||
|
||||
register(): void {
|
||||
this.api.register(this.registerEmail, this.name, this.registerPassword).subscribe((session: Session): void => {
|
||||
if(session.sessionId != null && session.sessionId !== -1) {
|
||||
UtilsService.saveSessionInfoToLocalStorage(session.sessionId, session.sessionKey);
|
||||
this.isLoggedIn = true;
|
||||
this.getEvents();
|
||||
confirm('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 {
|
||||
confirm('Regisistration unsuccessful. Please contact Patrick.');
|
||||
}
|
||||
}, (error) => {
|
||||
confirm('Login 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/* ── Host fills the padded app-root container ───────────────────── */
|
||||
:host {
|
||||
display: block;
|
||||
height: calc(100vh - 40px); /* compensates for app-root padding: 20px */
|
||||
}
|
||||
|
||||
/* ── Page shell ─────────────────────────────────────────────────── */
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #1a1714;
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 60% at 14% 56%, rgba(200, 169, 110, 0.07) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 50% 50% at 87% 24%, rgba(200, 169, 110, 0.045) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Subtle horizontal staff-line texture */
|
||||
.page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 59px,
|
||||
rgba(200, 169, 110, 0.033) 59px,
|
||||
rgba(200, 169, 110, 0.033) 60px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Admin link ─────────────────────────────────────────────────── */
|
||||
.admin-link {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 230, 211, 0.28);
|
||||
text-decoration: none;
|
||||
transition: color 0.25s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
color: #c8a96e;
|
||||
}
|
||||
|
||||
/* ── Hero content ───────────────────────────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.25rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Title block ────────────────────────────────────────────────── */
|
||||
.title-block {
|
||||
text-align: center;
|
||||
animation: rise 0.85s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.clef {
|
||||
display: block;
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
color: #c8a96e;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: clamp(2.8rem, 7.5vw, 5rem);
|
||||
font-weight: 200;
|
||||
letter-spacing: 0.12em;
|
||||
color: #f0e6d3;
|
||||
margin: 0 0 0.25em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: clamp(0.7rem, 1.6vw, 0.85rem);
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
color: #c8a96e;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.ornament-line {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #c8a96e 35%, #c8a96e 65%, transparent);
|
||||
margin: 0 auto 1.1rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 300;
|
||||
color: rgba(240, 230, 211, 0.45);
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Cards grid ─────────────────────────────────────────────────── */
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
animation: rise 0.85s cubic-bezier(0.16, 1, 0.3, 1) 0.14s both;
|
||||
}
|
||||
|
||||
/* ── Individual card ────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(200, 169, 110, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 2rem 1.25rem 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: inherit;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color 0.3s ease,
|
||||
background 0.3s ease,
|
||||
transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(145deg, rgba(200, 169, 110, 0.07) 0%, transparent 65%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(200, 169, 110, 0.4);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Icon container ─────────────────────────────────────────────── */
|
||||
.icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-wrap svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-wrap.copy {
|
||||
background: rgba(200, 169, 110, 0.1);
|
||||
color: #c8a96e;
|
||||
padding: 11px;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* ── Card text ──────────────────────────────────────────────────── */
|
||||
.card-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #ede0cc;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: rgba(240, 230, 211, 0.38);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ── Copy card: confirmed state ─────────────────────────────────── */
|
||||
.copy-card.copied .card-label {
|
||||
color: #7cc48e;
|
||||
}
|
||||
|
||||
.copy-card.copied .icon-wrap.copy {
|
||||
background: rgba(124, 196, 142, 0.1);
|
||||
color: #7cc48e;
|
||||
}
|
||||
|
||||
/* ── Page-load animation ────────────────────────────────────────── */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||
@media (max-width: 580px) {
|
||||
:host {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: calc(100vh - 40px);
|
||||
padding: 2.5rem 0;
|
||||
justify-content: flex-start;
|
||||
padding-top: 15%;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
h1 {
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,71 @@
|
||||
<h1>Nachklang Calendar</h1>
|
||||
<a href="webcal://api.nachklang.art/calendar/events/public/ical">Abonnieren</a>
|
||||
<div class="page">
|
||||
<a routerLink="/admin" class="admin-link">Admin-Login ›</a>
|
||||
|
||||
<main class="hero">
|
||||
|
||||
<div class="title-block">
|
||||
<span class="clef" aria-hidden="true">𝄞</span>
|
||||
<h1>Nachklang</h1>
|
||||
<p class="tagline">Veranstaltungskalender</p>
|
||||
<div class="ornament-line"></div>
|
||||
<p class="description">Abonniere unseren Kalender und verpasse keine Veranstaltung mehr.</p>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<!-- Apple Calendar -->
|
||||
<a href="webcal://api.nachklang.art/calendar/events/public/ical" class="card">
|
||||
<div class="icon-wrap apple">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
<rect width="48" height="14" fill="#FF3B30"/>
|
||||
<rect x="13" y="3" width="5" height="9" rx="2.5" fill="#c7c7cc"/>
|
||||
<rect x="30" y="3" width="5" height="9" rx="2.5" fill="#c7c7cc"/>
|
||||
<text x="24" y="13" text-anchor="middle"
|
||||
font-family="Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||
font-size="6.5" font-weight="600" fill="rgba(255,255,255,0.92)"
|
||||
letter-spacing="1.5">DEC</text>
|
||||
<text x="24" y="41" text-anchor="middle"
|
||||
font-family="Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||
font-size="21" font-weight="200" fill="#1c1c1e">31</text>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="card-label">Apple Kalender</span>
|
||||
<span class="card-sub">iPhone · iPad · Mac</span>
|
||||
</a>
|
||||
|
||||
<!-- Google Calendar -->
|
||||
<a href="https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fapi.nachklang.art%2Fcalendar%2Fevents%2Fpublic%2Fical"
|
||||
target="_blank" rel="noopener noreferrer" class="card">
|
||||
<div class="icon-wrap google">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
<rect width="48" height="14" fill="#1a73e8"/>
|
||||
<rect x="13" y="3" width="5" height="9" rx="2.5" fill="#80868b"/>
|
||||
<rect x="30" y="3" width="5" height="9" rx="2.5" fill="#80868b"/>
|
||||
<rect x="8" y="18" width="14" height="12" rx="1.5" fill="#4285f4"/>
|
||||
<rect x="26" y="18" width="14" height="12" rx="1.5" fill="#ea4335"/>
|
||||
<rect x="8" y="34" width="14" height="11" rx="1.5" fill="#34a853"/>
|
||||
<rect x="26" y="34" width="14" height="11" rx="1.5" fill="#fbbc05"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="card-label">Google Kalender</span>
|
||||
<span class="card-sub">Android · Web</span>
|
||||
</a>
|
||||
|
||||
<!-- Copy iCal link -->
|
||||
<button type="button" (click)="copyUrl()" class="card copy-card" [class.copied]="copied">
|
||||
<div class="icon-wrap copy">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="card-label">{{ copied ? '✓ Kopiert!' : 'iCal-Link kopieren' }}</span>
|
||||
<span class="card-sub">Outlook · andere Apps</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,16 @@ import {Component, OnInit} from '@angular/core';
|
||||
})
|
||||
export class LandingpageComponent implements OnInit {
|
||||
|
||||
constructor() {
|
||||
}
|
||||
copied = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
copyUrl(): void {
|
||||
navigator.clipboard.writeText('https://api.nachklang.art/calendar/events/public/ical').then(() => {
|
||||
this.copied = true;
|
||||
setTimeout(() => { this.copied = false; }, 2500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
-118
@@ -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<Session> {
|
||||
try {
|
||||
let registerEvent: any = {
|
||||
"email": email,
|
||||
"fullName": fullName,
|
||||
"password": password
|
||||
};
|
||||
|
||||
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>();
|
||||
/**
|
||||
* 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<User> {
|
||||
return this.http.get<User>(environment.apiUrl + '/admin/me', this.withSession);
|
||||
}
|
||||
|
||||
getEvents(calendar: string): Observable<Event[]> {
|
||||
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<Event[]>((this.apiUrl + calendar + '/json'), {params});
|
||||
} catch (exception) {
|
||||
console.log('Error fetching events from API');
|
||||
}
|
||||
return new Observable<Event[]>();
|
||||
return this.http.get<Event[]>(this.apiUrl + calendar + '/json', this.withSession);
|
||||
}
|
||||
|
||||
updateEvent(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);
|
||||
|
||||
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>();
|
||||
return this.http.put(this.apiUrl + event.eventId, event, this.withSession);
|
||||
}
|
||||
|
||||
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
|
||||
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<any>();
|
||||
|
||||
return this.http.post(this.apiUrl, event, this.withSession);
|
||||
}
|
||||
|
||||
deleteEvent(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);
|
||||
|
||||
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>();
|
||||
return this.http.delete(this.apiUrl + event.eventId, {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: event,
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
moveEvent(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);
|
||||
|
||||
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>();
|
||||
return this.http.put(this.apiUrl + 'move/' + event.eventId, event, this.withSession);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Session} from '../models/session';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -9,33 +8,24 @@ export class UtilsService {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
static saveUserInfoToLocalStorage(password: string, name: string): void {
|
||||
localStorage.setItem('password', password);
|
||||
/**
|
||||
* 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 getPasswordFromLocalStorage(): string {
|
||||
return localStorage.getItem('password') ?? '';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://api.nachklang.art'
|
||||
apiUrl: 'https://api.nachklang.art',
|
||||
adminAppUrl: 'https://admin.nachklang.art'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user