berechtigungen

This commit is contained in:
Bastian Wagner
2026-08-03 10:16:42 +02:00
parent 6eeaa3a624
commit 27ab505aba
50 changed files with 1443 additions and 217 deletions

View File

@@ -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: () =>

View File

@@ -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 });
});
});

View File

@@ -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,
);
}
}

View File

@@ -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);
});
});

View File

@@ -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];
}
}

View File

@@ -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 #')
);

View File

@@ -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');

View File

@@ -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();

View File

@@ -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">

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -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) =>

View File

@@ -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>
}

View File

@@ -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%;
}
}

View File

@@ -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');
});
});

View File

@@ -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);
},
});
}
}

View File

@@ -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(() => {

View File

@@ -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>;

View File

@@ -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[];
}