Skip to content

Commit 83a9726

Browse files
fix[frontend](federation_service): added team management
1 parent 8e5f335 commit 83a9726

12 files changed

Lines changed: 690 additions & 4 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<div class="modal-header">
2+
<h5 class="modal-title">Team management</h5>
3+
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
4+
<span aria-hidden="true">&times;</span>
5+
</button>
6+
</div>
7+
8+
<div class="modal-body federation-team-modal">
9+
<div class="d-flex justify-content-between align-items-center mb-3 federation-team-toolbar">
10+
<div class="flex-grow-1 mr-3">
11+
<input type="text" class="form-control" placeholder="Search by name, login or email"
12+
[ngModel]="searchTerm" (ngModelChange)="onSearchChange($event)">
13+
</div>
14+
<button type="button" class="btn utm-button utm-button-primary"
15+
(click)="openInvite()">
16+
<i class="icon-plus3 mr-1"></i>
17+
Invite user
18+
</button>
19+
</div>
20+
21+
<div *ngIf="errorMessage" class="alert alert-danger">{{ errorMessage }}</div>
22+
23+
<div class="table-responsive">
24+
<table class="table table-sm federation-team-table">
25+
<thead>
26+
<tr>
27+
<th>Name</th>
28+
<th>Login</th>
29+
<th>Email</th>
30+
<th>Status</th>
31+
<th>2FA</th>
32+
<th class="text-right">Actions</th>
33+
</tr>
34+
</thead>
35+
<tbody>
36+
<tr *ngIf="loading">
37+
<td colspan="6" class="text-center text-muted py-3">Loading...</td>
38+
</tr>
39+
<tr *ngIf="!loading && users.length === 0">
40+
<td colspan="6" class="text-center text-muted py-3">No team members found.</td>
41+
</tr>
42+
<tr *ngFor="let user of users; trackBy: trackById">
43+
<td>{{ displayName(user) }}</td>
44+
<td>{{ user.login }}</td>
45+
<td>{{ user.email || '—' }}</td>
46+
<td>
47+
<span *ngIf="user.pending" class="badge badge-warning">Pending</span>
48+
<span *ngIf="!user.pending && user.activated" class="badge badge-success">Active</span>
49+
<span *ngIf="!user.pending && !user.activated" class="badge badge-secondary">Inactive</span>
50+
</td>
51+
<td>
52+
<span *ngIf="user.tfa_enabled" class="badge badge-info">On</span>
53+
<span *ngIf="!user.tfa_enabled" class="text-muted"></span>
54+
</td>
55+
<td class="text-right federation-team-actions">
56+
<button type="button" class="btn btn-sm btn-link p-1"
57+
title="Edit" (click)="openEdit(user)"
58+
[disabled]="pendingActionId === user.id">
59+
<i class="icon-pencil"></i>
60+
</button>
61+
<button *ngIf="user.pending" type="button" class="btn btn-sm btn-link p-1"
62+
title="Resend invite" (click)="resendInvite(user)"
63+
[disabled]="pendingActionId === user.id">
64+
<i class="icon-envelop3"></i>
65+
</button>
66+
<button *ngIf="user.tfa_enabled" type="button" class="btn btn-sm btn-link p-1 text-warning"
67+
title="Disable 2FA" (click)="disableTfa(user)"
68+
[disabled]="pendingActionId === user.id">
69+
<i class="icon-shield-cross"></i>
70+
</button>
71+
<button type="button" class="btn btn-sm btn-link p-1"
72+
[class.text-danger]="user.activated"
73+
[class.text-success]="!user.activated"
74+
[title]="user.activated ? 'Deactivate' : 'Activate'"
75+
(click)="toggleActivation(user)"
76+
[disabled]="pendingActionId === user.id">
77+
<i [class.icon-cancel-circle2]="user.activated"
78+
[class.icon-checkmark-circle]="!user.activated"></i>
79+
</button>
80+
</td>
81+
</tr>
82+
</tbody>
83+
</table>
84+
</div>
85+
86+
<div *ngIf="pageInfo && pageInfo.total_items > 0"
87+
class="d-flex justify-content-between align-items-center mt-2">
88+
<small class="text-muted">
89+
Page {{ pageInfo.page }} of {{ pageInfo.total_pages }}
90+
({{ pageInfo.total_items }} total)
91+
</small>
92+
<div>
93+
<button type="button" class="btn btn-sm utm-button utm-button-grey mr-2"
94+
(click)="prevPage()" [disabled]="!pageInfo.has_prev || loading">
95+
Previous
96+
</button>
97+
<button type="button" class="btn btn-sm utm-button utm-button-grey"
98+
(click)="nextPage()" [disabled]="!pageInfo.has_next || loading">
99+
Next
100+
</button>
101+
</div>
102+
</div>
103+
</div>
104+
105+
<div class="modal-footer">
106+
<button type="button" class="btn utm-button utm-button-grey" (click)="activeModal.close()">Close</button>
107+
</div>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
.federation-team-modal {
2+
.federation-team-toolbar {
3+
gap: 1rem;
4+
}
5+
6+
.federation-team-table {
7+
th {
8+
font-size: 0.75rem;
9+
text-transform: uppercase;
10+
letter-spacing: 0.03em;
11+
color: #6c757d;
12+
}
13+
14+
td {
15+
vertical-align: middle;
16+
}
17+
18+
.federation-team-actions {
19+
white-space: nowrap;
20+
21+
.btn {
22+
margin-left: 0.15rem;
23+
}
24+
}
25+
}
26+
27+
.badge {
28+
font-weight: 500;
29+
}
30+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import {Component, OnDestroy, OnInit} from '@angular/core';
2+
import {NgbActiveModal, NgbModal} from '@ng-bootstrap/ng-bootstrap';
3+
import {Subject} from 'rxjs';
4+
import {debounceTime, distinctUntilChanged, takeUntil} from 'rxjs/operators';
5+
import {UtmToastService} from '../../../shared/alert/utm-toast.service';
6+
import {
7+
ModalConfirmationComponent
8+
} from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component';
9+
import {TeamUser, TeamUserPageInfo} from '../../domain/team-user.model';
10+
import {FederationTeamService} from '../../services/federation-team.service';
11+
import {TeamUserFormModalComponent} from '../team-user-form-modal/team-user-form-modal.component';
12+
13+
const DEFAULT_PAGE_SIZE = 20;
14+
15+
@Component({
16+
selector: 'app-federation-team-management-modal',
17+
templateUrl: './team-management-modal.component.html',
18+
styleUrls: ['./team-management-modal.component.scss']
19+
})
20+
export class TeamManagementModalComponent implements OnInit, OnDestroy {
21+
users: TeamUser[] = [];
22+
pageInfo: TeamUserPageInfo | null = null;
23+
loading = false;
24+
errorMessage: string | null = null;
25+
searchTerm = '';
26+
pendingActionId: number | null = null;
27+
28+
private currentPage = 1;
29+
private readonly pageSize = DEFAULT_PAGE_SIZE;
30+
private searchSubject = new Subject<string>();
31+
private destroy$ = new Subject<void>();
32+
33+
constructor(public activeModal: NgbActiveModal,
34+
private teamService: FederationTeamService,
35+
private modalService: NgbModal,
36+
private toast: UtmToastService) {}
37+
38+
ngOnInit(): void {
39+
this.searchSubject
40+
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
41+
.subscribe(() => {
42+
this.currentPage = 1;
43+
this.load();
44+
});
45+
this.load();
46+
}
47+
48+
ngOnDestroy(): void {
49+
this.destroy$.next();
50+
this.destroy$.complete();
51+
}
52+
53+
onSearchChange(value: string): void {
54+
this.searchTerm = value;
55+
this.searchSubject.next(value);
56+
}
57+
58+
prevPage(): void {
59+
if (!this.pageInfo || !this.pageInfo.has_prev || this.loading) {
60+
return;
61+
}
62+
this.currentPage = this.pageInfo.page - 1;
63+
this.load();
64+
}
65+
66+
nextPage(): void {
67+
if (!this.pageInfo || !this.pageInfo.has_next || this.loading) {
68+
return;
69+
}
70+
this.currentPage = this.pageInfo.page + 1;
71+
this.load();
72+
}
73+
74+
openInvite(): void {
75+
const ref = this.modalService.open(TeamUserFormModalComponent, {
76+
centered: true,
77+
backdrop: 'static'
78+
});
79+
ref.componentInstance.saved.subscribe(() => {
80+
ref.close();
81+
this.toast.showSuccessBottom('Invitation sent.');
82+
this.currentPage = 1;
83+
this.load();
84+
});
85+
}
86+
87+
openEdit(user: TeamUser): void {
88+
const ref = this.modalService.open(TeamUserFormModalComponent, {
89+
centered: true,
90+
backdrop: 'static'
91+
});
92+
ref.componentInstance.user = user;
93+
ref.componentInstance.saved.subscribe(() => {
94+
ref.close();
95+
this.toast.showSuccessBottom('Team member updated.');
96+
this.load();
97+
});
98+
}
99+
100+
resendInvite(user: TeamUser): void {
101+
if (this.pendingActionId !== null) {
102+
return;
103+
}
104+
this.pendingActionId = user.id;
105+
this.teamService.resendInvite(user.id).subscribe({
106+
next: () => {
107+
this.pendingActionId = null;
108+
this.toast.showSuccessBottom('Invitation re-sent.');
109+
},
110+
error: err => {
111+
this.pendingActionId = null;
112+
this.toast.showError('Resend invite failed', this.extractError(err));
113+
}
114+
});
115+
}
116+
117+
disableTfa(user: TeamUser): void {
118+
if (this.pendingActionId !== null) {
119+
return;
120+
}
121+
const ref = this.modalService.open(ModalConfirmationComponent, {
122+
backdrop: 'static',
123+
centered: true
124+
});
125+
ref.componentInstance.header = 'Disable 2FA';
126+
ref.componentInstance.message = `Disable 2FA for ${this.displayName(user)}?`;
127+
ref.componentInstance.confirmBtnText = 'Disable';
128+
ref.componentInstance.confirmBtnIcon = 'icon-shield-cross';
129+
ref.componentInstance.confirmBtnType = 'delete';
130+
ref.componentInstance.textDisplay =
131+
'The user will be able to sign in without a 2FA code until they re-enroll.';
132+
ref.componentInstance.textType = 'warning';
133+
ref.result.then(() => this.runDisableTfa(user), () => undefined);
134+
}
135+
136+
toggleActivation(user: TeamUser): void {
137+
if (this.pendingActionId !== null) {
138+
return;
139+
}
140+
if (user.activated) {
141+
this.confirmAndDeactivate(user);
142+
} else {
143+
this.activate(user);
144+
}
145+
}
146+
147+
trackById(_index: number, item: TeamUser): number {
148+
return item.id;
149+
}
150+
151+
displayName(user: TeamUser): string {
152+
const composed = [user.first_name, user.last_name].filter(part => !!part).join(' ').trim();
153+
return composed || user.login;
154+
}
155+
156+
private confirmAndDeactivate(user: TeamUser): void {
157+
const ref = this.modalService.open(ModalConfirmationComponent, {
158+
backdrop: 'static',
159+
centered: true
160+
});
161+
ref.componentInstance.header = 'Deactivate team member';
162+
ref.componentInstance.message = `Deactivate ${this.displayName(user)}?`;
163+
ref.componentInstance.confirmBtnText = 'Deactivate';
164+
ref.componentInstance.confirmBtnIcon = 'icon-cancel-circle2';
165+
ref.componentInstance.confirmBtnType = 'delete';
166+
ref.componentInstance.textDisplay = 'The user will lose access to this federation server.';
167+
ref.componentInstance.textType = 'warning';
168+
ref.result.then(() => this.runDeactivate(user), () => undefined);
169+
}
170+
171+
private runDeactivate(user: TeamUser): void {
172+
this.pendingActionId = user.id;
173+
this.teamService.deactivate(user.id).subscribe({
174+
next: () => {
175+
this.pendingActionId = null;
176+
this.toast.showSuccessBottom('Team member deactivated.');
177+
this.load();
178+
},
179+
error: err => {
180+
this.pendingActionId = null;
181+
this.toast.showError('Deactivation failed', this.extractError(err));
182+
}
183+
});
184+
}
185+
186+
private activate(user: TeamUser): void {
187+
this.pendingActionId = user.id;
188+
this.teamService.update(user.id, {
189+
email: user.email || '',
190+
first_name: user.first_name || '',
191+
last_name: user.last_name || '',
192+
activated: true
193+
}).subscribe({
194+
next: () => {
195+
this.pendingActionId = null;
196+
this.toast.showSuccessBottom('Team member activated.');
197+
this.load();
198+
},
199+
error: err => {
200+
this.pendingActionId = null;
201+
this.toast.showError('Activation failed', this.extractError(err));
202+
}
203+
});
204+
}
205+
206+
private runDisableTfa(user: TeamUser): void {
207+
this.pendingActionId = user.id;
208+
this.teamService.disableTfa(user.id).subscribe({
209+
next: () => {
210+
this.pendingActionId = null;
211+
this.toast.showSuccessBottom('2FA disabled.');
212+
this.load();
213+
},
214+
error: err => {
215+
this.pendingActionId = null;
216+
this.toast.showError('Disable 2FA failed', this.extractError(err));
217+
}
218+
});
219+
}
220+
221+
private load(): void {
222+
this.loading = true;
223+
this.errorMessage = null;
224+
this.teamService.list({
225+
page: this.currentPage,
226+
page_size: this.pageSize,
227+
search: this.searchTerm ? this.searchTerm.trim() : undefined
228+
}).subscribe({
229+
next: response => {
230+
this.loading = false;
231+
this.users = response.data || [];
232+
this.pageInfo = response.page_info;
233+
},
234+
error: err => {
235+
this.loading = false;
236+
this.users = [];
237+
this.pageInfo = null;
238+
this.errorMessage = this.extractError(err);
239+
}
240+
});
241+
}
242+
243+
private extractError(err: {error?: {message?: string}}): string {
244+
if (err && err.error && err.error.message) {
245+
return err.error.message;
246+
}
247+
return 'Operation failed. Please try again.';
248+
}
249+
}

0 commit comments

Comments
 (0)