1854 lines
56 KiB
Markdown
1854 lines
56 KiB
Markdown
# Foundation & App Shell 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:** Stand up the shared infrastructure for the TeamWallet frontend rebuild — Angular Material theming, PWA scaffolding, environment config, domain models, auth state/API/guards, HTTP interceptors, a working login screen, and the app shell with bottom navigation — so a user can log in against the real backend and land in a navigable (still mostly stubbed) team area.
|
||
|
||
**Architecture:** Standalone Angular 21 components throughout, no NgModules. App-wide auth state lives in an injectable `AuthStore` built on signals. Guards and HTTP interceptors are plain functions (`CanActivateFn`, `HttpInterceptorFn`). Feature routes are lazy-loaded via `loadComponent`. This plan builds only the shared shell and stub routes; later plans (Auth extras, Team-Select, Overview, Members, Cashbox, More, Public-Team) fill in the real feature screens behind these stubs.
|
||
|
||
**Tech Stack:** Angular 21 (standalone, zoneless, esbuild builder), Angular Material 21 + CDK, `@angular/service-worker` (PWA), native Angular Signals, Reactive Forms, Vitest (`@angular/build:unit-test`).
|
||
|
||
## Global Constraints
|
||
|
||
- Angular 21, standalone components only — no NgModules, no `standalone: true` flag needed (it's the default).
|
||
- Angular Material 21 is the UI library; no Bootstrap/PrimeNG.
|
||
- State management is native Signals in injectable services — no NgRx.
|
||
- Guards and interceptors are functional (`CanActivateFn`, `HttpInterceptorFn`) — no class-based guards/interceptors.
|
||
- No i18n layer — German text is hardcoded directly in templates.
|
||
- PWA is required (`@angular/service-worker`, installable manifest).
|
||
- Backend base URL pattern: `{environment.apiUrl}<controller>/<path>`, e.g. `{apiUrl}auth/email/login`. Dev backend runs at `http://localhost:3999/api/v1/`.
|
||
- Tests use Vitest via the `@angular/build:unit-test` builder; `describe`/`it`/`expect`/`vi` are globals (see existing `src/app/app.spec.ts` — no test-framework imports needed).
|
||
- File/class naming follows this repo's existing scaffold convention (confirmed via `ng generate` dry-runs): components live in their own folder as `<name>/<name>.ts` (class `PascalCase`, **no** `Component` suffix), services as `<name>.ts` (class **no** `Service` suffix), guards as `<name>-guard.ts` (const `<name>Guard`), interceptors as `<name>-interceptor.ts` (const `<name>Interceptor`).
|
||
- This app is zoneless (no `zone.js` dependency) — tests must use `async`/`await` with Promise-returning APIs (e.g. `await router.navigateByUrl(...)`), never `fakeAsync`/`tick`.
|
||
|
||
---
|
||
|
||
## File Structure (produced by this plan)
|
||
|
||
```
|
||
src/environments/
|
||
environment.ts # prod apiUrl
|
||
environment.development.ts # dev apiUrl (localhost:3999)
|
||
src/app/
|
||
models/
|
||
role.model.ts
|
||
status.model.ts
|
||
user.model.ts
|
||
team-role.model.ts # TeamRole enum + canBook/canInvite
|
||
core/
|
||
auth/
|
||
auth-store.ts # signal-based session state
|
||
auth-api.ts # login/me HTTP calls
|
||
auth-guard.ts # protects authenticated routes
|
||
root-redirect-guard.ts # '/' -> /team-select or /auth/login
|
||
http/
|
||
auth-interceptor.ts # attaches Bearer token
|
||
error-interceptor.ts # 401 -> logout + redirect + snackbar
|
||
layout/
|
||
shell/shell.ts # header + bottom nav, wraps /team/:id/*
|
||
not-found/not-found.ts
|
||
features/
|
||
auth/login/login.ts
|
||
team-select/team-select.ts # stub, real impl in a later plan
|
||
team/
|
||
overview/overview.ts # stub
|
||
members/members.ts # stub
|
||
cashbox/cashbox.ts # stub
|
||
more/more.ts # stub
|
||
app.ts / app.html / app.spec.ts # trimmed to <router-outlet/>
|
||
app.config.ts # Material, PWA, HTTP, router providers
|
||
app.routes.ts
|
||
app.routes.spec.ts
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Angular Material Install & Custom Theme
|
||
|
||
**Files:**
|
||
- Modify: `package.json`, `package-lock.json` (via CLI, not hand-edited)
|
||
- Modify: `src/styles.scss`
|
||
- Modify: `src/index.html`
|
||
|
||
**Interfaces:**
|
||
- Produces: Material component modules available for import throughout the app (`@angular/material/*`); a global M3 theme using `mat.$green-palette` (primary) and `mat.$orange-palette` (tertiary) to match the "frisch/sportlich" direction from the design spec.
|
||
|
||
- [ ] **Step 1: Run the Material schematic**
|
||
|
||
Run in `myteamwallet_frontend_modern/`:
|
||
|
||
```bash
|
||
npx ng add @angular/material --skip-confirmation --theme=custom --typography=true --animations=enabledAsync
|
||
```
|
||
|
||
Expected: `package.json`/`package-lock.json` gain `@angular/material` and `@angular/cdk` (^21.2.x); `src/styles.scss` is rewritten with a `@use '@angular/material' as mat;` block and a `mat.theme(...)` include; `src/index.html` gains Roboto + Material Icons font `<link>` tags.
|
||
|
||
- [ ] **Step 2: Swap in the custom palette**
|
||
|
||
Edit `src/styles.scss` — replace the generated `color` block (which defaults to `mat.$azure-palette` / `mat.$blue-palette`) with:
|
||
|
||
```scss
|
||
@include mat.theme(
|
||
(
|
||
color: (
|
||
primary: mat.$green-palette,
|
||
tertiary: mat.$orange-palette,
|
||
),
|
||
typography: Roboto,
|
||
density: 0,
|
||
)
|
||
);
|
||
```
|
||
|
||
Keep the rest of the generated file (the `body` rule with `--mat-sys-*` variables) as-is.
|
||
|
||
- [ ] **Step 3: Verify the build picks up the theme**
|
||
|
||
Run: `npm run build -- --configuration development`
|
||
Expected: build succeeds with no Sass errors; `dist/` output contains compiled CSS referencing `--mat-sys-primary` custom properties.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json src/styles.scss src/index.html
|
||
git commit -m "chore: add Angular Material with a green/orange M3 theme"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: PWA Setup
|
||
|
||
**Files:**
|
||
- Modify: `package.json`, `package-lock.json`, `angular.json`, `src/app/app.config.ts`, `src/index.html`
|
||
- Create: `ngsw-config.json`, `public/manifest.webmanifest`, `public/icons/*.png`
|
||
|
||
**Interfaces:**
|
||
- Produces: `provideServiceWorker(...)` registered in `app.config.ts` providers (later tasks append to the same providers array, don't replace it).
|
||
|
||
- [ ] **Step 1: Run the PWA schematic**
|
||
|
||
```bash
|
||
npx ng add @angular/pwa --skip-confirmation
|
||
```
|
||
|
||
Expected: `@angular/service-worker` added to `package.json`; `ngsw-config.json` created; `public/manifest.webmanifest` + `public/icons/*.png` created; `angular.json`'s production build configuration gains `"serviceWorker": "ngsw-config.json"`; `src/index.html` gains `<link rel="manifest" href="manifest.webmanifest">` and a `<noscript>` tag; `src/app/app.config.ts` gains a `provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' })` entry in `providers`.
|
||
|
||
- [ ] **Step 2: Brand the manifest**
|
||
|
||
Edit `public/manifest.webmanifest` — change the top of the file to:
|
||
|
||
```json
|
||
{
|
||
"name": "TeamWallet",
|
||
"short_name": "TeamWallet",
|
||
"theme_color": "#2e7d32",
|
||
"background_color": "#ffffff",
|
||
"display": "standalone",
|
||
```
|
||
|
||
Keep `scope`, `start_url`, and the `icons` array exactly as generated.
|
||
|
||
- [ ] **Step 3: Verify a production build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds; `dist/myteamwallet_frontend_modern/browser/ngsw.json` exists.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json angular.json ngsw-config.json public/manifest.webmanifest public/icons src/app/app.config.ts src/index.html
|
||
git commit -m "chore: add PWA support (service worker, manifest, icons)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Environment Configuration
|
||
|
||
**Files:**
|
||
- Create: `src/environments/environment.ts`
|
||
- Create: `src/environments/environment.development.ts`
|
||
- Modify: `angular.json:51-55` (the build target's `development` configuration)
|
||
|
||
**Interfaces:**
|
||
- Produces: `environment: { production: boolean; apiUrl: string }`, importable as `import { environment } from '../../environments/environment'` (path depends on the importing file's depth — see later tasks for exact relative paths).
|
||
|
||
- [ ] **Step 1: Create the production environment file**
|
||
|
||
Create `src/environments/environment.ts`:
|
||
|
||
```ts
|
||
export const environment = {
|
||
production: true,
|
||
apiUrl: 'https://myteamwallet.de/api/v1/',
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Create the development environment file**
|
||
|
||
Create `src/environments/environment.development.ts`:
|
||
|
||
```ts
|
||
export const environment = {
|
||
production: false,
|
||
apiUrl: 'http://localhost:3999/api/v1/',
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Wire up the file replacement**
|
||
|
||
In `angular.json`, find the `build` target's `configurations.development` block:
|
||
|
||
```json
|
||
"development": {
|
||
"optimization": false,
|
||
"extractLicenses": false,
|
||
"sourceMap": true
|
||
}
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```json
|
||
"development": {
|
||
"optimization": false,
|
||
"extractLicenses": false,
|
||
"sourceMap": true,
|
||
"fileReplacements": [
|
||
{
|
||
"replace": "src/environments/environment.ts",
|
||
"with": "src/environments/environment.development.ts"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify both configurations build**
|
||
|
||
Run: `npm run build -- --configuration development` then `npm run build -- --configuration production`
|
||
Expected: both succeed with no errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/environments angular.json
|
||
git commit -m "feat: add dev/prod environment configuration with backend apiUrl"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Core Domain Models & Permission Helpers
|
||
|
||
**Files:**
|
||
- Create: `src/app/models/role.model.ts`
|
||
- Create: `src/app/models/status.model.ts`
|
||
- Create: `src/app/models/user.model.ts`
|
||
- Create: `src/app/models/team-role.model.ts`
|
||
- Test: `src/app/models/team-role.model.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `interface Role { id: number; name?: string }`
|
||
- `enum RoleId { Admin = 1, User = 2 }`
|
||
- `interface Status { id: number; name?: string }`
|
||
- `interface UserPhoto { id: string; path: string }`
|
||
- `interface User { id: number; email: string | null; firstName: string | null; lastName: string | null; role?: Role | null; status?: Status; photo?: UserPhoto | null }`
|
||
- `enum TeamRole { Player = 1, ScndTreasurer = 2, Captain = 3, Treasurer = 4, Coach = 5 }`
|
||
- `function canBook(role: TeamRole): boolean`
|
||
- `function canInvite(role: TeamRole): boolean`
|
||
|
||
- [ ] **Step 1: Write the failing test for permission helpers**
|
||
|
||
Create `src/app/models/team-role.model.spec.ts`:
|
||
|
||
```ts
|
||
import { TeamRole, canBook, canInvite } from './team-role.model';
|
||
|
||
describe('team-role permissions', () => {
|
||
it('canBook is false for Player', () => {
|
||
expect(canBook(TeamRole.Player)).toBe(false);
|
||
});
|
||
|
||
it('canBook is true from ScndTreasurer upwards', () => {
|
||
expect(canBook(TeamRole.ScndTreasurer)).toBe(true);
|
||
expect(canBook(TeamRole.Captain)).toBe(true);
|
||
expect(canBook(TeamRole.Treasurer)).toBe(true);
|
||
expect(canBook(TeamRole.Coach)).toBe(true);
|
||
});
|
||
|
||
it('canInvite is false for Player and ScndTreasurer', () => {
|
||
expect(canInvite(TeamRole.Player)).toBe(false);
|
||
expect(canInvite(TeamRole.ScndTreasurer)).toBe(false);
|
||
});
|
||
|
||
it('canInvite is true above ScndTreasurer', () => {
|
||
expect(canInvite(TeamRole.Captain)).toBe(true);
|
||
expect(canInvite(TeamRole.Treasurer)).toBe(true);
|
||
expect(canInvite(TeamRole.Coach)).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/team-role.model.spec.ts'`
|
||
Expected: FAIL — `team-role.model.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Create the supporting model files**
|
||
|
||
Create `src/app/models/role.model.ts`:
|
||
|
||
```ts
|
||
export interface Role {
|
||
id: number;
|
||
name?: string;
|
||
}
|
||
|
||
export enum RoleId {
|
||
Admin = 1,
|
||
User = 2,
|
||
}
|
||
```
|
||
|
||
Create `src/app/models/status.model.ts`:
|
||
|
||
```ts
|
||
export interface Status {
|
||
id: number;
|
||
name?: string;
|
||
}
|
||
```
|
||
|
||
Create `src/app/models/user.model.ts`:
|
||
|
||
```ts
|
||
import { Role } from './role.model';
|
||
import { Status } from './status.model';
|
||
|
||
export interface UserPhoto {
|
||
id: string;
|
||
path: string;
|
||
}
|
||
|
||
export interface User {
|
||
id: number;
|
||
email: string | null;
|
||
firstName: string | null;
|
||
lastName: string | null;
|
||
role?: Role | null;
|
||
status?: Status;
|
||
photo?: UserPhoto | null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the permission helpers**
|
||
|
||
Create `src/app/models/team-role.model.ts`:
|
||
|
||
```ts
|
||
export enum TeamRole {
|
||
Player = 1,
|
||
ScndTreasurer = 2,
|
||
Captain = 3,
|
||
Treasurer = 4,
|
||
Coach = 5,
|
||
}
|
||
|
||
export function canBook(role: TeamRole): boolean {
|
||
return role >= TeamRole.ScndTreasurer;
|
||
}
|
||
|
||
export function canInvite(role: TeamRole): boolean {
|
||
return role > TeamRole.ScndTreasurer;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/team-role.model.spec.ts'`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/app/models
|
||
git commit -m "feat: add core domain models and team-role permission helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: AuthStore (Session State)
|
||
|
||
**Files:**
|
||
- Create: `src/app/core/auth/auth-store.ts`
|
||
- Test: `src/app/core/auth/auth-store.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `User` from `../../models/user.model`.
|
||
- Produces: `class AuthStore` (`providedIn: 'root'`) with:
|
||
- `readonly token: Signal<string | null>`
|
||
- `readonly currentUser: Signal<User | null>`
|
||
- `readonly isLoggedIn: Signal<boolean>`
|
||
- `setSession(token: string, user: User): void`
|
||
- `clearSession(): void`
|
||
- persists to `localStorage` under keys `tw_token` / `tw_user`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/app/core/auth/auth-store.spec.ts`:
|
||
|
||
```ts
|
||
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('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();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/auth-store.spec.ts'`
|
||
Expected: FAIL — `auth-store.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement AuthStore**
|
||
|
||
Create `src/app/core/auth/auth-store.ts`:
|
||
|
||
```ts
|
||
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>(
|
||
localStorage.getItem(AuthStore.TOKEN_KEY),
|
||
);
|
||
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);
|
||
}
|
||
|
||
private static readStoredUser(): User | null {
|
||
const raw = localStorage.getItem(AuthStore.USER_KEY);
|
||
return raw ? (JSON.parse(raw) as User) : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/auth-store.spec.ts'`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/core/auth/auth-store.ts src/app/core/auth/auth-store.spec.ts
|
||
git commit -m "feat: add signal-based AuthStore for session state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: HTTP Interceptors (Auth + Error)
|
||
|
||
**Files:**
|
||
- Create: `src/app/core/http/auth-interceptor.ts`
|
||
- Test: `src/app/core/http/auth-interceptor.spec.ts`
|
||
- Create: `src/app/core/http/error-interceptor.ts`
|
||
- Test: `src/app/core/http/error-interceptor.spec.ts`
|
||
- Modify: `src/app/app.config.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AuthStore` from `../auth/auth-store`.
|
||
- Produces: `authInterceptor: HttpInterceptorFn`, `errorInterceptor: HttpInterceptorFn`, both registered via `provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))` in `app.config.ts`.
|
||
|
||
- [ ] **Step 1: Write the failing test for the auth interceptor**
|
||
|
||
Create `src/app/core/http/auth-interceptor.spec.ts`:
|
||
|
||
```ts
|
||
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({});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/auth-interceptor.spec.ts'`
|
||
Expected: FAIL — `auth-interceptor.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement the auth interceptor**
|
||
|
||
Create `src/app/core/http/auth-interceptor.ts`:
|
||
|
||
```ts
|
||
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}` } }));
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/auth-interceptor.spec.ts'`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Write the failing test for the error interceptor**
|
||
|
||
Create `src/app/core/http/error-interceptor.spec.ts`:
|
||
|
||
```ts
|
||
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 { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||
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;
|
||
|
||
beforeEach(() => {
|
||
localStorage.clear();
|
||
TestBed.configureTestingModule({
|
||
providers: [
|
||
provideHttpClient(withInterceptors([errorInterceptor])),
|
||
provideHttpClientTesting(),
|
||
provideRouter([]),
|
||
provideAnimationsAsync(),
|
||
],
|
||
});
|
||
httpMock = TestBed.inject(HttpTestingController);
|
||
httpClient = TestBed.inject(HttpClient);
|
||
authStore = TestBed.inject(AuthStore);
|
||
router = TestBed.inject(Router);
|
||
});
|
||
|
||
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('leaves other error statuses untouched', () => {
|
||
httpClient.get('/secure').subscribe({ error: () => undefined });
|
||
httpMock.expectOne('/secure').flush('server error', { status: 500, statusText: 'Server Error' });
|
||
|
||
expect(authStore.isLoggedIn()).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/error-interceptor.spec.ts'`
|
||
Expected: FAIL — `error-interceptor.ts` does not exist yet.
|
||
|
||
- [ ] **Step 7: Implement the error interceptor**
|
||
|
||
Create `src/app/core/http/error-interceptor.ts`:
|
||
|
||
```ts
|
||
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 && error.status === 401) {
|
||
authStore.clearSession();
|
||
void router.navigate(['/auth/login']);
|
||
snackBar.open('Sitzung abgelaufen. Bitte erneut anmelden.', 'OK', { duration: 5000 });
|
||
}
|
||
return throwError(() => error);
|
||
}),
|
||
);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 8: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/error-interceptor.spec.ts'`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 9: Wire the interceptors, HttpClient, and animations into app.config.ts**
|
||
|
||
`ng add @angular/material` (Task 1) does **not** register `provideAnimationsAsync()` in `app.config.ts` — it only rewrites `styles.scss`/`index.html`. Verify this is still missing (`grep -n "provideAnimationsAsync" src/app/app.config.ts` should find nothing), then edit `src/app/app.config.ts` to its final Foundation-plan state, adding it together with the HTTP client:
|
||
|
||
```ts
|
||
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||
import { provideRouter } from '@angular/router';
|
||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||
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),
|
||
provideAnimationsAsync(),
|
||
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||
provideServiceWorker('ngsw-worker.js', {
|
||
enabled: !isDevMode(),
|
||
registrationStrategy: 'registerWhenStable:30000',
|
||
}),
|
||
],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 10: Verify the full build still succeeds**
|
||
|
||
Run: `npm run build -- --configuration development`
|
||
Expected: no compile errors.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add src/app/core/http src/app/app.config.ts
|
||
git commit -m "feat: add auth and error HTTP interceptors"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: AuthApi (Login + Current User)
|
||
|
||
**Files:**
|
||
- Create: `src/app/core/auth/auth-api.ts`
|
||
- Test: `src/app/core/auth/auth-api.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `environment` from `../../../environments/environment`, `User` from `../../models/user.model`.
|
||
- Produces: `interface LoginResponse { token: string; user: User }`, `class AuthApi` (`providedIn: 'root'`) with:
|
||
- `login(email: string, password: string): Observable<LoginResponse>` → `POST {apiUrl}auth/email/login`
|
||
- `me(): Observable<User>` → `GET {apiUrl}auth/me`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/app/core/auth/auth-api.spec.ts`:
|
||
|
||
```ts
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/auth-api.spec.ts'`
|
||
Expected: FAIL — `auth-api.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement AuthApi**
|
||
|
||
Create `src/app/core/auth/auth-api.ts`:
|
||
|
||
```ts
|
||
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;
|
||
}
|
||
|
||
@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<User> {
|
||
return this.http.get<User>(`${this.baseUrl}/me`);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/auth-api.spec.ts'`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/core/auth/auth-api.ts src/app/core/auth/auth-api.spec.ts
|
||
git commit -m "feat: add AuthApi for login and current-user requests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Guards (authGuard + rootRedirectGuard)
|
||
|
||
**Files:**
|
||
- Create: `src/app/core/auth/auth-guard.ts`
|
||
- Test: `src/app/core/auth/auth-guard.spec.ts`
|
||
- Create: `src/app/core/auth/root-redirect-guard.ts`
|
||
- Test: `src/app/core/auth/root-redirect-guard.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AuthStore` from `./auth-store`.
|
||
- Produces: `authGuard: CanActivateFn` (blocks unauthenticated access, redirects to `/auth/login`), `rootRedirectGuard: CanActivateFn` (always redirects `/` to `/team-select` or `/auth/login` based on session state).
|
||
|
||
- [ ] **Step 1: Write the failing test for authGuard**
|
||
|
||
Create `src/app/core/auth/auth-guard.spec.ts`:
|
||
|
||
```ts
|
||
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'));
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/auth-guard.spec.ts'`
|
||
Expected: FAIL — `auth-guard.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement authGuard**
|
||
|
||
Create `src/app/core/auth/auth-guard.ts`:
|
||
|
||
```ts
|
||
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');
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/auth-guard.spec.ts'`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Write the failing test for rootRedirectGuard**
|
||
|
||
Create `src/app/core/auth/root-redirect-guard.spec.ts`:
|
||
|
||
```ts
|
||
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'));
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/root-redirect-guard.spec.ts'`
|
||
Expected: FAIL — `root-redirect-guard.ts` does not exist yet.
|
||
|
||
- [ ] **Step 7: Implement rootRedirectGuard**
|
||
|
||
Create `src/app/core/auth/root-redirect-guard.ts`:
|
||
|
||
```ts
|
||
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');
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 8: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/root-redirect-guard.spec.ts'`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/app/core/auth/auth-guard.ts src/app/core/auth/auth-guard.spec.ts src/app/core/auth/root-redirect-guard.ts src/app/core/auth/root-redirect-guard.spec.ts
|
||
git commit -m "feat: add authGuard and rootRedirectGuard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Login Feature
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/auth/login/login.ts`
|
||
- Create: `src/app/features/auth/login/login.html`
|
||
- Create: `src/app/features/auth/login/login.scss`
|
||
- Test: `src/app/features/auth/login/login.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AuthApi` from `../../../core/auth/auth-api`, `AuthStore` from `../../../core/auth/auth-store`.
|
||
- Produces: `class Login`, selector `app-login`, standalone component with a reactive email/password form; on success calls `authStore.setSession(token, user)` and navigates to `/team-select`; on failure sets a visible error message.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/app/features/auth/login/login.spec.ts`:
|
||
|
||
```ts
|
||
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 { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||
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([]), provideAnimationsAsync()],
|
||
}).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.',
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/login.spec.ts'`
|
||
Expected: FAIL — `login.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement the Login component**
|
||
|
||
Create `src/app/features/auth/login/login.ts`:
|
||
|
||
```ts
|
||
import { Component, inject, signal } from '@angular/core';
|
||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||
import { Router } 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,
|
||
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.');
|
||
},
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `src/app/features/auth/login/login.html`:
|
||
|
||
```html
|
||
<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 color="primary" type="submit" [disabled]="form.invalid || isSubmitting()">
|
||
@if (isSubmitting()) {
|
||
<mat-spinner diameter="20" />
|
||
} @else {
|
||
Anmelden
|
||
}
|
||
</button>
|
||
</form>
|
||
</mat-card-content>
|
||
</mat-card>
|
||
</div>
|
||
```
|
||
|
||
Create `src/app/features/auth/login/login.scss`:
|
||
|
||
```scss
|
||
.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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/login.spec.ts'`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/auth/login
|
||
git commit -m "feat: add login screen"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: App Shell (Header + Bottom Navigation)
|
||
|
||
**Files:**
|
||
- Create: `src/app/core/layout/shell/shell.ts`
|
||
- Create: `src/app/core/layout/shell/shell.html`
|
||
- Create: `src/app/core/layout/shell/shell.scss`
|
||
- Test: `src/app/core/layout/shell/shell.spec.ts`
|
||
- Create: `src/app/core/layout/not-found/not-found.ts`
|
||
- Test: `src/app/core/layout/not-found/not-found.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `class Shell` (selector `app-shell`) rendering a header, a `<router-outlet>` for the active team route, and a 4-item bottom nav (`overview`/`members`/`cashbox`/`more`, relative `routerLink`s). `class NotFound` (selector `app-not-found`) for the wildcard route.
|
||
|
||
- [ ] **Step 1: Write the failing test for Shell**
|
||
|
||
Create `src/app/core/layout/shell/shell.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { provideRouter } from '@angular/router';
|
||
import { Shell } from './shell';
|
||
|
||
describe('Shell', () => {
|
||
beforeEach(async () => {
|
||
await TestBed.configureTestingModule({
|
||
imports: [Shell],
|
||
providers: [provideRouter([])],
|
||
}).compileComponents();
|
||
});
|
||
|
||
it('renders the bottom navigation with four tabs', () => {
|
||
const fixture = TestBed.createComponent(Shell);
|
||
fixture.detectChanges();
|
||
|
||
const links = fixture.nativeElement.querySelectorAll('.shell-bottom-nav__item');
|
||
expect(links.length).toBe(4);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/shell.spec.ts'`
|
||
Expected: FAIL — `shell.ts` does not exist yet.
|
||
|
||
- [ ] **Step 3: Implement Shell**
|
||
|
||
Create `src/app/core/layout/shell/shell.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||
import { MatIconModule } from '@angular/material/icon';
|
||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||
|
||
@Component({
|
||
selector: 'app-shell',
|
||
imports: [RouterOutlet, RouterLink, RouterLinkActive, MatToolbarModule, MatIconModule],
|
||
templateUrl: './shell.html',
|
||
styleUrl: './shell.scss',
|
||
})
|
||
export class Shell {}
|
||
```
|
||
|
||
Create `src/app/core/layout/shell/shell.html`:
|
||
|
||
```html
|
||
<mat-toolbar color="primary" class="shell-header">
|
||
<span>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>
|
||
```
|
||
|
||
Create `src/app/core/layout/shell/shell.scss`:
|
||
|
||
```scss
|
||
:host {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100dvh;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/shell.spec.ts'`
|
||
Expected: PASS (1 test).
|
||
|
||
- [ ] **Step 5: Write the failing test for NotFound**
|
||
|
||
Create `src/app/core/layout/not-found/not-found.spec.ts`:
|
||
|
||
```ts
|
||
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');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/not-found.spec.ts'`
|
||
Expected: FAIL — `not-found.ts` does not exist yet.
|
||
|
||
- [ ] **Step 7: Implement NotFound**
|
||
|
||
Create `src/app/core/layout/not-found/not-found.ts`:
|
||
|
||
```ts
|
||
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 {}
|
||
```
|
||
|
||
- [ ] **Step 8: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/not-found.spec.ts'`
|
||
Expected: PASS (1 test).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/app/core/layout
|
||
git commit -m "feat: add app shell with bottom navigation and not-found page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Stub Feature Screens (Overview, Members, Cashbox, More, Team-Select)
|
||
|
||
**Files:**
|
||
- Create: `src/app/features/team/overview/overview.ts` + `overview.spec.ts`
|
||
- Create: `src/app/features/team/members/members.ts` + `members.spec.ts`
|
||
- Create: `src/app/features/team/cashbox/cashbox.ts` + `cashbox.spec.ts`
|
||
- Create: `src/app/features/team/more/more.ts` + `more.spec.ts`
|
||
- Create: `src/app/features/team-select/team-select.ts` + `team-select.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: five minimal standalone components, each rendering a single `<h1>` heading. These are intentional placeholders that later plans (Team-Select & Team Store, Overview, Members, Cashbox, More) will replace with real functionality — this task only proves the shell/routing wiring works end-to-end.
|
||
|
||
- [ ] **Step 1: Write the failing tests for all five stubs**
|
||
|
||
Create `src/app/features/team/overview/overview.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { Overview } from './overview';
|
||
|
||
describe('Overview', () => {
|
||
it('renders the overview heading', async () => {
|
||
await TestBed.configureTestingModule({ imports: [Overview] }).compileComponents();
|
||
const fixture = TestBed.createComponent(Overview);
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Übersicht');
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `src/app/features/team/members/members.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { Members } from './members';
|
||
|
||
describe('Members', () => {
|
||
it('renders the members heading', async () => {
|
||
await TestBed.configureTestingModule({ imports: [Members] }).compileComponents();
|
||
const fixture = TestBed.createComponent(Members);
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Mitglieder');
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `src/app/features/team/cashbox/cashbox.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { Cashbox } from './cashbox';
|
||
|
||
describe('Cashbox', () => {
|
||
it('renders the cashbox heading', async () => {
|
||
await TestBed.configureTestingModule({ imports: [Cashbox] }).compileComponents();
|
||
const fixture = TestBed.createComponent(Cashbox);
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Kasse');
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `src/app/features/team/more/more.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { More } from './more';
|
||
|
||
describe('More', () => {
|
||
it('renders the more heading', async () => {
|
||
await TestBed.configureTestingModule({ imports: [More] }).compileComponents();
|
||
const fixture = TestBed.createComponent(More);
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Mehr');
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `src/app/features/team-select/team-select.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { TeamSelect } from './team-select';
|
||
|
||
describe('TeamSelect', () => {
|
||
it('renders the team-select heading', async () => {
|
||
await TestBed.configureTestingModule({ imports: [TeamSelect] }).compileComponents();
|
||
const fixture = TestBed.createComponent(TeamSelect);
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Team auswählen');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
Run: `npm test -- --include '**/overview.spec.ts' --include '**/members.spec.ts' --include '**/cashbox.spec.ts' --include '**/more.spec.ts' --include '**/team-select.spec.ts'`
|
||
Expected: FAIL — none of the five component files exist yet.
|
||
|
||
- [ ] **Step 3: Implement the five stub components**
|
||
|
||
Create `src/app/features/team/overview/overview.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
|
||
@Component({
|
||
selector: 'app-overview',
|
||
template: `<h1>Übersicht</h1>`,
|
||
})
|
||
export class Overview {}
|
||
```
|
||
|
||
Create `src/app/features/team/members/members.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
|
||
@Component({
|
||
selector: 'app-members',
|
||
template: `<h1>Mitglieder</h1>`,
|
||
})
|
||
export class Members {}
|
||
```
|
||
|
||
Create `src/app/features/team/cashbox/cashbox.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
|
||
@Component({
|
||
selector: 'app-cashbox',
|
||
template: `<h1>Kasse</h1>`,
|
||
})
|
||
export class Cashbox {}
|
||
```
|
||
|
||
Create `src/app/features/team/more/more.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
|
||
@Component({
|
||
selector: 'app-more',
|
||
template: `<h1>Mehr</h1>`,
|
||
})
|
||
export class More {}
|
||
```
|
||
|
||
Create `src/app/features/team-select/team-select.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
|
||
@Component({
|
||
selector: 'app-team-select',
|
||
template: `<h1>Team auswählen</h1>`,
|
||
})
|
||
export class TeamSelect {}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `npm test -- --include '**/overview.spec.ts' --include '**/members.spec.ts' --include '**/cashbox.spec.ts' --include '**/more.spec.ts' --include '**/team-select.spec.ts'`
|
||
Expected: PASS (5 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/features/team src/app/features/team-select
|
||
git commit -m "feat: add stub screens for overview, members, cashbox, more, team-select"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Root Routing Wiring
|
||
|
||
**Files:**
|
||
- Modify: `src/app/app.routes.ts`
|
||
- Create: `src/app/app.routes.spec.ts`
|
||
- Modify: `src/app/app.ts`
|
||
- Modify: `src/app/app.html`
|
||
- Modify: `src/app/app.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: every component/guard produced in Tasks 8–11.
|
||
- Produces: the final `routes: Routes` array; `App` trimmed to a bare `<router-outlet>` shell (the Angular welcome-page placeholder markup is removed).
|
||
|
||
- [ ] **Step 1: Write the failing routing integration test**
|
||
|
||
Create `src/app/app.routes.spec.ts`:
|
||
|
||
```ts
|
||
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 { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||
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(), provideAnimationsAsync()],
|
||
});
|
||
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');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npm test -- --include '**/app.routes.spec.ts'`
|
||
Expected: FAIL — `app.routes.ts` still exports an empty array.
|
||
|
||
- [ ] **Step 3: Implement the routes**
|
||
|
||
Replace the contents of `src/app/app.routes.ts`:
|
||
|
||
```ts
|
||
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: 'team-select',
|
||
canActivate: [authGuard],
|
||
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
|
||
},
|
||
{
|
||
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: 'cashbox',
|
||
loadComponent: () => import('./features/team/cashbox/cashbox').then((m) => m.Cashbox),
|
||
},
|
||
{
|
||
path: 'more',
|
||
loadComponent: () => import('./features/team/more/more').then((m) => m.More),
|
||
},
|
||
],
|
||
},
|
||
{
|
||
path: '**',
|
||
loadComponent: () => import('./core/layout/not-found/not-found').then((m) => m.NotFound),
|
||
},
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- --include '**/app.routes.spec.ts'`
|
||
Expected: PASS (5 tests).
|
||
|
||
- [ ] **Step 5: Trim the root App component down to the router outlet**
|
||
|
||
Replace `src/app/app.html` entirely with:
|
||
|
||
```html
|
||
<router-outlet />
|
||
```
|
||
|
||
Replace `src/app/app.ts`:
|
||
|
||
```ts
|
||
import { Component } from '@angular/core';
|
||
import { RouterOutlet } from '@angular/router';
|
||
|
||
@Component({
|
||
selector: 'app-root',
|
||
imports: [RouterOutlet],
|
||
templateUrl: './app.html',
|
||
styleUrl: './app.scss',
|
||
})
|
||
export class App {}
|
||
```
|
||
|
||
Replace `src/app/app.spec.ts`:
|
||
|
||
```ts
|
||
import { TestBed } from '@angular/core/testing';
|
||
import { provideRouter } from '@angular/router';
|
||
import { App } from './app';
|
||
|
||
describe('App', () => {
|
||
beforeEach(async () => {
|
||
await TestBed.configureTestingModule({
|
||
imports: [App],
|
||
providers: [provideRouter([])],
|
||
}).compileComponents();
|
||
});
|
||
|
||
it('should create the app', () => {
|
||
const fixture = TestBed.createComponent(App);
|
||
expect(fixture.componentInstance).toBeTruthy();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full test suite**
|
||
|
||
Run: `npm test -- --include '**/app.spec.ts' --include '**/app.routes.spec.ts'`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/app/app.routes.ts src/app/app.routes.spec.ts src/app/app.ts src/app/app.html src/app/app.spec.ts
|
||
git commit -m "feat: wire root routing (auth, team-select, team shell, not-found)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Final Verification
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [ ] **Step 1: Run the full unit test suite**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests pass, no failures.
|
||
|
||
- [ ] **Step 2: Run a production build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds; `dist/myteamwallet_frontend_modern/browser/` contains `index.html`, `ngsw.json`, and hashed JS/CSS bundles.
|
||
|
||
- [ ] **Step 3: Check formatting**
|
||
|
||
Run: `npx prettier --check "src/**/*.{ts,html,scss}"`
|
||
Expected: no formatting issues. If issues are reported, run `npx prettier --write "src/**/*.{ts,html,scss}"` and re-check.
|
||
|
||
- [ ] **Step 4: Manual smoke check (requires the backend running on localhost:3999)**
|
||
|
||
Run: `npm start`, open `http://localhost:4200`. Expected: redirected to `/auth/login`; a valid backend login redirects to `/team-select` showing "Team auswählen"; manually navigating to `/team/1/overview` shows the app shell with header + bottom nav, and the four tabs switch between the stub screens.
|
||
|
||
- [ ] **Step 5: Commit (only if Steps 1–3 required fixes)**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: fix formatting/build issues found during final verification"
|
||
```
|