Skip to content

Commit 0be442d

Browse files
committed
feat(user-management): implement username update functionality with validation
1 parent e5414d1 commit 0be442d

6 files changed

Lines changed: 189 additions & 18 deletions

File tree

src/app/features/backbone/login/user-management.service.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { Router } from '@angular/router';
44
import {
55
from,
66
NEVER,
7+
Observable,
78
ReplaySubject,
8-
Subject
9+
Subject,
10+
throwError
911
} from 'rxjs';
1012
import {
1113
catchError,
1214
filter,
15+
map,
1316
startWith,
1417
switchMap,
1518
take,
@@ -60,6 +63,9 @@ export class UserManagementService extends SubManager {
6063
/** Emits when handling OAuth callback after redirect */
6164
public handleOAuthCallbackAction$ = new Subject<void>();
6265

66+
/** Emits when user wants to update their username */
67+
public updateUsernameAction$ = new Subject<string>();
68+
6369
// Track current user ID for cross-tab sync comparison
6470
private currentUserId: string | undefined = undefined;
6571

@@ -83,6 +89,7 @@ export class UserManagementService extends SubManager {
8389
this.initializeResetPasswordHandler();
8490
this.initializeSSOLoginHandler();
8591
this.initializeOAuthCallbackHandler();
92+
this.initializeUpdateUsernameHandler();
8693
}
8794

8895
private initializeUserBoxHandler(): void {
@@ -358,4 +365,59 @@ export class UserManagementService extends SubManager {
358365
}
359366
});
360367
}
368+
369+
private initializeUpdateUsernameHandler(): void {
370+
this.updateUsernameAction$.pipe(
371+
withLatestFrom(this.loggedUserFullProfile$),
372+
filter(([_, profile]) => !!profile),
373+
switchMap(([newUsername, profile]) =>
374+
this.backend.updateUsername$(profile!.id, newUsername).pipe(
375+
catchError((error) => {
376+
const errorMessage = error?.message || SharedConstants.messages.operationFailed;
377+
SharedConstants.errorCustom(this.snackBar, errorMessage);
378+
return NEVER;
379+
})
380+
)
381+
),
382+
// Refresh the user profile after successful update
383+
switchMap(() => this.backend.getRichUserSession$()),
384+
filter(x => !!x),
385+
tap(updatedProfile => {
386+
this._loggedUserFullProfile$.next(updatedProfile);
387+
SharedConstants.successCustom(this.snackBar, 'Username updated successfully!');
388+
}),
389+
takeUntil(this.destroy$)
390+
).subscribe();
391+
}
392+
393+
/**
394+
* Updates the username for the currently logged-in user
395+
* Works for both email/password and SSO users
396+
*
397+
* @param newUsername - The new username to set
398+
* @returns Observable that completes when username is updated
399+
*/
400+
updateUsername$(newUsername: string): Observable<void> {
401+
return this.loggedUserFullProfile$.pipe(
402+
take(1),
403+
filter((profile): profile is RichUserModel => !!profile),
404+
switchMap(profile =>
405+
this.backend.updateUsername$(profile.id, newUsername).pipe(
406+
catchError((error) => {
407+
const errorMessage = error?.message || SharedConstants.messages.operationFailed;
408+
SharedConstants.errorCustom(this.snackBar, errorMessage);
409+
return throwError(() => error);
410+
})
411+
)
412+
),
413+
// Refresh the user profile after successful update
414+
switchMap(() => this.backend.getRichUserSession$()),
415+
filter(x => !!x),
416+
tap(updatedProfile => {
417+
this._loggedUserFullProfile$.next(updatedProfile);
418+
SharedConstants.successCustom(this.snackBar, 'Username updated successfully!');
419+
}),
420+
map(() => void 0)
421+
);
422+
}
361423
}

src/app/features/backbone/user-management/user-management.component.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
fxFlex="grow"
2828
>{{ bag.userProfile.username }}
2929
</app-label-value-showcase>
30-
<app-brand-primary-button disabled="true"
31-
matTooltip="Sorry, this feature is not yet implemented, but will be soon, reach out to us via mail if you need help with this"
32-
theme="warning"
30+
<app-brand-primary-button
31+
(click$)="changeUsername()"
32+
matTooltip="Change your display name"
33+
theme="primary"
3334
>Change
3435
</app-brand-primary-button>
3536
</div>

src/app/features/backbone/user-management/user-management.component.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@ import {
44
Input,
55
OnInit
66
} from '@angular/core';
7+
import {
8+
FormControl,
9+
Validators
10+
} from '@angular/forms';
11+
import { MatDialog } from '@angular/material/dialog';
712
import { UserManagementService } from 'src/app/features/backbone/login/user-management.service';
813
import { SeoAndUtilsService } from '../seo-and-utils.service';
914
import {
1015
filter,
1116
take
1217
} from 'rxjs/operators';
1318
import { RichUserModel } from 'src/app/features/backend/supabase.service';
19+
import {
20+
InputDialogComponent,
21+
InputDialogDataInModel,
22+
InputDialogDataOutModel
23+
} from 'src/app/shared-interproject/dialogs/input-dialog/input-dialog.component';
24+
import { FormTypes } from 'src/app/shared-interproject/components/@smart/mat-form-entity/form-element-models';
1425

1526

1627
@Component({
@@ -25,7 +36,8 @@ export class UserManagementComponent implements OnInit {
2536

2637
constructor(
2738
public userManagementService: UserManagementService,
28-
readonly seoAndUtilsService: SeoAndUtilsService
39+
readonly seoAndUtilsService: SeoAndUtilsService,
40+
private dialog: MatDialog
2941
) { }
3042

3143
ngOnInit(): void {
@@ -37,6 +49,44 @@ export class UserManagementComponent implements OnInit {
3749
}
3850
}
3951

52+
changeUsername(): void {
53+
this.userManagementService.loggedUserFullProfile$
54+
.pipe(
55+
filter((userProfile): userProfile is RichUserModel => userProfile !== undefined),
56+
take(1)
57+
)
58+
.subscribe((userProfile) => {
59+
const usernameControl = new FormControl(userProfile.username, [
60+
Validators.required,
61+
Validators.minLength(3),
62+
Validators.maxLength(30),
63+
Validators.pattern(/^[a-zA-Z0-9_-]+$/)
64+
]);
65+
66+
const dialogData: InputDialogDataInModel = {
67+
title: 'Change Display Name',
68+
description: 'Enter your new display name (3-30 characters). Only letters, numbers, hyphens (-), and underscores (_) are allowed. No spaces.',
69+
control: usernameControl,
70+
type: FormTypes.TEXT,
71+
label: 'New Display Name'
72+
};
73+
74+
const dialogRef = this.dialog.open<InputDialogComponent, InputDialogDataInModel, InputDialogDataOutModel>(
75+
InputDialogComponent,
76+
{
77+
data: dialogData,
78+
width: '400px'
79+
}
80+
);
81+
82+
dialogRef.afterClosed().subscribe((result) => {
83+
if (result?.result && result.result !== userProfile.username) {
84+
this.userManagementService.updateUsername$(result.result).subscribe();
85+
}
86+
});
87+
});
88+
}
89+
4090
resetPassword(): void {
4191
this.userManagementService.loggedUserFullProfile$
4292
.pipe(

src/app/features/backbone/user-management/user-management.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { BrandPrimaryButtonModule } from 'src/app/shared-interproject/components
99
import { HeroContentCardModule } from 'src/app/shared-interproject/components/@visual/hero-content-card/hero-content-card.module';
1010
import { LabelValueShowcaseModule } from 'src/app/shared-interproject/components/@visual/label-value-showcase/label-value-showcase.module';
1111
import { ScreenWrapperModule } from 'src/app/shared-interproject/components/@visual/screen-wrapper/screen-wrapper.module';
12+
import { InputDialogModule } from 'src/app/shared-interproject/dialogs/input-dialog/input-dialog.module';
1213
import { MatCardModule } from "@angular/material/card";
14+
import { MatDialogModule } from "@angular/material/dialog";
1315
import { MatTooltipModule } from "@angular/material/tooltip";
1416

1517

@@ -43,6 +45,8 @@ import { MatTooltipModule } from "@angular/material/tooltip";
4345
ScreenWrapperModule,
4446
LabelValueShowcaseModule,
4547
MatTooltipModule,
48+
MatDialogModule,
49+
InputDialogModule,
4650
TimeagoModule
4751

4852
],

src/app/features/backend/supabase.service.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,57 @@ export class SupabaseService extends SubManager {
18101810
private isValidPassword(password: string): boolean {
18111811
return password.length >= 8; // Add more complexity checks if needed
18121812
}
1813+
1814+
/**
1815+
* Updates the username in the profiles table for the current user
1816+
* This works for both email/password and SSO users
1817+
*
1818+
* @param userId - The user ID to update
1819+
* @param newUsername - The new username to set
1820+
* @returns Observable that completes when username is updated
1821+
*/
1822+
updateUsername$(userId: string, newUsername: string): Observable<void> {
1823+
const trimmedUsername = newUsername.trim();
1824+
1825+
if (!trimmedUsername || trimmedUsername.length < 3) {
1826+
return throwError(() => new Error('Username must be at least 3 characters long.'));
1827+
}
1828+
1829+
if (trimmedUsername.length > 30) {
1830+
return throwError(() => new Error('Username must be 30 characters or less.'));
1831+
}
1832+
1833+
// Check if username contains only valid characters (alphanumeric, underscore, hyphen)
1834+
const validUsernameRegex = /^[a-zA-Z0-9_-]+$/;
1835+
if (!validUsernameRegex.test(trimmedUsername)) {
1836+
return throwError(() => new Error('Username can only contain letters, numbers, underscores, and hyphens.'));
1837+
}
1838+
1839+
return rxFrom(
1840+
this.supabase
1841+
.from(DbPaths.profiles)
1842+
.update({
1843+
username: trimmedUsername,
1844+
updated_at: new Date().toISOString()
1845+
})
1846+
.eq('id', userId)
1847+
).pipe(
1848+
map((response) => {
1849+
if (response.error) {
1850+
// Check for unique constraint violation
1851+
if (response.error.code === '23505' || response.error.message?.includes('unique')) {
1852+
throw new Error('This username is already taken. Please choose another one.');
1853+
}
1854+
throw new Error(response.error.message || 'Failed to update username.');
1855+
}
1856+
return void 0;
1857+
}),
1858+
catchError((error) => {
1859+
console.error('Username update failed:', error);
1860+
return throwError(() => error);
1861+
})
1862+
);
1863+
}
18131864
}
18141865

18151866
class PasswordResetError extends Error {

src/app/shared-interproject/components/@smart/mat-form-entity/app-form-utils.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { UntypedFormControl } from '@angular/forms';
2-
import { DomSanitizer } from "@angular/platform-browser";
3-
import { Observable } from "rxjs";
2+
import { DomSanitizer } from "@angular/platform-browser";
3+
import { Observable } from "rxjs";
44
import {
55
filter,
66
map
7-
} from "rxjs/operators";
8-
import DOMPurify from "dompurify";
7+
} from "rxjs/operators";
8+
import DOMPurify from "dompurify";
99

1010

1111
export class AppFormUtils {
@@ -18,15 +18,16 @@ export class AppFormUtils {
1818
input.hasError(ErrorCodes.form.errorCode.minlength) ? ErrorMessages.form.error_minLength :
1919
input.hasError(ErrorCodes.form.errorCode.maxlength) ? ErrorMessages.form.error_maxLength :
2020
input.hasError(ErrorCodes.form.errorCode.max) ? ErrorMessages.form.error_max :
21-
input.hasError(ErrorCodes.form.errorCode.custom.codeNotValid) ? ErrorMessages.form.error_codeNotValid :
22-
input.hasError(ErrorCodes.form.errorCode.custom.lessThanOneElement) ? ErrorMessages.form.error_lessThanOneElement :
23-
input.hasError(ErrorCodes.form.errorCode.custom.notInOptions) ? ErrorMessages.form.error_notInOptions :
24-
input.hasError(ErrorCodes.form.errorCode.custom.numberNot) ? ErrorMessages.form.error_numberNot :
25-
input.hasError(ErrorCodes.form.errorCode.custom.numberNotInteger) ? ErrorMessages.form.error_numberNotInteger :
26-
input.hasError(ErrorCodes.form.errorCode.custom.numberNotPositiveInteger) ? ErrorMessages.form.error_numberNotPositiveInteger :
27-
input.hasError(ErrorCodes.form.errorCode.custom.numberBiggerThanInterval) ? ErrorMessages.form.error_numberBiggerThanInterval :
28-
input.hasError(ErrorCodes.form.errorCode.custom.doesNotContainHttps) ? ErrorMessages.form.error_doesNotContainHttps :
29-
input.hasError(ErrorCodes.form.errorCode.min) ? ErrorMessages.form.error_min : noErrorMessageChar;
21+
input.hasError(ErrorCodes.form.errorCode.pattern) ? ErrorMessages.form.error_pattern :
22+
input.hasError(ErrorCodes.form.errorCode.custom.codeNotValid) ? ErrorMessages.form.error_codeNotValid :
23+
input.hasError(ErrorCodes.form.errorCode.custom.lessThanOneElement) ? ErrorMessages.form.error_lessThanOneElement :
24+
input.hasError(ErrorCodes.form.errorCode.custom.notInOptions) ? ErrorMessages.form.error_notInOptions :
25+
input.hasError(ErrorCodes.form.errorCode.custom.numberNot) ? ErrorMessages.form.error_numberNot :
26+
input.hasError(ErrorCodes.form.errorCode.custom.numberNotInteger) ? ErrorMessages.form.error_numberNotInteger :
27+
input.hasError(ErrorCodes.form.errorCode.custom.numberNotPositiveInteger) ? ErrorMessages.form.error_numberNotPositiveInteger :
28+
input.hasError(ErrorCodes.form.errorCode.custom.numberBiggerThanInterval) ? ErrorMessages.form.error_numberBiggerThanInterval :
29+
input.hasError(ErrorCodes.form.errorCode.custom.doesNotContainHttps) ? ErrorMessages.form.error_doesNotContainHttps :
30+
input.hasError(ErrorCodes.form.errorCode.min) ? ErrorMessages.form.error_min : noErrorMessageChar;
3031

3132
}
3233

@@ -40,6 +41,7 @@ export class ErrorCodes {
4041
required: 'required',
4142
minlength: 'minlength',
4243
maxlength: 'maxlength',
44+
pattern: 'pattern',
4345

4446
custom: {
4547
codeNotValid: 'codeNotValid',
@@ -64,6 +66,7 @@ export class ErrorMessages {
6466
error_maxLength: 'Length over maximum',
6567
error_max: 'Value above the maximum',
6668
error_min: 'Value below minimum',
69+
error_pattern: 'Invalid format',
6770
error_lessThanOneElement: 'At least one element is required',
6871
error_codeNotValid: 'The code entered is invalid',
6972
error_notInOptions: 'Value not present in options',

0 commit comments

Comments
 (0)