From 04e03ab26610098c9e40f7e023304498b2bdc287 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 11 Sep 2025 14:25:17 -0700 Subject: [PATCH 1/4] feat(profile): implement complete profile management system - Create profile layout with navigation menu and breadcrumbs - Build comprehensive profile edit form with all user/profile fields - Implement backend API with Supabase integration and REST endpoints - Add profile routes with authentication guards - Extend UserService with profile CRUD operations - Add countries dropdown with 83 country options - Standardize T-shirt sizes in constants package - Add US states dropdown with conditional logic - Add contextual guidance banners and tooltips LFXV2-454, LFXV2-465, LFXV2-466, LFXV2-467, LFXV2-468, LFXV2-469, LFXV2-470, LFXV2-471, LFXV2-472, LFXV2-473 --signoff Signed-off-by: Asitha de Silva --- .vscode/settings.json | 1 + apps/lfx-pcc/src/app/app.routes.ts | 6 + .../profile-layout.component.html | 80 +++++ .../profile-layout.component.scss | 18 + .../profile-layout.component.ts | 155 +++++++++ .../profile/edit/profile-edit.component.html | 307 ++++++++++++++++++ .../profile/edit/profile-edit.component.ts | 214 ++++++++++++ .../email/profile-email.component.html | 15 + .../profile/email/profile-email.component.ts | 16 + .../password/profile-password.component.html | 15 + .../password/profile-password.component.ts | 16 + .../src/app/modules/profile/profile.routes.ts | 19 ++ .../components/header/header.component.ts | 5 +- .../src/app/shared/services/user.service.ts | 25 +- .../server/controllers/profile.controller.ts | 206 ++++++++++++ .../src/server/routes/profile.route.ts | 26 ++ apps/lfx-pcc/src/server/server.ts | 4 +- .../src/server/services/supabase.service.ts | 146 +++++++++ apps/lfx-pcc/src/styles.scss | 4 + .../src/constants/countries.constants.ts | 110 +++++++ packages/shared/src/constants/index.ts | 3 + .../shared/src/constants/states.constants.ts | 64 ++++ .../src/constants/tshirt-sizes.constants.ts | 20 ++ packages/shared/src/interfaces/index.ts | 3 + .../src/interfaces/user-profile.interface.ts | 67 ++++ 25 files changed, 1540 insertions(+), 5 deletions(-) create mode 100644 apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html create mode 100644 apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.scss create mode 100644 apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts create mode 100644 apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html create mode 100644 apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.ts create mode 100644 apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html create mode 100644 apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.ts create mode 100644 apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html create mode 100644 apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.ts create mode 100644 apps/lfx-pcc/src/app/modules/profile/profile.routes.ts create mode 100644 apps/lfx-pcc/src/server/controllers/profile.controller.ts create mode 100644 apps/lfx-pcc/src/server/routes/profile.route.ts create mode 100644 packages/shared/src/constants/countries.constants.ts create mode 100644 packages/shared/src/constants/states.constants.ts create mode 100644 packages/shared/src/constants/tshirt-sizes.constants.ts create mode 100644 packages/shared/src/interfaces/user-profile.interface.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f0381fe8..c31d0e61 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "styleclass", "supabase", "timegrid", + "TSHIRT", "Turborepo", "Uids", "viewports" diff --git a/apps/lfx-pcc/src/app/app.routes.ts b/apps/lfx-pcc/src/app/app.routes.ts index d30b05a8..d5c9e453 100644 --- a/apps/lfx-pcc/src/app/app.routes.ts +++ b/apps/lfx-pcc/src/app/app.routes.ts @@ -22,4 +22,10 @@ export const routes: Routes = [ canActivate: [authGuard], data: { preload: true, preloadDelay: 1000 }, // Preload after 1 second for likely navigation }, + { + path: 'profile', + loadComponent: () => import('./layouts/profile-layout/profile-layout.component').then((m) => m.ProfileLayoutComponent), + loadChildren: () => import('./modules/profile/profile.routes').then((m) => m.PROFILE_ROUTES), + canActivate: [authGuard], + }, ]; diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html new file mode 100644 index 00000000..7993fa6d --- /dev/null +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html @@ -0,0 +1,80 @@ + + + +
+
+ + + +
+
+ + + + +
+ +

+ {{ profileTitle() }} +

+ + + @if (profileSubtitle(); as subtitle) { +

+ {{ subtitle }} +

+ } +
+
+
+ + +
+
+ @for (menu of menuItems(); track menu.label) { + + @if (menu.icon) { + + } + {{ menu.label }} + + } +
+
+
+
+ + +@if (!loading()) { + +} @else { +
+
+ + Loading profile... +
+
+} diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.scss b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.scss new file mode 100644 index 00000000..556bc71c --- /dev/null +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.scss @@ -0,0 +1,18 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Profile layout specific styles +// Most styling comes from Tailwind classes in the template +// This file is reserved for any custom styling that can't be achieved with Tailwind + +:host { + display: block; + + .pill { + @apply inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full transition-colors text-gray-600 border border-gray-200 hover:bg-gray-50; + } + + .profile-avatar-placeholder { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + } +} diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts new file mode 100644 index 00000000..ad813892 --- /dev/null +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts @@ -0,0 +1,155 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input, Signal, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { RouterModule } from '@angular/router'; +import { CombinedProfile } from '@lfx-pcc/shared/interfaces'; +import { UserService } from '@services/user.service'; +import { AvatarComponent } from '@shared/components/avatar/avatar.component'; +import { BreadcrumbComponent } from '@shared/components/breadcrumb/breadcrumb.component'; +import { MenuItem } from 'primeng/api'; +import { ChipModule } from 'primeng/chip'; +import { finalize } from 'rxjs'; + +@Component({ + selector: 'lfx-profile-layout', + standalone: true, + imports: [CommonModule, RouterModule, AvatarComponent, BreadcrumbComponent, ChipModule], + templateUrl: './profile-layout.component.html', + styleUrl: './profile-layout.component.scss', +}) +export class ProfileLayoutComponent { + private readonly userService = inject(UserService); + + // Load current user profile data + public loading = signal(true); + + public profile = this.initializeProfile(); + // Computed profile data + public readonly profileTitle = this.initializeProfileTitle(); + public readonly profileSubtitle = this.initializeProfileSubtitle(); + public readonly profileLocation = this.initializeProfileLocation(); + public readonly userInitials = this.initializeUserInitials(); + // Loading state + + public readonly breadcrumbItems = input([ + { + label: 'Home', + routerLink: '/', + icon: 'fa-light fa-chevron-left', + routerLinkActiveOptions: { exact: false }, + }, + ]); + + // Menu items for profile sections + public readonly menuItems: Signal = this.initializeMenuItems(); + + private initializeProfile(): Signal { + return toSignal(this.userService.getCurrentUserProfile().pipe(finalize(() => this.loading.set(false))), { initialValue: null }); + } + + private initializeUserInitials(): Signal { + return computed(() => { + const profile = this.profile(); + if (!profile?.user) return ''; + + const user = profile.user; + const firstName = user.first_name; + const lastName = user.last_name; + const email = user.email; + const username = user.username; + + // Priority: first/last name > username > email + if (firstName && lastName) { + return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + } + + if (firstName) { + return firstName.charAt(0).toUpperCase(); + } + + if (username) { + return username.charAt(0).toUpperCase(); + } + + return email.charAt(0).toUpperCase(); + }); + } + + private initializeMenuItems(): Signal { + return computed(() => [ + { + label: 'Edit Profile', + icon: 'fa-light fa-user-edit text-blue-500', + routerLink: '/profile', + routerLinkActiveOptions: { exact: true }, + }, + { + label: 'Password', + icon: 'fa-light fa-key text-amber-500', + routerLink: '/profile/password', + routerLinkActiveOptions: { exact: true }, + }, + { + label: 'Email Settings', + icon: 'fa-light fa-envelope text-green-500', + routerLink: '/profile/email', + routerLinkActiveOptions: { exact: true }, + }, + ]); + } + + private initializeProfileTitle(): Signal { + return computed(() => { + const profile = this.profile(); + if (!profile?.user) return ''; + + const user = profile.user; + const firstName = user.first_name; + const lastName = user.last_name; + + // If the user has a first name and last name, use it + if (firstName && lastName) { + return `${firstName} ${lastName}`; + } + + // If the user has a first name, use it + if (firstName) { + return firstName; + } + + // If the user has a username, use it + if (user.username) { + return user.username; + } + + // If the user has an email, use it + return user.email; + }); + } + + private initializeProfileSubtitle(): Signal { + return computed(() => { + const profile = this.profile(); + if (!profile?.profile) return ''; + + return profile.profile.title || ''; + }); + } + + private initializeProfileLocation(): Signal { + return computed(() => { + const profile = this.profile(); + if (!profile?.profile) return ''; + + const parts = []; + if (profile.profile.city) parts.push(profile.profile.city); + if (profile.profile.state) parts.push(profile.profile.state); + if (profile.profile.country) parts.push(profile.profile.country); + + return parts.join(', '); + }); + } +} diff --git a/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html b/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html new file mode 100644 index 00000000..ff7d5814 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html @@ -0,0 +1,307 @@ + + + +
+ @if (isLoading()) { +
+ + Loading profile... +
+ } @else { +
+ +
+
+ +
+
Complete Your Open Source Profile
+

+ A complete profile helps community members identify and connect with you, enables event organizers to plan in-person meetups, and ensures you + receive contributor recognition and swag. Your information builds trust and enhances collaboration in open source projects. +

+
+
+
+ + +
+ +
+
+

Personal Information

+

Helps community members identify and connect with you in discussions and contributions

+
+ +
+ +
+ + + +
+ + +
+ + + +
+ + +
+
+ + + +
+ + +
+
+
+ + +
+
+

Professional Information

+

Showcase your expertise and affiliations to build credibility and unlock networking opportunities

+
+ +
+ +
+ + + +
+ + +
+
+ + + +
+ + +
+
+
+ + +
+
+

Location Information

+
+
+ +

+ Why we need this: Enables event organizers to plan in-person meetups, ship contributor swag to your location, and + coordinate with team members across timezones for better collaboration. +

+
+
+
+ +
+ +
+ + + +
+ + +
+ + @if (isUSA()) { + + + } @else { + + + } +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +

Optional - Only used for urgent event notifications and is kept private

+
+
+
+ + +
+
+

Additional Information

+

Helps us send you the right swag and plan for community events

+
+ +
+ +
+
+ + + +
+ + +
+
+
+ + +
+ + + + +
+
+
+
+ } +
+ + + diff --git a/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.ts b/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.ts new file mode 100644 index 00000000..38e82d42 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.ts @@ -0,0 +1,214 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { COUNTRIES, TSHIRT_SIZES, US_STATES } from '@lfx-pcc/shared'; +import { CombinedProfile, UpdateProfileDetailsRequest, UpdateUserProfileRequest } from '@lfx-pcc/shared/interfaces'; +import { UserService } from '@services/user.service'; +import { ButtonComponent } from '@shared/components/button/button.component'; +import { CardComponent } from '@shared/components/card/card.component'; +import { InputTextComponent } from '@shared/components/input-text/input-text.component'; +import { SelectComponent } from '@shared/components/select/select.component'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { TooltipModule } from 'primeng/tooltip'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'lfx-profile-edit', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, CardComponent, InputTextComponent, SelectComponent, ButtonComponent, ToastModule, TooltipModule], + providers: [MessageService], + templateUrl: './profile-edit.component.html', +}) +export class ProfileEditComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly userService = inject(UserService); + private readonly messageService = inject(MessageService); + + // Form state signals + private readonly loadingSignal = signal(true); + private readonly savingSignal = signal(false); + private readonly profileSignal = signal(null); + private readonly selectedCountrySignal = signal(''); + + public readonly isLoading = computed(() => this.loadingSignal()); + public readonly isSaving = computed(() => this.savingSignal()); + public readonly profile = computed(() => this.profileSignal()); + + // T-shirt size options + public readonly tshirtSizeOptions = TSHIRT_SIZES.map((size) => ({ + label: size.label, + value: size.value, + })); + + // Country options - using country names as values for database compatibility + public readonly countryOptions = COUNTRIES.map((country: { label: string; value: string }) => ({ + label: country.label, + value: country.label, + })); + + // US states options - using state names as values for database compatibility + public readonly stateOptions = US_STATES.map((state) => ({ + label: state.label, + value: state.label, + })); + + // Computed property to check if selected country is USA + public readonly isUSA = computed(() => { + return this.selectedCountrySignal() === 'United States'; + }); + + // Profile form + public profileForm: FormGroup = this.fb.group({ + // User table fields + first_name: ['', [Validators.maxLength(50)]], + last_name: ['', [Validators.maxLength(50)]], + username: [{ value: '', disabled: true }, [Validators.required, Validators.minLength(3), Validators.maxLength(30)]], + + // Profile table fields + title: ['', [Validators.maxLength(100)]], + organization: ['', [Validators.maxLength(100)]], + country: ['', [Validators.maxLength(50)]], + state: ['', [Validators.maxLength(50)]], + city: ['', [Validators.maxLength(50)]], + address: ['', [Validators.maxLength(200)]], + zipcode: ['', [Validators.maxLength(20)]], + phone_number: ['', [Validators.maxLength(20)]], + tshirt_size: ['', []], + }); + + public ngOnInit(): void { + this.loadProfile(); + + // Subscribe to country field changes to update the signal + this.profileForm.get('country')?.valueChanges.subscribe((country: string) => { + this.selectedCountrySignal.set(country || ''); + + // Clear state field when country changes to avoid invalid state/country combinations + if (country !== 'United States') { + this.profileForm.get('state')?.setValue(''); + } + }); + } + + public async onSubmit(): Promise { + if (this.profileForm.invalid) { + this.markFormGroupTouched(this.profileForm); + return; + } + + try { + this.savingSignal.set(true); + const formValue = this.profileForm.value; + const currentProfile = this.profile(); + + if (!currentProfile) { + throw new Error('No profile data available'); + } + + // Prepare user update data + const userUpdate: UpdateUserProfileRequest = { + first_name: formValue.first_name || null, + last_name: formValue.last_name || null, + username: formValue.username || null, + }; + + // Prepare profile update data + const profileUpdate: UpdateProfileDetailsRequest = { + title: formValue.title || null, + organization: formValue.organization || null, + country: formValue.country || null, + state: formValue.state || null, + city: formValue.city || null, + address: formValue.address || null, + zipcode: formValue.zipcode || null, + phone_number: formValue.phone_number || null, + tshirt_size: formValue.tshirt_size || null, + }; + + // Update both user and profile data in parallel + await Promise.all([firstValueFrom(this.userService.updateUserInfo(userUpdate)), firstValueFrom(this.userService.updateProfileDetails(profileUpdate))]); + + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Profile updated successfully!', + }); + + // Reload profile data + await this.loadProfile(); + } catch (error) { + console.error('Error saving profile:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to save profile. Please try again.', + }); + } finally { + this.savingSignal.set(false); + } + } + + public onReset(): void { + const currentProfile = this.profile(); + if (currentProfile) { + this.populateForm(currentProfile); + } + this.profileForm.markAsUntouched(); + } + + private async loadProfile(): Promise { + try { + this.loadingSignal.set(true); + + const profile = await firstValueFrom(this.userService.getCurrentUserProfile()); + + this.profileSignal.set(profile); + this.populateForm(profile); + } catch (error) { + console.error('Error loading profile:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to load profile data. Please try again.', + }); + } finally { + this.loadingSignal.set(false); + } + } + + private populateForm(profile: CombinedProfile): void { + const countryValue = profile.profile?.country || ''; + + this.profileForm.patchValue({ + // User fields + first_name: profile.user.first_name || '', + last_name: profile.user.last_name || '', + username: profile.user.username || '', + + // Profile fields + title: profile.profile?.title || '', + organization: profile.profile?.organization || '', + country: countryValue, + state: profile.profile?.state || '', + city: profile.profile?.city || '', + address: profile.profile?.address || '', + zipcode: profile.profile?.zipcode || '', + phone_number: profile.profile?.phone_number || '', + tshirt_size: profile.profile?.tshirt_size || '', + }); + + // Set the initial country signal value + this.selectedCountrySignal.set(countryValue); + } + + private markFormGroupTouched(formGroup: FormGroup): void { + Object.keys(formGroup.controls).forEach((field) => { + const control = formGroup.get(field); + control?.markAsTouched({ onlySelf: true }); + }); + } +} diff --git a/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html b/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html new file mode 100644 index 00000000..e08b9c5e --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html @@ -0,0 +1,15 @@ + + + +
+
+ +
+

Email Settings

+

Email management functionality will be implemented in a future update.

+
📧
+

Coming Soon

+
+
+
+
diff --git a/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.ts b/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.ts new file mode 100644 index 00000000..09b3f759 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.ts @@ -0,0 +1,16 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { CardComponent } from '@shared/components/card/card.component'; + +@Component({ + selector: 'lfx-profile-email', + standalone: true, + imports: [CommonModule, CardComponent], + templateUrl: './profile-email.component.html', +}) +export class ProfileEmailComponent { + // TODO: Implement email change functionality +} diff --git a/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html b/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html new file mode 100644 index 00000000..6b9ee423 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html @@ -0,0 +1,15 @@ + + + +
+
+ +
+

Change Password

+

Password management functionality will be implemented in a future update.

+
🔒
+

Coming Soon

+
+
+
+
diff --git a/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.ts b/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.ts new file mode 100644 index 00000000..98887678 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.ts @@ -0,0 +1,16 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { CardComponent } from '@shared/components/card/card.component'; + +@Component({ + selector: 'lfx-profile-password', + standalone: true, + imports: [CommonModule, CardComponent], + templateUrl: './profile-password.component.html', +}) +export class ProfilePasswordComponent { + // TODO: Implement password change functionality +} diff --git a/apps/lfx-pcc/src/app/modules/profile/profile.routes.ts b/apps/lfx-pcc/src/app/modules/profile/profile.routes.ts new file mode 100644 index 00000000..dcd8db2f --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/profile/profile.routes.ts @@ -0,0 +1,19 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Routes } from '@angular/router'; + +export const PROFILE_ROUTES: Routes = [ + { + path: '', + loadComponent: () => import('./edit/profile-edit.component').then((m) => m.ProfileEditComponent), + }, + { + path: 'password', + loadComponent: () => import('./password/profile-password.component').then((m) => m.ProfilePasswordComponent), + }, + { + path: 'email', + loadComponent: () => import('./email/profile-email.component').then((m) => m.ProfileEmailComponent), + }, +]; diff --git a/apps/lfx-pcc/src/app/shared/components/header/header.component.ts b/apps/lfx-pcc/src/app/shared/components/header/header.component.ts index 8bb1f3fd..2d1f0f97 100644 --- a/apps/lfx-pcc/src/app/shared/components/header/header.component.ts +++ b/apps/lfx-pcc/src/app/shared/components/header/header.component.ts @@ -15,7 +15,7 @@ import { UserService } from '@services/user.service'; import { MenuItem } from 'primeng/api'; import { AutoCompleteCompleteEvent, AutoCompleteSelectEvent } from 'primeng/autocomplete'; import { RippleModule } from 'primeng/ripple'; -import { of, catchError, debounceTime, distinctUntilChanged, startWith, switchMap } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, of, startWith, switchMap } from 'rxjs'; import { AutocompleteComponent } from '../autocomplete/autocomplete.component'; import { MenuComponent } from '../menu/menu.component'; @@ -53,8 +53,7 @@ export class HeaderComponent { { label: 'Profile', icon: 'fa-light fa-user', - url: environment.urls.profile, - target: '_blank', + routerLink: '/profile', }, { label: 'Developer Settings', diff --git a/apps/lfx-pcc/src/app/shared/services/user.service.ts b/apps/lfx-pcc/src/app/shared/services/user.service.ts index 51132316..0ef0d333 100644 --- a/apps/lfx-pcc/src/app/shared/services/user.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/user.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { CreateUserPermissionRequest, User } from '@lfx-pcc/shared/interfaces'; +import { CombinedProfile, CreateUserPermissionRequest, UpdateProfileDetailsRequest, UpdateUserProfileRequest, User } from '@lfx-pcc/shared/interfaces'; import { Observable } from 'rxjs'; @Injectable({ @@ -19,4 +19,27 @@ export class UserService { public createUserWithPermissions(userData: CreateUserPermissionRequest): Observable { return this.http.post(`/api/projects/${userData.project_uid}/permissions`, userData); } + + // Profile management methods + + /** + * Get current user's combined profile data + */ + public getCurrentUserProfile(): Observable { + return this.http.get('/api/profile'); + } + + /** + * Update user info fields (first_name, last_name, username) + */ + public updateUserInfo(data: UpdateUserProfileRequest): Observable { + return this.http.patch('/api/profile/user', data); + } + + /** + * Update profile details fields (title, organization, location, etc.) + */ + public updateProfileDetails(data: UpdateProfileDetailsRequest): Observable { + return this.http.patch('/api/profile/details', data); + } } diff --git a/apps/lfx-pcc/src/server/controllers/profile.controller.ts b/apps/lfx-pcc/src/server/controllers/profile.controller.ts new file mode 100644 index 00000000..518cad23 --- /dev/null +++ b/apps/lfx-pcc/src/server/controllers/profile.controller.ts @@ -0,0 +1,206 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CombinedProfile } from '@lfx-pcc/shared/interfaces'; +import { NextFunction, Request, Response } from 'express'; + +import { ServiceValidationError } from '../errors'; +import { Logger } from '../helpers/logger'; +import { SupabaseService } from '../services/supabase.service'; +import { getUsernameFromAuth } from '../utils/auth-helper'; + +/** + * Controller for handling profile HTTP requests + */ +export class ProfileController { + private supabaseService: SupabaseService = new SupabaseService(); + + /** + * GET /api/profile - Get current user's combined profile + */ + public async getCurrentUserProfile(req: Request, res: Response, next: NextFunction): Promise { + const startTime = Logger.start(req, 'get_current_user_profile'); + + try { + // Get user ID from auth context + const userId = await getUsernameFromAuth(req); + + if (!userId) { + Logger.error(req, 'get_current_user_profile', startTime, new Error('User not authenticated or user ID not found')); + + const validationError = ServiceValidationError.forField('user_id', 'User authentication required', { + operation: 'get_current_user_profile', + service: 'profile_controller', + path: req.path, + }); + + return next(validationError); + } + + let combinedProfile: CombinedProfile | null = null; + + // Get combined profile using JOIN + const user = await this.supabaseService.getUser(userId); + + if (!user) { + const validationError = ServiceValidationError.forField('user_id', 'User profile not found', { + operation: 'get_current_user_profile', + service: 'profile_controller', + path: req.path, + }); + + return next(validationError); + } + + combinedProfile = { + user, + profile: null, + }; + + // Get profile details + const profile = await this.supabaseService.getProfile(user.id); + + // If no profile details exist, create them + if (!profile) { + await this.supabaseService.createProfileIfNotExists(user.id); + // Refetch the combined profile with the newly created profile + const updatedProfile = await this.supabaseService.getProfile(user.id); + combinedProfile.profile = updatedProfile || null; + } else { + combinedProfile.profile = profile; + } + + Logger.success(req, 'get_current_user_profile', startTime, { + user_id: user.id, + has_profile_details: !!combinedProfile.profile, + }); + + res.json(combinedProfile); + } catch (error) { + Logger.error(req, 'get_current_user_profile', startTime, error); + next(error); + } + } + + /** + * PATCH /api/profile/user - Update user table fields + */ + public async updateCurrentUser(req: Request, res: Response, next: NextFunction): Promise { + const startTime = Logger.start(req, 'update_current_user', { + request_body_keys: Object.keys(req.body), + }); + + try { + // Get user ID from auth context + const username = await getUsernameFromAuth(req); + + if (!username) { + Logger.error(req, 'update_current_user', startTime, new Error('User not authenticated or user ID not found')); + + const validationError = ServiceValidationError.forField('user_id', 'User authentication required', { + operation: 'update_current_user', + service: 'profile_controller', + path: req.path, + }); + + return next(validationError); + } + + // Validate request body contains valid user profile fields + const allowedFields = ['first_name', 'last_name', 'username']; + const updateData: any = {}; + + for (const [key, value] of Object.entries(req.body)) { + if (allowedFields.includes(key)) { + updateData[key] = value; + } + } + + if (Object.keys(updateData).length === 0) { + Logger.error(req, 'update_current_user', startTime, new Error('No valid fields provided for update')); + + const validationError = ServiceValidationError.forField('request_body', 'No valid fields provided for update', { + operation: 'update_current_user', + service: 'profile_controller', + path: req.path, + }); + + return next(validationError); + } + + // Update user + const updatedUser = await this.supabaseService.updateUser(username, updateData); + + Logger.success(req, 'update_current_user', startTime, { + username: username, + updated_fields: Object.keys(updateData), + }); + + res.json(updatedUser); + } catch (error) { + Logger.error(req, 'update_current_user', startTime, error); + next(error); + } + } + + /** + * PATCH /api/profile/details - Update profile details table fields + */ + public async updateCurrentProfile(req: Request, res: Response, next: NextFunction): Promise { + const startTime = Logger.start(req, 'update_current_profile_details', { + request_body_keys: Object.keys(req.body), + }); + + try { + // Get user ID from auth context + const userId = await getUsernameFromAuth(req); + + if (!userId) { + Logger.error(req, 'update_current_profile_details', startTime, new Error('User not authenticated or user ID not found')); + + const validationError = ServiceValidationError.forField('user_id', 'User authentication required', { + operation: 'update_current_profile_details', + service: 'profile_controller', + path: req.path, + }); + + return next(validationError); + } + + // Validate request body contains valid profile detail fields + const allowedFields = ['title', 'organization', 'country', 'state', 'city', 'address', 'zipcode', 'phone_number', 'tshirt_size']; + const updateData: any = {}; + + for (const [key, value] of Object.entries(req.body)) { + if (allowedFields.includes(key)) { + updateData[key] = value; + } + } + + if (Object.keys(updateData).length === 0) { + Logger.error(req, 'update_current_profile_details', startTime, new Error('No valid fields provided for update')); + + const validationError = ServiceValidationError.forField('request_body', 'No valid fields provided for update', { + operation: 'update_current_profile_details', + service: 'profile_controller', + path: req.path, + }); + + return next(validationError); + } + + // Update profile details + const updatedProfile = await this.supabaseService.updateProfileDetails(userId, updateData); + + Logger.success(req, 'update_current_profile_details', startTime, { + user_id: userId, + updated_fields: Object.keys(updateData), + }); + + res.json(updatedProfile); + } catch (error) { + Logger.error(req, 'update_current_profile_details', startTime, error); + next(error); + } + } +} diff --git a/apps/lfx-pcc/src/server/routes/profile.route.ts b/apps/lfx-pcc/src/server/routes/profile.route.ts new file mode 100644 index 00000000..b418644a --- /dev/null +++ b/apps/lfx-pcc/src/server/routes/profile.route.ts @@ -0,0 +1,26 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Router } from 'express'; + +import { ProfileController } from '../controllers/profile.controller'; + +const router = Router(); + +const profileController = new ProfileController(); + +/** + * Profile routes for authenticated users + * All routes require authentication via auth middleware + */ + +// GET /api/profile - Get current user's combined profile data +router.get('/', (req, res, next) => profileController.getCurrentUserProfile(req, res, next)); + +// PATCH /api/profile/user - Update user table fields (first_name, last_name, username) +router.patch('/user', (req, res, next) => profileController.updateCurrentUser(req, res, next)); + +// PATCH /api/profile/details - Update profile table fields (title, organization, etc.) +router.patch('/details', (req, res, next) => profileController.updateCurrentProfile(req, res, next)); + +export default router; diff --git a/apps/lfx-pcc/src/server/server.ts b/apps/lfx-pcc/src/server/server.ts index cf93d8a6..967b77f4 100644 --- a/apps/lfx-pcc/src/server/server.ts +++ b/apps/lfx-pcc/src/server/server.ts @@ -21,6 +21,7 @@ import committeesRouter from './routes/committees.route'; import meetingsRouter from './routes/meetings.route'; import pastMeetingsRouter from './routes/past-meetings.route'; import permissionsRouter from './routes/permissions.route'; +import profileRouter from './routes/profile.route'; import projectsRouter from './routes/projects.route'; import publicMeetingsRouter from './routes/public-meetings.route'; @@ -201,6 +202,7 @@ app.use('/api/projects', permissionsRouter); app.use('/api/committees', committeesRouter); app.use('/api/meetings', meetingsRouter); app.use('/api/past-meetings', pastMeetingsRouter); +app.use('/api/profile', profileRouter); // Add API error handler middleware app.use('/api/*', apiErrorHandler); @@ -221,7 +223,7 @@ app.use('/**', async (req: Request, res: Response, next: NextFunction) => { // Fetch user info from OIDC auth.user = req.oidc?.user as User; - if (!auth.user?.email) { + if (!auth.user?.name) { auth.user = await req.oidc.fetchUserInfo(); } } catch (error) { diff --git a/apps/lfx-pcc/src/server/services/supabase.service.ts b/apps/lfx-pcc/src/server/services/supabase.service.ts index f0813a42..53d210be 100644 --- a/apps/lfx-pcc/src/server/services/supabase.service.ts +++ b/apps/lfx-pcc/src/server/services/supabase.service.ts @@ -8,13 +8,17 @@ import { CreateUserPermissionRequest, MeetingAttachment, PermissionLevel, + ProfileDetails, ProjectPermission, ProjectSearchResult, RecentActivity, + UpdateProfileDetailsRequest, UpdateUserPermissionRequest, + UpdateUserProfileRequest, UploadFileResponse, User, UserPermissionSummary, + UserProfile, } from '@lfx-pcc/shared/interfaces'; import dotenv from 'dotenv'; @@ -524,6 +528,148 @@ export class SupabaseService { } } + /** + * Get user profile data from public.users table + */ + public async getUser(username: string): Promise { + const params = new URLSearchParams({ + username: `eq.${username}`, + limit: '1', + }); + const url = `${this.baseUrl}/users?${params.toString()}`; + + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch user profile: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data?.[0] || null; + } + + /** + * Get profile details data from public.profiles table + */ + public async getProfile(userUid: string): Promise { + const params = new URLSearchParams({ + user_id: `eq.${userUid}`, + limit: '1', + }); + const url = `${this.baseUrl}/profiles?${params.toString()}`; + + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch profile details: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data?.[0] || null; + } + + public async updateUser(username: string, data: UpdateUserProfileRequest): Promise { + const url = `${this.baseUrl}/users`; + const params = new URLSearchParams({ + username: `eq.${username}`, + }); + + const updateData = { + ...data, + updated_at: new Date().toISOString(), + }; + + const response = await fetch(`${url}?${params.toString()}`, { + method: 'PATCH', + headers: this.getHeaders(), + body: JSON.stringify(updateData), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to update user: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + return result?.[0]; + } + + /** + * Update profile details data in public.profiles table + */ + public async updateProfileDetails(username: string, data: UpdateProfileDetailsRequest): Promise { + const user = await this.getUser(username); + + if (!user) { + throw new Error(`User not found: ${username}`); + } + + const params = new URLSearchParams({ + user_id: `eq.${user.id}`, + }); + const url = `${this.baseUrl}/profiles?${params.toString()}`; + + const updateData = { + ...data, + updated_at: new Date().toISOString(), + }; + + const response = await fetch(url, { + method: 'PATCH', + headers: this.getHeaders(), + body: JSON.stringify(updateData), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to update profile details: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + return result?.[0]; + } + + /** + * Create profile record if it doesn't exist + */ + public async createProfileIfNotExists(userUid: string): Promise { + // First check if profile exists + const existing = await this.getProfile(userUid); + if (existing) { + return existing; + } + + // Create new profile record + const url = `${this.baseUrl}/profiles`; + const newProfile = { + user_id: userUid, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(newProfile), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to create profile: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + return result?.[0]; + } + private async fallbackProjectSearch(query: string): Promise { let url = `${this.baseUrl}/projects?limit=10&order=name`; diff --git a/apps/lfx-pcc/src/styles.scss b/apps/lfx-pcc/src/styles.scss index 6ef2c363..c72342ca 100644 --- a/apps/lfx-pcc/src/styles.scss +++ b/apps/lfx-pcc/src/styles.scss @@ -35,6 +35,10 @@ --p-form-field-sm-font-size: 0.875rem; --p-inputtext-sm-font-size: 0.875rem; + --p-inputtext-color: #333; + --p-select-color: #333; + --p-multiselect-color: #333; + --p-textarea-color: #333; --p-select-sm-font-size: 0.875rem; --p-form-field-md-font-size: 1rem; --p-form-field-lg-font-size: 1.125rem; diff --git a/packages/shared/src/constants/countries.constants.ts b/packages/shared/src/constants/countries.constants.ts new file mode 100644 index 00000000..e77236ce --- /dev/null +++ b/packages/shared/src/constants/countries.constants.ts @@ -0,0 +1,110 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Common countries list for use in dropdowns and forms + */ +export const COUNTRIES = [ + { label: 'Afghanistan', value: 'AF' }, + { label: 'Albania', value: 'AL' }, + { label: 'Algeria', value: 'DZ' }, + { label: 'Argentina', value: 'AR' }, + { label: 'Armenia', value: 'AM' }, + { label: 'Australia', value: 'AU' }, + { label: 'Austria', value: 'AT' }, + { label: 'Azerbaijan', value: 'AZ' }, + { label: 'Bahrain', value: 'BH' }, + { label: 'Bangladesh', value: 'BD' }, + { label: 'Belarus', value: 'BY' }, + { label: 'Belgium', value: 'BE' }, + { label: 'Bolivia', value: 'BO' }, + { label: 'Brazil', value: 'BR' }, + { label: 'Bulgaria', value: 'BG' }, + { label: 'Cambodia', value: 'KH' }, + { label: 'Canada', value: 'CA' }, + { label: 'Chile', value: 'CL' }, + { label: 'China', value: 'CN' }, + { label: 'Colombia', value: 'CO' }, + { label: 'Costa Rica', value: 'CR' }, + { label: 'Croatia', value: 'HR' }, + { label: 'Czech Republic', value: 'CZ' }, + { label: 'Denmark', value: 'DK' }, + { label: 'Ecuador', value: 'EC' }, + { label: 'Egypt', value: 'EG' }, + { label: 'Estonia', value: 'EE' }, + { label: 'Ethiopia', value: 'ET' }, + { label: 'Finland', value: 'FI' }, + { label: 'France', value: 'FR' }, + { label: 'Georgia', value: 'GE' }, + { label: 'Germany', value: 'DE' }, + { label: 'Ghana', value: 'GH' }, + { label: 'Greece', value: 'GR' }, + { label: 'Guatemala', value: 'GT' }, + { label: 'Hungary', value: 'HU' }, + { label: 'Iceland', value: 'IS' }, + { label: 'India', value: 'IN' }, + { label: 'Indonesia', value: 'ID' }, + { label: 'Iran', value: 'IR' }, + { label: 'Iraq', value: 'IQ' }, + { label: 'Ireland', value: 'IE' }, + { label: 'Israel', value: 'IL' }, + { label: 'Italy', value: 'IT' }, + { label: 'Japan', value: 'JP' }, + { label: 'Jordan', value: 'JO' }, + { label: 'Kazakhstan', value: 'KZ' }, + { label: 'Kenya', value: 'KE' }, + { label: 'Kuwait', value: 'KW' }, + { label: 'Latvia', value: 'LV' }, + { label: 'Lebanon', value: 'LB' }, + { label: 'Lithuania', value: 'LT' }, + { label: 'Luxembourg', value: 'LU' }, + { label: 'Malaysia', value: 'MY' }, + { label: 'Mexico', value: 'MX' }, + { label: 'Morocco', value: 'MA' }, + { label: 'Netherlands', value: 'NL' }, + { label: 'New Zealand', value: 'NZ' }, + { label: 'Nigeria', value: 'NG' }, + { label: 'Norway', value: 'NO' }, + { label: 'Pakistan', value: 'PK' }, + { label: 'Peru', value: 'PE' }, + { label: 'Philippines', value: 'PH' }, + { label: 'Poland', value: 'PL' }, + { label: 'Portugal', value: 'PT' }, + { label: 'Qatar', value: 'QA' }, + { label: 'Romania', value: 'RO' }, + { label: 'Russia', value: 'RU' }, + { label: 'Saudi Arabia', value: 'SA' }, + { label: 'Serbia', value: 'RS' }, + { label: 'Singapore', value: 'SG' }, + { label: 'Slovakia', value: 'SK' }, + { label: 'Slovenia', value: 'SI' }, + { label: 'South Africa', value: 'ZA' }, + { label: 'South Korea', value: 'KR' }, + { label: 'Spain', value: 'ES' }, + { label: 'Sri Lanka', value: 'LK' }, + { label: 'Sweden', value: 'SE' }, + { label: 'Switzerland', value: 'CH' }, + { label: 'Taiwan', value: 'TW' }, + { label: 'Thailand', value: 'TH' }, + { label: 'Turkey', value: 'TR' }, + { label: 'Ukraine', value: 'UA' }, + { label: 'United Arab Emirates', value: 'AE' }, + { label: 'United Kingdom', value: 'GB' }, + { label: 'United States', value: 'US' }, + { label: 'Uruguay', value: 'UY' }, + { label: 'Venezuela', value: 'VE' }, + { label: 'Vietnam', value: 'VN' }, +] as const; + +/** + * Type for country codes + */ +export type CountryCode = (typeof COUNTRIES)[number]['value']; + +/** + * Helper function to get country name by code + */ +export function getCountryByCode(code: string): string { + const country = COUNTRIES.find((c) => c.value === code); + return country?.label || code; +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 4dc413cb..738b6d20 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -5,8 +5,11 @@ export * from './ai.constants'; export * from './api.constants'; export * from './colors.constants'; export * from './committees.constants'; +export * from './countries.constants'; export * from './file-upload.constants'; export * from './font-sizes.constants'; export * from './meeting.constants'; export * from './server.constants'; +export * from './states.constants'; export * from './timezones.constants'; +export * from './tshirt-sizes.constants'; diff --git a/packages/shared/src/constants/states.constants.ts b/packages/shared/src/constants/states.constants.ts new file mode 100644 index 00000000..7003ab36 --- /dev/null +++ b/packages/shared/src/constants/states.constants.ts @@ -0,0 +1,64 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * US states options for profile forms when country is United States + */ +export const US_STATES = [ + { label: 'Alabama', value: 'AL' }, + { label: 'Alaska', value: 'AK' }, + { label: 'Arizona', value: 'AZ' }, + { label: 'Arkansas', value: 'AR' }, + { label: 'California', value: 'CA' }, + { label: 'Colorado', value: 'CO' }, + { label: 'Connecticut', value: 'CT' }, + { label: 'Delaware', value: 'DE' }, + { label: 'Florida', value: 'FL' }, + { label: 'Georgia', value: 'GA' }, + { label: 'Hawaii', value: 'HI' }, + { label: 'Idaho', value: 'ID' }, + { label: 'Illinois', value: 'IL' }, + { label: 'Indiana', value: 'IN' }, + { label: 'Iowa', value: 'IA' }, + { label: 'Kansas', value: 'KS' }, + { label: 'Kentucky', value: 'KY' }, + { label: 'Louisiana', value: 'LA' }, + { label: 'Maine', value: 'ME' }, + { label: 'Maryland', value: 'MD' }, + { label: 'Massachusetts', value: 'MA' }, + { label: 'Michigan', value: 'MI' }, + { label: 'Minnesota', value: 'MN' }, + { label: 'Mississippi', value: 'MS' }, + { label: 'Missouri', value: 'MO' }, + { label: 'Montana', value: 'MT' }, + { label: 'Nebraska', value: 'NE' }, + { label: 'Nevada', value: 'NV' }, + { label: 'New Hampshire', value: 'NH' }, + { label: 'New Jersey', value: 'NJ' }, + { label: 'New Mexico', value: 'NM' }, + { label: 'New York', value: 'NY' }, + { label: 'North Carolina', value: 'NC' }, + { label: 'North Dakota', value: 'ND' }, + { label: 'Ohio', value: 'OH' }, + { label: 'Oklahoma', value: 'OK' }, + { label: 'Oregon', value: 'OR' }, + { label: 'Pennsylvania', value: 'PA' }, + { label: 'Rhode Island', value: 'RI' }, + { label: 'South Carolina', value: 'SC' }, + { label: 'South Dakota', value: 'SD' }, + { label: 'Tennessee', value: 'TN' }, + { label: 'Texas', value: 'TX' }, + { label: 'Utah', value: 'UT' }, + { label: 'Vermont', value: 'VT' }, + { label: 'Virginia', value: 'VA' }, + { label: 'Washington', value: 'WA' }, + { label: 'West Virginia', value: 'WV' }, + { label: 'Wisconsin', value: 'WI' }, + { label: 'Wyoming', value: 'WY' }, + { label: 'District of Columbia', value: 'DC' }, +] as const; + +/** + * Type for US state values + */ +export type USState = (typeof US_STATES)[number]['value']; diff --git a/packages/shared/src/constants/tshirt-sizes.constants.ts b/packages/shared/src/constants/tshirt-sizes.constants.ts new file mode 100644 index 00000000..32570611 --- /dev/null +++ b/packages/shared/src/constants/tshirt-sizes.constants.ts @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * T-shirt size options for profile forms and swag distribution + */ +export const TSHIRT_SIZES = [ + { label: 'Extra Small', value: 'XS' }, + { label: 'Small', value: 'S' }, + { label: 'Medium', value: 'M' }, + { label: 'Large', value: 'L' }, + { label: 'Extra Large', value: 'XL' }, + { label: 'XXL', value: 'XXL' }, + { label: 'XXXL', value: 'XXXL' }, +] as const; + +/** + * Type for T-shirt size values + */ +export type TShirtSize = (typeof TSHIRT_SIZES)[number]['value']; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 98cd5072..70213a0b 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -48,3 +48,6 @@ export * from './ai.interface'; // Access check interfaces export * from './access-check.interface'; + +// User profile interfaces +export * from './user-profile.interface'; diff --git a/packages/shared/src/interfaces/user-profile.interface.ts b/packages/shared/src/interfaces/user-profile.interface.ts new file mode 100644 index 00000000..80e5f341 --- /dev/null +++ b/packages/shared/src/interfaces/user-profile.interface.ts @@ -0,0 +1,67 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * User profile data from public.users table + */ +export interface UserProfile { + id: string; + email: string; + first_name: string | null; + last_name: string | null; + username: string | null; + created_at: string; + updated_at: string; +} + +/** + * Profile details data from public.profiles table + */ +export interface ProfileDetails { + id: number; + user_id: string; // UUID reference to auth.users.id + title: string | null; + organization: string | null; + country: string | null; + state: string | null; + city: string | null; + address: string | null; + zipcode: string | null; + phone_number: string | null; + tshirt_size: string | null; + created_at: string; + updated_at: string; +} + +/** + * Combined user profile and details + */ +export interface CombinedProfile { + user: UserProfile; + profile: ProfileDetails | null; +} + +/** + * Update request for user profile data + */ +export interface UpdateUserProfileRequest { + first_name?: string | null; + last_name?: string | null; + username?: string | null; + // Note: email updates may require special handling/verification +} + +/** + * Update request for profile details data + */ +export interface UpdateProfileDetailsRequest { + title?: string | null; + organization?: string | null; + country?: string | null; + state?: string | null; + city?: string | null; + address?: string | null; + zipcode?: string | null; + phone_number?: string | null; + tshirt_size?: string | null; +} From 4acdc61278501ab097832a78844e46d217119fa2 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 11 Sep 2025 15:25:33 -0700 Subject: [PATCH 2/4] refactor(profile): move member info to layout and align data-testid - Move member since and last active to profile layout opposite menu items - Add computed signals for memberSince and lastActive in ProfileLayoutComponent - Remove member info cards from ProfileStatsComponent sidebar - Reorganize profile stats component order: quick actions first, then statistics - Align all profile module data-testid with [section]-[component]-[element] convention - Update profile-email, profile-password, and profile-edit components data-testids Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva --- .../profile-stats.component.html | 86 +++++++++++++++++++ .../profile-stats.component.scss | 5 ++ .../profile-stats/profile-stats.component.ts | 86 +++++++++++++++++++ .../profile-layout.component.html | 52 ++++++++++- .../profile-layout.component.ts | 76 +++++++++++++++- .../profile/edit/profile-edit.component.html | 53 ++++++------ .../email/profile-email.component.html | 6 +- .../password/profile-password.component.html | 6 +- .../src/server/services/supabase.service.ts | 1 + packages/shared/src/interfaces/index.ts | 3 + .../interfaces/user-statistics.interface.ts | 14 +++ 11 files changed, 351 insertions(+), 37 deletions(-) create mode 100644 apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html create mode 100644 apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.scss create mode 100644 apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.ts create mode 100644 packages/shared/src/interfaces/user-statistics.interface.ts diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html new file mode 100644 index 00000000..cca6ef79 --- /dev/null +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html @@ -0,0 +1,86 @@ + + + +@if (statistics()) { +
+ + +
+

Quick Actions

+ + + +
+
+ + +
+ +

Profile Statistics

+
+ + +
+ + +
+
+ +
+
+

{{ statistics()!.committees }}

+

Committees

+
+
+
+ + + +
+
+ +
+
+

{{ statistics()!.meetings }}

+

Meetings

+
+
+
+ + + +
+
+ +
+
+

{{ statistics()!.contributions }}

+

Contributions

+
+
+
+ + + +
+
+ +
+
+

{{ statistics()!.activeProjects }}

+

Active Projects

+
+
+
+
+
+} diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.scss b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.scss new file mode 100644 index 00000000..60a8ab03 --- /dev/null +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.scss @@ -0,0 +1,5 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Additional styles for profile stats component +// Currently using Tailwind classes, custom styles can be added here if needed diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.ts b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.ts new file mode 100644 index 00000000..070dc2ee --- /dev/null +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.ts @@ -0,0 +1,86 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, input, Signal } from '@angular/core'; +import { CombinedProfile, UserStatistics } from '@lfx-pcc/shared/interfaces'; +import { CardComponent } from '@shared/components/card/card.component'; + +@Component({ + selector: 'lfx-profile-stats', + standalone: true, + imports: [CommonModule, CardComponent], + templateUrl: './profile-stats.component.html', + styleUrl: './profile-stats.component.scss', +}) +export class ProfileStatsComponent { + // Input profile data + public readonly profile = input(null); + + // Computed statistics + public readonly statistics: Signal = computed(() => { + const profileData = this.profile(); + if (!profileData?.user) return null; + + return { + committees: 5, // Mock data + meetings: 23, // Mock data + contributions: 147, // Mock data + activeProjects: 3, // Mock data + memberSince: this.calculateMemberSince(profileData.user.created_at), + lastActive: this.calculateLastActive(profileData.user.updated_at), + }; + }); + + /** + * Calculate how long the user has been a member + */ + private calculateMemberSince(createdAt: string): string { + const created = new Date(createdAt); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - created.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 30) { + return `${diffDays} day${diffDays === 1 ? '' : 's'}`; + } else if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `${months} month${months === 1 ? '' : 's'}`; + } + + const years = Math.floor(diffDays / 365); + const remainingMonths = Math.floor((diffDays % 365) / 30); + if (remainingMonths > 0) { + return `${years} year${years === 1 ? '' : 's'}, ${remainingMonths} month${remainingMonths === 1 ? '' : 's'}`; + } + return `${years} year${years === 1 ? '' : 's'}`; + } + + /** + * Calculate last active time + */ + private calculateLastActive(updatedAt: string): string { + const updated = new Date(updatedAt); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - updated.getTime()); + const diffMinutes = Math.floor(diffTime / (1000 * 60)); + const diffHours = Math.floor(diffTime / (1000 * 60 * 60)); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffMinutes < 1) { + return 'Just now'; + } else if (diffMinutes < 60) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffDays < 30) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `${months} month${months === 1 ? '' : 's'} ago`; + } + + const years = Math.floor(diffDays / 365); + return `${years} year${years === 1 ? '' : 's'} ago`; + } +} diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html index 7993fa6d..88849f18 100644 --- a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.html @@ -17,8 +17,8 @@ -
-
+
+
+

{{ subtitle }}

}
+ + +
+ + + + + + + + + +
@@ -63,13 +76,44 @@

+ +
+ + Member Since: + {{ memberSince() }} +
+ + +
+ + Last Active: + {{ lastActive() }} +
+

+ } @if (!loading()) { - +
+
+ +
+ +
+ + +
+ +
+
+
} @else {
diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts index ad813892..e401d587 100644 --- a/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/profile-layout.component.ts @@ -13,10 +13,12 @@ import { MenuItem } from 'primeng/api'; import { ChipModule } from 'primeng/chip'; import { finalize } from 'rxjs'; +import { ProfileStatsComponent } from './components/profile-stats/profile-stats.component'; + @Component({ selector: 'lfx-profile-layout', standalone: true, - imports: [CommonModule, RouterModule, AvatarComponent, BreadcrumbComponent, ChipModule], + imports: [CommonModule, RouterModule, AvatarComponent, BreadcrumbComponent, ChipModule, ProfileStatsComponent], templateUrl: './profile-layout.component.html', styleUrl: './profile-layout.component.scss', }) @@ -32,6 +34,8 @@ export class ProfileLayoutComponent { public readonly profileSubtitle = this.initializeProfileSubtitle(); public readonly profileLocation = this.initializeProfileLocation(); public readonly userInitials = this.initializeUserInitials(); + public readonly memberSince = this.initializeMemberSince(); + public readonly lastActive = this.initializeLastActive(); // Loading state public readonly breadcrumbItems = input([ @@ -152,4 +156,74 @@ export class ProfileLayoutComponent { return parts.join(', '); }); } + + private initializeMemberSince(): Signal { + return computed(() => { + const profile = this.profile(); + if (!profile?.user) return ''; + + return this.calculateMemberSince(profile.user.created_at); + }); + } + + private initializeLastActive(): Signal { + return computed(() => { + const profile = this.profile(); + if (!profile?.user) return ''; + + return this.calculateLastActive(profile.user.updated_at); + }); + } + + /** + * Calculate how long the user has been a member + */ + private calculateMemberSince(createdAt: string): string { + const created = new Date(createdAt); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - created.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 30) { + return `${diffDays} day${diffDays === 1 ? '' : 's'}`; + } else if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `${months} month${months === 1 ? '' : 's'}`; + } + + const years = Math.floor(diffDays / 365); + const remainingMonths = Math.floor((diffDays % 365) / 30); + if (remainingMonths > 0) { + return `${years} year${years === 1 ? '' : 's'}, ${remainingMonths} month${remainingMonths === 1 ? '' : 's'}`; + } + return `${years} year${years === 1 ? '' : 's'}`; + } + + /** + * Calculate last active time + */ + private calculateLastActive(updatedAt: string): string { + const updated = new Date(updatedAt); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - updated.getTime()); + const diffMinutes = Math.floor(diffTime / (1000 * 60)); + const diffHours = Math.floor(diffTime / (1000 * 60 * 60)); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffMinutes < 1) { + return 'Just now'; + } else if (diffMinutes < 60) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffDays < 30) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `${months} month${months === 1 ? '' : 's'} ago`; + } + + const years = Math.floor(diffDays / 365); + return `${years} year${years === 1 ? '' : 's'} ago`; + } } diff --git a/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html b/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html index ff7d5814..5e0c11e7 100644 --- a/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html +++ b/apps/lfx-pcc/src/app/modules/profile/edit/profile-edit.component.html @@ -1,14 +1,14 @@ -
+
@if (isLoading()) { -
- +
+ Loading profile...
} @else { -
+
@@ -28,7 +28,7 @@
Complete Your Open Source P
-

Personal Information

+

Personal Information

Helps community members identify and connect with you in discussions and contributions

@@ -43,7 +43,7 @@

+ data-testid="profile-edit-personal-first-name">

@@ -57,7 +57,7 @@

+ data-testid="profile-edit-personal-last-name">

@@ -69,7 +69,7 @@

+ data-testid="profile-edit-personal-username-tooltip">

+ data-testid="profile-edit-personal-username">
@@ -88,7 +88,7 @@

-

Professional Information

+

Professional Information

Showcase your expertise and affiliations to build credibility and unlock networking opportunities

@@ -103,7 +103,7 @@

+ data-testid="profile-edit-professional-title">

@@ -115,7 +115,7 @@

+ data-testid="profile-edit-professional-organization-tooltip">

+ data-testid="profile-edit-professional-organization">
@@ -134,7 +134,7 @@

-

Location Information

+

Location Information

@@ -159,7 +159,7 @@

+ data-testid="profile-edit-location-country">

@@ -176,7 +176,7 @@

+ data-testid="profile-edit-location-state-select"> } @else { + data-testid="profile-edit-location-state-input"> }

@@ -201,7 +201,7 @@

+ data-testid="profile-edit-location-city">

@@ -215,7 +215,7 @@

+ data-testid="profile-edit-location-address"> @@ -229,7 +229,7 @@

+ data-testid="profile-edit-location-zipcode"> @@ -243,7 +243,7 @@

+ data-testid="profile-edit-location-phone">

Optional - Only used for urgent event notifications and is kept private

@@ -253,7 +253,7 @@

-

Additional Information

+

Additional Information

Helps us send you the right swag and plan for community events

@@ -266,7 +266,7 @@

+ data-testid="profile-edit-additional-tshirt-tooltip"> + data-testid="profile-edit-additional-tshirt-size"> @@ -285,7 +285,8 @@

- + + + data-testid="profile-edit-actions-save-button"> diff --git a/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html b/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html index e08b9c5e..7b896927 100644 --- a/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html +++ b/apps/lfx-pcc/src/app/modules/profile/email/profile-email.component.html @@ -5,10 +5,10 @@
-

Email Settings

-

Email management functionality will be implemented in a future update.

+

Email Settings

+

Email management functionality will be implemented in a future update.

📧
-

Coming Soon

+

Coming Soon

diff --git a/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html b/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html index 6b9ee423..ee3a9096 100644 --- a/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html +++ b/apps/lfx-pcc/src/app/modules/profile/password/profile-password.component.html @@ -5,10 +5,10 @@
-

Change Password

-

Password management functionality will be implemented in a future update.

+

Change Password

+

Password management functionality will be implemented in a future update.

🔒
-

Coming Soon

+

Coming Soon

diff --git a/apps/lfx-pcc/src/server/services/supabase.service.ts b/apps/lfx-pcc/src/server/services/supabase.service.ts index 53d210be..3780a910 100644 --- a/apps/lfx-pcc/src/server/services/supabase.service.ts +++ b/apps/lfx-pcc/src/server/services/supabase.service.ts @@ -584,6 +584,7 @@ export class SupabaseService { const updateData = { ...data, + username: username, updated_at: new Date().toISOString(), }; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 70213a0b..577ad5e9 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -51,3 +51,6 @@ export * from './access-check.interface'; // User profile interfaces export * from './user-profile.interface'; + +// User statistics interfaces +export * from './user-statistics.interface'; diff --git a/packages/shared/src/interfaces/user-statistics.interface.ts b/packages/shared/src/interfaces/user-statistics.interface.ts new file mode 100644 index 00000000..30557fa1 --- /dev/null +++ b/packages/shared/src/interfaces/user-statistics.interface.ts @@ -0,0 +1,14 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * User statistics interface for profile metrics + */ +export interface UserStatistics { + committees: number; + meetings: number; + contributions: number; + activeProjects: number; + memberSince: string; + lastActive: string; +} From e131fb69a39cc04fca879a1b41a3ed961e283d8e Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 11 Sep 2025 17:31:09 -0700 Subject: [PATCH 3/4] fix(server): m2m auth0 issuer base url Signed-off-by: Asitha de Silva --- apps/lfx-pcc/.env.example | 2 +- apps/lfx-pcc/src/server/utils/m2m-token.util.ts | 10 +++++----- docs/architecture/backend/authentication.md | 4 ++-- docs/deployment.md | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/lfx-pcc/.env.example b/apps/lfx-pcc/.env.example index fb5b92a5..184967a3 100644 --- a/apps/lfx-pcc/.env.example +++ b/apps/lfx-pcc/.env.example @@ -14,7 +14,7 @@ PCC_AUTH0_SECRET=sufficiently-long-string # M2M Token Generation M2M_AUTH_CLIENT_ID=your-auth0-client-id M2M_AUTH_CLIENT_SECRET=your-auth0-client-secret -M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local +M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local/ M2M_AUTH_AUDIENCE=http://lfx-api.k8s.orb.local/ # Microservice Configuration diff --git a/apps/lfx-pcc/src/server/utils/m2m-token.util.ts b/apps/lfx-pcc/src/server/utils/m2m-token.util.ts index 892dc2b1..e2e2a0af 100644 --- a/apps/lfx-pcc/src/server/utils/m2m-token.util.ts +++ b/apps/lfx-pcc/src/server/utils/m2m-token.util.ts @@ -29,7 +29,7 @@ export async function generateM2MToken(req: Request): Promise { // Select the appropriate request configuration const config = isAuthelia ? AUTHELIA_TOKEN_REQUEST : AUTH0_TOKEN_REQUEST; - const tokenEndpoint = `${issuerBaseUrl}${config.endpoint}`; + const tokenEndpoint = `${issuerBaseUrl}/${config.endpoint}`; // Prepare request options based on auth provider const requestOptions = { @@ -113,8 +113,8 @@ export async function generateM2MToken(req: Request): Promise { * Request configuration for Auth0 M2M token generation */ const AUTH0_TOKEN_REQUEST = { - endpoint: '/oauth/token', - method: 'POST' as const, + endpoint: 'oauth/token', + method: 'POST', createHeaders: () => ({ ['Cache-Control']: 'no-cache', ['Content-Type']: 'application/json', @@ -132,8 +132,8 @@ const AUTH0_TOKEN_REQUEST = { * Request configuration for Authelia M2M token generation */ const AUTHELIA_TOKEN_REQUEST = { - endpoint: '/api/oidc/token', - method: 'POST' as const, + endpoint: 'api/oidc/token', + method: 'POST', createHeaders: () => { const clientId = process.env['M2M_AUTH_CLIENT_ID']; const clientSecret = process.env['M2M_AUTH_CLIENT_SECRET']; diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 09894610..dc189e26 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -12,7 +12,7 @@ The application uses Auth0 for user authentication via `express-openid-connect` # User Authentication (Auth0/Authelia) PCC_AUTH0_SECRET='your-auth0-secret' PCC_BASE_URL='http://localhost:4000' -PCC_AUTH0_ISSUER_BASE_URL='https://your-domain.auth0.com' +PCC_AUTH0_ISSUER_BASE_URL='https://your-domain.auth0.com/' PCC_AUTH0_CLIENT_ID='your-client-id' PCC_AUTH0_CLIENT_SECRET='your-client-secret' PCC_AUTH0_AUDIENCE='https://your-api-audience' @@ -20,7 +20,7 @@ PCC_AUTH0_AUDIENCE='https://your-api-audience' # Machine-to-Machine (M2M) Token Authentication M2M_AUTH_CLIENT_ID='your-m2m-client-id' M2M_AUTH_CLIENT_SECRET='your-m2m-client-secret' -M2M_AUTH_ISSUER_BASE_URL='https://auth.k8s.orb.local' +M2M_AUTH_ISSUER_BASE_URL='https://auth.k8s.orb.local/' M2M_AUTH_AUDIENCE='http://lfx-api.k8s.orb.local/' ``` diff --git a/docs/deployment.md b/docs/deployment.md index eb12700b..437bef14 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -208,7 +208,7 @@ LOG_LEVEL=info # Get these values from your Auth0 dashboard PCC_AUTH0_CLIENT_ID=your-auth0-client-id PCC_AUTH0_CLIENT_SECRET=your-auth0-client-secret -PCC_AUTH0_ISSUER_BASE_URL=https://auth.k8s.orb.local +PCC_AUTH0_ISSUER_BASE_URL=https://auth.k8s.orb.local/ PCC_AUTH0_AUDIENCE=http://lfx-api.k8s.orb.local/ PCC_AUTH0_SECRET=sufficiently-long-string @@ -216,7 +216,7 @@ PCC_AUTH0_SECRET=sufficiently-long-string # For server-side API calls from public endpoints M2M_AUTH_CLIENT_ID=your-m2m-client-id M2M_AUTH_CLIENT_SECRET=your-m2m-client-secret -M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local +M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local/ M2M_AUTH_AUDIENCE=http://lfx-api.k8s.orb.local/ # Microservice Configuration From c41d8d7c74b291c08ac668c1f1cbaf72c4e8eadc Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 11 Sep 2025 17:33:40 -0700 Subject: [PATCH 4/4] fix(ui): reuse stats Signed-off-by: Asitha de Silva --- .../profile-stats/profile-stats.component.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html index cca6ef79..38e54436 100644 --- a/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html +++ b/apps/lfx-pcc/src/app/layouts/profile-layout/components/profile-stats/profile-stats.component.html @@ -1,7 +1,7 @@ -@if (statistics()) { +@if (statistics(); as stats) {
@@ -37,7 +37,7 @@

Profile Statistics

-

{{ statistics()!.committees }}

+

{{ stats!.committees }}

Committees

@@ -50,7 +50,7 @@

Profile Statistics

-

{{ statistics()!.meetings }}

+

{{ stats!.meetings }}

Meetings

@@ -63,7 +63,7 @@

Profile Statistics

-

{{ statistics()!.contributions }}

+

{{ stats!.contributions }}

Contributions

@@ -76,7 +76,7 @@

Profile Statistics

-

{{ statistics()!.activeProjects }}

+

{{ stats!.activeProjects }}

Active Projects