berechtigungen
This commit is contained in:
@@ -101,6 +101,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess),
|
||||
},
|
||||
{
|
||||
path: 'more/permissions',
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/permissions/permissions').then((m) => m.Permissions),
|
||||
},
|
||||
{
|
||||
path: 'more/guide',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 { TeamPermissionsApi } from './team-permissions-api';
|
||||
|
||||
describe('TeamPermissionsApi', () => {
|
||||
let api: TeamPermissionsApi;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const permissions = {
|
||||
transactionCreateMinRole: 2,
|
||||
transactionReverseMinRole: 2,
|
||||
inviteMinRole: 3,
|
||||
memberManageMinRole: 3,
|
||||
penaltyManageMinRole: 3,
|
||||
publicAccessManageMinRole: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
api = TestBed.inject(TeamPermissionsApi);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('loads the team permissions', () => {
|
||||
api.getPermissions(5).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/permissions`);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush(permissions);
|
||||
});
|
||||
|
||||
it('updates only the changed permissions', () => {
|
||||
api.updatePermissions(5, { inviteMinRole: 4 }).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/permissions`);
|
||||
expect(request.request.method).toBe('PATCH');
|
||||
expect(request.request.body).toEqual({ inviteMinRole: 4 });
|
||||
request.flush({ ...permissions, inviteMinRole: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { TeamPermissions, UpdateTeamPermissions } from '../../models/team-permissions.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TeamPermissionsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getPermissions(teamId: number): Observable<TeamPermissions> {
|
||||
return this.http.get<TeamPermissions>(`${environment.apiUrl}teams/${teamId}/permissions`);
|
||||
}
|
||||
|
||||
updatePermissions(
|
||||
teamId: number,
|
||||
changes: UpdateTeamPermissions,
|
||||
): Observable<TeamPermissions> {
|
||||
return this.http.patch<TeamPermissions>(
|
||||
`${environment.apiUrl}teams/${teamId}/permissions`,
|
||||
changes,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RoleId } from '../../models/role.model';
|
||||
import { Team } from '../../models/team.model';
|
||||
import { AuthStore } from '../auth/auth-store';
|
||||
import { TeamPermissionsService } from './team-permissions';
|
||||
|
||||
describe('TeamPermissionsService', () => {
|
||||
let service: TeamPermissionsService;
|
||||
let authStore: AuthStore;
|
||||
|
||||
const team: Team = {
|
||||
id: 9,
|
||||
name: 'Team A',
|
||||
alias: 'a',
|
||||
balance: 0,
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Cap',
|
||||
lastName: 'Tain',
|
||||
balance: 0,
|
||||
active: true,
|
||||
user: { id: 42, email: null, firstName: null, lastName: null },
|
||||
teamRole: { id: 3 },
|
||||
},
|
||||
],
|
||||
settings: [{ key: 'invite_min_role', value: '4' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(TeamPermissionsService);
|
||||
authStore = TestBed.inject(AuthStore);
|
||||
});
|
||||
|
||||
it('denies everything when nobody is logged in', () => {
|
||||
authStore.clearSession();
|
||||
expect(service.canDo(team, 'invite')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a global admin bypass all checks', () => {
|
||||
authStore.setSession('t', {
|
||||
id: 99,
|
||||
email: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
role: { id: RoleId.Admin },
|
||||
});
|
||||
expect(service.canDo(team, 'invite')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the configured team setting instead of the built-in default', () => {
|
||||
authStore.setSession('t', {
|
||||
id: 42,
|
||||
email: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
role: { id: RoleId.User },
|
||||
});
|
||||
// captain (3) is below the configured invite_min_role of 4
|
||||
expect(service.canDo(team, 'invite')).toBe(false);
|
||||
// but still allowed for the default-threshold action memberManage (3)
|
||||
expect(service.canDo(team, 'memberManage')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the built-in default when no setting exists for that action', () => {
|
||||
authStore.setSession('t', {
|
||||
id: 42,
|
||||
email: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
role: { id: RoleId.User },
|
||||
});
|
||||
expect(service.canDo(team, 'transactionCreate')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores inactive players', () => {
|
||||
authStore.setSession('t', {
|
||||
id: 42,
|
||||
email: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
role: { id: RoleId.User },
|
||||
});
|
||||
const inactiveTeam: Team = {
|
||||
...team,
|
||||
players: [{ ...team.players![0], active: false }],
|
||||
};
|
||||
expect(service.canDo(inactiveTeam, 'memberManage')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { RoleId } from '../../models/role.model';
|
||||
import { Team } from '../../models/team.model';
|
||||
import { TeamPermissionAction } from '../../models/team-permissions.model';
|
||||
import { AuthStore } from '../auth/auth-store';
|
||||
|
||||
const ACTION_TO_SETTING_KEY: Record<TeamPermissionAction, string> = {
|
||||
transactionCreate: 'transaction_create_min_role',
|
||||
transactionReverse: 'transaction_reverse_min_role',
|
||||
invite: 'invite_min_role',
|
||||
memberManage: 'member_manage_min_role',
|
||||
penaltyManage: 'penalty_manage_min_role',
|
||||
publicAccessManage: 'public_access_manage_min_role',
|
||||
};
|
||||
|
||||
const DEFAULT_MIN_ROLE: Record<TeamPermissionAction, number> = {
|
||||
transactionCreate: 2,
|
||||
transactionReverse: 2,
|
||||
invite: 3,
|
||||
memberManage: 3,
|
||||
penaltyManage: 3,
|
||||
publicAccessManage: 3,
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TeamPermissionsService {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
|
||||
canDo(team: Team | null | undefined, action: TeamPermissionAction): boolean {
|
||||
const user = this.authStore.currentUser();
|
||||
if (!user) return false;
|
||||
if (user.role?.id === RoleId.Admin) return true;
|
||||
|
||||
const minRole = this.minRoleFor(team, action);
|
||||
return (
|
||||
team?.players?.some(
|
||||
(player) =>
|
||||
player.active && player.user?.id === user.id && (player.teamRole?.id ?? 0) >= minRole,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
private minRoleFor(team: Team | null | undefined, action: TeamPermissionAction): number {
|
||||
const key = ACTION_TO_SETTING_KEY[action];
|
||||
const setting = team?.settings?.find((s) => s.key === key);
|
||||
const parsed = setting ? Number(setting.value) : NaN;
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 5 ? parsed : DEFAULT_MIN_ROLE[action];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||
import { PenaltyApi } from '../../../core/team/penalty-api';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||
@@ -55,10 +55,10 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
||||
styleUrl: './cashbox.scss',
|
||||
})
|
||||
export class Cashbox {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly penaltyApi = inject(PenaltyApi);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -85,15 +85,9 @@ export class Cashbox {
|
||||
{ id: 14, label: 'Ausgabe' },
|
||||
];
|
||||
|
||||
protected readonly canBook = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canBook = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'transactionCreate'),
|
||||
);
|
||||
|
||||
protected readonly activePlayers = computed(() =>
|
||||
(this.team()?.players ?? []).filter((player) => player.active),
|
||||
@@ -206,7 +200,7 @@ export class Cashbox {
|
||||
|
||||
protected canReverse(activity: TeamActivity): boolean {
|
||||
return (
|
||||
this.canBook() &&
|
||||
this.permissions.canDo(this.team(), 'transactionReverse') &&
|
||||
!activity.isTeamWalletTransaction &&
|
||||
!activity.note?.startsWith('Stornierung von Buchung #')
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
||||
@@ -35,8 +35,8 @@ registerLocaleData(localeDe);
|
||||
styleUrl: './members.scss',
|
||||
})
|
||||
export class Members {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly teamsApi = inject(TeamsApi);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
protected readonly team = this.teamStore.team;
|
||||
@@ -50,15 +50,9 @@ export class Members {
|
||||
teamRole: [1, Validators.required],
|
||||
});
|
||||
|
||||
protected readonly canManage = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'memberManage'),
|
||||
);
|
||||
|
||||
protected readonly players = computed(() => {
|
||||
const query = this.search().trim().toLocaleLowerCase('de');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthApi } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
@@ -32,8 +32,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
})
|
||||
export class Invite {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
protected readonly team = this.teamStore.team;
|
||||
@@ -45,15 +45,9 @@ export class Invite {
|
||||
protected readonly availablePlayers = computed(() =>
|
||||
(this.team()?.players ?? []).filter((player) => player.active && !player.user),
|
||||
);
|
||||
protected readonly canInvite = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) > 2,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canInvite = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'invite'),
|
||||
);
|
||||
|
||||
protected generateLink(): void {
|
||||
const team = this.team();
|
||||
|
||||
@@ -54,6 +54,17 @@
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></a
|
||||
>
|
||||
@if (canManagePermissions()) {
|
||||
<a routerLink="permissions"
|
||||
><mat-card
|
||||
><mat-icon>admin_panel_settings</mat-icon>
|
||||
<div>
|
||||
<strong>Berechtigungen</strong><span>Festlegen, wer was im Team darf</span>
|
||||
</div>
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></a
|
||||
>
|
||||
}
|
||||
</section>
|
||||
|
||||
<mat-card class="account-card">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { More } from './more';
|
||||
|
||||
@Component({ template: '' })
|
||||
@@ -23,6 +24,7 @@ describe('More', () => {
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
{ provide: TeamStore, useValue: { team: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
@@ -56,6 +58,7 @@ describe('More', () => {
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
|
||||
{ provide: TeamStore, useValue: { team: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
@@ -63,4 +66,49 @@ describe('More', () => {
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Anleitung für Verantwortliche');
|
||||
});
|
||||
|
||||
it('shows the permissions link only to a team manager', async () => {
|
||||
const managerTeam = {
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'a',
|
||||
balance: 0,
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
balance: 0,
|
||||
active: true,
|
||||
teamRole: { id: 3 },
|
||||
user: { id: 42 },
|
||||
},
|
||||
],
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [More],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({
|
||||
id: 42,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
email: 'a@b.de',
|
||||
role: { id: 2 },
|
||||
}),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
|
||||
{ provide: TeamStore, useValue: { team: signal(managerTeam) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Berechtigungen');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
import { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-more',
|
||||
@@ -15,9 +17,14 @@ import { HelpAccessService } from '../../../core/help/help-access';
|
||||
export class More {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
protected readonly user = this.authStore.currentUser;
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
protected readonly canManagePermissions = computed(() =>
|
||||
this.permissions.canDo(this.teamStore.team(), 'memberManage'),
|
||||
);
|
||||
|
||||
protected logout(): void {
|
||||
this.authStore.clearSession();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { PenaltyApi } from '../../../../core/team/penalty-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Penalty } from '../../../../models/penalty.model';
|
||||
@@ -41,11 +41,11 @@ registerLocaleData(localeDe);
|
||||
styleUrl: './penalties.scss',
|
||||
})
|
||||
export class Penalties {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly penaltyApi = inject(PenaltyApi);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
@@ -86,26 +86,12 @@ export class Penalties {
|
||||
],
|
||||
],
|
||||
});
|
||||
protected readonly canManage = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) =>
|
||||
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canBook = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) =>
|
||||
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'penaltyManage'),
|
||||
);
|
||||
protected readonly canBook = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'transactionCreate'),
|
||||
);
|
||||
protected readonly filteredPenalties = computed(() => {
|
||||
const query = this.search().trim().toLocaleLowerCase('de');
|
||||
return this.penalties().filter((penalty) =>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
|
||||
<header>
|
||||
<p class="eyebrow">Team</p>
|
||||
<h1>Berechtigungen</h1>
|
||||
<p>Lege pro Aktion fest, ab welcher Rolle Mitglieder sie ausführen dürfen.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Berechtigungen bewusst einstellen"
|
||||
[hints]="[
|
||||
'Änderungen wirken sofort für alle Mitglieder des Teams.',
|
||||
'Globale Administratoren dürfen unabhängig von dieser Einstellung immer alles.',
|
||||
]"
|
||||
sectionId="roles"
|
||||
/>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state"><mat-spinner diameter="38" /><span>Berechtigungen werden geladen …</span></div>
|
||||
} @else if (loadFailed()) {
|
||||
<div class="state">
|
||||
<mat-icon>cloud_off</mat-icon><strong>Berechtigungen konnten nicht geladen werden.</strong>
|
||||
<button mat-stroked-button type="button" (click)="retry()">Erneut versuchen</button>
|
||||
</div>
|
||||
} @else if (values(); as current) {
|
||||
@if (!canManage()) {
|
||||
<mat-card class="hint-card">
|
||||
<mat-icon>lock</mat-icon>
|
||||
<span>Nur Kapitän, Kassenwart oder Trainer können Berechtigungen ändern.</span>
|
||||
</mat-card>
|
||||
}
|
||||
<div class="permission-list">
|
||||
@for (field of fields; track field.key) {
|
||||
<mat-card class="permission-row">
|
||||
<span class="permission-row__label">{{ field.label }}</span>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Mindestrolle</mat-label>
|
||||
<mat-select
|
||||
[value]="current[field.key]"
|
||||
[disabled]="!canManage() || saving()"
|
||||
(selectionChange)="changeRole(field.key, $event.value)"
|
||||
>
|
||||
@for (option of roleOptions; track option.id) {
|
||||
<mat-option [value]="option.id">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin: 18px 0 28px;
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: clamp(2rem, 4vw, 3rem);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 6px;
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.state {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.permission-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.permission-row__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-row mat-form-field {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
:host {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.permission-row mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { TeamPermissionsApi } from '../../../../core/team/team-permissions-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Permissions } from './permissions';
|
||||
|
||||
describe('Permissions', () => {
|
||||
const permissions = {
|
||||
transactionCreateMinRole: 2,
|
||||
transactionReverseMinRole: 2,
|
||||
inviteMinRole: 3,
|
||||
memberManageMinRole: 3,
|
||||
penaltyManageMinRole: 3,
|
||||
publicAccessManageMinRole: 3,
|
||||
};
|
||||
|
||||
const managerTeam = {
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'a',
|
||||
balance: 0,
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
balance: 0,
|
||||
active: true,
|
||||
teamRole: { id: 3 },
|
||||
user: { id: 42 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function setup(team = managerTeam) {
|
||||
const getPermissions = vi.fn(() => of(permissions));
|
||||
const updatePermissions = vi.fn(() => of({ ...permissions, inviteMinRole: 4 }));
|
||||
const refreshTeam = vi.fn();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Permissions],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({ id: 42, email: null, firstName: null, lastName: null, role: { id: 2 } }),
|
||||
},
|
||||
},
|
||||
{ provide: TeamStore, useValue: { team: signal(team), refreshTeam } },
|
||||
{ provide: TeamPermissionsApi, useValue: { getPermissions, updatePermissions } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Permissions);
|
||||
fixture.detectChanges();
|
||||
return { fixture, component: fixture.componentInstance, getPermissions, updatePermissions, refreshTeam };
|
||||
}
|
||||
|
||||
it('loads and displays the configured minimum roles for a manager', async () => {
|
||||
const { fixture, component, getPermissions } = await setup();
|
||||
|
||||
expect(getPermissions).toHaveBeenCalledWith(5);
|
||||
expect(fixture.nativeElement.textContent).toContain('Mitglieder einladen');
|
||||
expect(component['canManage']()).toBe(true);
|
||||
});
|
||||
|
||||
it('saves a changed minimum role and refreshes the team', async () => {
|
||||
const { component, updatePermissions, refreshTeam } = await setup();
|
||||
|
||||
component['changeRole']('inviteMinRole', 4);
|
||||
|
||||
expect(updatePermissions).toHaveBeenCalledWith(5, { inviteMinRole: 4 });
|
||||
expect(refreshTeam).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores changes from a non-manager', async () => {
|
||||
const playerTeam = {
|
||||
...managerTeam,
|
||||
players: [{ ...managerTeam.players[0], teamRole: { id: 1 } }],
|
||||
};
|
||||
const { component, updatePermissions } = await setup(playerTeam);
|
||||
|
||||
component['changeRole']('inviteMinRole', 4);
|
||||
|
||||
expect(updatePermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error state when loading fails', async () => {
|
||||
const refreshTeam = vi.fn();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Permissions],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({ id: 42, email: null, firstName: null, lastName: null, role: { id: 2 } }),
|
||||
},
|
||||
},
|
||||
{ provide: TeamStore, useValue: { team: signal(managerTeam), refreshTeam } },
|
||||
{
|
||||
provide: TeamPermissionsApi,
|
||||
useValue: {
|
||||
getPermissions: vi.fn(() => throwError(() => new Error('fail'))),
|
||||
updatePermissions: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Permissions);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('konnten nicht geladen werden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
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 { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { TeamPermissionsApi } from '../../../../core/team/team-permissions-api';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { TeamPermissions } from '../../../../models/team-permissions.model';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
interface PermissionField {
|
||||
key: keyof TeamPermissions;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const FIELDS: PermissionField[] = [
|
||||
{ key: 'transactionCreateMinRole', label: 'Buchung erfassen' },
|
||||
{ key: 'transactionReverseMinRole', label: 'Buchung stornieren' },
|
||||
{ key: 'inviteMinRole', label: 'Mitglieder einladen' },
|
||||
{ key: 'memberManageMinRole', label: 'Mitglieder verwalten' },
|
||||
{ key: 'penaltyManageMinRole', label: 'Strafenkatalog verwalten' },
|
||||
{ key: 'publicAccessManageMinRole', label: 'Öffentliche Freigabe verwalten' },
|
||||
];
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ id: 1, label: 'Spieler' },
|
||||
{ id: 2, label: '2. Kassenwart' },
|
||||
{ id: 3, label: 'Kapitän' },
|
||||
{ id: 4, label: 'Kassenwart' },
|
||||
{ id: 5, label: 'Trainer' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-permissions',
|
||||
imports: [
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
ContextHelp,
|
||||
],
|
||||
templateUrl: './permissions.html',
|
||||
styleUrl: './permissions.scss',
|
||||
})
|
||||
export class Permissions {
|
||||
private readonly api = inject(TeamPermissionsApi);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
|
||||
protected readonly team = this.teamStore.team;
|
||||
protected readonly fields = FIELDS;
|
||||
protected readonly roleOptions = ROLE_OPTIONS;
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly loadFailed = signal(false);
|
||||
protected readonly values = signal<TeamPermissions | null>(null);
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'memberManage'),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const teamId = this.team()?.id;
|
||||
if (teamId && teamId !== this.loadedTeamId) {
|
||||
this.loadedTeamId = teamId;
|
||||
this.load(teamId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected changeRole(field: keyof TeamPermissions, roleId: number): void {
|
||||
const teamId = this.team()?.id;
|
||||
const current = this.values();
|
||||
if (!this.canManage() || !teamId || !current || this.saving() || current[field] === roleId) {
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
this.api.updatePermissions(teamId, { [field]: roleId }).subscribe({
|
||||
next: (updated) => {
|
||||
this.saving.set(false);
|
||||
this.values.set(updated);
|
||||
this.teamStore.refreshTeam();
|
||||
this.snackBar.open('Berechtigung wurde gespeichert.', undefined, { duration: 4000 });
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Berechtigung konnte nicht gespeichert werden.', undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected retry(): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (teamId) this.load(teamId);
|
||||
}
|
||||
|
||||
private load(teamId: number): void {
|
||||
this.loading.set(true);
|
||||
this.loadFailed.set(false);
|
||||
this.api.getPermissions(teamId).subscribe({
|
||||
next: (values) => {
|
||||
this.loading.set(false);
|
||||
this.values.set(values);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loadFailed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { PublicAccessApi } from '../../../../core/team/public-access-api';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { PublicAccessStatus } from '../../../../models/public-access.model';
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
@@ -33,8 +33,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
})
|
||||
export class PublicAccess {
|
||||
private readonly api = inject(PublicAccessApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
@@ -49,16 +49,9 @@ export class PublicAccess {
|
||||
const status = this.status();
|
||||
return status?.enabled && status.token ? `${location.origin}/t/${status.token}` : '';
|
||||
});
|
||||
protected readonly canManage = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) =>
|
||||
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'publicAccessManage'),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export type TeamPermissionAction =
|
||||
| 'transactionCreate'
|
||||
| 'transactionReverse'
|
||||
| 'invite'
|
||||
| 'memberManage'
|
||||
| 'penaltyManage'
|
||||
| 'publicAccessManage';
|
||||
|
||||
export interface TeamPermissions {
|
||||
transactionCreateMinRole: number;
|
||||
transactionReverseMinRole: number;
|
||||
inviteMinRole: number;
|
||||
memberManageMinRole: number;
|
||||
penaltyManageMinRole: number;
|
||||
publicAccessManageMinRole: number;
|
||||
}
|
||||
|
||||
export type UpdateTeamPermissions = Partial<TeamPermissions>;
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Player } from './player.model';
|
||||
|
||||
export interface TeamSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -7,4 +12,5 @@ export interface Team {
|
||||
balance: number;
|
||||
outstanding?: number;
|
||||
players?: Player[];
|
||||
settings?: TeamSetting[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user