first commit
17
myteamwallet_frontend_modern/.editorconfig
Normal 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
@@ -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
|
||||
12
myteamwallet_frontend_modern/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
myteamwallet_frontend_modern/.superdesign/design-system.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
80
myteamwallet_frontend_modern/.superdesign/init/layouts.md
Normal 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);
|
||||
}
|
||||
```
|
||||
29
myteamwallet_frontend_modern/.superdesign/init/pages.md
Normal 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.
|
||||
14
myteamwallet_frontend_modern/.superdesign/init/routes.md
Normal 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`.
|
||||
41
myteamwallet_frontend_modern/.superdesign/init/theme.md
Normal 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%;
|
||||
}
|
||||
```
|
||||
4
myteamwallet_frontend_modern/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
myteamwallet_frontend_modern/.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
myteamwallet_frontend_modern/.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
myteamwallet_frontend_modern/.vscode/tasks.json
vendored
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
myteamwallet_frontend_modern/README.md
Normal 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.
|
||||
107
myteamwallet_frontend_modern/angular.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 3–5 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 3–5 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 user’s 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.
|
||||
@@ -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.
|
||||
28
myteamwallet_frontend_modern/ngsw-config.json
Normal 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)"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
8990
myteamwallet_frontend_modern/package-lock.json
generated
Normal file
38
myteamwallet_frontend_modern/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
myteamwallet_frontend_modern/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
59
myteamwallet_frontend_modern/public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
myteamwallet_frontend_modern/src/app/app.config.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
};
|
||||
1
myteamwallet_frontend_modern/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
63
myteamwallet_frontend_modern/src/app/app.routes.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
100
myteamwallet_frontend_modern/src/app/app.routes.ts
Normal 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),
|
||||
},
|
||||
];
|
||||
0
myteamwallet_frontend_modern/src/app/app.scss
Normal file
76
myteamwallet_frontend_modern/src/app/app.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
31
myteamwallet_frontend_modern/src/app/app.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
120
myteamwallet_frontend_modern/src/app/core/auth/auth-api.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
myteamwallet_frontend_modern/src/app/core/auth/auth-api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
10
myteamwallet_frontend_modern/src/app/core/auth/auth-guard.ts
Normal 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');
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
51
myteamwallet_frontend_modern/src/app/core/auth/auth-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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}` } }));
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
56
myteamwallet_frontend_modern/src/app/core/team/team-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
40
myteamwallet_frontend_modern/src/app/core/team/teams-api.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||