diff --git a/src/@seed/api/user/user.service.ts b/src/@seed/api/user/user.service.ts index b706b900..a9339080 100644 --- a/src/@seed/api/user/user.service.ts +++ b/src/@seed/api/user/user.service.ts @@ -1,8 +1,16 @@ +import type { HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' -import { distinctUntilChanged, ReplaySubject, switchMap, take, tap } from 'rxjs' -import type { CurrentUser, SetDefaultOrganizationResponse } from '@seed/api/user' +import { catchError, distinctUntilChanged, ReplaySubject, switchMap, take, tap, throwError } from 'rxjs' +import type { + CurrentUser, + GenerateApiKeyResponse, + PasswordUpdateRequest, + PasswordUpdateResponse, + SetDefaultOrganizationResponse, + UserUpdateRequest, +} from '@seed/api/user' @Injectable({ providedIn: 'root' }) export class UserService { @@ -42,4 +50,53 @@ export class UserService { }), ) } + + /** + * Update user + */ + updateUser(userId: number, params: UserUpdateRequest): Observable { + return this._httpClient.put(`api/v3/users/${userId}/`, params).pipe( + tap((user) => { + this._currentUser.next(user) + }), + catchError((error: HttpErrorResponse) => { + console.error('Error occurred while updating user:', error.error) + return this._currentUser + }), + ) + } + + /** + * Update user + */ + updatePassword(params: PasswordUpdateRequest): Observable { + return this.currentUser$.pipe( + take(1), + switchMap(({ id: userId }) => { + return this._httpClient.put(`api/v3/users/${userId}/set_password/`, params) + }), + tap(() => { + this.getCurrentUser().subscribe() + }), + catchError((error: HttpErrorResponse) => { + return throwError(() => error) + }), + ) + } + + /** + * Generate API Key + */ + generateApiKey(): Observable { + return this.currentUser$.pipe( + take(1), + switchMap(({ id: userId }) => { + return this._httpClient.post(`api/v3/users/${userId}/generate_api_key/`, {}) + }), + tap(() => { + // Refresh user info after changing the API key + this.getCurrentUser().subscribe() + }), + ) + } } diff --git a/src/@seed/api/user/user.types.ts b/src/@seed/api/user/user.types.ts index 4422482a..0dbfa332 100644 --- a/src/@seed/api/user/user.types.ts +++ b/src/@seed/api/user/user.types.ts @@ -19,6 +19,18 @@ export type CurrentUser = { is_ali_leaf: boolean; } +export type UserUpdateRequest = { + first_name: string; + last_name: string; + email: string; +} + +export type PasswordUpdateRequest = { + current_password: string; + password_1: string; + password_2: string; +} + export type SetDefaultOrganizationResponse = { status: string; user: { @@ -29,3 +41,12 @@ export type SetDefaultOrganizationResponse = { }; }; } + +export type GenerateApiKeyResponse = { + status: string; + api_key: string; +} + +export type PasswordUpdateResponse = { + status: string; +} diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 05a72901..e1503ba9 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,7 @@ import { AboutComponent } from './modules/main/about/about.component' import { ContactComponent } from './modules/main/contact/contact.component' import { DocumentationComponent } from './modules/main/documentation/documentation.component' import { HomeComponent } from './modules/main/home/home.component' +import { ProfileComponent } from './modules/profile/profile.component' const inventoryTypeMatcher = (segments: UrlSegment[]) => { if (segments.length === 1 && ['properties', 'taxlots'].includes(segments[0].path)) { @@ -60,6 +61,11 @@ export const appRoutes: Route[] = [ title: 'Dashboard', component: HomeComponent, }, + { + path: 'profile', + component: ProfileComponent, + loadChildren: () => import('app/modules/profile/profile.routes'), + }, { matcher: inventoryTypeMatcher, loadChildren: () => import('app/modules/inventory/inventory.routes'), diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 5df03392..ab007e67 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -32,8 +32,8 @@ export const authInterceptor = (req: HttpRequest, next: HttpHandlerFn): }) return next(newReq).pipe( - catchError(() => { - return throwError(() => new Error(`Failed request ${req.method} ${req.url}`)) + catchError((error: HttpErrorResponse) => { + return throwError(() => error) }), ) } else { diff --git a/src/app/layout/common/user/user.component.html b/src/app/layout/common/user/user.component.html index 13122e1d..af6b45a1 100644 --- a/src/app/layout/common/user/user.component.html +++ b/src/app/layout/common/user/user.component.html @@ -16,7 +16,7 @@ - diff --git a/src/app/layout/common/user/user.component.ts b/src/app/layout/common/user/user.component.ts index 84cdeb07..bd598205 100644 --- a/src/app/layout/common/user/user.component.ts +++ b/src/app/layout/common/user/user.component.ts @@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button' import { MatDividerModule } from '@angular/material/divider' import { MatIconModule } from '@angular/material/icon' import { MatMenuModule } from '@angular/material/menu' +import { Router } from '@angular/router' import { Subject, takeUntil } from 'rxjs' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' @@ -21,6 +22,7 @@ import { AuthService } from 'app/core/auth/auth.service' export class UserComponent implements OnInit, OnDestroy { private _authService = inject(AuthService) private _changeDetectorRef = inject(ChangeDetectorRef) + private _router = inject(Router) private _userService = inject(UserService) showAvatar = input(true, { transform: booleanAttribute }) @@ -48,4 +50,8 @@ export class UserComponent implements OnInit, OnDestroy { signOut(): void { this._authService.signOut() } + + goToProfile() { + void this._router.navigate(['/profile']) + } } diff --git a/src/app/modules/profile/admin/admin.component.html b/src/app/modules/profile/admin/admin.component.html new file mode 100644 index 00000000..ab1d7708 --- /dev/null +++ b/src/app/modules/profile/admin/admin.component.html @@ -0,0 +1,4 @@ +
+

Admin

+

Admin content goes here.

+
diff --git a/src/app/modules/profile/admin/admin.component.ts b/src/app/modules/profile/admin/admin.component.ts new file mode 100644 index 00000000..b265ee23 --- /dev/null +++ b/src/app/modules/profile/admin/admin.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core' +import { SharedImports } from '@seed/directives' + +@Component({ + selector: 'seed-admin', + templateUrl: './admin.component.html', + imports: [SharedImports], +}) +export class AdminComponent {} diff --git a/src/app/modules/profile/developer/developer.component.html b/src/app/modules/profile/developer/developer.component.html new file mode 100644 index 00000000..48306457 --- /dev/null +++ b/src/app/modules/profile/developer/developer.component.html @@ -0,0 +1,40 @@ +
+
+

Developer

+ +
+

{{ t('Manage Your API Key') }}

+ +
+
+ API Key: + {{ user.api_key }} +
+
+ +
+
+ + + @if (showAlert) { +
+ + {{ alert.message }} + +
+ } + + +
+

{{ t('Example Usage') }}

+
+        curl -X GET \
+        'URL/api/version/' \
+        -H 'Accept: application/json' \
+        -u  USEREMAIL + ':' + APIKEY
+      
+
+
+
diff --git a/src/app/modules/profile/developer/developer.component.ts b/src/app/modules/profile/developer/developer.component.ts new file mode 100644 index 00000000..939774eb --- /dev/null +++ b/src/app/modules/profile/developer/developer.component.ts @@ -0,0 +1,63 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { ChangeDetectorRef, Component, inject } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { Subject, takeUntil } from 'rxjs' +import type { CurrentUser } from '@seed/api/user' +import { UserService } from '@seed/api/user' +import type { Alert } from '@seed/components' +import { AlertComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +@Component({ + selector: 'seed-profile-developer', + templateUrl: './developer.component.html', + imports: [AlertComponent, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, ReactiveFormsModule, SharedImports], +}) +export class ProfileDeveloperComponent implements OnInit, OnDestroy { + private _userService = inject(UserService) + private _changeDetectorRef = inject(ChangeDetectorRef) + + alert: Alert + showAlert = false + user: CurrentUser + + private readonly _unsubscribeAll$ = new Subject() + + ngOnInit(): void { + // Subscribe to user changes + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.user = currentUser + + // Mark for check + this._changeDetectorRef.markForCheck() + }) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + generateKey(): void { + // send request to generate a new key; refresh user + this._userService.generateApiKey().subscribe({ + error: (error) => { + console.error('Error:', error) + this.alert = { + type: 'error', + message: 'Generate New Key Unsuccessful...', + } + this.showAlert = true + }, + complete: () => { + this.alert = { + type: 'success', + message: 'New API Key Generated!', + } + this.showAlert = true + }, + }) + } +} diff --git a/src/app/modules/profile/info/info.component.html b/src/app/modules/profile/info/info.component.html new file mode 100644 index 00000000..6de2412b --- /dev/null +++ b/src/app/modules/profile/info/info.component.html @@ -0,0 +1,51 @@ +
+
+

+ Profile Information +

+ +
+
+ + {{ t('First Name') }} + + + @if (profileForm.controls['firstName']?.invalid) { + {{ t('Last Name required') }} + } + + + {{ t('Last Name') }} + + + @if (profileForm.controls['lastName']?.invalid) { + {{ t('Last Name required') }} + } + +
+
+ + Email + + + @if (profileForm.controls['email']?.invalid) { + {{ t('Invalid email address') }} + } + +
+
+ +
+ + @if (showAlert) { +
+ + {{ alert.message }} + +
+ } +
+
+
diff --git a/src/app/modules/profile/info/info.component.ts b/src/app/modules/profile/info/info.component.ts new file mode 100644 index 00000000..177bf172 --- /dev/null +++ b/src/app/modules/profile/info/info.component.ts @@ -0,0 +1,92 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { ChangeDetectorRef, Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { Subject, takeUntil } from 'rxjs' +import type { CurrentUser, UserUpdateRequest } from '@seed/api/user' +import { UserService } from '@seed/api/user' +import type { Alert } from '@seed/components' +import { AlertComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +@Component({ + selector: 'seed-profile-info', + templateUrl: './info.component.html', + imports: [ + AlertComponent, + MatIconModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + MatButtonModule, + SharedImports, + ], +}) +export class ProfileInfoComponent implements OnInit, OnDestroy { + private _userService = inject(UserService) + private _changeDetectorRef = inject(ChangeDetectorRef) + + alert: Alert + showAlert = false + user: CurrentUser + + profileForm = new FormGroup({ + firstName: new FormControl('', [Validators.required]), + lastName: new FormControl('', [Validators.required]), + email: new FormControl('', [Validators.required, Validators.email]), + }) + + private readonly _unsubscribeAll$ = new Subject() + + ngOnInit(): void { + // Subscribe to user changes + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.user = currentUser + + // pre-populate form (here?) + if (this.user.first_name) this.profileForm.get('firstName')?.setValue(this.user.first_name) + if (this.user.last_name) this.profileForm.get('lastName')?.setValue(this.user.last_name) + if (this.user.email) this.profileForm.get('email')?.setValue(this.user.email) + + // Mark for check + this._changeDetectorRef.markForCheck() + }) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + onSubmit(): void { + // Handle form submission + if (this.profileForm.valid) { + const userData = { + first_name: this.profileForm.value.firstName, + last_name: this.profileForm.value.lastName, + email: this.profileForm.value.email, + } as UserUpdateRequest + + this._userService.updateUser(this.user.id, userData).subscribe({ + error: (error) => { + console.error('Error:', error) + this.alert = { + type: 'error', + message: 'Update User Unsuccessful...', + } + this.showAlert = true + }, + complete: () => { + this.alert = { + type: 'success', + message: 'Changes saved!', + } + this.showAlert = true + }, + }) + } + } +} diff --git a/src/app/modules/profile/profile.component.html b/src/app/modules/profile/profile.component.html new file mode 100644 index 00000000..da1d5666 --- /dev/null +++ b/src/app/modules/profile/profile.component.html @@ -0,0 +1,7 @@ +
+
+ +
+
+ + diff --git a/src/app/modules/profile/profile.component.ts b/src/app/modules/profile/profile.component.ts new file mode 100644 index 00000000..5c9a0dee --- /dev/null +++ b/src/app/modules/profile/profile.component.ts @@ -0,0 +1,47 @@ +import { CommonModule } from '@angular/common' +import { Component, ViewEncapsulation } from '@angular/core' +import { RouterOutlet } from '@angular/router' +import type { NavigationItem } from '@seed/components' +import { HorizontalNavigationComponent } from '@seed/components/navigation/horizontal/horizontal.component' +import { SharedImports } from '@seed/directives' + +@Component({ + selector: 'seed-profile', + templateUrl: './profile.component.html', + encapsulation: ViewEncapsulation.None, + imports: [CommonModule, HorizontalNavigationComponent, SharedImports, RouterOutlet], +}) +export class ProfileComponent { + tabs = ['Profile Info', 'Security', 'Developer', 'Admin'] + + readonly navigation: NavigationItem[] = [ + { + id: 'profile', + title: 'Profile', + type: 'basic', + icon: 'fa-solid:user', + link: '/profile/info', + }, + { + id: 'security', + title: 'Security', + type: 'basic', + icon: 'fa-solid:lock', + link: '/profile/security', + }, + { + id: 'developer', + title: 'Developer', + type: 'basic', + icon: 'fa-solid:code', + link: '/profile/developer', + }, + { + id: 'admin', + title: 'Admin', + type: 'basic', + icon: 'fa-solid:user-gear', + link: '/profile/admin', + }, + ] +} diff --git a/src/app/modules/profile/profile.routes.ts b/src/app/modules/profile/profile.routes.ts new file mode 100644 index 00000000..279894ee --- /dev/null +++ b/src/app/modules/profile/profile.routes.ts @@ -0,0 +1,33 @@ +import type { Routes } from '@angular/router' +import { AdminComponent } from 'app/modules/profile/admin/admin.component' +import { ProfileDeveloperComponent } from 'app/modules/profile/developer/developer.component' +import { ProfileInfoComponent } from 'app/modules/profile/info/info.component' +import { ProfileSecurityComponent } from 'app/modules/profile/security/security.component' + +export default [ + { + path: '', + pathMatch: 'full', + redirectTo: 'info', // Default tab (first tab) + }, + { + path: 'info', + title: 'Profile Info', + component: ProfileInfoComponent, + }, + { + path: 'security', + title: 'Security', + component: ProfileSecurityComponent, + }, + { + path: 'developer', + title: 'Developer', + component: ProfileDeveloperComponent, + }, + { + path: 'admin', + title: 'Admin', + component: AdminComponent, + }, +] satisfies Routes diff --git a/src/app/modules/profile/security/security.component.html b/src/app/modules/profile/security/security.component.html new file mode 100644 index 00000000..7d6bc55c --- /dev/null +++ b/src/app/modules/profile/security/security.component.html @@ -0,0 +1,59 @@ +
+
+

Security

+ +
+
+ + {{ t('Current Password') }} + + + + + + {{ t('New Password') }} + + + {{ + t('Passwords must be a combination of lower-case, upper-case, numbers, and must contain at least 8 characters') + }} + @if (passwordForm.controls['newPassword']?.invalid) { + {{ + t('Passwords must be a combination of lower-case, upper-case, numbers, and must contain at least 8 characters') + }} + } + +
+
+ + {{ t('Confirm New Password') }} + + + + @if (passwordForm.get('confirmNewPassword')?.hasError('notSame')) { + passwords do not match + } + +
+
+ +
+ + @if (showAlert) { +
+ + {{ alert.message }} + +
+ } +
+
+
diff --git a/src/app/modules/profile/security/security.component.ts b/src/app/modules/profile/security/security.component.ts new file mode 100644 index 00000000..3fd9ab09 --- /dev/null +++ b/src/app/modules/profile/security/security.component.ts @@ -0,0 +1,100 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject, signal } from '@angular/core' +import type { AbstractControl, ValidationErrors } from '@angular/forms' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { Subject, takeUntil } from 'rxjs' +import type { CurrentUser, PasswordUpdateRequest } from '@seed/api/user' +import { UserService } from '@seed/api/user' +import type { Alert } from '@seed/components' +import { AlertComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +@Component({ + selector: 'seed-profile-security', + templateUrl: './security.component.html', + imports: [ + AlertComponent, + MatIconModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + MatButtonModule, + SharedImports, + ], +}) +export class ProfileSecurityComponent implements OnInit, OnDestroy { + private _userService = inject(UserService) + + alert: Alert + showAlert = false + user: CurrentUser + + // password rules: 8 characters, 1 Uppercase, 1 Lowercase, 1 Number + pwdPattern = '^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z]).{8,}$' + + passwordForm = new FormGroup({ + currentPassword: new FormControl('', [Validators.required]), + newPassword: new FormControl('', [Validators.required, Validators.pattern(this.pwdPattern)]), + confirmNewPassword: new FormControl('', [Validators.required, this.validateSamePassword]), + }) + + hide = signal(true) + + private readonly _unsubscribeAll$ = new Subject() + + validateSamePassword(control: AbstractControl): ValidationErrors | null { + const password = control.parent?.get('newPassword') + const confirmPassword = control.parent?.get('confirmNewPassword') + return password?.value == confirmPassword?.value ? null : { notSame: true } + } + + ngOnInit(): void { + // Subscribe to user changes + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.user = currentUser + }) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + onSubmit() { + // Handle form submission + if (this.passwordForm.valid) { + const passwordData = { + current_password: this.passwordForm.value.currentPassword, + password_1: this.passwordForm.value.newPassword, + password_2: this.passwordForm.value.confirmNewPassword, + } as PasswordUpdateRequest + + this._userService.updatePassword(passwordData).subscribe({ + error: (error: { error?: { message?: string } }) => { + const error_msg = error.error?.message || 'An unknown error occurred.' + this.alert = { + type: 'error', + message: `Error Updating Password: ${error_msg}`, + } + this.showAlert = true + }, + complete: () => { + this.alert = { + type: 'success', + message: 'Changes saved!', + } + this.showAlert = true + }, + }) + } + } + + clickEvent(event: MouseEvent) { + this.hide.set(!this.hide()) + event.stopPropagation() + } +}