first commit

This commit is contained in:
Bastian Wagner
2026-07-31 21:02:47 +02:00
commit 6bea4f766a
512 changed files with 64459 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

48
myteamwallet_frontend_modern/.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Superpowers subagent-driven-development scratch workspace
/.superpowers
/.superdesign/tmp
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -0,0 +1,38 @@
# TeamWallet Design System
TeamWallet is a German mobile-first club cashbox app for amateur sports teams. Members quickly inspect balances and activity; authorized captains and treasurers create and reverse bookings, manage players, penalties and invitations.
## Visual direction
- Fresh, friendly and sporty rather than banking-like or corporate.
- Angular Material 3 is the component foundation.
- Green is the primary/action color; orange is reserved for warm accents and warnings.
- Light surfaces, strong contrast, large readable euro balances and generous touch targets.
- Roboto only. Do not introduce decorative or serif fonts.
- Cards use restrained elevation, rounded Material corners and clear spacing.
- Positive, negative and neutral balances must be distinguishable by text/icon as well as color.
## Layout and interaction
- Mobile first, usable at 360px width, progressively centered/constrained on larger screens.
- Authenticated team pages always retain the existing toolbar and four-tab bottom navigation.
- Primary actions are prominent but never obscure content.
- Loading uses skeletons or small progress indicators; empty and error states include a clear next action.
- Motion is short and functional, following Material defaults; respect reduced-motion preferences.
## Key pages
- Login and account recovery
- Team selection
- Team overview with wallet balance, outstanding player total and recent activity
- Members list and member history
- Cashbox transaction creation and reversal
- More: penalties, invite, profile and logout
- Public alias-based team overview and member history
## Hard constraints
- Use only Material system colors derived from the existing green/orange M3 theme.
- German copy is embedded directly in templates.
- Preserve the existing AppShell navigation structure.
- No dark mode, i18n layer, NgRx, Bootstrap or custom icon pack.

View File

@@ -0,0 +1,3 @@
# Shared UI Components
The application currently has no custom shared UI primitives. It uses Angular Material 21 directly (`MatButton`, `MatCard`, `MatFormField`, `MatInput`, `MatList`, `MatMenu`, `MatToolbar`, `MatIcon`, `MatProgressSpinner`). Feature-specific UI lives beside each standalone component. Reusable primitives such as balance display, empty state, skeleton and confirmation dialog are planned but not implemented yet.

View File

@@ -0,0 +1,11 @@
# Extractable Components
## AppShell
- Source: `src/app/core/layout/shell/`
- Category: layout
- Description: Authenticated toolbar, team switcher, scrolling content and four-tab bottom navigation.
- Extractable props: `teamName`, `activeTab`, `showTeamSwitcher`
- Hardcoded: navigation labels, Material icon names, green primary styling
No custom basic primitives exist yet. Angular Material primitives should remain inline in drafts.

View File

@@ -0,0 +1,80 @@
# Shared Layouts
## App Shell
Source: `src/app/core/layout/shell/`
The authenticated team workspace has a green Material toolbar with an optional team-switcher menu, a scrolling content area, and a persistent four-item mobile bottom navigation.
```html
<mat-toolbar class="shell-header">
@if (myTeams().length > 1) {
<button mat-button [matMenuTriggerFor]="teamMenu" class="shell-team-switcher">
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #teamMenu="matMenu">
@for (team of myTeams(); track team.id) {
<button mat-menu-item (click)="switchTeam(team.id)">{{ team.name }}</button>
}
</mat-menu>
} @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
}
</mat-toolbar>
<main class="shell-content"><router-outlet /></main>
<nav class="shell-bottom-nav">
<a routerLink="overview" routerLinkActive="active" class="shell-bottom-nav__item"
><mat-icon>account_balance_wallet</mat-icon><span>Übersicht</span></a
>
<a routerLink="members" routerLinkActive="active" class="shell-bottom-nav__item"
><mat-icon>groups</mat-icon><span>Mitglieder</span></a
>
<a routerLink="cashbox" routerLinkActive="active" class="shell-bottom-nav__item"
><mat-icon>payments</mat-icon><span>Kasse</span></a
>
<a routerLink="more" routerLinkActive="active" class="shell-bottom-nav__item"
><mat-icon>more_horiz</mat-icon><span>Mehr</span></a
>
</nav>
```
```scss
:host {
display: flex;
flex-direction: column;
height: 100dvh;
}
.shell-header {
background-color: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
}
.shell-team-switcher {
color: var(--mat-sys-on-primary);
}
.shell-content {
flex: 1;
overflow-y: auto;
}
.shell-bottom-nav {
display: flex;
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
}
.shell-bottom-nav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.5rem 0;
color: var(--mat-sys-on-surface-variant);
text-decoration: none;
font-size: 0.75rem;
}
.shell-bottom-nav__item.active {
color: var(--mat-sys-primary);
}
```

View File

@@ -0,0 +1,29 @@
# Page Dependency Trees
## /auth/login
- `src/app/features/auth/login/login.ts`
- `login.html`
- `login.scss`
- `src/app/core/auth/auth-api.ts`
- `src/app/core/auth/auth-store.ts`
## /team-select
- `src/app/features/team-select/team-select.ts`
- `team-select.html`
- `team-select.scss`
- `src/app/core/team/my-teams-store.ts`
- `src/app/models/player.model.ts`
- `src/app/models/team.model.ts`
## /team/:id/*
- `src/app/core/layout/shell/shell.ts`
- `shell.html`
- `shell.scss`
- `src/app/core/team/team-store.ts`
- `src/app/core/team/my-teams-store.ts`
- child page: overview, members, cashbox or more
The four team feature pages are currently stubs and will reuse the shell plus Angular Material components.

View File

@@ -0,0 +1,14 @@
# Routes
- `/` → session-dependent redirect
- `/auth/login``features/auth/login/login.ts`
- `/team-select``features/team-select/team-select.ts`
- `/team/:id/overview``features/team/overview/overview.ts`, inside authenticated App Shell
- `/team/:id/members``features/team/members/members.ts`, inside authenticated App Shell
- `/team/:id/cashbox``features/team/cashbox/cashbox.ts`, inside authenticated App Shell
- `/team/:id/more``features/team/more/more.ts`, inside authenticated App Shell
- `**` → Not Found
Planned routes include registration, forgot/reset password, member detail, penalties, invite, and public alias views.
The full router source of truth is `src/app/app.routes.ts`.

View File

@@ -0,0 +1,41 @@
# Theme
## Compact token summary
- Framework: Angular 21 standalone, Angular Material 21 M3
- Color: `mat.$green-palette` primary, `mat.$orange-palette` tertiary, light color scheme
- Typography: Roboto via Material system typography
- Surfaces: `--mat-sys-surface`, `--mat-sys-on-surface`, `--mat-sys-outline-variant`
- Actions: `--mat-sys-primary`, `--mat-sys-on-primary`
- Errors: `--mat-sys-error`
- Density: Material default `0`
- Layout: mobile-first, full-height app shell, fixed bottom navigation, 1rem page padding
## Raw global theme source
```scss
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$green-palette,
tertiary: mat.$orange-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
color-scheme: light;
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
margin: 0;
height: 100%;
}
```

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

View File

@@ -0,0 +1,59 @@
# MyteamwalletFrontendModern
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,107 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "c9f61bac-e2c7-4508-a888-987e87bee5ff"
},
"newProjectRoot": "projects",
"projects": {
"myteamwallet_frontend_modern": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"allowedCommonJsDependencies": ["qrcode"],
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.scss"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "525kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
},
"container": {
"budgets": [
{
"type": "initial",
"maximumWarning": "525kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.container.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "myteamwallet_frontend_modern:build:production"
},
"development": {
"buildTarget": "myteamwallet_frontend_modern:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
# Public Team Sharing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add revocable public team links with controlled management, safe public DTOs, sharing, and QR presentation.
**Architecture:** The backend stores a disabled-by-default public-access flag and a random token on each team. Authenticated management endpoints enforce team membership and a minimum team role, while a separate public controller returns deliberately shaped responses and verifies player ownership. The modern Angular frontend consumes these endpoints through focused API services and presents management under “Mehr”.
**Tech Stack:** NestJS 9, TypeORM 0.3, Jest, Angular 21 standalone components/signals, Angular Material, Vitest, `qrcode`.
## Global Constraints
- Modify only `myteamwallet_backend` and `myteamwallet_frontend_modern`; leave the legacy frontend untouched.
- New teams are private by default; disabled or invalid links return HTTP 404.
- Team roles 35 and global admins may mutate sharing; authenticated team members may read and share an enabled link.
- Public responses expose only the agreed team, active-player, penalty, and transaction fields.
- Follow red-green-refactor for every behavior change.
---
### Task 1: Backend persistence and access policy
**Files:**
- Modify: `myteamwallet_backend/src/teams/entities/team.entity.ts`
- Create: `myteamwallet_backend/src/database/migrations/1785513600000-AddTeamPublicAccess.ts`
- Create: `myteamwallet_backend/src/teams/team-access.service.ts`
- Test: `myteamwallet_backend/src/teams/team-access.service.spec.ts`
**Interfaces:**
- Produce `Team.publicAccessEnabled`, `Team.publicAccessToken`, `TeamAccessService.assertMember(userId, teamId)`, and `TeamAccessService.assertManager(userId, teamId)`.
- [ ] Write tests proving admins bypass membership, members can read, roles 35 can manage, and lower/non-members receive `ForbiddenException`.
- [ ] Run the focused Jest test and confirm failure because the service and fields do not exist.
- [ ] Implement the entity fields, reversible migration, and repository-backed access service using the users highest role in the team.
- [ ] Run the focused test and backend build.
- [ ] Commit the backend persistence/policy change.
### Task 2: Backend management and public APIs
**Files:**
- Create: `myteamwallet_backend/src/teams/dto/public-access.dto.ts`
- Create: `myteamwallet_backend/src/teams/public-teams.controller.ts`
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts`, `teams.service.ts`, `teams.module.ts`
- Modify: `myteamwallet_backend/src/penalty/penalty.controller.ts`
- Test: `myteamwallet_backend/src/teams/teams.service.spec.ts`, `teams.controller.spec.ts`
**Interfaces:**
- Produce `GET/PATCH /teams/:id/public-access`, `POST /teams/:id/public-access/rotate`, `GET /public/teams/:token`, and `GET /public/teams/:token/players/:playerId/transactions`.
- Public team response contains `name`, `balance`, `outstanding`, active players (`id`, names, balance), and penalties (`id`, description, amount`).
- [ ] Write failing service/controller tests for activation, stable reactivation, rotation, disabled/unknown tokens, cross-team player rejection, 20-item ordering, and response whitelisting.
- [ ] Run focused backend tests and verify expected failures.
- [ ] Implement DTOs, token generation with `randomBytes(32)`, management endpoints, safe public queries, and module wiring.
- [ ] Remove the old anonymous alias handlers and require authentication for direct team-penalty loading.
- [ ] Run focused tests, the full backend suite, and backend build.
- [ ] Commit the completed backend API.
### Task 3: Frontend API contracts and routes
**Files:**
- Create: `myteamwallet_frontend_modern/src/app/models/public-access.model.ts`
- Create: `myteamwallet_frontend_modern/src/app/core/team/public-access-api.ts`
- Modify: `myteamwallet_frontend_modern/src/app/core/team/public-team-api.ts`, `app.routes.ts`
- Test: matching API and route specs.
**Interfaces:**
- Produce typed status, public-team, and public-player-history responses plus API methods for status, enable/disable, rotate, public overview, and public history.
- [ ] Write failing HttpTestingController and router tests for all new URLs and payloads.
- [ ] Run focused Vitest specs and confirm expected failures.
- [ ] Implement the typed API clients and token-based route contract.
- [ ] Run focused specs and commit the frontend API layer.
### Task 4: Sharing management UI
**Files:**
- Create: `myteamwallet_frontend_modern/src/app/features/team/more/public-access/*`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/more/more.html`, `app.routes.ts`, `package.json`
- Test: `public-access.spec.ts`, affected route/more specs.
**Interfaces:**
- Consume `PublicAccessApi`; render state, role-gated mutations, copy/share, QR, preview, and rotate confirmation.
- [ ] Install `qrcode` and its types, then write failing component tests for visibility, permissions, activation, disable, rotation confirmation, copy, native share, and fallback behavior.
- [ ] Run focused tests and verify failures.
- [ ] Implement the standalone Material component, responsive styles, accessible QR alternative, and “Mehr” navigation entry.
- [ ] Run focused tests and commit the management UI.
### Task 5: Public pages and end-to-end verification
**Files:**
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-team.*`, `public-player.*`
- Test: both public component specs.
**Interfaces:**
- Consume combined public overview and player-history DTOs; do not call the authenticated penalty API.
- [ ] Write failing tests proving combined response rendering, player identity rendering, invalid-token state, and absence of the separate penalty request.
- [ ] Run focused tests and verify failures.
- [ ] Update both public pages and models to use the token APIs.
- [ ] Run all frontend tests and production build; run all backend tests and build.
- [ ] Review both diffs for public-data leakage and confirm the legacy frontend remains untouched.
- [ ] Commit final integration fixes.

View File

@@ -0,0 +1,132 @@
# TeamWallet Frontend Modern — Design
Datum: 2026-07-31
Status: Genehmigt (Brainstorming abgeschlossen)
## 1. Kontext & Ziel
`myteamwallet_frontend_modern` ist ein frisch mit `ng new` erzeugtes Angular-21-Projekt (Standalone, esbuild, Vitest) ohne fachlichen Inhalt. Es soll die bestehende Angular-18-App (`myteamwallet_frontend`) vollständig ersetzen, die gegen das NestJS-Backend (`myteamwallet_backend`) arbeitet — eine Vereinskassen-App für Sportteams (TeamWallet).
Die neue App wird komplett neu gebaut. Backend-Logik/Datenmodell bleiben unverändert Referenz; visuelle Gestaltung und Informationsarchitektur werden bewusst neu gedacht, die etablierten Kern-User-Flows bleiben erhalten, damit bestehende Vereinsmitglieder sich nicht neu einarbeiten müssen.
## 2. Scope
**In Scope (Feature-Parität zum alten Frontend):**
- Auth: Login, Registrierung über Einladungslink (Token verknüpft neuen Account mit bestehendem Spieler), Passwort vergessen/zurücksetzen
- Team-Auswahl bei mehreren Teams/Spielern eines Nutzers
- Team-Übersicht mit Saldo und Aktivitäts-Feed
- Mitgliederverwaltung: Spielerliste mit Salden, neuen Spieler anlegen, Spielerdetail/Transaktionshistorie
- Buchungen: Spieler-Transaktionen (Mehrfachauswahl + Betrag-Split, Bestätigung ab 300 €), Team-Wallet-Transaktionen, Storno bestehender Buchungen
- Strafenkatalog pro Team (anzeigen, für berechtigte Rollen anlegen)
- Einladungslinks generieren und kopieren
- Öffentliche, nicht angemeldete Team-Ansicht über Alias-Link (Read-only Übersicht + Spieler-Transaktionshistorie + Strafenkatalog)
- Rollenbasierte Sichtbarkeit von Aktionen (globale `Role` admin/user, teamspezifische `TeamRole` player/scnd_treasurer/captain/treasurer/coach)
- PWA (installierbar, Service Worker)
**Explizit nicht in Scope:**
- Admin-UI für Team-/Userverwaltung (gab es im alten Frontend auch nicht, Backend-Endpunkte existieren, aber keine Oberfläche geplant)
- Mehrsprachigkeit/i18n-Layer (Deutsch fest im Code, kein ngx-translate/Backend-Translate-Anbindung)
- E2E-Test-Framework (nur Unit-/Component-Tests mit Vitest)
## 3. Ähnlichkeit zum alten Frontend
- **Optik:** komplett neu, eigenständiges Theming (siehe Abschnitt 8), keine Übernahme des alten Material-Blue-Looks.
- **Struktur/Informationsarchitektur:** bewusst neu gedacht (siehe Abschnitt 6) — statt einer großen Team-Detail-Seite mit vielen Dialogen eine App-Shell mit Bottom-Navigation und Team-Switcher.
- **User-Führung/Flows:** bleiben inhaltlich erhalten — dieselben Abläufe für Login, Einladung/Registrierung, Buchungen, Storno, öffentlichen Team-Link. Nur die Interaktionsdetails (Navigation, Layout) werden verbessert.
## 4. Architektur & Ordnerstruktur
Standalone-Components durchgängig, kein NgModule. Guards und Interceptors funktional (`CanActivateFn`, `HttpInterceptorFn`). App-weiter State über injizierbare Signal-Services (kein NgRx). Datenladen signal-/effect-basiert mit Skeleton-States (kein Router-Resolver-basiertes Blocking-Loading), damit die App auf mobilen Verbindungen unterwegs responsiv bleibt.
```
src/app/
core/
auth/ auth.store.ts (Signal-Store: currentUser, isLoggedIn), auth-api.service.ts,
auth.guard.ts (CanActivateFn), role.guard.ts (TeamRole-Check)
http/ auth.interceptor.ts (Bearer-Token), error.interceptor.ts (401 → Logout+Snackbar)
layout/ app-shell.component.ts (Header mit Team-Switcher, Bottom-Nav), not-found.component.ts
models/ user.model.ts, team.model.ts, player.model.ts, transaction.model.ts,
penalty.model.ts, team-role.enum.ts, transaction-type.enum.ts
features/
auth/ login/, register/, forgot-password/, reset-password/
team-select/ team-select.component.ts (Team-Auswahl bei >1 Team), teams.store.ts (eigene Teams-Liste)
team/
overview/ team-overview.component.ts (Saldo, Activity-Feed)
members/ members-list.component.ts, player-card/, player-detail/, create-player-dialog/
cashbox/ cashbox.component.ts, new-transaction-dialog/, team-transaction-dialog/, recent-bookings-list/
more/ more.component.ts, penalties/, invite/
team.store.ts, teams-api.service.ts, transactions-api.service.ts
public-team/ public-team.component.ts, public-team-shell/, public-team-api.service.ts
penalty/ penalty-api.service.ts (gemeinsam von team/more/penalties & public-team genutzt)
shared/ui/ confirm-dialog/, empty-state/, skeleton/, currency.pipe.ts
app.routes.ts, app.config.ts
```
## 5. Datenmodell (TS-Interfaces, gespiegelt vom Backend)
```ts
interface User { id: string; email: string; firstName: string; lastName: string; role: Role; status: Status; photo?: { id: string; path: string }; }
interface Team { id: string; name: string; alias: string; balance: number; }
interface Player { id: string; firstName: string; lastName: string; teamRole: TeamRole; balance: number; active: boolean; user?: User; }
enum TeamRole { Player = 1, ScndTreasurer = 2, Captain = 3, Treasurer = 4, Coach = 5 }
// Berechtigungshelfer (reine Funktionen in models/):
// canBook(role: TeamRole) => role >= TeamRole.ScndTreasurer
// canInvite(role: TeamRole) => role > TeamRole.ScndTreasurer
interface Transaction { id: string; note: string; date: string; amount: number; type: TransactionType; createdAt: string; }
interface TeamWalletTransaction { id: string; note: string; date: string; amount: number; type: TeamWalletTransactionType; createdAt: string; }
interface Penalty { id: string; teamId: string; description: string; amount: number; }
```
## 6. Routing-Map
| Route | Zugriff | Zeigt |
|---|---|---|
| `/auth/login` | öffentlich | Login |
| `/auth/register?token=` | öffentlich | Registrierung, liest Invite-Token via `auth/verify-invite`, verknüpft via `linkPlayerId` |
| `/auth/forgot-password` | öffentlich | Passwort vergessen |
| `/auth/reset-password/:hash` | öffentlich | Neues Passwort setzen |
| `/team-select` | AuthGuard | Team-Auswahl — nur erreichbar/angezeigt, wenn Nutzer >1 Team hat; bei genau einem Team automatischer Redirect zu dessen Übersicht |
| `/team/:id/overview` | AuthGuard | Saldo + chronologischer Activity-Feed (Spieler- & Team-Buchungen gemischt) |
| `/team/:id/members` | AuthGuard | Mitgliederliste mit Salden, Spieler anlegen |
| `/team/:id/members/:playerId` | AuthGuard | Spielerdetail/Transaktionshistorie |
| `/team/:id/cashbox` | AuthGuard | Buchungen erstellen (Spieler-Mehrfachauswahl+Split, Team-Buchung), Storno, letzte Buchungen |
| `/team/:id/more` | AuthGuard | Einstiegspunkt: Strafenkatalog, Einladen, Profil/Logout |
| `/team/:id/more/penalties` | AuthGuard | Strafenkatalog verwalten |
| `/team/:id/more/invite` | AuthGuard | Einladungslink generieren & kopieren (`POST auth/invite`) |
| `/t/:alias` | öffentlich | Public Read-Only-Übersicht (`GET teams/:alias`) |
| `/t/:alias/:playerId` | öffentlich | Public Spieler-Transaktionshistorie (`GET teams/:alias/:user`) |
| `/**` | — | NotFound |
`/` leitet abhängig von `AuthStore.isLoggedIn()` zu `/team-select` (bzw. direkt zum einzigen Team) oder `/auth/login` weiter.
Bottom-Nav (Übersicht/Mitglieder/Kasse/Mehr) und Team-Switcher im Header sind nur innerhalb `/team/:id/*` sichtbar. Der Team-Switcher wechselt die aktive `:id` in der Route und aktualisiert `TeamStore`. Die öffentliche Ansicht (`/t/:alias*`) nutzt eine eigene, schlanke Shell ohne Bottom-Nav.
## 7. Features im Detail
- **Auth**: Login/Register/Forgot/Reset wie im Backend-Flow vorgesehen. Register liest `?token=` aus der URL, ruft `verify-invite` auf, zeigt Team-/Spielername zur Bestätigung, registriert mit `linkPlayerId`.
- **Team-Select**: Kartenliste der Team-/Spieler-Zuordnungen des Nutzers (`GET /users/:id/teams`), Klick → Team-Übersicht. Bei genau einem Eintrag übersprungen.
- **Übersicht**: Prominenter Team-Saldo, darunter Activity-Feed der letzten Buchungen (Spieler- und Team-Wallet-Transaktionen chronologisch gemischt).
- **Mitglieder**: Spielerliste (Karten, sortier-/filterbar) mit Saldo und TeamRole-Badge, "+"-Button zum Anlegen (sichtbar je nach Berechtigung), Klick → Spielerdetail mit Transaktionshistorie.
- **Kasse**: Spieler-Buchung (Mehrfachauswahl + Split-Betrag, Bestätigungsdialog ab 300 €) und Team-Buchung, darunter Liste letzter Buchungen mit Storno-Aktion (`POST transactions/:id/reverse`).
- **Mehr**: Strafenkatalog (Liste, Anlegen für berechtigte Rollen via `POST /penalty`), Einladungslink erzeugen, Profil bearbeiten (`PATCH auth/me`), Logout.
- **Public-Team**: Sortierbare/filterbare Tabelle aller Spieler mit Saldo, Klick → Transaktionshistorie, eigener Bereich für den Strafenkatalog (read-only).
Sichtbarkeit/Aktivierung von Buchungs- und Einladungs-Aktionen wird durchgehend über `canBook`/`canInvite` (aus `TeamRole` des aktuellen Spielers im Team) gesteuert.
## 8. Tech-Stack & Design-Stil
- Angular 21, durchgängig Standalone Components
- Angular Material 21 als UI-Basis, eigenes Theming: frische/sportliche Akzentfarbe, große gut lesbare Saldo-Zahlen, klare Kontraste — bewusst kein Banking-Look
- PWA via `ng add @angular/pwa` (Manifest + Service Worker)
- State: native Angular Signals in injizierbaren Store-Services, kein NgRx
- HTTP: `provideHttpClient` mit funktionalen Interceptors
- Kein i18n-Layer, Texte direkt in Templates (Deutsch)
- Tests: Vitest (bereits vorhanden durch `ng new`), keine E2E-Suite im Scope
## 9. Fehlerbehandlung
Funktionaler `error.interceptor.ts`: bei 401 → `AuthStore` leeren, Redirect zu `/auth/login`, Snackbar „Sitzung abgelaufen". Bei sonstigen 4xx mit Backend-Fehlermeldung → Snackbar mit dieser Message. Bei 5xx → generische Fehlermeldung. Formulare (Reactive Forms + Validators) zeigen Feldfehler inline, kein globaler Error-State nötig.
## 10. Testing
Vitest-Unit-/Component-Tests für: Berechtigungslogik (`canBook`/`canInvite`), Split-Betrag-Berechnung im Buchungsdialog, Signal-Stores (Auth/Team/Teams) mit gemocktem `HttpClient` über `provideHttpClientTesting`. Kein E2E-Framework im Scope; kann bei Bedarf später (z.B. Playwright) ergänzt werden.

View File

@@ -0,0 +1,28 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"]
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "myteamwallet-frontend-modern",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:container": "ng build --configuration=container",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/cdk": "^21.2.14",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.14",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0",
"@types/qrcode": "^1.5.6",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,59 @@
{
"name": "TeamWallet",
"short_name": "TeamWallet",
"theme_color": "#2e7d32",
"background_color": "#ffffff",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View File

@@ -0,0 +1,20 @@
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
import { routes } from './app.routes';
import { authInterceptor } from './core/http/auth-interceptor';
import { errorInterceptor } from './core/http/error-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};

View File

@@ -0,0 +1 @@
<router-outlet />

View File

@@ -0,0 +1,63 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { routes } from './app.routes';
import { AuthStore } from './core/auth/auth-store';
describe('app routing', () => {
let router: Router;
let authStore: AuthStore;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideRouter(routes), provideHttpClient(), provideHttpClientTesting()],
});
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
});
it('redirects the root path to login when logged out', async () => {
await router.navigateByUrl('/');
expect(router.url).toBe('/auth/login');
});
it('redirects a protected team route to login when logged out', async () => {
await router.navigateByUrl('/team/1/overview');
expect(router.url).toBe('/auth/login');
});
it('redirects the root path to team-select when logged in', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/');
expect(router.url).toBe('/team-select');
});
it('redirects the bare team route to its overview child', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/team/1');
expect(router.url).toBe('/team/1/overview');
});
it('falls back to the not-found route for unknown paths', async () => {
await router.navigateByUrl('/does-not-exist');
expect(router.url).toBe('/does-not-exist');
});
it('allows public team routes without a session', async () => {
await router.navigateByUrl('/t/team-a');
expect(router.url).toBe('/t/team-a');
});
it('registers public access management below the protected team shell', () => {
const teamRoute = routes.find((route) => route.path === 'team/:id');
expect(teamRoute?.children?.some((route) => route.path === 'more/public-access')).toBe(true);
});
it('accepts the password reset URL generated by the backend', async () => {
expect(routes.some((route) => route.path === 'password-change/:hash')).toBe(true);
await router.navigateByUrl('/password-change/reset-hash');
expect(router.url).toBe('/password-change/reset-hash');
});
});

View File

@@ -0,0 +1,100 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth-guard';
import { rootRedirectGuard } from './core/auth/root-redirect-guard';
import { Shell } from './core/layout/shell/shell';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
canActivate: [rootRedirectGuard],
children: [],
},
{
path: 'auth/login',
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
},
{
path: 'auth/register',
loadComponent: () => import('./features/auth/register/register').then((m) => m.Register),
},
{
path: 'auth/forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
},
{
path: 'auth/reset-password/:hash',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{
path: 'password-change/:hash',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{
path: 'team-select',
canActivate: [authGuard],
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
},
{
path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
},
{
path: 't/:token',
loadComponent: () => import('./features/public-team/public-team').then((m) => m.PublicTeam),
},
{
path: 'team/:id',
canActivate: [authGuard],
component: Shell,
children: [
{ path: '', pathMatch: 'full', redirectTo: 'overview' },
{
path: 'overview',
loadComponent: () => import('./features/team/overview/overview').then((m) => m.Overview),
},
{
path: 'members',
loadComponent: () => import('./features/team/members/members').then((m) => m.Members),
},
{
path: 'members/:playerId',
loadComponent: () =>
import('./features/team/members/player-detail').then((m) => m.PlayerDetail),
},
{
path: 'cashbox',
loadComponent: () => import('./features/team/cashbox/cashbox').then((m) => m.Cashbox),
},
{
path: 'more',
loadComponent: () => import('./features/team/more/more').then((m) => m.More),
},
{
path: 'more/penalties',
loadComponent: () =>
import('./features/team/more/penalties/penalties').then((m) => m.Penalties),
},
{
path: 'more/invite',
loadComponent: () => import('./features/team/more/invite/invite').then((m) => m.Invite),
},
{
path: 'more/profile',
loadComponent: () => import('./features/team/more/profile/profile').then((m) => m.Profile),
},
{
path: 'more/public-access',
loadComponent: () =>
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess),
},
],
},
{
path: '**',
loadComponent: () => import('./core/layout/not-found/not-found').then((m) => m.NotFound),
},
];

View File

@@ -0,0 +1,76 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { App } from './app';
import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store';
describe('App', () => {
const token = signal<string | null>(null);
const updateUser = vi.fn();
const meResponse = signal<Record<string, unknown>>({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
const me = vi.fn(() => of(meResponse()));
const setSession = vi.fn();
beforeEach(async () => {
token.set(null);
meResponse.set({ id: 1, email: 'a@b.de', firstName: 'Alex', lastName: 'Muster' });
updateUser.mockClear();
setSession.mockClear();
me.mockClear();
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
{ provide: AuthStore, useValue: { token, updateUser, setSession } },
{ provide: AuthApi, useValue: { me } },
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy();
});
it('validates and refreshes a restored session on startup', () => {
token.set('stored-token');
TestBed.createComponent(App);
expect(me).toHaveBeenCalled();
expect(updateUser).toHaveBeenCalledWith({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
});
it('replaces an expired stored token when me returns a refreshed token', () => {
token.set('expired-token');
meResponse.set({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
token: 'refreshed-token',
});
TestBed.createComponent(App);
expect(setSession).toHaveBeenCalledWith('refreshed-token', {
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
expect(updateUser).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
constructor() {
if (this.authStore.token()) {
this.authApi.me().subscribe({
next: (response) => {
const { token, ...user } = response;
if (token) {
this.authStore.setSession(token, user);
} else {
this.authStore.updateUser(user);
}
},
error: () => undefined,
});
}
}
}

View File

@@ -0,0 +1,120 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthApi } from './auth-api';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';
describe('AuthApi', () => {
let service: AuthApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AuthApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('posts credentials to the login endpoint', () => {
const user: User = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
service.login('a@b.de', 'secret').subscribe((response) => {
expect(response).toEqual({ token: 'jwt-token', user });
});
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/login`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ email: 'a@b.de', password: 'secret' });
request.flush({ token: 'jwt-token', user });
});
it('fetches the current user from the me endpoint', () => {
const user: User = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
service.me().subscribe((response) => {
expect(response).toEqual(user);
});
const request = httpMock.expectOne(`${environment.apiUrl}auth/me`);
expect(request.request.method).toBe('GET');
request.flush(user);
});
it('verifies an invitation token', () => {
const invitation = { teamId: 5, teamName: 'Team A', playerId: 7, playerName: 'Alex' };
service
.verifyInvite('invite-token')
.subscribe((response) => expect(response).toEqual(invitation));
const request = httpMock.expectOne(`${environment.apiUrl}auth/verify-invite`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ token: 'invite-token' });
request.flush(invitation);
});
it('registers and links an invited player', () => {
const registration = {
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
lastName: 'Muster',
linkPlayerId: 7,
};
service.register(registration).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(registration);
request.flush(null);
});
it('requests a password reset email', () => {
service.forgotPassword('alex@example.de').subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/forgot/password`);
expect(request.request.body).toEqual({ email: 'alex@example.de' });
request.flush(null);
});
it('sets a new password using a reset hash', () => {
service.resetPassword('reset-hash', 'new-secret').subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/reset/password`);
expect(request.request.body).toEqual({ hash: 'reset-hash', password: 'new-secret' });
request.flush(null);
});
it('creates a personalized team invitation', () => {
const invitation = {
teamId: 5,
teamName: 'Team A',
playerId: 7,
playerName: 'Alex Muster',
};
service.createInvite(invitation).subscribe((response) => expect(response.token).toBe('invite'));
const request = httpMock.expectOne(`${environment.apiUrl}auth/invite`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(invitation);
request.flush({ token: 'invite' });
});
it('updates the current profile', () => {
const update = { firstName: 'Alex', lastName: 'Neu' };
const user: User = { id: 1, email: 'a@b.de', ...update };
service.updateProfile(update).subscribe((response) => expect(response).toEqual(user));
const request = httpMock.expectOne(`${environment.apiUrl}auth/me`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual(update);
request.flush(user);
});
});

View File

@@ -0,0 +1,74 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';
export interface LoginResponse {
token: string;
user: User;
}
export type AuthMeResponse = User & { token?: string };
export interface InviteDetails {
teamId: number;
teamName: string;
playerId: number;
playerName: string;
}
export interface RegistrationRequest {
email: string;
password: string;
firstName: string;
lastName: string;
linkPlayerId: number;
}
export interface CreateInviteRequest extends InviteDetails {}
export interface UpdateProfileRequest {
firstName?: string;
lastName?: string;
oldPassword?: string;
password?: string;
}
@Injectable({ providedIn: 'root' })
export class AuthApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}auth`;
login(email: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${this.baseUrl}/email/login`, { email, password });
}
me(): Observable<AuthMeResponse> {
return this.http.get<AuthMeResponse>(`${this.baseUrl}/me`);
}
verifyInvite(token: string): Observable<InviteDetails> {
return this.http.post<InviteDetails>(`${this.baseUrl}/verify-invite`, { token });
}
register(request: RegistrationRequest): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/email/register`, request);
}
forgotPassword(email: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/forgot/password`, { email });
}
resetPassword(hash: string, password: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/reset/password`, { hash, password });
}
createInvite(request: CreateInviteRequest): Observable<{ token: string }> {
return this.http.post<{ token: string }>(`${this.baseUrl}/invite`, request);
}
updateProfile(request: UpdateProfileRequest): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/me`, request);
}
}

View File

@@ -0,0 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { authGuard } from './auth-guard';
import { AuthStore } from './auth-store';
describe('authGuard', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({ providers: [provideRouter([])] });
});
it('allows navigation when logged in', () => {
const authStore = TestBed.inject(AuthStore);
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toBe(true);
});
it('redirects to login when logged out', () => {
const router = TestBed.inject(Router);
const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toEqual(router.parseUrl('/auth/login'));
});
});

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore } from './auth-store';
export const authGuard: CanActivateFn = () => {
const authStore = inject(AuthStore);
const router = inject(Router);
return authStore.isLoggedIn() ? true : router.parseUrl('/auth/login');
};

View File

@@ -0,0 +1,79 @@
import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth-store';
describe('AuthStore', () => {
beforeEach(() => {
localStorage.clear();
});
it('starts logged out when nothing is stored', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
expect(store.isLoggedIn()).toBe(false);
expect(store.currentUser()).toBeNull();
});
it('stores the session and exposes it as logged in', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
const user = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
store.setSession('jwt-token', user);
expect(store.isLoggedIn()).toBe(true);
expect(store.token()).toBe('jwt-token');
expect(store.currentUser()).toEqual(user);
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
});
it('restores the session from localStorage on creation', () => {
localStorage.setItem('tw_token', 'stored-token');
localStorage.setItem(
'tw_user',
JSON.stringify({ id: 2, email: 'c@d.de', firstName: 'C', lastName: 'D' }),
);
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
expect(store.isLoggedIn()).toBe(true);
expect(store.token()).toBe('stored-token');
expect(store.currentUser()?.email).toBe('c@d.de');
});
it('does not throw and starts logged out when stored user JSON is invalid', () => {
localStorage.setItem('tw_user', 'not-json');
TestBed.configureTestingModule({});
expect(() => TestBed.inject(AuthStore)).not.toThrow();
const store = TestBed.inject(AuthStore);
expect(store.currentUser()).toBeNull();
});
it('clears the session', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
store.clearSession();
expect(store.isLoggedIn()).toBe(false);
expect(store.currentUser()).toBeNull();
expect(localStorage.getItem('tw_token')).toBeNull();
});
it('updates and persists the current user without changing the token', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const updated = { id: 1, email: 'a@b.de', firstName: 'Alex', lastName: 'Neu' };
store.updateUser(updated);
expect(store.currentUser()).toEqual(updated);
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
expect(JSON.parse(localStorage.getItem('tw_user')!)).toEqual(updated);
});
});

View File

@@ -0,0 +1,51 @@
import { Injectable, computed, signal } from '@angular/core';
import { User } from '../../models/user.model';
@Injectable({ providedIn: 'root' })
export class AuthStore {
private static readonly TOKEN_KEY = 'tw_token';
private static readonly USER_KEY = 'tw_user';
private readonly tokenSignal = signal<string | null>(AuthStore.readToken());
private readonly userSignal = signal<User | null>(AuthStore.readStoredUser());
readonly token = this.tokenSignal.asReadonly();
readonly currentUser = this.userSignal.asReadonly();
readonly isLoggedIn = computed(() => this.tokenSignal() !== null);
setSession(token: string, user: User): void {
localStorage.setItem(AuthStore.TOKEN_KEY, token);
localStorage.setItem(AuthStore.USER_KEY, JSON.stringify(user));
this.tokenSignal.set(token);
this.userSignal.set(user);
}
clearSession(): void {
localStorage.removeItem(AuthStore.TOKEN_KEY);
localStorage.removeItem(AuthStore.USER_KEY);
this.tokenSignal.set(null);
this.userSignal.set(null);
}
updateUser(user: User): void {
localStorage.setItem(AuthStore.USER_KEY, JSON.stringify(user));
this.userSignal.set(user);
}
private static readToken(): string | null {
try {
return localStorage.getItem(AuthStore.TOKEN_KEY);
} catch {
return null;
}
}
private static readStoredUser(): User | null {
try {
const raw = localStorage.getItem(AuthStore.USER_KEY);
return raw ? (JSON.parse(raw) as User) : null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { rootRedirectGuard } from './root-redirect-guard';
import { AuthStore } from './auth-store';
describe('rootRedirectGuard', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({ providers: [provideRouter([])] });
});
it('redirects to team-select when logged in', () => {
const authStore = TestBed.inject(AuthStore);
const router = TestBed.inject(Router);
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as never, {} as never));
expect(result).toEqual(router.parseUrl('/team-select'));
});
it('redirects to login when logged out', () => {
const router = TestBed.inject(Router);
const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as never, {} as never));
expect(result).toEqual(router.parseUrl('/auth/login'));
});
});

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore } from './auth-store';
export const rootRedirectGuard: CanActivateFn = () => {
const authStore = inject(AuthStore);
const router = inject(Router);
return router.parseUrl(authStore.isLoggedIn() ? '/team-select' : '/auth/login');
};

View File

@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { authInterceptor } from './auth-interceptor';
import { AuthStore } from '../auth/auth-store';
describe('authInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
let authStore: AuthStore;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
authStore = TestBed.inject(AuthStore);
});
afterEach(() => {
httpMock.verify();
});
it('attaches the bearer token when a session exists', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
httpClient.get('/ping').subscribe();
const request = httpMock.expectOne('/ping');
expect(request.request.headers.get('Authorization')).toBe('Bearer abc123');
request.flush({});
});
it('does not attach a header when no session exists', () => {
httpClient.get('/ping').subscribe();
const request = httpMock.expectOne('/ping');
expect(request.request.headers.has('Authorization')).toBe(false);
request.flush({});
});
});

View File

@@ -0,0 +1,14 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from '../auth/auth-store';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authStore = inject(AuthStore);
const token = authStore.token();
if (!token) {
return next(req);
}
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

View File

@@ -0,0 +1,96 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { errorInterceptor } from './error-interceptor';
import { AuthStore } from '../auth/auth-store';
describe('errorInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
let authStore: AuthStore;
let router: Router;
let snackBar: MatSnackBar;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([errorInterceptor])),
provideHttpClientTesting(),
provideRouter([]),
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
authStore = TestBed.inject(AuthStore);
router = TestBed.inject(Router);
snackBar = TestBed.inject(MatSnackBar);
});
afterEach(() => {
httpMock.verify();
});
it('clears the session and redirects to login on a 401 response', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const navigateSpy = vi.spyOn(router, 'navigate');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock
.expectOne('/secure')
.flush('unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(authStore.isLoggedIn()).toBe(false);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login']);
});
it('shows the backend message for other 4xx errors without clearing the session', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const navigateSpy = vi.spyOn(router, 'navigate');
const snackSpy = vi.spyOn(snackBar, 'open');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock
.expectOne('/secure')
.flush(
{ message: 'Ungültige Eingabe.' },
{ status: 422, statusText: 'Unprocessable Entity' },
);
expect(snackSpy).toHaveBeenCalledWith('Ungültige Eingabe.', 'OK', { duration: 5000 });
expect(authStore.isLoggedIn()).toBe(true);
expect(navigateSpy).not.toHaveBeenCalled();
});
it('shows a generic message for 4xx errors without a backend message', () => {
const snackSpy = vi.spyOn(snackBar, 'open');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock.expectOne('/secure').flush(null, { status: 404, statusText: 'Not Found' });
expect(snackSpy).toHaveBeenCalledWith('Es ist ein Fehler aufgetreten.', 'OK', {
duration: 5000,
});
});
it('shows a generic message for 5xx errors without clearing the session', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const navigateSpy = vi.spyOn(router, 'navigate');
const snackSpy = vi.spyOn(snackBar, 'open');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock
.expectOne('/secure')
.flush('server error', { status: 500, statusText: 'Server Error' });
expect(snackSpy).toHaveBeenCalledWith(
'Es ist ein Fehler aufgetreten. Bitte versuche es später erneut.',
'OK',
{ duration: 5000 },
);
expect(authStore.isLoggedIn()).toBe(true);
expect(navigateSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,35 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthStore } from '../auth/auth-store';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const authStore = inject(AuthStore);
const router = inject(Router);
const snackBar = inject(MatSnackBar);
return next(req).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse) {
if (error.status === 401) {
authStore.clearSession();
void router.navigate(['/auth/login']);
snackBar.open('Sitzung abgelaufen. Bitte erneut anmelden.', 'OK', { duration: 5000 });
} else if (error.status >= 400 && error.status < 500) {
const message =
typeof error.error?.message === 'string' && error.error.message.trim().length > 0
? error.error.message
: 'Es ist ein Fehler aufgetreten.';
snackBar.open(message, 'OK', { duration: 5000 });
} else if (error.status >= 500) {
snackBar.open('Es ist ein Fehler aufgetreten. Bitte versuche es später erneut.', 'OK', {
duration: 5000,
});
}
}
return throwError(() => error);
}),
);
};

View File

@@ -0,0 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NotFound } from './not-found';
describe('NotFound', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotFound],
providers: [provideRouter([])],
}).compileComponents();
});
it('renders a not-found message', () => {
const fixture = TestBed.createComponent(NotFound);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain(
'Seite nicht gefunden',
);
});
});

View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-not-found',
imports: [RouterLink],
template: `
<div class="not-found">
<h1>Seite nicht gefunden</h1>
<a routerLink="/">Zurück zur Startseite</a>
</div>
`,
})
export class NotFound {}

View File

@@ -0,0 +1,38 @@
<mat-toolbar class="shell-header">
@if (myTeams().length > 1) {
<button mat-button [matMenuTriggerFor]="teamMenu" class="shell-team-switcher">
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #teamMenu="matMenu">
@for (team of myTeams(); track team.id) {
<button mat-menu-item (click)="switchTeam(team.id)">{{ team.name }}</button>
}
</mat-menu>
} @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
}
</mat-toolbar>
<main class="shell-content">
<router-outlet />
</main>
<nav class="shell-bottom-nav">
<a routerLink="overview" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>account_balance_wallet</mat-icon>
<span>Übersicht</span>
</a>
<a routerLink="members" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>groups</mat-icon>
<span>Mitglieder</span>
</a>
<a routerLink="cashbox" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>payments</mat-icon>
<span>Kasse</span>
</a>
<a routerLink="more" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>more_horiz</mat-icon>
<span>Mehr</span>
</a>
</nav>

View File

@@ -0,0 +1,42 @@
:host {
display: flex;
flex-direction: column;
height: 100dvh;
}
.shell-header {
background: #F7F8F2;
color: #20251F;
border-bottom: 1px solid #DDE3D8;
}
.shell-team-switcher {
color: var(--mat-sys-on-primary);
}
.shell-content {
flex: 1;
overflow-y: auto;
}
.shell-bottom-nav {
display: flex;
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.5rem 0;
color: var(--mat-sys-on-surface-variant);
text-decoration: none;
font-size: 0.75rem;
&.active {
color: var(--mat-sys-primary);
}
}
}

View File

@@ -0,0 +1,156 @@
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject } from 'rxjs';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model';
describe('Shell', () => {
let httpMock: HttpTestingController;
let authStore: AuthStore;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
beforeEach(async () => {
localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
await TestBed.configureTestingModule({
imports: [Shell],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() },
},
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
authStore = TestBed.inject(AuthStore);
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
});
afterEach(() => {
httpMock.verify();
});
it('renders the bottom navigation with four tabs', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
const links = fixture.nativeElement.querySelectorAll('.shell-bottom-nav__item');
expect(links.length).toBe(4);
});
it('loads the team for the route id and shows its name in the header', async () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.shell-header')?.textContent).toContain('Team A');
});
it('shows a team switcher when the user belongs to more than one team', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
{
id: 2,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 6, name: 'Team B', alias: 'b', balance: 0 },
},
];
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.shell-team-switcher')).toBeTruthy();
});
it('does not show a team switcher when the user belongs to only one team', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
];
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.shell-team-switcher')).toBeFalsy();
});
it('ignores missing, non-positive, fractional, and non-numeric team ids', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
for (const id of [null, '0', '-1', '1.5', 'abc']) {
routeParams.next(convertToParamMap(id === null ? {} : { id }));
}
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
});
});

View File

@@ -0,0 +1,78 @@
import { Component, computed, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
import { Team } from '../../../models/team.model';
@Component({
selector: 'app-shell',
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
MatToolbarModule,
MatIconModule,
MatMenuModule,
MatButtonModule,
],
templateUrl: './shell.html',
styleUrl: './shell.scss',
})
export class Shell {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore);
protected readonly currentTeam = this.teamStore.team;
protected readonly myTeams = computed(() => {
const seen = new Set<number>();
const teams: Team[] = [];
for (const player of this.myTeamsStore.players()) {
if (player.team && !seen.has(player.team.id)) {
seen.add(player.team.id);
teams.push(player.team);
}
}
return teams;
});
constructor() {
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.ensureLoaded(userId);
}
// A direct subscription (not `effect()` + `toSignal()`) so the initial
// team load happens synchronously during construction, exactly like
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
// current value synchronously to a new subscriber. This keeps the
// component's behavior deterministic and trivial to test: no signal
// effect scheduling to wait for.
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id);
}
});
}
protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']);
}
}

View File

@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { MyTeamsStore } from './my-teams-store';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
describe('MyTeamsStore', () => {
let store: MyTeamsStore;
let httpMock: HttpTestingController;
const player: Player = {
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'team-a', balance: 0 },
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(MyTeamsStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('loads and exposes the players for a user', () => {
store.ensureLoaded(42);
expect(store.loading()).toBe(true);
const request = httpMock.expectOne(`${environment.apiUrl}users/42/teams`);
request.flush([player]);
expect(store.loading()).toBe(false);
expect(store.players()).toEqual([player]);
});
it('does not reload for the same user id', () => {
store.ensureLoaded(42);
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]);
store.ensureLoaded(42);
httpMock.expectNone(`${environment.apiUrl}users/42/teams`);
});
it('resets loading on error without throwing', () => {
store.ensureLoaded(42);
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush('error', {
status: 500,
statusText: 'Server Error',
});
expect(store.loading()).toBe(false);
expect(store.players()).toEqual([]);
});
});

View File

@@ -0,0 +1,33 @@
import { Injectable, inject, signal } from '@angular/core';
import { Player } from '../../models/player.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class MyTeamsStore {
private readonly teamsApi = inject(TeamsApi);
private readonly playersSignal = signal<Player[]>([]);
private readonly loadingSignal = signal(false);
private readonly loadedForUserId = signal<number | null>(null);
readonly players = this.playersSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
ensureLoaded(userId: number): void {
if (this.loadedForUserId() === userId || this.loadingSignal()) {
return;
}
this.loadingSignal.set(true);
this.teamsApi.loadMyTeams(userId).subscribe({
next: (players) => {
this.playersSignal.set(players);
this.loadedForUserId.set(userId);
this.loadingSignal.set(false);
},
error: () => {
this.loadingSignal.set(false);
},
});
}
}

View File

@@ -0,0 +1,36 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { PenaltyApi } from './penalty-api';
describe('PenaltyApi', () => {
let api: PenaltyApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(PenaltyApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads a team penalty catalog', () => {
api.loadPenalties(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}penalty/5`);
expect(request.request.method).toBe('GET');
request.flush([]);
});
it('creates a penalty catalog entry', () => {
const penalty = { teamId: 5, description: 'Zu spät', amount: 5 };
api.createPenalty(penalty).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}penalty`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(penalty);
request.flush({ id: 1, ...penalty });
});
});

View File

@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { CreatePenalty, Penalty } from '../../models/penalty.model';
@Injectable({ providedIn: 'root' })
export class PenaltyApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}penalty`;
loadPenalties(teamId: number): Observable<Penalty[]> {
return this.http.get<Penalty[]>(`${this.baseUrl}/${teamId}`);
}
createPenalty(penalty: CreatePenalty): Observable<Penalty> {
return this.http.post<Penalty>(this.baseUrl, penalty);
}
}

View File

@@ -0,0 +1,43 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { PublicAccessApi } from './public-access-api';
describe('PublicAccessApi', () => {
let api: PublicAccessApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(PublicAccessApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the sharing status', () => {
api.getStatus(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/public-access`);
expect(request.request.method).toBe('GET');
request.flush({ enabled: false, token: null });
});
it('updates the sharing status', () => {
api.setEnabled(5, true).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/public-access`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ enabled: true });
request.flush({ enabled: true, token: 'token' });
});
it('rotates the sharing token', () => {
api.rotate(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/public-access/rotate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({ enabled: true, token: 'new-token' });
});
});

View File

@@ -0,0 +1,28 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { PublicAccessStatus } from '../../models/public-access.model';
@Injectable({ providedIn: 'root' })
export class PublicAccessApi {
private readonly http = inject(HttpClient);
getStatus(teamId: number): Observable<PublicAccessStatus> {
return this.http.get<PublicAccessStatus>(`${environment.apiUrl}teams/${teamId}/public-access`);
}
setEnabled(teamId: number, enabled: boolean): Observable<PublicAccessStatus> {
return this.http.patch<PublicAccessStatus>(
`${environment.apiUrl}teams/${teamId}/public-access`,
{ enabled },
);
}
rotate(teamId: number): Observable<PublicAccessStatus> {
return this.http.post<PublicAccessStatus>(
`${environment.apiUrl}teams/${teamId}/public-access/rotate`,
{},
);
}
}

View File

@@ -0,0 +1,36 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { PublicTeamApi } from './public-team-api';
describe('PublicTeamApi', () => {
let api: PublicTeamApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(PublicTeamApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads a public team by access token', () => {
api.loadTeam('public-token').subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}public/teams/public-token`);
expect(request.request.method).toBe('GET');
request.flush({ name: 'Team A', balance: 0, outstanding: 0, players: [], penalties: [] });
});
it('loads public player history by access token and player id', () => {
api.loadPlayerHistory('public-token', 7).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}public/teams/public-token/players/7/transactions`,
);
expect(request.request.method).toBe('GET');
request.flush({ player: { id: 7 }, transactions: [] });
});
});

View File

@@ -0,0 +1,22 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { PublicPlayerHistory, PublicTeamOverview } from '../../models/public-access.model';
@Injectable({ providedIn: 'root' })
export class PublicTeamApi {
private readonly http = inject(HttpClient);
loadTeam(token: string): Observable<PublicTeamOverview> {
return this.http.get<PublicTeamOverview>(
`${environment.apiUrl}public/teams/${encodeURIComponent(token)}`,
);
}
loadPlayerHistory(token: string, playerId: number): Observable<PublicPlayerHistory> {
return this.http.get<PublicPlayerHistory>(
`${environment.apiUrl}public/teams/${encodeURIComponent(token)}/players/${playerId}/transactions`,
);
}
}

View File

@@ -0,0 +1,109 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TeamStore } from './team-store';
import { environment } from '../../../environments/environment';
import { Team } from '../../models/team.model';
describe('TeamStore', () => {
let store: TeamStore;
let httpMock: HttpTestingController;
const team: Team = { id: 5, name: 'Team A', alias: 'team-a', balance: 100 };
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(TeamStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('loads and exposes the active team', () => {
store.loadTeam(5);
expect(store.loading()).toBe(true);
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`);
request.flush(team);
expect(store.loading()).toBe(false);
expect(store.team()).toEqual(team);
});
it('does not reload for the same team id', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
store.loadTeam(5);
httpMock.expectNone(`${environment.apiUrl}teams/5/overview`);
});
it('reloads when the team id changes', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
store.loadTeam(6);
httpMock.expectOne(`${environment.apiUrl}teams/6/overview`).flush({ ...team, id: 6 });
expect(store.team()?.id).toBe(6);
});
it('resets loading on error without throwing', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush('error', {
status: 500,
statusText: 'Server Error',
});
expect(store.loading()).toBe(false);
expect(store.team()).toBeNull();
});
it('allows retrying the same team after a failed request', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush('error', {
status: 500,
statusText: 'Server Error',
});
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
expect(store.team()).toEqual(team);
expect(store.loading()).toBe(false);
});
it('refreshes an already loaded team on demand', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
store.refreshTeam();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ ...team, balance: 200 });
expect(store.team()?.balance).toBe(200);
});
it('discards a stale response when the requested team changes before it resolves', () => {
store.loadTeam(5);
const firstRequest = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`);
store.loadTeam(6);
const secondRequest = httpMock.expectOne(`${environment.apiUrl}teams/6/overview`);
// The second request supersedes the first via switchMap — Angular's test
// harness refuses to flush a request once its subscription has been
// cancelled, which is precisely the guarantee this test is after: a late
// arriving stale response can never reach the store and overwrite it.
expect(firstRequest.cancelled).toBe(true);
secondRequest.flush({ ...team, id: 6 });
expect(store.team()?.id).toBe(6);
});
});

View File

@@ -0,0 +1,56 @@
import { Injectable, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { Team } from '../../models/team.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class TeamStore {
private readonly teamsApi = inject(TeamsApi);
private readonly teamSignal = signal<Team | null>(null);
private readonly loadingSignal = signal(false);
private readonly requestedTeamId = signal<number | null>(null);
private readonly requests = new Subject<number>();
readonly team = this.teamSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
constructor() {
this.requests
.pipe(
switchMap((teamId) =>
this.teamsApi.loadTeamOverview(teamId).pipe(
catchError(() => {
this.requestedTeamId.set(null);
this.loadingSignal.set(false);
return EMPTY;
}),
),
),
takeUntilDestroyed(),
)
.subscribe((team) => {
this.teamSignal.set(team);
this.loadingSignal.set(false);
});
}
loadTeam(teamId: number): void {
if (this.requestedTeamId() === teamId) {
return;
}
this.requestedTeamId.set(teamId);
this.loadingSignal.set(true);
this.requests.next(teamId);
}
refreshTeam(): void {
const teamId = this.requestedTeamId();
if (teamId === null) return;
this.loadingSignal.set(true);
this.requests.next(teamId);
}
}

View File

@@ -0,0 +1,79 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TeamsApi } from './teams-api';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
import { Team } from '../../models/team.model';
describe('TeamsApi', () => {
let service: TeamsApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(TeamsApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('fetches the players/teams belonging to a user', () => {
const players: Player[] = [{ id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true }];
service.loadMyTeams(42).subscribe((response) => {
expect(response).toEqual(players);
});
const request = httpMock.expectOne(`${environment.apiUrl}users/42/teams`);
expect(request.request.method).toBe('GET');
request.flush(players);
});
it('fetches a team overview by id', () => {
const team: Team = { id: 5, name: 'Team A', alias: 'team-a', balance: 0 };
service.loadTeamOverview(5).subscribe((response) => {
expect(response).toEqual(team);
});
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`);
expect(request.request.method).toBe('GET');
request.flush(team);
});
it('creates a player in a team', () => {
const player = { firstName: 'Alex', lastName: 'Muster', teamRole: 1 };
service.createPlayer(5, player).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(player);
request.flush({ id: 7, ...player, balance: 0, active: true });
});
it('updates an existing player', () => {
const player: Player = {
id: 7,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: false,
};
service.updatePlayer(5, player).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players`);
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual(player);
request.flush(player);
});
it('loads an authenticated player history through the team id', () => {
service.loadPlayerTransactions(5, 7).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players/7/transactions`);
expect(request.request.method).toBe('GET');
request.flush([]);
});
});

View File

@@ -0,0 +1,40 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
import { Team } from '../../models/team.model';
import { PlayerTransaction } from '../../models/transaction.model';
export interface CreatePlayerRequest {
firstName: string;
lastName: string;
teamRole: number;
}
@Injectable({ providedIn: 'root' })
export class TeamsApi {
private readonly http = inject(HttpClient);
loadMyTeams(userId: number): Observable<Player[]> {
return this.http.get<Player[]>(`${environment.apiUrl}users/${userId}/teams`);
}
loadTeamOverview(teamId: number): Observable<Team> {
return this.http.get<Team>(`${environment.apiUrl}teams/${teamId}/overview`);
}
createPlayer(teamId: number, player: CreatePlayerRequest): Observable<Player> {
return this.http.post<Player>(`${environment.apiUrl}teams/${teamId}/players`, player);
}
updatePlayer(teamId: number, player: Player): Observable<Player> {
return this.http.put<Player>(`${environment.apiUrl}teams/${teamId}/players`, player);
}
loadPlayerTransactions(teamId: number, playerId: number): Observable<PlayerTransaction[]> {
return this.http.get<PlayerTransaction[]>(
`${environment.apiUrl}teams/${teamId}/players/${playerId}/transactions`,
);
}
}

View File

@@ -0,0 +1,87 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TransactionsApi } from './transactions-api';
import { environment } from '../../../environments/environment';
import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
} from '../../models/transaction.model';
describe('TransactionsApi', () => {
let api: TransactionsApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(TransactionsApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the combined activity feed for a team', () => {
const activities = [
{
id: 1,
date: '2026-07-31',
amount: 12,
type: 'credit',
note: '',
playerName: 'Alex',
isTeamWalletTransaction: false,
},
];
api.loadTeamTransactions(5).subscribe((response) => expect(response).toEqual(activities));
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/transactions`);
expect(request.request.method).toBe('GET');
request.flush(activities);
});
it('creates player transactions as one batch', () => {
const transactions: CreatePlayerTransaction[] = [
{
playerId: 7,
date: '2026-07-31T10:00:00.000Z',
amount: 12.5,
type: 11,
note: 'Training',
},
];
api.createPlayerTransactions(transactions).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}transactions`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(transactions);
request.flush([]);
});
it('creates a team wallet transaction', () => {
const transaction: CreateTeamWalletTransaction = {
teamId: 5,
date: '2026-07-31T10:00:00.000Z',
amount: 35,
type: 14,
note: 'Material',
};
api.createTeamWalletTransaction(transaction).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}team-wallet-transactions`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(transaction);
request.flush({ id: 3 });
});
it('reverses a player transaction', () => {
api.reverseTransaction(42).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}transactions/42/reverse`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({ id: 43 });
});
});

View File

@@ -0,0 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
TeamActivity,
} from '../../models/transaction.model';
@Injectable({ providedIn: 'root' })
export class TransactionsApi {
private readonly http = inject(HttpClient);
loadTeamTransactions(teamId: number): Observable<TeamActivity[]> {
return this.http.get<TeamActivity[]>(`${environment.apiUrl}teams/${teamId}/transactions`);
}
createPlayerTransactions(transactions: CreatePlayerTransaction[]): Observable<TeamActivity[]> {
return this.http.post<TeamActivity[]>(`${environment.apiUrl}transactions`, transactions);
}
createTeamWalletTransaction(transaction: CreateTeamWalletTransaction): Observable<TeamActivity> {
return this.http.post<TeamActivity>(
`${environment.apiUrl}team-wallet-transactions`,
transaction,
);
}
reverseTransaction(transactionId: number): Observable<TeamActivity> {
return this.http.post<TeamActivity>(
`${environment.apiUrl}transactions/${transactionId}/reverse`,
{},
);
}
}

View File

@@ -0,0 +1,28 @@
<div class="auth-page">
<mat-card class="auth-card"
><mat-card-header><mat-card-title>Passwort vergessen</mat-card-title></mat-card-header
><mat-card-content>
@if (sent()) {
<div class="state">
<strong>E-Mail versendet</strong>
<p>Wenn ein Konto existiert, erhältst du einen Link zum Zurücksetzen.</p>
<a mat-button routerLink="/auth/login">Zur Anmeldung</a>
</div>
} @else {
<p>Gib die E-Mail-Adresse deines Kontos ein.</p>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline"
><mat-label>E-Mail</mat-label
><input matInput type="email" formControlName="email" autocomplete="email"
/></mat-form-field>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<button mat-flat-button type="submit" [disabled]="form.invalid || submitting()">
Link anfordern</button
><a mat-button routerLink="/auth/login">Abbrechen</a>
</form>
}
</mat-card-content></mat-card
>
</div>

View File

@@ -0,0 +1,20 @@
.auth-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
box-sizing: border-box;
}
.auth-card {
width: min(100%, 420px);
}
form,
.state {
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.error {
color: var(--mat-sys-error);
}

View File

@@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { ForgotPassword } from './forgot-password';
import { environment } from '../../../../environments/environment';
describe('ForgotPassword', () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ForgotPassword],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('requests an email and shows the success state', () => {
const fixture = TestBed.createComponent(ForgotPassword);
fixture.componentInstance['form'].setValue({ email: 'alex@example.de' });
fixture.componentInstance['onSubmit']();
httpMock.expectOne(`${environment.apiUrl}auth/forgot/password`).flush(null);
expect(fixture.componentInstance['sent']()).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { AuthApi } from '../../../core/auth/auth-api';
@Component({
selector: 'app-forgot-password',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './forgot-password.html',
styleUrl: './forgot-password.scss',
})
export class ForgotPassword {
private readonly authApi = inject(AuthApi);
private readonly formBuilder = inject(FormBuilder);
protected readonly sent = signal(false);
protected readonly submitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
protected onSubmit(): void {
if (this.form.invalid || this.submitting()) return;
this.submitting.set(true);
this.authApi.forgotPassword(this.form.getRawValue().email).subscribe({
next: () => {
this.submitting.set(false);
this.sent.set(true);
},
error: () => {
this.submitting.set(false);
this.errorMessage.set('Die Anfrage konnte nicht gesendet werden.');
},
});
}
}

View File

@@ -0,0 +1,45 @@
<div class="login-page">
<mat-card class="login-card">
<mat-card-header>
<mat-card-title>TeamWallet</mat-card-title>
<mat-card-subtitle>Anmelden</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
type="password"
formControlName="password"
autocomplete="current-password"
/>
</mat-form-field>
@if (errorMessage()) {
<p class="login-error">{{ errorMessage() }}</p>
}
<button
mat-flat-button
class="login-submit"
type="submit"
[disabled]="form.invalid || isSubmitting()"
>
@if (isSubmitting()) {
<mat-spinner diameter="20" />
} @else {
Anmelden
}
</button>
<a mat-button routerLink="/auth/forgot-password">Passwort vergessen?</a>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,28 @@
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100dvh;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 360px;
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.login-error {
color: var(--mat-sys-error);
margin: 0;
}
.login-submit {
background-color: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
}

View File

@@ -0,0 +1,56 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { Login } from './login';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../../core/auth/auth-store';
describe('Login', () => {
let httpMock: HttpTestingController;
let router: Router;
let authStore: AuthStore;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Login],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
});
afterEach(() => {
httpMock.verify();
});
it('logs in and navigates to team-select on success', () => {
const fixture = TestBed.createComponent(Login);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.componentInstance['form'].setValue({ email: 'a@b.de', password: 'secret' });
fixture.componentInstance['onSubmit']();
const user = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
httpMock.expectOne(`${environment.apiUrl}auth/email/login`).flush({ token: 'jwt-token', user });
expect(authStore.isLoggedIn()).toBe(true);
expect(navigateSpy).toHaveBeenCalledWith(['/team-select']);
});
it('shows an error message when login fails', () => {
const fixture = TestBed.createComponent(Login);
fixture.componentInstance['form'].setValue({ email: 'a@b.de', password: 'wrong' });
fixture.componentInstance['onSubmit']();
httpMock
.expectOne(`${environment.apiUrl}auth/email/login`)
.flush('unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(fixture.componentInstance['errorMessage']()).toBe(
'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.',
);
});
});

View File

@@ -0,0 +1,61 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthApi } from '../../../core/auth/auth-api';
import { AuthStore } from '../../../core/auth/auth-store';
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule,
RouterLink,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatProgressSpinnerModule,
],
templateUrl: './login.html',
styleUrl: './login.scss',
})
export class Login {
private readonly formBuilder = inject(FormBuilder);
private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
private readonly router = inject(Router);
protected readonly isSubmitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required]],
});
protected onSubmit(): void {
if (this.form.invalid || this.isSubmitting()) {
return;
}
this.isSubmitting.set(true);
this.errorMessage.set(null);
const { email, password } = this.form.getRawValue();
this.authApi.login(email, password).subscribe({
next: ({ token, user }) => {
this.authStore.setSession(token, user);
this.isSubmitting.set(false);
void this.router.navigate(['/team-select']);
},
error: () => {
this.isSubmitting.set(false);
this.errorMessage.set('Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.');
},
});
}
}

View File

@@ -0,0 +1,61 @@
<div class="auth-page">
<mat-card class="auth-card">
<mat-card-header
><mat-card-title>TeamWallet</mat-card-title
><mat-card-subtitle>Konto erstellen</mat-card-subtitle></mat-card-header
>
<mat-card-content>
@if (loadingInvitation()) {
<div class="state"><mat-spinner diameter="32" /><span>Einladung wird geprüft …</span></div>
} @else if (invitation(); as invite) {
<div class="invite-summary">
<strong>{{ invite.teamName }}</strong
><span>Du registrierst dich als {{ invite.playerName }}.</span>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline"
><mat-label>E-Mail</mat-label
><input matInput type="email" formControlName="email" autocomplete="email"
/></mat-form-field>
<div class="name-row">
<mat-form-field appearance="outline"
><mat-label>Vorname</mat-label
><input matInput formControlName="firstName" autocomplete="given-name"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nachname</mat-label
><input matInput formControlName="lastName" autocomplete="family-name"
/></mat-form-field>
</div>
<mat-form-field appearance="outline"
><mat-label>Passwort</mat-label
><input matInput type="password" formControlName="password" autocomplete="new-password"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Passwort wiederholen</mat-label
><input
matInput
type="password"
formControlName="passwordConfirmation"
autocomplete="new-password"
/></mat-form-field>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<button mat-flat-button type="submit" [disabled]="form.invalid || isSubmitting()">
@if (isSubmitting()) {
<mat-spinner diameter="20" />
} @else {
Konto erstellen
}
</button>
</form>
} @else {
<div class="state error">
<span>{{ errorMessage() }}</span
><a mat-button routerLink="/auth/login">Zur Anmeldung</a>
</div>
}
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,38 @@
.auth-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
box-sizing: border-box;
}
.auth-card {
width: min(100%, 520px);
}
.state,
.invite-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0;
text-align: center;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.name-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.error {
color: var(--mat-sys-error);
}
@media (max-width: 440px) {
.name-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { Register } from './register';
import { environment } from '../../../../environments/environment';
describe('Register', () => {
let httpMock: HttpTestingController;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Register],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap({ token: 'invite-token' }) } },
},
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
});
afterEach(() => httpMock.verify());
it('verifies the invitation and links its player during registration', () => {
const fixture = TestBed.createComponent(Register);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}auth/verify-invite`).flush({
teamId: 5,
teamName: 'Team A',
playerId: 7,
playerName: 'Alex Muster',
});
fixture.componentInstance['form'].setValue({
email: 'alex@example.de',
firstName: 'Alex',
lastName: 'Muster',
password: 'secret1',
passwordConfirmation: 'secret1',
});
fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.body.linkPlayerId).toBe(7);
request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });
});
});

View File

@@ -0,0 +1,94 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthApi, InviteDetails } from '../../../core/auth/auth-api';
@Component({
selector: 'app-register',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
templateUrl: './register.html',
styleUrl: './register.scss',
})
export class Register {
private readonly authApi = inject(AuthApi);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly token = this.route.snapshot.queryParamMap.get('token');
protected readonly invitation = signal<InviteDetails | null>(null);
protected readonly loadingInvitation = signal(true);
protected readonly isSubmitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
firstName: ['', Validators.required],
lastName: ['', Validators.required],
password: ['', [Validators.required, Validators.minLength(6)]],
passwordConfirmation: ['', [Validators.required, Validators.minLength(6)]],
});
constructor() {
if (!this.token) {
this.loadingInvitation.set(false);
this.errorMessage.set('Der Einladungslink ist unvollständig.');
return;
}
this.authApi.verifyInvite(this.token).subscribe({
next: (invitation) => {
this.invitation.set(invitation);
this.loadingInvitation.set(false);
},
error: () => {
this.loadingInvitation.set(false);
this.errorMessage.set('Der Einladungslink ist ungültig oder abgelaufen.');
},
});
}
protected onSubmit(): void {
const invitation = this.invitation();
const value = this.form.getRawValue();
if (this.form.invalid || !invitation || this.isSubmitting()) return;
if (value.password !== value.passwordConfirmation) {
this.errorMessage.set('Die Passwörter stimmen nicht überein.');
return;
}
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.authApi
.register({
email: value.email,
password: value.password,
firstName: value.firstName,
lastName: value.lastName,
linkPlayerId: invitation.playerId,
})
.subscribe({
next: () => {
this.isSubmitting.set(false);
void this.router.navigate(['/auth/login'], { replaceUrl: true });
},
error: () => {
this.isSubmitting.set(false);
this.errorMessage.set('Die Registrierung ist fehlgeschlagen.');
},
});
}
}

View File

@@ -0,0 +1,28 @@
<div class="auth-page">
<mat-card class="auth-card">
<mat-card-header><mat-card-title>Neues Passwort</mat-card-title></mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline"
><mat-label>Passwort</mat-label
><input matInput type="password" formControlName="password" autocomplete="new-password"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Passwort wiederholen</mat-label
><input
matInput
type="password"
formControlName="passwordConfirmation"
autocomplete="new-password"
/></mat-form-field>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<button mat-flat-button type="submit" [disabled]="form.invalid || submitting()">
Passwort speichern
</button>
<a mat-button routerLink="/auth/login">Abbrechen</a>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,18 @@
.auth-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
box-sizing: border-box;
}
.auth-card {
width: min(100%, 420px);
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.error {
color: var(--mat-sys-error);
}

View File

@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { ResetPassword } from './reset-password';
import { environment } from '../../../../environments/environment';
describe('ResetPassword', () => {
let httpMock: HttpTestingController;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ResetPassword],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ hash: 'reset-hash' }) } },
},
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
});
afterEach(() => httpMock.verify());
it('sets the password for the route hash and returns to login', () => {
const fixture = TestBed.createComponent(ResetPassword);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.componentInstance['form'].setValue({
password: 'new-secret',
passwordConfirmation: 'new-secret',
});
fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/reset/password`);
expect(request.request.body).toEqual({ hash: 'reset-hash', password: 'new-secret' });
request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });
});
});

View File

@@ -0,0 +1,55 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { AuthApi } from '../../../core/auth/auth-api';
@Component({
selector: 'app-reset-password',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './reset-password.html',
styleUrl: './reset-password.scss',
})
export class ResetPassword {
private readonly authApi = inject(AuthApi);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly hash = this.route.snapshot.paramMap.get('hash');
protected readonly submitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
password: ['', [Validators.required, Validators.minLength(6)]],
passwordConfirmation: ['', [Validators.required, Validators.minLength(6)]],
});
protected onSubmit(): void {
const value = this.form.getRawValue();
if (this.form.invalid || !this.hash || this.submitting()) return;
if (value.password !== value.passwordConfirmation) {
this.errorMessage.set('Die Passwörter stimmen nicht überein.');
return;
}
this.submitting.set(true);
this.authApi.resetPassword(this.hash, value.password).subscribe({
next: () => {
this.submitting.set(false);
void this.router.navigate(['/auth/login'], { replaceUrl: true });
},
error: () => {
this.submitting.set(false);
this.errorMessage.set('Das Passwort konnte nicht geändert werden.');
},
});
}
}

View File

@@ -0,0 +1,40 @@
<header class="public-header">
<a class="brand" [routerLink]="['/t', token]"
><mat-icon>account_balance_wallet</mat-icon><span>TeamWallet</span></a
><a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
</header>
<main>
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
<header class="page-title">
<p class="eyebrow">Öffentliche Ansicht</p>
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
<p>Die letzten Buchungen dieses Mitglieds.</p>
</header>
@if (loading()) {
<div class="state"><mat-spinner diameter="40" /></div>
} @else if (notFound()) {
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
} @else if (transactions().length === 0) {
<div class="state">
<mat-icon>receipt_long</mat-icon><span>Noch keine Buchungen vorhanden.</span>
</div>
} @else {
<div class="transactions">
@for (transaction of transactions(); track transaction.id) {
<mat-card
><div class="icon"><mat-icon>receipt_long</mat-icon></div>
<div>
<strong>{{ typeLabel(transaction) }}</strong
><span>{{ transaction.date | date: 'dd.MM.yyyy' }}</span>
@if (transaction.note) {
<small>{{ transaction.note }}</small>
}
</div>
<strong class="amount">{{
displayAmount(transaction) | currency: 'EUR'
}}</strong></mat-card
>
}
</div>
}
</main>

View File

@@ -0,0 +1,92 @@
:host {
display: block;
min-height: 100%;
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
}
.public-header {
height: 64px;
padding: 0 max(20px, calc((100vw - 900px) / 2));
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mat-sys-surface);
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 800;
font-size: 1.1rem;
}
main {
max-width: 900px;
margin: 0 auto;
padding: 28px 24px 64px;
}
.page-title {
margin: 28px 0;
}
.page-title h1 {
font-size: clamp(2.2rem, 5vw, 3.5rem);
line-height: clamp(2.2rem, 5vw, 3.5rem);
margin: 0 0 8px;
}
.page-title p {
margin-top: 0;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
margin-bottom: 6px;
}
.transactions {
display: grid;
gap: 10px;
}
.transactions mat-card {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 14px;
align-items: center;
padding: 16px 18px;
border-radius: 17px;
}
.transactions .icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
color: var(--mat-sys-primary);
background: var(--mat-sys-primary-container);
}
.transactions div:nth-child(2) {
display: grid;
gap: 2px;
}
.transactions span,
.transactions small {
color: var(--mat-sys-on-surface-variant);
}
.amount {
font-variant-numeric: tabular-nums;
}
.state {
min-height: 300px;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 600px) {
main {
padding: 22px 16px 48px;
}
}

View File

@@ -0,0 +1,53 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PublicPlayer } from './public-player';
describe('PublicPlayer', () => {
it('renders player identity and public transaction history', async () => {
await TestBed.configureTestingModule({
imports: [PublicPlayer],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ token: 'public-token', playerId: '7' }) },
},
},
{
provide: PublicTeamApi,
useValue: {
loadPlayerHistory: vi.fn(() =>
of({
player: {
id: 7,
firstName: 'Ada',
lastName: 'Lovelace',
balance: -5,
active: true,
},
transactions: [
{
id: 1,
date: '2026-07-31',
amount: 5,
note: 'Training',
type: { id: 11, name: 'fine' },
},
],
}),
),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(PublicPlayer);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Ada Lovelace');
expect(fixture.nativeElement.textContent).toContain('Training');
expect(fixture.nativeElement.textContent).toContain('-5,00');
});
});

View File

@@ -0,0 +1,79 @@
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PlayerTransaction } from '../../models/transaction.model';
import { signedTransactionAmount } from '../../models/transaction-amount';
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
registerLocaleData(localeDe);
@Component({
selector: 'app-public-player',
imports: [
CurrencyPipe,
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './public-player.html',
styleUrl: './public-player.scss',
})
export class PublicPlayer {
private readonly api = inject(PublicTeamApi);
private readonly route = inject(ActivatedRoute);
protected readonly token = this.route.snapshot.paramMap.get('token') ?? '';
protected readonly player = signal<PublicPlayerModel | null>(null);
protected readonly transactions = signal<PlayerTransaction[]>([]);
protected readonly loading = signal(true);
protected readonly notFound = signal(false);
constructor() {
const playerId = Number(this.route.snapshot.paramMap.get('playerId'));
if (!this.token || !Number.isInteger(playerId) || playerId <= 0) {
this.loading.set(false);
this.notFound.set(true);
return;
}
this.api.loadPlayerHistory(this.token, playerId).subscribe({
next: (history) => {
this.player.set(history.player);
this.transactions.set(history.transactions);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.notFound.set(true);
},
});
}
protected typeLabel(transaction: PlayerTransaction): string {
const type =
typeof transaction.type === 'string' ? transaction.type : (transaction.type?.name ?? '');
return (
(
{
payment: 'Zahlung',
credit: 'Guthaben',
fine: 'Strafe',
levy: 'Umlage',
fee: 'Gebühr',
} as Record<string, string>
)[type] ?? type
);
}
protected displayAmount(transaction: PlayerTransaction): number {
return signedTransactionAmount(transaction.amount, transaction.type);
}
}

View File

@@ -0,0 +1,76 @@
<header class="public-header">
<a class="brand" [routerLink]="['/t', token]"
><mat-icon>account_balance_wallet</mat-icon><span>TeamWallet</span></a
>
<a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
</header>
<main>
@if (loading()) {
<div class="state"><mat-spinner diameter="42" /><span>Team wird geladen …</span></div>
} @else if (notFound() || !team()) {
<div class="state">
<mat-icon>search_off</mat-icon>
<h1>Team nicht gefunden</h1>
<p>Bitte prüfe den öffentlichen Link.</p>
</div>
} @else {
<section class="hero">
<p class="eyebrow">Öffentliche Teamansicht</p>
<h1>{{ team()!.name }}</h1>
<div class="balance-grid">
<mat-card
><span>In der Kasse</span
><strong>{{ team()!.balance | currency: 'EUR' }}</strong></mat-card
>
<mat-card
><span>Ausstehend</span
><strong>{{ team()!.outstanding | currency: 'EUR' }}</strong></mat-card
>
<mat-card class="total"
><span>Theoretischer Gesamtstand</span
><strong>{{ team()!.balance + team()!.outstanding | currency: 'EUR' }}</strong></mat-card
>
</div>
</section>
<section class="content-grid">
<div>
<div class="section-heading">
<div>
<p class="eyebrow">Mannschaft</p>
<h2>Mitglieder</h2>
</div>
<span>{{ players().length }} aktiv</span>
</div>
<mat-form-field appearance="outline" class="search"
><mat-label>Mitglied suchen</mat-label><mat-icon matPrefix>search</mat-icon
><input matInput [value]="search()" (input)="search.set($any($event.target).value)"
/></mat-form-field>
<div class="player-list">
@for (player of players(); track player.id) {
<a [routerLink]="['/t', token, player.id]"
><span>{{ player.firstName }} {{ player.lastName }}</span
><strong>{{ player.balance | currency: 'EUR' }}</strong
><mat-icon>chevron_right</mat-icon></a
>
} @empty {
<div class="empty">Keine Mitglieder gefunden.</div>
}
</div>
</div>
<aside>
<p class="eyebrow">Teamregeln</p>
<h2>Strafenkatalog</h2>
<div class="penalty-list">
@for (penalty of penalties(); track penalty.id) {
<mat-card
><span>{{ penalty.description }}</span
><strong>{{ penalty.amount | currency: 'EUR' }}</strong></mat-card
>
} @empty {
<div class="empty">Keine Einträge vorhanden.</div>
}
</div>
</aside>
</section>
}
</main>

View File

@@ -0,0 +1,134 @@
:host {
display: block;
min-height: 100%;
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
}
.public-header {
height: 64px;
padding: 0 max(20px, calc((100vw - 1180px) / 2));
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mat-sys-surface);
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 800;
font-size: 1.1rem;
}
main {
max-width: 1180px;
margin: 0 auto;
padding: 40px 24px 64px;
}
.hero h1 {
font-size: clamp(2.4rem, 6vw, 4.5rem);
line-height: clamp(2.4rem, 6vw, 4.5rem);
margin: 0 0 26px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
margin: 0 0 6px;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.balance-grid mat-card {
padding: 20px;
border-radius: 20px;
display: grid;
gap: 7px;
}
.balance-grid strong {
font-size: clamp(1.45rem, 3vw, 2rem);
}
.balance-grid .total {
background: var(--mat-sys-primary-container);
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.7fr);
gap: 34px;
margin-top: 42px;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: end;
}
.section-heading h2,
aside h2 {
margin: 0 0 14px;
}
.search {
width: 100%;
}
.player-list {
overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 20px;
background: var(--mat-sys-surface);
}
.player-list a {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 12px;
padding: 17px 18px;
color: inherit;
text-decoration: none;
}
.player-list a + a {
border-top: 1px solid var(--mat-sys-outline-variant);
}
.player-list a:hover {
background: var(--mat-sys-surface-container);
}
.penalty-list {
display: grid;
gap: 10px;
}
.penalty-list mat-card {
padding: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 14px;
border-radius: 16px;
}
.empty {
padding: 30px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
}
.state {
min-height: 70vh;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
text-align: center;
}
@media (max-width: 800px) {
.balance-grid,
.content-grid {
grid-template-columns: 1fr;
}
.content-grid {
gap: 32px;
}
main {
padding: 28px 16px 48px;
}
}

View File

@@ -0,0 +1,38 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PublicTeam } from './public-team';
describe('PublicTeam', () => {
it('renders the combined public team response without a separate penalty request', async () => {
const team = {
name: 'Team A',
balance: 120,
outstanding: 15,
players: [
{ id: 7, firstName: 'Bea', lastName: 'Test', balance: -5, active: true },
{ id: 8, firstName: 'Inaktiv', lastName: 'Mitglied', balance: 0, active: false },
],
penalties: [{ id: 1, description: 'Zu spät', amount: 5 }],
};
await TestBed.configureTestingModule({
imports: [PublicTeam],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ token: 'public-token' }) } },
},
{ provide: PublicTeamApi, useValue: { loadTeam: vi.fn(() => of(team)) } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PublicTeam);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Team A');
expect(fixture.nativeElement.textContent).toContain('Bea Test');
expect(fixture.nativeElement.textContent).not.toContain('Inaktiv Mitglied');
expect(fixture.nativeElement.textContent).toContain('Zu spät');
});
});

View File

@@ -0,0 +1,73 @@
import { CurrencyPipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { Penalty } from '../../models/penalty.model';
import { PublicTeamOverview } from '../../models/public-access.model';
registerLocaleData(localeDe);
@Component({
selector: 'app-public-team',
imports: [
CurrencyPipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './public-team.html',
styleUrl: './public-team.scss',
})
export class PublicTeam {
private readonly publicTeamApi = inject(PublicTeamApi);
protected readonly token = inject(ActivatedRoute).snapshot.paramMap.get('token') ?? '';
protected readonly team = signal<PublicTeamOverview | null>(null);
protected readonly penalties = signal<Penalty[]>([]);
protected readonly loading = signal(true);
protected readonly notFound = signal(false);
protected readonly search = signal('');
protected readonly players = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de');
return (this.team()?.players ?? [])
.filter((player) => player.active)
.filter((player) =>
`${player.firstName} ${player.lastName}`.toLocaleLowerCase('de').includes(query),
)
.sort((a, b) => a.lastName.localeCompare(b.lastName, 'de'));
});
constructor() {
if (!this.token) {
this.loading.set(false);
this.notFound.set(true);
return;
}
this.publicTeamApi.loadTeam(this.token).subscribe({
next: (team) => {
this.team.set({
...team,
balance: Number(team.balance),
outstanding: Number(team.outstanding ?? 0),
});
this.penalties.set(team.penalties);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.notFound.set(true);
},
});
}
}

View File

@@ -0,0 +1,21 @@
@if (loading()) {
<div class="team-select-loading">
<mat-spinner diameter="32" />
</div>
} @else if (players().length === 0) {
<div class="team-select-empty">
<p>Du bist noch keinem Team zugeordnet.</p>
</div>
} @else {
<div class="team-select-page">
<h1>Team auswählen</h1>
<mat-nav-list>
@for (player of players(); track player.id) {
<a mat-list-item [routerLink]="['/team', player.team?.id, 'overview']">
<span matListItemTitle>{{ player.team?.name }}</span>
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
</a>
}
</mat-nav-list>
</div>
}

View File

@@ -0,0 +1,13 @@
.team-select-loading,
.team-select-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 60dvh;
padding: 1rem;
text-align: center;
}
.team-select-page {
padding: 1rem;
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { TeamSelect } from './team-select';
import { environment } from '../../../environments/environment';
import { AuthStore } from '../../core/auth/auth-store';
import { Player } from '../../models/player.model';
describe('TeamSelect', () => {
let httpMock: HttpTestingController;
let router: Router;
let authStore: AuthStore;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [TeamSelect],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
});
afterEach(() => {
httpMock.verify();
});
it('renders one entry per team when the user has several', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
{
id: 2,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 6, name: 'Team B', alias: 'b', balance: 0 },
},
];
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('a[mat-list-item]');
expect(items.length).toBe(2);
});
it('redirects automatically when the user has exactly one team', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
];
const navigateSpy = vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'overview'], { replaceUrl: true });
});
it('shows an empty state when the user has no teams', async () => {
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Du bist noch keinem Team zugeordnet.');
});
});

View File

@@ -0,0 +1,37 @@
import { Component, effect, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthStore } from '../../core/auth/auth-store';
import { MyTeamsStore } from '../../core/team/my-teams-store';
@Component({
selector: 'app-team-select',
imports: [RouterLink, MatListModule, MatProgressSpinnerModule],
templateUrl: './team-select.html',
styleUrl: './team-select.scss',
})
export class TeamSelect {
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly router = inject(Router);
protected readonly players = this.myTeamsStore.players;
protected readonly loading = this.myTeamsStore.loading;
constructor() {
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.ensureLoaded(userId);
}
effect(() => {
const players = this.myTeamsStore.players();
if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) {
void this.router.navigate(['/team', players[0].team.id, 'overview'], {
replaceUrl: true,
});
}
});
}
}

Some files were not shown because too many files have changed in this diff Show More