From 8c3d1f67df9f7271a77553978e630cec0d574f66 Mon Sep 17 00:00:00 2001 From: Salve Date: Sat, 11 Apr 2026 16:45:28 -0400 Subject: [PATCH 1/2] add password reset flow --- src/app/app.routes.ts | 4 + src/app/components/login/login.css | 5 + src/app/components/login/login.html | 1 + src/app/components/login/login.spec.ts | 17 ++- src/app/components/login/login.ts | 34 ++++- .../reset-password/reset-password.css | 130 ++++++++++++++++++ .../reset-password/reset-password.html | 50 +++++++ .../reset-password/reset-password.spec.ts | 86 ++++++++++++ .../reset-password/reset-password.ts | 80 +++++++++++ src/app/services/auth.ts | 32 +++++ 10 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 src/app/components/reset-password/reset-password.css create mode 100644 src/app/components/reset-password/reset-password.html create mode 100644 src/app/components/reset-password/reset-password.spec.ts create mode 100644 src/app/components/reset-password/reset-password.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ee8f7f7..fac7972 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,6 +8,10 @@ import { WorkspaceMembersComponent } from './components/workspace-members/worksp export const routes: Routes = [ { path: 'login', component: Login }, { path: 'signup', component: Login }, + { + path: 'reset-password/:token', + loadComponent: () => import('./components/reset-password/reset-password').then(m => m.ResetPasswordComponent) + }, { path: 'invitations/:token', loadComponent: () => import('./components/accept-invitation/accept-invitation').then(m => m.AcceptInvitationComponent) diff --git a/src/app/components/login/login.css b/src/app/components/login/login.css index 40dab63..ff8c554 100644 --- a/src/app/components/login/login.css +++ b/src/app/components/login/login.css @@ -329,6 +329,11 @@ h1 { cursor: default; } +.success-link { + margin-top: 14px; + text-decoration: none; +} + .subtle-copy { margin: 0 0 6px; color: var(--muted); diff --git a/src/app/components/login/login.html b/src/app/components/login/login.html index 99315bf..ead13f5 100644 --- a/src/app/components/login/login.html +++ b/src/app/components/login/login.html @@ -127,6 +127,7 @@

Welcome back

{{ successTitle }}

{{ successText }}

+ {{ successActionLabel }} diff --git a/src/app/components/login/login.spec.ts b/src/app/components/login/login.spec.ts index 2852bd6..b04c4d1 100644 --- a/src/app/components/login/login.spec.ts +++ b/src/app/components/login/login.spec.ts @@ -15,7 +15,7 @@ describe('Login', () => { let document: Document; beforeEach(async () => { - mockAuthService = jasmine.createSpyObj('AuthService', ['login', 'signup']); + mockAuthService = jasmine.createSpyObj('AuthService', ['login', 'signup', 'requestPasswordReset']); spyOn(localStorage, 'getItem').and.returnValue(null); spyOn(localStorage, 'setItem'); @@ -188,4 +188,19 @@ describe('Login', () => { expect(component.forgotError).toBe('Enter a valid email address'); expect(component.isForgotSubmitting).toBeFalse(); }); + + it('requests a password reset link from the backend', () => { + mockAuthService.requestPasswordReset.and.returnValue(of({ + message: 'If an account exists, a reset link has been generated.', + resetUrl: 'http://localhost:4200/reset-password/reset-token', + })); + component.showForgotPassword(); + component.forgotEmail = 'user@example.com'; + + component.handleForgot(); + + expect(mockAuthService.requestPasswordReset).toHaveBeenCalledWith('user@example.com'); + expect(component.showSuccess).toBeTrue(); + expect(component.successActionUrl).toContain('/reset-password/reset-token'); + }); }); diff --git a/src/app/components/login/login.ts b/src/app/components/login/login.ts index 686367b..82611d5 100644 --- a/src/app/components/login/login.ts +++ b/src/app/components/login/login.ts @@ -37,6 +37,8 @@ export class Login implements OnInit { showSuccess = false; successTitle = 'Check your email'; successText = 'We sent you a verification link.'; + successActionLabel = ''; + successActionUrl = ''; isDarkMode = false; isLoginSubmitting = false; @@ -187,17 +189,39 @@ export class Login implements OnInit { return; } this.isForgotSubmitting = true; - setTimeout(() => { - this.isForgotSubmitting = false; - this.showSuccessMessage('Check your email', 'We sent a password reset link.'); - }, 1200); + + this.authService.requestPasswordReset(this.forgotEmail.trim()).pipe( + timeout(8000), + finalize(() => { + this.isForgotSubmitting = false; + this.syncView(); + }) + ).subscribe({ + next: (response) => { + this.showSuccessMessage( + 'Check your email', + response.resetUrl + ? 'A password reset link is ready. Use the button below to continue.' + : response.message, + response.resetUrl ? 'Open Reset Link' : '', + response.resetUrl ?? '' + ); + this.syncView(); + }, + error: () => { + this.forgotError = 'Could not start password reset. Please try again.'; + this.syncView(); + } + }); } - private showSuccessMessage(title: string, text: string): void { + private showSuccessMessage(title: string, text: string, actionLabel: string = '', actionUrl: string = ''): void { this.showForgot = false; this.showSuccess = true; this.successTitle = title; this.successText = text; + this.successActionLabel = actionLabel; + this.successActionUrl = actionUrl; } private applyTheme(): void { diff --git a/src/app/components/reset-password/reset-password.css b/src/app/components/reset-password/reset-password.css new file mode 100644 index 0000000..a0d4734 --- /dev/null +++ b/src/app/components/reset-password/reset-password.css @@ -0,0 +1,130 @@ +:host { + display: block; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(15, 23, 42, 0.08), transparent 38%), + linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); + color: #111827; + font-family: Inter, sans-serif; +} + +* { + box-sizing: border-box; +} + +.reset-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.reset-card { + width: min(100%, 460px); + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 20px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12); + padding: 32px; +} + +.eyebrow { + margin: 0 0 10px; + color: #475569; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1 { + margin: 0 0 12px; + font-family: "Space Grotesk", sans-serif; + font-size: 34px; + line-height: 1.05; +} + +.supporting-copy { + margin: 0 0 18px; + color: #475569; + line-height: 1.6; +} + +.reset-form { + display: grid; + gap: 12px; +} + +label { + font-size: 13px; + font-weight: 600; + color: #334155; +} + +input { + width: 100%; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 12px; + padding: 13px 14px; + background: #ffffff; + color: #111827; +} + +input:focus { + outline: none; + border-color: #111827; + box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.08); +} + +.primary-btn, +.link-btn { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 46px; + border-radius: 12px; + text-decoration: none; + font-weight: 700; +} + +.primary-btn { + border: none; + background: #111827; + color: #f8fafc; + cursor: pointer; +} + +.primary-btn:disabled { + opacity: 0.7; + cursor: default; +} + +.link-btn { + color: #111827; +} + +.message { + display: grid; + gap: 12px; + padding: 16px; + border-radius: 14px; +} + +.message.success { + background: rgba(22, 163, 74, 0.08); + color: #166534; +} + +.message.error, +.inline-error { + color: #b91c1c; +} + +.message.error { + background: rgba(239, 68, 68, 0.08); +} + +.inline-error { + margin: 0; + font-size: 13px; +} diff --git a/src/app/components/reset-password/reset-password.html b/src/app/components/reset-password/reset-password.html new file mode 100644 index 0000000..e10fc9e --- /dev/null +++ b/src/app/components/reset-password/reset-password.html @@ -0,0 +1,50 @@ +
+
+

Account Recovery

+

Reset your password

+ +

Validating your reset link...

+ +
+

{{ errorMessage }}

+ Back to sign in +
+ +
+

+ Choose a new password for {{ email }}. +

+ + + + + + + +

{{ errorMessage }}

+ + + Back to sign in +
+ +
+

{{ successMessage }}

+
+
+
diff --git a/src/app/components/reset-password/reset-password.spec.ts b/src/app/components/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..c3e475f --- /dev/null +++ b/src/app/components/reset-password/reset-password.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { ResetPasswordComponent } from './reset-password'; +import { AuthService } from '../../services/auth'; + +describe('ResetPasswordComponent', () => { + let component: ResetPasswordComponent; + let fixture: ComponentFixture; + let mockAuthService: jasmine.SpyObj; + let router: Router; + + beforeEach(async () => { + mockAuthService = jasmine.createSpyObj('AuthService', [ + 'validatePasswordResetToken', + 'resetPassword', + ]); + + mockAuthService.validatePasswordResetToken.and.returnValue(of({ + valid: true, + email: 'user@example.com', + })); + mockAuthService.resetPassword.and.returnValue(of(void 0)); + + await TestBed.configureTestingModule({ + imports: [ResetPasswordComponent, RouterTestingModule], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ token: 'reset-token' }), + }, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ResetPasswordComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + fixture.detectChanges(); + }); + + it('validates the reset token on init', () => { + expect(mockAuthService.validatePasswordResetToken).toHaveBeenCalledWith('reset-token'); + expect(component.isTokenValid).toBeTrue(); + expect(component.email).toBe('user@example.com'); + }); + + it('shows an error when passwords do not match', () => { + component.password = 'password123'; + component.confirmPassword = 'password321'; + + component.submit(); + + expect(component.errorMessage).toBe('Passwords do not match.'); + expect(mockAuthService.resetPassword).not.toHaveBeenCalled(); + }); + + it('submits a new password and redirects to login', fakeAsync(() => { + spyOn(router, 'navigate'); + component.password = 'password123'; + component.confirmPassword = 'password123'; + + component.submit(); + tick(1200); + + expect(mockAuthService.resetPassword).toHaveBeenCalledWith('reset-token', 'password123'); + expect(router.navigate).toHaveBeenCalledWith(['/login']); + })); + + it('shows an error when the reset token is invalid', async () => { + mockAuthService.validatePasswordResetToken.and.returnValue( + throwError(() => new Error('Reset link is invalid or expired.')), + ); + + const invalidFixture = TestBed.createComponent(ResetPasswordComponent); + invalidFixture.detectChanges(); + await invalidFixture.whenStable(); + + expect(invalidFixture.componentInstance.errorMessage).toContain('invalid'); + }); +}); diff --git a/src/app/components/reset-password/reset-password.ts b/src/app/components/reset-password/reset-password.ts new file mode 100644 index 0000000..b807b3b --- /dev/null +++ b/src/app/components/reset-password/reset-password.ts @@ -0,0 +1,80 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { finalize } from 'rxjs'; +import { AuthService } from '../../services/auth'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink], + templateUrl: './reset-password.html', + styleUrl: './reset-password.css', +}) +export class ResetPasswordComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + + token = ''; + email = ''; + password = ''; + confirmPassword = ''; + errorMessage = ''; + successMessage = ''; + isLoading = true; + isSubmitting = false; + isTokenValid = false; + + ngOnInit(): void { + this.token = this.route.snapshot.paramMap.get('token') ?? ''; + if (!this.token) { + this.errorMessage = 'Reset link is invalid.'; + this.isLoading = false; + return; + } + + this.authService.validatePasswordResetToken(this.token).pipe( + finalize(() => { + this.isLoading = false; + }) + ).subscribe({ + next: (response) => { + this.isTokenValid = response.valid; + this.email = response.email; + }, + error: (error: Error) => { + this.errorMessage = error.message || 'Reset link is invalid or expired.'; + } + }); + } + + submit(): void { + this.errorMessage = ''; + + if (this.password.trim().length < 8) { + this.errorMessage = 'Password must be at least 8 characters.'; + return; + } + if (this.password !== this.confirmPassword) { + this.errorMessage = 'Passwords do not match.'; + return; + } + + this.isSubmitting = true; + this.authService.resetPassword(this.token, this.password).pipe( + finalize(() => { + this.isSubmitting = false; + }) + ).subscribe({ + next: () => { + this.successMessage = 'Password updated successfully. Redirecting to sign in...'; + setTimeout(() => this.router.navigate(['/login']), 1200); + }, + error: (error: Error) => { + this.errorMessage = error.message || 'Could not reset your password.'; + } + }); + } +} diff --git a/src/app/services/auth.ts b/src/app/services/auth.ts index b91a977..86f7b4c 100644 --- a/src/app/services/auth.ts +++ b/src/app/services/auth.ts @@ -12,6 +12,16 @@ export interface SignupProfile { organization?: string; } +interface ForgotPasswordResponse { + message: string; + reset_url?: string; +} + +interface ResetTokenValidationResponse { + valid: boolean; + email: string; +} + @Injectable({ providedIn: 'root', }) @@ -41,6 +51,28 @@ export class AuthService { ); } + requestPasswordReset(email: string): Observable<{ message: string; resetUrl?: string }> { + return this.http.post(`${this.apiUrl}/forgot-password`, { email }).pipe( + map((response) => ({ + message: response.message, + resetUrl: response.reset_url, + })), + ); + } + + validatePasswordResetToken(token: string): Observable<{ valid: boolean; email: string }> { + return this.http.get(`${this.apiUrl}/reset-password/${token}`).pipe( + map((response) => ({ + valid: response.valid, + email: response.email, + })), + ); + } + + resetPassword(token: string, password: string): Observable { + return this.http.post(`${this.apiUrl}/reset-password/${token}`, { password }); + } + logout(): void { localStorage.removeItem(this.tokenKey); } From ca856a7f73003bd76d0856ecbef1e6e0f7da8611 Mon Sep 17 00:00:00 2001 From: Neethika Date: Sun, 12 Apr 2026 14:09:33 -0400 Subject: [PATCH 2/2] feat(profile): add profile page flow and resilient rendering (ref Sentinent-AI/Sentinent#23) --- proxy.conf.json | 2 +- src/app/app.routes.ts | 5 + src/app/components/dashboard/dashboard.css | 13 +- src/app/components/dashboard/dashboard.html | 5 +- src/app/components/login/login.css | 6 - src/app/components/profile/profile.css | 323 ++++++++++++++++++ src/app/components/profile/profile.html | 169 +++++++++ src/app/components/profile/profile.spec.ts | 92 +++++ src/app/components/profile/profile.ts | 193 +++++++++++ src/app/models/user-profile.model.ts | 11 + src/app/services/auth.ts | 6 + src/app/services/user-profile.service.spec.ts | 94 +++++ src/app/services/user-profile.service.ts | 65 ++++ 13 files changed, 974 insertions(+), 10 deletions(-) create mode 100644 src/app/components/profile/profile.css create mode 100644 src/app/components/profile/profile.html create mode 100644 src/app/components/profile/profile.spec.ts create mode 100644 src/app/components/profile/profile.ts create mode 100644 src/app/models/user-profile.model.ts create mode 100644 src/app/services/user-profile.service.spec.ts create mode 100644 src/app/services/user-profile.service.ts diff --git a/proxy.conf.json b/proxy.conf.json index e7c761e..975a708 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -1,6 +1,6 @@ { "/api": { - "target": "http://127.0.0.1:8080", + "target": "http://localhost:8080", "secure": false, "changeOrigin": true } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index fac7972..9a76a31 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -21,6 +21,11 @@ export const routes: Routes = [ loadComponent: () => import('./components/dashboard/dashboard').then(m => m.Dashboard), canActivate: [authGuard] }, + { + path: 'profile', + loadComponent: () => import('./components/profile/profile').then(m => m.ProfileComponent), + canActivate: [authGuard] + }, { path: 'workspace/create', component: CreateWorkspace, diff --git a/src/app/components/dashboard/dashboard.css b/src/app/components/dashboard/dashboard.css index 9e382dc..722f339 100644 --- a/src/app/components/dashboard/dashboard.css +++ b/src/app/components/dashboard/dashboard.css @@ -37,6 +37,12 @@ gap: 16px; } +.hero-actions { + display: flex; + align-items: center; + gap: 10px; +} + .brand { display: inline-flex; align-items: center; @@ -56,16 +62,19 @@ place-items: center; } -.logout-btn { +.logout-btn, +.profile-btn { border: 1px solid rgba(255, 255, 255, 0.24); background: transparent; color: #f7f7f7; border-radius: 10px; padding: 9px 14px; cursor: pointer; + text-decoration: none; } -.logout-btn:hover { +.logout-btn:hover, +.profile-btn:hover { background: rgba(255, 255, 255, 0.1); } diff --git a/src/app/components/dashboard/dashboard.html b/src/app/components/dashboard/dashboard.html index 515456e..281ace7 100644 --- a/src/app/components/dashboard/dashboard.html +++ b/src/app/components/dashboard/dashboard.html @@ -12,7 +12,10 @@ Sentinent - +
+ Profile + +
diff --git a/src/app/components/login/login.css b/src/app/components/login/login.css index ff8c554..9b9a3b7 100644 --- a/src/app/components/login/login.css +++ b/src/app/components/login/login.css @@ -334,12 +334,6 @@ h1 { text-decoration: none; } -.subtle-copy { - margin: 0 0 6px; - color: var(--muted); - font-size: 13px; -} - .error-text, .global-error { margin: 0; diff --git a/src/app/components/profile/profile.css b/src/app/components/profile/profile.css new file mode 100644 index 0000000..e794a6c --- /dev/null +++ b/src/app/components/profile/profile.css @@ -0,0 +1,323 @@ +:host { + display: block; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.14), transparent 24%), + linear-gradient(180deg, #161616 0%, #090909 100%); + color: #f5f5f5; +} + +* { + box-sizing: border-box; +} + +.profile-shell { + max-width: 1180px; + margin: 0 auto; + padding: 42px 24px 64px; +} + +.profile-hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding: 36px; + border-radius: 32px; + background: linear-gradient(180deg, #ffffff 0%, #ececec 100%); + color: #090909; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28); +} + +.eyebrow { + margin: 0 0 10px; + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(9, 9, 9, 0.6); +} + +.hero-copy h1 { + margin: 0 0 12px; + font-size: 44px; + line-height: 1; +} + +.intro { + margin: 0; + max-width: 640px; + font-size: 17px; + line-height: 1.7; + color: rgba(9, 9, 9, 0.72); +} + +.hero-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; +} + +.hero-meta span { + padding: 9px 12px; + border-radius: 999px; + background: #111111; + color: #ffffff; + font-size: 13px; + font-weight: 600; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; +} + +.primary-btn, +.secondary-btn { + border-radius: 999px; + padding: 12px 18px; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: transform 0.16s ease, background 0.16s ease, color 0.16s ease; +} + +.primary-btn { + border: 1px solid #111111; + background: #111111; + color: #ffffff; +} + +.secondary-btn { + border: 1px solid rgba(9, 9, 9, 0.16); + background: transparent; + color: #111111; +} + +.primary-btn:hover, +.secondary-btn:hover { + transform: translateY(-1px); +} + +.secondary-btn:hover { + background: rgba(17, 17, 17, 0.06); +} + +.feedback { + margin: 18px 0 0; + padding: 14px 16px; + border-radius: 16px; + font-weight: 600; +} + +.feedback.success { + background: #f2f2f2; + color: #111111; +} + +.feedback.error { + background: #1a1a1a; + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.profile-layout { + display: grid; + grid-template-columns: 290px minmax(0, 1fr); + gap: 24px; + margin-top: 28px; + align-items: start; +} + +.profile-sidebar, +.profile-main { + display: grid; + gap: 20px; +} + +.profile-card { + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 28px; + padding: 26px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.24); + color: #111111; +} + +.loading-card { + margin-top: 12px; +} + +.summary-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 20px; +} + +.avatar { + width: 84px; + height: 84px; + border-radius: 24px; + display: grid; + place-items: center; + background: #111111; + color: #ffffff; + font-size: 34px; + font-weight: 800; + text-transform: uppercase; +} + +.summary-copy h2, +.profile-card h3 { + margin: 0 0 8px; +} + +.summary-copy h2 { + font-size: 28px; +} + +.summary-copy p, +.summary-copy small, +.bio-copy { + margin: 0; + line-height: 1.7; + color: #5b5b5b; +} + +.compact-card h3, +.section-card h3 { + font-size: 22px; +} + +.snapshot-list { + display: grid; + gap: 14px; +} + +.snapshot-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(17, 17, 17, 0.08); +} + +.snapshot-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.snapshot-row span, +.detail-tile span { + color: #666666; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.snapshot-row strong, +.detail-tile strong { + font-size: 15px; + line-height: 1.5; +} + +.section-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; +} + +.section-kicker { + margin: 0 0 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: #7a7a7a; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.detail-tile { + display: grid; + gap: 10px; + padding: 18px; + border-radius: 20px; + background: #f6f6f6; + border: 1px solid rgba(17, 17, 17, 0.05); +} + +.edit-form { + display: grid; + gap: 18px; +} + +.edit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.field-group { + display: grid; + gap: 8px; +} + +.field-group-wide { + grid-column: 1 / -1; +} + +.field-label { + font-size: 13px; + font-weight: 700; + color: #4a4a4a; +} + +.field-input { + width: 100%; + border: 1px solid rgba(17, 17, 17, 0.14); + border-radius: 14px; + padding: 12px 14px; + background: #ffffff; + color: #111111; + font: inherit; +} + +.field-input:focus { + outline: none; + border-color: #111111; + box-shadow: 0 0 0 4px rgba(17, 17, 17, 0.06); +} + +.field-area { + min-height: 132px; + resize: vertical; +} + +.form-actions { + margin-top: 2px; +} + +@media (max-width: 860px) { + .profile-hero, + .profile-layout, + .detail-grid, + .edit-grid { + display: grid; + grid-template-columns: 1fr; + } + + .hero-actions { + justify-content: flex-start; + } +} diff --git a/src/app/components/profile/profile.html b/src/app/components/profile/profile.html new file mode 100644 index 0000000..88e9e21 --- /dev/null +++ b/src/app/components/profile/profile.html @@ -0,0 +1,169 @@ +
+
+

Loading

+

Loading your profile...

+

We’re pulling your account details from Sentinent now.

+
+
+ +
+
+

Profile unavailable

+

We couldn’t load this profile yet.

+

{{ errorMessage || 'Try refreshing after the backend is running with the new profile API.' }}

+
+ + Back to dashboard +
+
+
+ +
+
+
+

Account Profile

+

{{ displayName() }}

+

Keep your identity and workspace context accurate so teammates know who is behind each signal, decision, and integration.

+
+ {{ withFallback(profile.roleLabel, 'Role not set') }} + {{ withFallback(profile.organization, 'Organization not set') }} + {{ withFallback(profile.timezone, 'Timezone not set') }} +
+
+ +
+ Back to dashboard + + +
+
+ + + + +
+ + +
+
+
+
+

Identity

+

Profile details

+
+
+ +
+
+ Full name + {{ displayName() }} +
+
+ Email + {{ profile.email || 'Not provided yet' }} +
+
+ Job title + {{ withFallback(profile.jobTitle, 'Not provided yet') }} +
+
+ Organization or team + {{ withFallback(profile.organization, 'Not provided yet') }} +
+
+ Timezone + {{ withFallback(profile.timezone, 'Not provided yet') }} +
+
+ Role label + {{ withFallback(profile.roleLabel, 'Not provided yet') }} +
+
+
+ +
+
+
+

Edit mode

+

Update your profile

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

About

+

Personal summary

+
+
+

{{ withFallback(profile.bio, 'Add a short bio to help teammates understand your role.') }}

+
+
+
+
diff --git a/src/app/components/profile/profile.spec.ts b/src/app/components/profile/profile.spec.ts new file mode 100644 index 0000000..81eee33 --- /dev/null +++ b/src/app/components/profile/profile.spec.ts @@ -0,0 +1,92 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NEVER, of } from 'rxjs'; +import { ProfileComponent } from './profile'; +import { UserProfileService } from '../../services/user-profile.service'; + +describe('ProfileComponent', () => { + let component: ProfileComponent; + let fixture: ComponentFixture; + let mockUserProfileService: jasmine.SpyObj; + + beforeEach(async () => { + mockUserProfileService = jasmine.createSpyObj('UserProfileService', ['getProfile', 'updateProfile']); + mockUserProfileService.getProfile.and.returnValue(of({ + fullName: 'Avery Johnson', + email: 'avery@example.com', + jobTitle: 'Product Manager', + organization: 'Sentinent Ops', + timezone: 'America/New_York', + bio: 'Keeps workstreams aligned.', + roleLabel: 'Workspace Owner', + })); + mockUserProfileService.updateProfile.and.returnValue(of({ + fullName: 'Avery Johnson', + email: 'avery@example.com', + jobTitle: 'Director of Product', + organization: 'Sentinent Ops', + timezone: 'America/New_York', + bio: 'Keeps workstreams aligned.', + roleLabel: 'Workspace Owner', + })); + + await TestBed.configureTestingModule({ + imports: [ProfileComponent, RouterTestingModule], + providers: [{ provide: UserProfileService, useValue: mockUserProfileService }], + }).compileComponents(); + + fixture = TestBed.createComponent(ProfileComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders the user profile details', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Avery Johnson'); + expect(compiled.textContent).toContain('avery@example.com'); + expect(compiled.textContent).toContain('Workspace Owner'); + }); + + it('saves edited profile details', () => { + component.startEditing(); + component.draft.jobTitle = 'Director of Product'; + component.saveProfile(); + + expect(mockUserProfileService.updateProfile).toHaveBeenCalled(); + expect(component.successMessage).toContain('updated'); + expect(component.isEditing).toBeFalse(); + }); + + it('stops loading and shows an error when profile loading hangs', fakeAsync(() => { + mockUserProfileService.getProfile.and.returnValue(NEVER); + + component.profile = undefined; + component.retryLoadingProfile(); + tick(8001); + fixture.detectChanges(); + + expect(component.isLoading).toBeFalse(); + expect(component.errorMessage).toContain('taking longer than expected'); + })); + + it('shows meaningful fallback values when optional profile fields are empty', () => { + mockUserProfileService.getProfile.and.returnValue(of({ + fullName: '', + email: 'demo.user@example.com', + jobTitle: '', + organization: '', + timezone: '', + bio: '', + roleLabel: '', + })); + + component.retryLoadingProfile(); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('Demo User'); + expect(compiled.textContent).toContain('Role not set'); + expect(compiled.textContent).toContain('Organization not set'); + expect(compiled.textContent).toContain('Timezone not set'); + }); +}); diff --git a/src/app/components/profile/profile.ts b/src/app/components/profile/profile.ts new file mode 100644 index 0000000..8fd5049 --- /dev/null +++ b/src/app/components/profile/profile.ts @@ -0,0 +1,193 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { UserProfile, UserProfileUpdate } from '../../models/user-profile.model'; +import { UserProfileService } from '../../services/user-profile.service'; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink], + templateUrl: './profile.html', + styleUrl: './profile.css', +}) +export class ProfileComponent implements OnInit, OnDestroy { + private static readonly PROFILE_LOAD_TIMEOUT_MS = 8000; + + private readonly userProfileService = inject(UserProfileService); + private readonly cdr = inject(ChangeDetectorRef); + private loadSubscription?: Subscription; + private loadTimeoutId?: ReturnType; + + profile?: UserProfile; + draft: UserProfileUpdate = { + fullName: '', + jobTitle: '', + organization: '', + timezone: '', + bio: '', + roleLabel: '', + }; + isEditing = false; + isSaving = false; + isLoading = true; + errorMessage = ''; + successMessage = ''; + + ngOnInit(): void { + this.loadProfile(); + } + + ngOnDestroy(): void { + this.clearActiveLoad(); + } + + startEditing(): void { + if (!this.profile) { + return; + } + this.isEditing = true; + this.errorMessage = ''; + this.successMessage = ''; + this.draft = this.toDraft(this.profile); + } + + cancelEditing(): void { + this.isEditing = false; + this.errorMessage = ''; + this.successMessage = ''; + if (this.profile) { + this.draft = this.toDraft(this.profile); + } + } + + saveProfile(): void { + if (!this.draft.fullName.trim()) { + this.errorMessage = 'Full name is required.'; + return; + } + + this.isSaving = true; + this.errorMessage = ''; + this.successMessage = ''; + this.userProfileService.updateProfile({ + fullName: this.draft.fullName.trim(), + jobTitle: this.draft.jobTitle.trim(), + organization: this.draft.organization.trim(), + timezone: this.draft.timezone.trim(), + bio: this.draft.bio.trim(), + roleLabel: this.draft.roleLabel.trim(), + }).subscribe({ + next: (profile) => { + this.profile = profile; + this.draft = this.toDraft(profile); + this.isEditing = false; + this.isSaving = false; + this.successMessage = 'Profile updated successfully.'; + this.syncView(); + }, + error: () => { + this.isSaving = false; + this.errorMessage = 'Unable to save your profile right now.'; + this.syncView(); + }, + }); + } + + retryLoadingProfile(): void { + this.loadProfile(); + } + + displayName(): string { + const resolvedName = this.displayValue(this.profile?.fullName); + if (resolvedName) { + return resolvedName; + } + + const emailName = this.displayValue(this.profile?.email)?.split('@')[0] ?? ''; + if (!emailName) { + return 'Sentinent User'; + } + + return emailName + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' '); + } + + profileInitial(): string { + return this.displayName().charAt(0).toUpperCase() || '?'; + } + + withFallback(value: string | undefined, fallback: string): string { + return this.displayValue(value) || fallback; + } + + private loadProfile(): void { + this.clearActiveLoad(); + this.isLoading = true; + this.errorMessage = ''; + this.loadTimeoutId = setTimeout(() => { + this.clearLoadTimeout(); + this.loadSubscription?.unsubscribe(); + this.loadSubscription = undefined; + this.isLoading = false; + this.errorMessage = 'Loading your profile is taking longer than expected. Please try again.'; + this.syncView(); + }, ProfileComponent.PROFILE_LOAD_TIMEOUT_MS); + + this.loadSubscription = this.userProfileService.getProfile().subscribe({ + next: (profile) => { + this.clearLoadTimeout(); + this.loadSubscription = undefined; + this.profile = profile; + this.draft = this.toDraft(profile); + this.isLoading = false; + this.syncView(); + }, + error: (error: Error) => { + this.clearLoadTimeout(); + this.loadSubscription = undefined; + this.isLoading = false; + this.errorMessage = error.message || 'Unable to load your profile right now.'; + this.syncView(); + }, + }); + } + + private clearActiveLoad(): void { + this.loadSubscription?.unsubscribe(); + this.loadSubscription = undefined; + this.clearLoadTimeout(); + } + + private clearLoadTimeout(): void { + if (this.loadTimeoutId === undefined) { + return; + } + clearTimeout(this.loadTimeoutId); + this.loadTimeoutId = undefined; + } + + private toDraft(profile: UserProfile): UserProfileUpdate { + return { + fullName: profile.fullName, + jobTitle: profile.jobTitle, + organization: profile.organization, + timezone: profile.timezone, + bio: profile.bio, + roleLabel: profile.roleLabel, + }; + } + + private displayValue(value: string | undefined): string { + return (value ?? '').trim(); + } + + private syncView(): void { + this.cdr.detectChanges(); + } +} diff --git a/src/app/models/user-profile.model.ts b/src/app/models/user-profile.model.ts new file mode 100644 index 0000000..622a47f --- /dev/null +++ b/src/app/models/user-profile.model.ts @@ -0,0 +1,11 @@ +export interface UserProfile { + fullName: string; + email: string; + jobTitle: string; + organization: string; + timezone: string; + bio: string; + roleLabel: string; +} + +export type UserProfileUpdate = Omit; diff --git a/src/app/services/auth.ts b/src/app/services/auth.ts index 86f7b4c..ea558ad 100644 --- a/src/app/services/auth.ts +++ b/src/app/services/auth.ts @@ -22,6 +22,12 @@ interface ResetTokenValidationResponse { email: string; } +export interface SignupProfile { + fullName: string; + jobTitle?: string; + organization?: string; +} + @Injectable({ providedIn: 'root', }) diff --git a/src/app/services/user-profile.service.spec.ts b/src/app/services/user-profile.service.spec.ts new file mode 100644 index 0000000..aaa13b4 --- /dev/null +++ b/src/app/services/user-profile.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { UserProfileService } from './user-profile.service'; + +describe('UserProfileService', () => { + let service: UserProfileService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(UserProfileService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('loads the current user profile from the backend', () => { + let profileName = ''; + + service.getProfile().subscribe((profile) => { + profileName = profile.fullName; + }); + + const request = httpMock.expectOne('/api/profile'); + expect(request.request.method).toBe('GET'); + request.flush({ + full_name: 'Avery Johnson', + email: 'avery@example.com', + job_title: 'Product Manager', + organization: 'Sentinent Ops', + timezone: 'America/New_York', + bio: 'Keeps workstreams aligned.', + role_label: 'Workspace Owner', + }); + + expect(profileName).toBe('Avery Johnson'); + }); + + it('updates the profile through the backend API', () => { + let updatedRole = ''; + + service.updateProfile({ + fullName: 'Alex Rivera', + jobTitle: 'Engineering Lead', + organization: 'Platform', + timezone: 'America/New_York', + bio: 'Coordinates engineering work across multiple integrations.', + roleLabel: 'Technical Lead', + }).subscribe((profile) => { + updatedRole = profile.roleLabel; + }); + + const request = httpMock.expectOne('/api/profile'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body.full_name).toBe('Alex Rivera'); + request.flush({ + full_name: 'Alex Rivera', + email: 'alex@example.com', + job_title: 'Engineering Lead', + organization: 'Platform', + timezone: 'America/New_York', + bio: 'Coordinates engineering work across multiple integrations.', + role_label: 'Technical Lead', + }); + + expect(updatedRole).toBe('Technical Lead'); + }); + + it('maps camelCase profile payloads and defaults missing fields', () => { + let profileSnapshot = ''; + + service.getProfile().subscribe((profile) => { + profileSnapshot = `${profile.fullName}|${profile.jobTitle}|${profile.roleLabel}|${profile.bio}`; + }); + + const request = httpMock.expectOne('/api/profile'); + expect(request.request.method).toBe('GET'); + request.flush({ + fullName: 'Jamie Rivera', + email: 'jamie@example.com', + jobTitle: 'Delivery Lead', + organization: 'Sentinent Ops', + timezone: 'America/Chicago', + roleLabel: 'Maintainer', + }); + + expect(profileSnapshot).toBe('Jamie Rivera|Delivery Lead|Maintainer|'); + }); +}); diff --git a/src/app/services/user-profile.service.ts b/src/app/services/user-profile.service.ts new file mode 100644 index 0000000..8e59664 --- /dev/null +++ b/src/app/services/user-profile.service.ts @@ -0,0 +1,65 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { catchError, map, Observable, throwError, timeout } from 'rxjs'; +import { UserProfile, UserProfileUpdate } from '../models/user-profile.model'; +import { toError } from './http-error'; + +interface UserProfileResponse { + full_name?: string; + fullName?: string; + email?: string; + job_title?: string; + jobTitle?: string; + organization?: string; + timezone?: string; + bio?: string; + role_label?: string; + roleLabel?: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class UserProfileService { + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/profile'; + + getProfile(): Observable { + return this.http.get(this.apiUrl).pipe( + timeout(5000), + map((response) => this.mapProfile(response)), + catchError((error) => throwError(() => toError(error, 'Unable to load profile.'))), + ); + } + + updateProfile(update: UserProfileUpdate): Observable { + return this.http.patch(this.apiUrl, { + full_name: update.fullName, + job_title: update.jobTitle, + organization: update.organization, + timezone: update.timezone, + bio: update.bio, + role_label: update.roleLabel, + }).pipe( + timeout(5000), + map((response) => this.mapProfile(response)), + catchError((error) => throwError(() => toError(error, 'Unable to update profile.'))), + ); + } + + private mapProfile(response: UserProfileResponse): UserProfile { + return { + fullName: this.readField(response.full_name ?? response.fullName), + email: this.readField(response.email), + jobTitle: this.readField(response.job_title ?? response.jobTitle), + organization: this.readField(response.organization), + timezone: this.readField(response.timezone), + bio: this.readField(response.bio), + roleLabel: this.readField(response.role_label ?? response.roleLabel), + }; + } + + private readField(value: unknown): string { + return typeof value === 'string' ? value : ''; + } +}