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 ee8f7f7..9a76a31 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)
@@ -17,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
-
+
diff --git a/src/app/components/login/login.css b/src/app/components/login/login.css
index 40dab63..9b9a3b7 100644
--- a/src/app/components/login/login.css
+++ b/src/app/components/login/login.css
@@ -329,10 +329,9 @@ h1 {
cursor: default;
}
-.subtle-copy {
- margin: 0 0 6px;
- color: var(--muted);
- font-size: 13px;
+.success-link {
+ margin-top: 14px;
+ text-decoration: none;
}
.error-text,
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/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.' }}
+
+
+
+
+
+
+
+
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') }}
+
+
+
+
+
+
+ {{ successMessage }}
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
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/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...
+
+
+
+
+
+
+
+
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/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 b91a977..ea558ad 100644
--- a/src/app/services/auth.ts
+++ b/src/app/services/auth.ts
@@ -12,6 +12,22 @@ export interface SignupProfile {
organization?: string;
}
+interface ForgotPasswordResponse {
+ message: string;
+ reset_url?: string;
+}
+
+interface ResetTokenValidationResponse {
+ valid: boolean;
+ email: string;
+}
+
+export interface SignupProfile {
+ fullName: string;
+ jobTitle?: string;
+ organization?: string;
+}
+
@Injectable({
providedIn: 'root',
})
@@ -41,6 +57,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);
}
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 : '';
+ }
+}