Skip to content

Commit d44ed2e

Browse files
kfleminaxelstudioscrutan
authored
Profile Pages (#2)
* start of profile pages * more profile pages * fix width * more profile pages * fix width * Lint fixes * Fixed build * connect profile info to update user api * connect developer profile page * security password page * validation on info and security forms * lint * add password visibility toggle * display errors when updating user pwd does not work * simplify password match logic a bit * remove standalone flag * lint * simplify --------- Co-authored-by: Alex Swindler <Alex.Swindler@nrel.gov> Co-authored-by: Caleb Rutan <caleb.rutan@deptagency.com>
1 parent c6ba28f commit d44ed2e

17 files changed

+600
-5
lines changed

src/@seed/api/user/user.service.ts

+59-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import type { HttpErrorResponse } from '@angular/common/http'
12
import { HttpClient } from '@angular/common/http'
23
import { inject, Injectable } from '@angular/core'
34
import type { Observable } from 'rxjs'
4-
import { distinctUntilChanged, ReplaySubject, switchMap, take, tap } from 'rxjs'
5-
import type { CurrentUser, SetDefaultOrganizationResponse } from '@seed/api/user'
5+
import { catchError, distinctUntilChanged, ReplaySubject, switchMap, take, tap, throwError } from 'rxjs'
6+
import type {
7+
CurrentUser,
8+
GenerateApiKeyResponse,
9+
PasswordUpdateRequest,
10+
PasswordUpdateResponse,
11+
SetDefaultOrganizationResponse,
12+
UserUpdateRequest,
13+
} from '@seed/api/user'
614

715
@Injectable({ providedIn: 'root' })
816
export class UserService {
@@ -42,4 +50,53 @@ export class UserService {
4250
}),
4351
)
4452
}
53+
54+
/**
55+
* Update user
56+
*/
57+
updateUser(userId: number, params: UserUpdateRequest): Observable<CurrentUser> {
58+
return this._httpClient.put<CurrentUser>(`api/v3/users/${userId}/`, params).pipe(
59+
tap((user) => {
60+
this._currentUser.next(user)
61+
}),
62+
catchError((error: HttpErrorResponse) => {
63+
console.error('Error occurred while updating user:', error.error)
64+
return this._currentUser
65+
}),
66+
)
67+
}
68+
69+
/**
70+
* Update user
71+
*/
72+
updatePassword(params: PasswordUpdateRequest): Observable<PasswordUpdateResponse> {
73+
return this.currentUser$.pipe(
74+
take(1),
75+
switchMap(({ id: userId }) => {
76+
return this._httpClient.put<PasswordUpdateResponse>(`api/v3/users/${userId}/set_password/`, params)
77+
}),
78+
tap(() => {
79+
this.getCurrentUser().subscribe()
80+
}),
81+
catchError((error: HttpErrorResponse) => {
82+
return throwError(() => error)
83+
}),
84+
)
85+
}
86+
87+
/**
88+
* Generate API Key
89+
*/
90+
generateApiKey(): Observable<GenerateApiKeyResponse> {
91+
return this.currentUser$.pipe(
92+
take(1),
93+
switchMap(({ id: userId }) => {
94+
return this._httpClient.post<GenerateApiKeyResponse>(`api/v3/users/${userId}/generate_api_key/`, {})
95+
}),
96+
tap(() => {
97+
// Refresh user info after changing the API key
98+
this.getCurrentUser().subscribe()
99+
}),
100+
)
101+
}
45102
}

src/@seed/api/user/user.types.ts

+21
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ export type CurrentUser = {
1919
is_ali_leaf: boolean;
2020
}
2121

22+
export type UserUpdateRequest = {
23+
first_name: string;
24+
last_name: string;
25+
email: string;
26+
}
27+
28+
export type PasswordUpdateRequest = {
29+
current_password: string;
30+
password_1: string;
31+
password_2: string;
32+
}
33+
2234
export type SetDefaultOrganizationResponse = {
2335
status: string;
2436
user: {
@@ -29,3 +41,12 @@ export type SetDefaultOrganizationResponse = {
2941
};
3042
};
3143
}
44+
45+
export type GenerateApiKeyResponse = {
46+
status: string;
47+
api_key: string;
48+
}
49+
50+
export type PasswordUpdateResponse = {
51+
status: string;
52+
}

src/app/app.routes.ts

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AboutComponent } from './modules/main/about/about.component'
66
import { ContactComponent } from './modules/main/contact/contact.component'
77
import { DocumentationComponent } from './modules/main/documentation/documentation.component'
88
import { HomeComponent } from './modules/main/home/home.component'
9+
import { ProfileComponent } from './modules/profile/profile.component'
910

1011
const inventoryTypeMatcher = (segments: UrlSegment[]) => {
1112
if (segments.length === 1 && ['properties', 'taxlots'].includes(segments[0].path)) {
@@ -60,6 +61,11 @@ export const appRoutes: Route[] = [
6061
title: 'Dashboard',
6162
component: HomeComponent,
6263
},
64+
{
65+
path: 'profile',
66+
component: ProfileComponent,
67+
loadChildren: () => import('app/modules/profile/profile.routes'),
68+
},
6369
{
6470
matcher: inventoryTypeMatcher,
6571
loadChildren: () => import('app/modules/inventory/inventory.routes'),

src/app/core/auth/auth.interceptor.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export const authInterceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn):
3232
})
3333

3434
return next(newReq).pipe(
35-
catchError(() => {
36-
return throwError(() => new Error(`Failed request ${req.method} ${req.url}`))
35+
catchError((error: HttpErrorResponse) => {
36+
return throwError(() => error)
3737
}),
3838
)
3939
} else {

src/app/layout/common/user/user.component.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
</span>
1717
</div>
1818
<mat-divider class="my-2"></mat-divider>
19-
<button mat-menu-item>
19+
<button mat-menu-item (click)="goToProfile()">
2020
<mat-icon svgIcon="fa-solid:circle-user"></mat-icon>
2121
<span>Profile</span>
2222
</button>

src/app/layout/common/user/user.component.ts

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button'
44
import { MatDividerModule } from '@angular/material/divider'
55
import { MatIconModule } from '@angular/material/icon'
66
import { MatMenuModule } from '@angular/material/menu'
7+
import { Router } from '@angular/router'
78
import { Subject, takeUntil } from 'rxjs'
89
import type { CurrentUser } from '@seed/api/user'
910
import { UserService } from '@seed/api/user'
@@ -21,6 +22,7 @@ import { AuthService } from 'app/core/auth/auth.service'
2122
export class UserComponent implements OnInit, OnDestroy {
2223
private _authService = inject(AuthService)
2324
private _changeDetectorRef = inject(ChangeDetectorRef)
25+
private _router = inject(Router)
2426
private _userService = inject(UserService)
2527

2628
showAvatar = input(true, { transform: booleanAttribute })
@@ -48,4 +50,8 @@ export class UserComponent implements OnInit, OnDestroy {
4850
signOut(): void {
4951
this._authService.signOut()
5052
}
53+
54+
goToProfile() {
55+
void this._router.navigate(['/profile'])
56+
}
5157
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<div id="admin" class="tab-pane fade" *transloco="let t">
2+
<h1 class="mb-2 text-xl font-bold">Admin</h1>
3+
<p>Admin content goes here.</p>
4+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Component } from '@angular/core'
2+
import { SharedImports } from '@seed/directives'
3+
4+
@Component({
5+
selector: 'seed-admin',
6+
templateUrl: './admin.component.html',
7+
imports: [SharedImports],
8+
})
9+
export class AdminComponent {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<div class="flex-auto p-6 sm:p-10" *transloco="let t">
2+
<div class="max-w-2xl">
3+
<h1 class="mb-6 flex items-center text-center text-2xl"><mat-icon class="mr-2" svgIcon="fa-solid:code"></mat-icon>Developer</h1>
4+
5+
<div class="bg-card mt-8 flex flex-col overflow-hidden rounded-2xl p-8 pb-4 shadow">
6+
<h2 class="pb-5 text-xl">{{ t('Manage Your API Key') }}</h2>
7+
8+
<div class="gt-xs:flex-row flex flex-col pt-4 text-center">
9+
<div>
10+
<span class="font-bold">API Key: </span>
11+
<span class="bg-slate-200 px-5 py-3">{{ user.api_key }}</span>
12+
</div>
13+
<div class="pt-7">
14+
<button mat-flat-button color="primary" (click)="generateKey()">
15+
<span class="ml-2">{{ t('Get a New API Key') }}</span>
16+
</button>
17+
</div>
18+
</div>
19+
20+
<!-- Alert -->
21+
@if (showAlert) {
22+
<div class="mb-4 mt-0">
23+
<seed-alert class="-mb-4 mt-8" appearance="outline" [showIcon]="false" [type]="alert.type">
24+
{{ alert.message }}
25+
</seed-alert>
26+
</div>
27+
}
28+
29+
<!-- Divider -->
30+
<div class="mb-5 mt-10 border-t"></div>
31+
<h3 class="text-l mb-5">{{ t('Example Usage') }}</h3>
32+
<pre class="border-2 bg-slate-100 p-5">
33+
curl -X GET \
34+
'URL/api/version/' \
35+
-H 'Accept: application/json' \
36+
-u USEREMAIL + ':' + APIKEY
37+
</pre>
38+
</div>
39+
</div>
40+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { OnDestroy, OnInit } from '@angular/core'
2+
import { ChangeDetectorRef, Component, inject } from '@angular/core'
3+
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
4+
import { MatButtonModule } from '@angular/material/button'
5+
import { MatFormFieldModule } from '@angular/material/form-field'
6+
import { MatIconModule } from '@angular/material/icon'
7+
import { Subject, takeUntil } from 'rxjs'
8+
import type { CurrentUser } from '@seed/api/user'
9+
import { UserService } from '@seed/api/user'
10+
import type { Alert } from '@seed/components'
11+
import { AlertComponent } from '@seed/components'
12+
import { SharedImports } from '@seed/directives'
13+
@Component({
14+
selector: 'seed-profile-developer',
15+
templateUrl: './developer.component.html',
16+
imports: [AlertComponent, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, ReactiveFormsModule, SharedImports],
17+
})
18+
export class ProfileDeveloperComponent implements OnInit, OnDestroy {
19+
private _userService = inject(UserService)
20+
private _changeDetectorRef = inject(ChangeDetectorRef)
21+
22+
alert: Alert
23+
showAlert = false
24+
user: CurrentUser
25+
26+
private readonly _unsubscribeAll$ = new Subject<void>()
27+
28+
ngOnInit(): void {
29+
// Subscribe to user changes
30+
this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => {
31+
this.user = currentUser
32+
33+
// Mark for check
34+
this._changeDetectorRef.markForCheck()
35+
})
36+
}
37+
38+
ngOnDestroy(): void {
39+
this._unsubscribeAll$.next()
40+
this._unsubscribeAll$.complete()
41+
}
42+
43+
generateKey(): void {
44+
// send request to generate a new key; refresh user
45+
this._userService.generateApiKey().subscribe({
46+
error: (error) => {
47+
console.error('Error:', error)
48+
this.alert = {
49+
type: 'error',
50+
message: 'Generate New Key Unsuccessful...',
51+
}
52+
this.showAlert = true
53+
},
54+
complete: () => {
55+
this.alert = {
56+
type: 'success',
57+
message: 'New API Key Generated!',
58+
}
59+
this.showAlert = true
60+
},
61+
})
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<div class="flex-auto p-6 sm:p-10" *transloco="let t">
2+
<div class="max-w-2xl">
3+
<h2 class="mb-6 flex items-center text-center text-2xl">
4+
<mat-icon class="mr-2" svgIcon="fa-solid:circle-user"></mat-icon>Profile Information
5+
</h2>
6+
7+
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()" class="bg-card mt-8 flex flex-col overflow-hidden rounded-2xl p-8 pb-4 shadow">
8+
<div class="gt-xs:flex-row flex flex-col">
9+
<mat-form-field class="gt-xs:pr-3 flex-auto">
10+
<mat-label>{{ t('First Name') }}</mat-label>
11+
<input matInput [formControlName]="'firstName'" />
12+
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'fa-solid:circle-user'"></mat-icon>
13+
@if (profileForm.controls['firstName']?.invalid) {
14+
<mat-error>{{ t('Last Name required') }}</mat-error>
15+
}
16+
</mat-form-field>
17+
<mat-form-field class="gt-xs:pl-3 flex-auto">
18+
<mat-label>{{ t('Last Name') }}</mat-label>
19+
<input matInput [formControlName]="'lastName'" />
20+
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'fa-solid:circle-user'"></mat-icon>
21+
@if (profileForm.controls['lastName']?.invalid) {
22+
<mat-error>{{ t('Last Name required') }}</mat-error>
23+
}
24+
</mat-form-field>
25+
</div>
26+
<div class="flex">
27+
<mat-form-field class="w-full">
28+
<mat-label>Email</mat-label>
29+
<mat-icon class="icon-size-5" [svgIcon]="'fa-solid:envelope'" matPrefix></mat-icon>
30+
<input [formControlName]="'email'" matInput />
31+
@if (profileForm.controls['email']?.invalid) {
32+
<mat-error>{{ t('Invalid email address') }}</mat-error>
33+
}
34+
</mat-form-field>
35+
</div>
36+
<div class="flex justify-center">
37+
<button mat-flat-button color="primary" [disabled]="profileForm.invalid">
38+
<span class="ml-2">{{ t('Update') }}</span>
39+
</button>
40+
</div>
41+
<!-- Alert -->
42+
@if (showAlert) {
43+
<div class="mb-4 mt-0">
44+
<seed-alert class="-mb-4 mt-8" appearance="outline" [showIcon]="false" [type]="alert.type">
45+
{{ alert.message }}
46+
</seed-alert>
47+
</div>
48+
}
49+
</form>
50+
</div>
51+
</div>

0 commit comments

Comments
 (0)