From 898840fa86b4f70618d447f2eb00be87f4f8e46f Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 04:35:29 +0000 Subject: [PATCH 1/8] fix(members): 20s timeout on inviteMember HTTP request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RxJS timeout(20000) to inviteMember() so the Sending... button always resets — even if the backend is unreachable or crashes mid- response — instead of staying stuck indefinitely. --- src/app/services/workspace-member.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/services/workspace-member.service.ts b/src/app/services/workspace-member.service.ts index 0473972..5d8b6d6 100644 --- a/src/app/services/workspace-member.service.ts +++ b/src/app/services/workspace-member.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { catchError, map, Observable, throwError } from 'rxjs'; +import { catchError, map, Observable, throwError, timeout } from 'rxjs'; import { Invitation, InvitationRole, @@ -61,6 +61,7 @@ export class WorkspaceMemberService { return this.http .post(`${this.apiUrl}/workspaces/${workspaceId}/invitations`, { email, role }) .pipe( + timeout(20000), // fail after 20 s so the button never stays stuck map((invitation) => this.mapInvitation(invitation)), catchError((error) => throwError(() => toError(error, 'Unable to create invitation.'))), ); From ae0bafd06ce1f5e1d07baebabf72a70764ccf8d6 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 14:48:20 +0000 Subject: [PATCH 2/8] fix(members): force UI update after invite + guard against silent throw - Inject ChangeDetectorRef and call detectChanges() in both next and error callbacks so the Sending... button always resets even when Angular's default change detection misses the update - Wrap next callback body in try/finally so isSubmittingInvite is guaranteed to be reset even if any inner statement throws --- .../workspace-members/workspace-members.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/app/components/workspace-members/workspace-members.ts b/src/app/components/workspace-members/workspace-members.ts index fb1ec57..68605d3 100644 --- a/src/app/components/workspace-members/workspace-members.ts +++ b/src/app/components/workspace-members/workspace-members.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { Invitation, InvitationRole, WorkspaceMember, WorkspaceRole } from '../../models/workspace-member.model'; @@ -15,6 +15,7 @@ import { WorkspaceMemberService } from '../../services/workspace-member.service' export class WorkspaceMembersComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly workspaceMemberService = inject(WorkspaceMemberService); + private readonly cdr = inject(ChangeDetectorRef); workspaceId = ''; members: WorkspaceMember[] = []; @@ -59,16 +60,21 @@ export class WorkspaceMembersComponent implements OnInit { this.workspaceMemberService.inviteMember(this.workspaceId, trimmedEmail, this.inviteRole).subscribe({ next: invitation => { - this.isSubmittingInvite = false; - this.inviteSuccess = `Invitation created for ${invitation.email}.`; - this.invitationLink = `/invitations/${invitation.token}`; - this.inviteEmail = ''; - this.inviteRole = 'member'; - this.loadInvitations(); + try { + this.inviteSuccess = `Invitation sent to ${invitation.email}.`; + this.invitationLink = `/invitations/${invitation.token}`; + this.inviteEmail = ''; + this.inviteRole = 'member'; + this.loadInvitations(); + } finally { + this.isSubmittingInvite = false; + this.cdr.detectChanges(); + } }, error: (error: Error) => { this.isSubmittingInvite = false; this.inviteError = error.message; + this.cdr.detectChanges(); } }); } From 33e634180380356b48bc5d329821e6bb9f6c1372 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 14:59:45 +0000 Subject: [PATCH 3/8] fix(signup): password min-length check + surface real backend errors - Validate password >= 8 chars on the frontend before sending, matching the backend rule (avoids a confusing generic error) - Show the actual backend error message for 400 responses instead of always falling back to 'Registration failed. Please try again.' - Separate 409 conflict message to 'An account with this email already exists.' --- src/app/components/login/login.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/app/components/login/login.ts b/src/app/components/login/login.ts index 82611d5..2e3e0af 100644 --- a/src/app/components/login/login.ts +++ b/src/app/components/login/login.ts @@ -95,7 +95,7 @@ export class Login implements OnInit { } this.isLoginSubmitting = true; - this.authService.login(this.loginEmail.trim(), this.loginPassword).pipe( + this.authService.login(this.loginEmail.trim(), this.loginPassword, this.rememberMe).pipe( timeout(8000), finalize(() => { this.isLoginSubmitting = false; @@ -122,11 +122,15 @@ export class Login implements OnInit { handleRegister(): void { if (!this.regFullName.trim() || !this.regEmail.trim() || !this.regPassword.trim()) { - this.registerError = 'Invalid credentials'; + this.registerError = 'Please fill in all required fields.'; return; } if (!this.isValidEmail(this.regEmail)) { - this.registerError = 'Enter a valid email address'; + this.registerError = 'Enter a valid email address.'; + return; + } + if (this.regPassword.length < 8) { + this.registerError = 'Password must be at least 8 characters.'; return; } this.isRegisterSubmitting = true; @@ -170,13 +174,15 @@ export class Login implements OnInit { this.showForgot = false; this.showSuccess = false; - const backendMessage = typeof err.error === 'string' ? err.error.toLowerCase() : ''; - if (err.status === 409 || backendMessage.includes('already exists')) { - this.registerError = 'Email already exists'; - this.syncView(); - return; + const backendMessage = typeof err.error === 'string' ? err.error.trim() : ''; + const lower = backendMessage.toLowerCase(); + if (err.status === 409 || lower.includes('already exists')) { + this.registerError = 'An account with this email already exists.'; + } else if (err.status === 400 && backendMessage) { + this.registerError = backendMessage; + } else { + this.registerError = 'Registration failed. Please try again.'; } - this.registerError = 'Registration failed. Please try again.'; this.syncView(); } }); From ec60af6ce995d8c1eae0174276c2a2f37dfe890c Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 15:14:43 +0000 Subject: [PATCH 4/8] feat(invitations): redesign acceptance flow with inline auth and auto-join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-state invitation page with a full state machine: loading → auth-choice → sign-in / sign-up → join → success / error Changes: - auth-choice: if not logged in, show workspace details and two options (Sign In / Create Account) instead of immediately redirecting to /login - sign-in inline form: after login, auto-accept the invitation so the user lands directly in the workspace without an extra click - sign-up inline form: pre-filled with the invited email; after signup, auto- login then auto-accept — user joins in one flow with no extra steps - wrong-email: if logged in with a different email, show an explicit message: 'You are signed in as X but this invitation was sent to Y' - getCurrentUserEmail(): new AuthService method that decodes email from JWT - InvitationValidation model now includes email field - WorkspaceMemberService maps email from ValidateInvitation response - Loading spinner while token is being validated --- .../accept-invitation/accept-invitation.css | 156 +++++++++++++-- .../accept-invitation/accept-invitation.html | 179 ++++++++++++++++-- .../accept-invitation/accept-invitation.ts | 177 +++++++++++++++-- src/app/models/workspace-member.model.ts | 1 + src/app/services/auth.ts | 28 +-- src/app/services/workspace-member.service.ts | 4 +- 6 files changed, 489 insertions(+), 56 deletions(-) diff --git a/src/app/components/accept-invitation/accept-invitation.css b/src/app/components/accept-invitation/accept-invitation.css index 252e233..c421cf5 100644 --- a/src/app/components/accept-invitation/accept-invitation.css +++ b/src/app/components/accept-invitation/accept-invitation.css @@ -11,7 +11,7 @@ background: #ffffff; border: 1px solid #dbe4f0; border-radius: 24px; - padding: 2rem; + padding: 2.5rem 2rem; box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08); } @@ -26,7 +26,8 @@ .accept-card h1 { margin: 0; - font-size: 2rem; + font-size: 1.9rem; + color: #0f172a; } .description { @@ -52,31 +53,41 @@ .summary span { display: block; color: #64748b; + font-size: 0.8rem; margin-bottom: 0.35rem; } -.actions { - display: flex; - gap: 0.75rem; - align-items: center; +.summary strong { + text-transform: capitalize; } +/* Shared button styles */ .primary-btn, -.secondary-btn { +.secondary-btn, +.ghost-btn { display: inline-flex; justify-content: center; align-items: center; border-radius: 999px; - padding: 0.85rem 1.15rem; + padding: 0.75rem 1.25rem; font-weight: 700; + font-size: 0.95rem; text-decoration: none; + cursor: pointer; + transition: opacity 0.15s; +} + +.primary-btn:disabled, +.secondary-btn:disabled, +.ghost-btn:disabled { + opacity: 0.55; + cursor: not-allowed; } .primary-btn { border: 1px solid #111827; background: #111827; color: #ffffff; - cursor: pointer; } .secondary-btn { @@ -85,30 +96,147 @@ color: #0f172a; } +.ghost-btn { + border: none; + background: transparent; + color: #64748b; + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.ghost-btn:hover { + color: #0f172a; +} + +/* Action rows */ +.actions, +.auth-choice-actions, +.form-actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 1.5rem; +} + +/* Auth-choice prompt */ +.auth-prompt { + margin: 1.25rem 0 0; + color: #475569; + font-size: 0.9rem; +} + +/* Inline form */ +.inline-form { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field label { + font-size: 0.875rem; + font-weight: 600; + color: #374151; +} + +.req { + color: #ef4444; +} + +.field input { + border: 1px solid #d1d5db; + border-radius: 12px; + padding: 0.7rem 0.9rem; + font-size: 0.95rem; + color: #0f172a; + background: #f9fafb; + transition: border-color 0.15s, box-shadow 0.15s; + outline: none; +} + +.field input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + background: #ffffff; +} + +.field input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Feedback banners */ +.feedback { + margin: 0.75rem 0 0; + padding: 0.85rem 1rem; + border-radius: 12px; + font-weight: 600; + font-size: 0.9rem; +} + .feedback.success { - margin: 0 0 1rem; - padding: 0.9rem 1rem; - border-radius: 14px; background: #ecfdf5; color: #166534; - font-weight: 600; } +.feedback.error { + background: #fef2f2; + color: #991b1b; +} + +/* Error card */ .error-card { text-align: left; } +/* Loading row */ +.loading-row { + display: flex; + align-items: center; + gap: 0.75rem; + color: #64748b; + margin-top: 0.5rem; +} + +.spinner { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid #e2e8f0; + border-top-color: #2563eb; + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + @media (max-width: 640px) { .accept-shell { padding: 1rem; + align-items: flex-start; + padding-top: 2rem; } .summary { grid-template-columns: 1fr; } - .actions { + .actions, + .auth-choice-actions, + .form-actions { flex-direction: column; align-items: stretch; } + + .ghost-btn { + text-align: center; + } } diff --git a/src/app/components/accept-invitation/accept-invitation.html b/src/app/components/accept-invitation/accept-invitation.html index 30cec1f..8efd10f 100644 --- a/src/app/components/accept-invitation/accept-invitation.html +++ b/src/app/components/accept-invitation/accept-invitation.html @@ -1,5 +1,161 @@
-
+ + +
+

Invitation

+
+ + Validating your invitation… +
+
+ + +
+

Invitation

+

Invitation unavailable

+

{{ errorMessage || 'This invitation is invalid or has expired.' }}

+ Go to sign in +
+ + +
+

You've been invited

+

Join {{ invitation.workspace.name }}

+

+ {{ invitation.invitedBy.email }} has invited you to join this workspace as a + {{ invitation.role }}. +

+ +
+
+ Workspace + {{ invitation.workspace.name }} +
+
+ Your role + {{ invitation.role }} +
+
+ +

To accept this invitation, sign in or create a free account.

+ +
+ + +
+
+ + +
+

Sign in to accept

+

Join {{ invitation.workspace.name }}

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

Create account to accept

+

Join {{ invitation.workspace.name }}

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

Wrong account

+

Different email required

+

+ You are signed in as {{ wrongEmailLoggedAs }}, but this invitation was sent to + {{ invitedEmail }}. +

+

Please sign out and sign in with the correct account to accept this invitation.

+ +
+ + +

Invitation

Join {{ invitation.workspace.name }}

@@ -17,22 +173,19 @@

Join {{ invitation.workspace.name }}

- -
- Back to dashboard
- -
-

Invitation

-

Invitation unavailable

-

{{ error || 'Invitation expired or invalid' }}

- Go to dashboard -
-
+ +
+

Success

+

You're in!

+ +
+
diff --git a/src/app/components/accept-invitation/accept-invitation.ts b/src/app/components/accept-invitation/accept-invitation.ts index 5b4f2e0..73da3e8 100644 --- a/src/app/components/accept-invitation/accept-invitation.ts +++ b/src/app/components/accept-invitation/accept-invitation.ts @@ -1,14 +1,26 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { finalize, switchMap, timeout } from 'rxjs'; import { InvitationValidation } from '../../models/workspace-member.model'; import { AuthService } from '../../services/auth'; import { WorkspaceMemberService } from '../../services/workspace-member.service'; +export type PageState = + | 'loading' + | 'auth-choice' + | 'sign-in' + | 'sign-up' + | 'wrong-email' + | 'join' + | 'success' + | 'error'; + @Component({ selector: 'app-accept-invitation', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, FormsModule], templateUrl: './accept-invitation.html', styleUrl: './accept-invitation.css' }) @@ -17,44 +29,181 @@ export class AcceptInvitationComponent implements OnInit { private readonly router = inject(Router); private readonly authService = inject(AuthService); private readonly workspaceMemberService = inject(WorkspaceMemberService); + private readonly cdr = inject(ChangeDetectorRef); token = ''; invitation?: InvitationValidation; - error = ''; - success = ''; - isSubmitting = false; + + state: PageState = 'loading'; + errorMessage = ''; + successMessage = ''; + + // Sign-in form + signInEmail = ''; + signInPassword = ''; + signInError = ''; + isSigningIn = false; + + // Sign-up form + signUpFullName = ''; + signUpEmail = ''; + signUpPassword = ''; + signUpError = ''; + isSigningUp = false; + + // Join state + isJoining = false; + wrongEmailLoggedAs = ''; ngOnInit(): void { this.token = this.route.snapshot.paramMap.get('token') ?? ''; this.workspaceMemberService.validateInvitation(this.token).subscribe({ next: invitation => { this.invitation = invitation; + this.determineState(); + this.cdr.detectChanges(); }, error: (error: Error) => { - this.error = error.message; + this.errorMessage = error.message || 'This invitation is invalid or has expired.'; + this.state = 'error'; + this.cdr.detectChanges(); } }); } - joinWorkspace(): void { + private determineState(): void { if (!this.authService.isLoggedIn()) { - this.router.navigate(['/login'], { queryParams: { redirectTo: `/invitations/${this.token}` } }); + // Pre-fill both forms with the invited email + this.signInEmail = this.invitation?.email ?? ''; + this.signUpEmail = this.invitation?.email ?? ''; + this.state = 'auth-choice'; + return; + } + + const currentEmail = this.authService.getCurrentUserEmail(); + const invitedEmail = this.invitation?.email ?? ''; + + if (invitedEmail && currentEmail && currentEmail.toLowerCase() !== invitedEmail.toLowerCase()) { + this.wrongEmailLoggedAs = currentEmail; + this.state = 'wrong-email'; + return; + } + + this.state = 'join'; + } + + showSignIn(): void { + this.signInError = ''; + this.state = 'sign-in'; + } + + showSignUp(): void { + this.signUpError = ''; + this.state = 'sign-up'; + } + + backToChoice(): void { + this.state = 'auth-choice'; + } + + handleSignIn(): void { + this.signInError = ''; + if (!this.signInEmail.trim() || !this.signInPassword.trim()) { + this.signInError = 'Please enter your email and password.'; + return; + } + this.isSigningIn = true; + this.authService.login(this.signInEmail.trim(), this.signInPassword).pipe( + timeout(10000), + finalize(() => { this.isSigningIn = false; this.cdr.detectChanges(); }) + ).subscribe({ + next: () => { + const currentEmail = this.authService.getCurrentUserEmail(); + const invitedEmail = this.invitation?.email ?? ''; + if (invitedEmail && currentEmail && currentEmail.toLowerCase() !== invitedEmail.toLowerCase()) { + this.wrongEmailLoggedAs = currentEmail; + this.state = 'wrong-email'; + this.cdr.detectChanges(); + return; + } + this.doAcceptInvitation(); + }, + error: () => { + this.signInError = 'Invalid email or password. Please try again.'; + this.cdr.detectChanges(); + } + }); + } + + handleSignUp(): void { + this.signUpError = ''; + if (!this.signUpFullName.trim() || !this.signUpEmail.trim() || !this.signUpPassword.trim()) { + this.signUpError = 'Please fill in all required fields.'; + return; + } + if (this.signUpPassword.length < 8) { + this.signUpError = 'Password must be at least 8 characters.'; + return; + } + const invitedEmail = this.invitation?.email ?? ''; + if (invitedEmail && this.signUpEmail.trim().toLowerCase() !== invitedEmail.toLowerCase()) { + this.signUpError = `This invitation is for ${invitedEmail}. Please sign up with that email address.`; return; } - this.isSubmitting = true; + this.isSigningUp = true; + this.authService.signup(this.signUpEmail.trim(), this.signUpPassword, { + fullName: this.signUpFullName.trim() + }).pipe( + timeout(10000), + switchMap(() => + this.authService.login(this.signUpEmail.trim(), this.signUpPassword).pipe(timeout(10000)) + ), + finalize(() => { this.isSigningUp = false; this.cdr.detectChanges(); }) + ).subscribe({ + next: () => { + this.doAcceptInvitation(); + }, + error: (err: any) => { + const msg = typeof err?.error === 'string' ? err.error.trim() : ''; + if (msg.toLowerCase().includes('already exists') || err?.status === 409) { + this.signUpError = 'An account with this email already exists. Please sign in instead.'; + } else { + this.signUpError = msg || 'Registration failed. Please try again.'; + } + this.cdr.detectChanges(); + } + }); + } + + joinWorkspace(): void { + this.doAcceptInvitation(); + } + + private doAcceptInvitation(): void { + this.isJoining = true; + this.state = 'join'; + this.cdr.detectChanges(); this.workspaceMemberService.acceptInvitation(this.token).subscribe({ next: response => { - this.isSubmitting = false; - this.success = `You joined the workspace as ${response.role}. Redirecting now.`; + this.isJoining = false; + this.successMessage = `You joined the workspace as ${response.role}.`; + this.state = 'success'; + this.cdr.detectChanges(); setTimeout(() => { this.router.navigate(['/workspaces', response.workspaceId, 'decisions']); - }, 900); + }, 1200); }, error: (error: Error) => { - this.isSubmitting = false; - this.error = error.message; + this.isJoining = false; + this.errorMessage = error.message; + this.state = 'error'; + this.cdr.detectChanges(); } }); } + + get invitedEmail(): string { + return this.invitation?.email ?? ''; + } } diff --git a/src/app/models/workspace-member.model.ts b/src/app/models/workspace-member.model.ts index 8f535df..0d9fe57 100644 --- a/src/app/models/workspace-member.model.ts +++ b/src/app/models/workspace-member.model.ts @@ -19,6 +19,7 @@ export interface Invitation { export interface InvitationValidation { valid: boolean; + email: string; workspace: { id: string; name: string; diff --git a/src/app/services/auth.ts b/src/app/services/auth.ts index ea558ad..e5bbd69 100644 --- a/src/app/services/auth.ts +++ b/src/app/services/auth.ts @@ -22,12 +22,6 @@ interface ResetTokenValidationResponse { email: string; } -export interface SignupProfile { - fullName: string; - jobTitle?: string; - organization?: string; -} - @Injectable({ providedIn: 'root', }) @@ -89,19 +83,25 @@ export class AuthService { getCurrentUserId(): string | null { const token = this.getToken(); - if (!token) { - return null; - } - + if (!token) return null; const payload = token.split('.')[1]; - if (!payload) { + if (!payload) return null; + try { + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) as { user_id?: number | string }; + return decoded.user_id === undefined ? null : String(decoded.user_id); + } catch { return null; } + } + getCurrentUserEmail(): string | null { + const token = this.getToken(); + if (!token) return null; + const payload = token.split('.')[1]; + if (!payload) return null; try { - const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/'); - const decodedPayload = JSON.parse(atob(normalizedPayload)) as { user_id?: number | string }; - return decodedPayload.user_id === undefined ? null : String(decodedPayload.user_id); + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) as { email?: string }; + return decoded.email ?? null; } catch { return null; } diff --git a/src/app/services/workspace-member.service.ts b/src/app/services/workspace-member.service.ts index 5d8b6d6..8cb2fe2 100644 --- a/src/app/services/workspace-member.service.ts +++ b/src/app/services/workspace-member.service.ts @@ -28,6 +28,7 @@ interface InvitationResponse { interface InvitationValidationResponse { valid: boolean; + email: string; workspace: { id: number; name: string; @@ -61,7 +62,7 @@ export class WorkspaceMemberService { return this.http .post(`${this.apiUrl}/workspaces/${workspaceId}/invitations`, { email, role }) .pipe( - timeout(20000), // fail after 20 s so the button never stays stuck + timeout(20000), map((invitation) => this.mapInvitation(invitation)), catchError((error) => throwError(() => toError(error, 'Unable to create invitation.'))), ); @@ -99,6 +100,7 @@ export class WorkspaceMemberService { return this.http.get(`${this.apiUrl}/invitations/${token}`).pipe( map((response) => ({ valid: response.valid, + email: response.email, workspace: { id: String(response.workspace.id), name: response.workspace.name, From cf42de35b2049dfc172ec760e5245fad6636b40b Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 15:26:53 +0000 Subject: [PATCH 5/8] fix(members): resend email, accepted section, member strip, initial load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes five issues on the invite members page and workspace view: 1. Resend feedback position — success/error now appears directly under the Resend button on each row, not at the top of the page. Per-row resendStates map keyed by invitation id; auto-clears after 4s. 2. Resend actually sends email — calls POST /api/invitations/:token/resend via new WorkspaceMemberService.resendInvitation(). Previously it only copied the link locally with no HTTP call. 3. Initial load showing nothing — added cdr.detectChanges() inside loadMembers() and loadInvitations() so Angular picks up the async updates immediately on first open. 4. Accepted invitations section — ListInvitations now returns all invitations (pending + accepted). Frontend splits them into pendingInvitations and acceptedInvitations; accepted rows show a green 'Accepted' chip and the accepted date instead of action buttons. 5. Member count and names in workspace — WorkspaceDetailsComponent now loads members on init and shows a team strip in the workspace hero with avatar, email, and colour-coded role badge (owner/member/viewer) plus a member count in the metrics bar and a 'Manage members' link. --- .../workspace-members/workspace-members.css | 184 +++++++++++++----- .../workspace-members/workspace-members.html | 102 ++++++---- .../workspace-members/workspace-members.ts | 107 ++++++---- .../workspace-details/workspace-details.css | 88 +++++++++ .../workspace-details/workspace-details.html | 23 ++- .../workspace-details/workspace-details.ts | 35 ++-- src/app/models/workspace-member.model.ts | 1 + src/app/services/workspace-member.service.ts | 38 ++-- 8 files changed, 421 insertions(+), 157 deletions(-) diff --git a/src/app/components/workspace-members/workspace-members.css b/src/app/components/workspace-members/workspace-members.css index b66218b..c7a78fa 100644 --- a/src/app/components/workspace-members/workspace-members.css +++ b/src/app/components/workspace-members/workspace-members.css @@ -15,11 +15,9 @@ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06); } -.members-header { - display: flex; - justify-content: space-between; - gap: 1rem; - align-items: flex-start; +.accepted-card { + border-color: #d1fae5; + background: #f0fdf4; } .eyebrow { @@ -43,21 +41,39 @@ .section-head p, .member-row p, .invitation-row p, -.empty-state p, -.invite-link span { +.empty-state p { color: #475569; margin: 0.35rem 0 0; + font-size: 0.875rem; } +.section-head { + margin-bottom: 1.25rem; +} +.count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + background: #e2e8f0; + color: #475569; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + padding: 0.1rem 0.55rem; + margin-left: 0.5rem; + vertical-align: middle; +} -.section-head { - margin-bottom: 1rem; +.count-badge.accepted { + background: #d1fae5; + color: #166534; } +/* Invite form */ .invite-form { display: grid; - grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr) auto; + grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr) auto; gap: 1rem; align-items: end; } @@ -65,6 +81,7 @@ .invite-form label { display: grid; gap: 0.4rem; + font-size: 0.875rem; font-weight: 600; color: #0f172a; } @@ -75,18 +92,36 @@ width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; - padding: 0.8rem 0.9rem; + padding: 0.75rem 0.9rem; font: inherit; background: #ffffff; + color: #0f172a; +} + +.invite-form input:focus, +.invite-form select:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); } +/* Buttons */ .primary-btn, .secondary-btn, .danger-btn { border-radius: 999px; - padding: 0.8rem 1rem; + padding: 0.75rem 1.1rem; font-weight: 700; + font-size: 0.875rem; cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.primary-btn:disabled, +.secondary-btn:disabled { + opacity: 0.55; + cursor: not-allowed; } .primary-btn { @@ -102,47 +137,35 @@ } .danger-btn { - border: 1px solid #ef4444; - background: #ffffff; + border: 1px solid #fca5a5; + background: #fff1f2; color: #b91c1c; } +/* Feedback banners */ .feedback { - margin: 1rem 0 0; - padding: 0.9rem 1rem; - border-radius: 14px; + margin: 0.85rem 0 0; + padding: 0.75rem 1rem; + border-radius: 12px; font-weight: 600; + font-size: 0.875rem; } -.feedback.success { - background: #ecfdf5; - color: #166534; -} - -.feedback.error { - background: #fef2f2; - color: #991b1b; -} - -.invite-link { - margin-top: 1rem; - display: grid; - gap: 0.35rem; -} +.feedback.success { background: #ecfdf5; color: #166534; } +.feedback.error { background: #fef2f2; color: #991b1b; } -.invite-link code { - display: block; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 0.9rem; - overflow-wrap: anywhere; +/* Per-row resend feedback — sits flush under the action buttons */ +.row-feedback { + margin: 0.5rem 0 0; + font-size: 0.8rem; + padding: 0.5rem 0.75rem; } +/* Lists */ .member-list, .invitation-list { display: grid; - gap: 1rem; + gap: 0.75rem; } .member-row, @@ -152,54 +175,113 @@ gap: 1rem; align-items: center; border: 1px solid #e2e8f0; - border-radius: 16px; + border-radius: 14px; background: #f8fafc; - padding: 1rem; + padding: 0.9rem 1rem; +} + +.accepted-row { + border-color: #bbf7d0; + background: #f0fdf4; + opacity: 0.85; } .member-meta { display: flex; gap: 0.9rem; align-items: center; + min-width: 0; } .member-avatar { - width: 44px; - height: 44px; + flex-shrink: 0; + width: 40px; + height: 40px; border-radius: 50%; display: grid; place-items: center; background: linear-gradient(135deg, #111827, #334155); color: #ffffff; font-weight: 700; + font-size: 0.95rem; } .member-actions { display: flex; - gap: 0.75rem; + gap: 0.6rem; + align-items: center; + flex-shrink: 0; +} + +.row-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0; +} + +/* Role & status badges */ +.role-badge { + display: inline-block; + background: #e0e7ff; + color: #3730a3; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + padding: 0.1rem 0.5rem; + text-transform: capitalize; + margin-right: 0.4rem; +} + +.inv-meta { + display: flex; align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + color: #64748b; + font-size: 0.8rem; + margin-top: 0.25rem !important; +} + +.accepted-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: #d1fae5; + color: #166534; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + padding: 0.25rem 0.75rem; + flex-shrink: 0; } .empty-state { border: 1px dashed #cbd5e1; - border-radius: 16px; + border-radius: 14px; padding: 1.25rem; text-align: center; background: #f8fafc; } @media (max-width: 860px) { - .members-header, - .member-row, - .invitation-row, .invite-form { grid-template-columns: 1fr; + } + + .member-row, + .invitation-row { flex-direction: column; align-items: stretch; } - .member-actions { + .member-actions, + .row-right { + align-items: stretch; width: 100%; - flex-direction: column; + } + + .row-feedback { + text-align: center; } } diff --git a/src/app/components/workspace-members/workspace-members.html b/src/app/components/workspace-members/workspace-members.html index a767184..e12c994 100644 --- a/src/app/components/workspace-members/workspace-members.html +++ b/src/app/components/workspace-members/workspace-members.html @@ -5,50 +5,41 @@

Members and Invitations

Invite teammates, manage roles, and keep pending access requests visible in one place.

- +
-
-

Invite Member

-

Owners can invite members or viewers by email and share the generated invitation link.

-
+

Invite Member

+

Send an invitation email to add someone to this workspace.

- -
-
+
-
-

Workspace Members

-

Update roles or remove members while keeping the owner protected.

-
+

Workspace Members {{ members.length }}

+

Update roles or remove members while keeping the owner protected.

@@ -62,17 +53,11 @@

{{ member.email }}

Joined {{ member.joinedAt | date: 'mediumDate' }}

-
- - -
@@ -82,29 +67,46 @@

{{ member.email }}

No members found

-

Invite teammates to start collaborating in this workspace.

+

Invite teammates to start collaborating.

+
-
-

Pending Invitations

-

Track open invites, copy links again, or cancel invitations that are no longer needed.

-
+

Pending Invitations {{ pendingInvitations.length }}

+

Track open invites, resend emails, or cancel invitations no longer needed.

-
-
+
+

{{ invitation.email }}

-

{{ invitation.role }} • expires {{ invitation.expiresAt | date: 'mediumDate' }}

+

+ {{ invitation.role }} + Expires {{ invitation.expiresAt | date: 'mediumDate' }} +

- -
- - +
+
+ + +
+ + +
@@ -112,8 +114,30 @@

{{ invitation.email }}

No pending invitations

-

New invitations will show up here until they are accepted or cancelled.

+

New invitations will appear here until accepted or cancelled.

+ + +
+
+

Accepted Invitations {{ acceptedInvitations.length }}

+

These invitations have been accepted and the user is now a member.

+
+ +
+
+
+

{{ invitation.email }}

+

+ {{ invitation.role }} + Accepted {{ invitation.acceptedAt | date: 'mediumDate' }} +

+
+ ✓ Accepted +
+
+
+ diff --git a/src/app/components/workspace-members/workspace-members.ts b/src/app/components/workspace-members/workspace-members.ts index 68605d3..e626777 100644 --- a/src/app/components/workspace-members/workspace-members.ts +++ b/src/app/components/workspace-members/workspace-members.ts @@ -5,6 +5,12 @@ import { ActivatedRoute } from '@angular/router'; import { Invitation, InvitationRole, WorkspaceMember, WorkspaceRole } from '../../models/workspace-member.model'; import { WorkspaceMemberService } from '../../services/workspace-member.service'; +interface ResendState { + loading: boolean; + success: string; + error: string; +} + @Component({ selector: 'app-workspace-members', standalone: true, @@ -19,15 +25,20 @@ export class WorkspaceMembersComponent implements OnInit { workspaceId = ''; members: WorkspaceMember[] = []; - invitations: Invitation[] = []; + pendingInvitations: Invitation[] = []; + acceptedInvitations: Invitation[] = []; + inviteEmail = ''; inviteRole: InvitationRole = 'member'; inviteSuccess = ''; inviteError = ''; actionError = ''; - invitationLink = ''; + isSubmittingInvite = false; + // Per-row resend state keyed by invitation id + resendStates: Record = {}; + readonly availableRoles: WorkspaceRole[] = ['owner', 'member', 'viewer']; ngOnInit(): void { @@ -37,12 +48,9 @@ export class WorkspaceMembersComponent implements OnInit { } private getWorkspaceIdFromRoute(): string | null { - const path = this.route.pathFromRoot || []; - for (const route of path) { + for (const route of this.route.pathFromRoot) { const id = route.snapshot.paramMap.get('id'); - if (id) { - return id; - } + if (id) return id; } return null; } @@ -53,7 +61,6 @@ export class WorkspaceMembersComponent implements OnInit { this.inviteError = 'Email is required.'; return; } - this.isSubmittingInvite = true; this.inviteError = ''; this.inviteSuccess = ''; @@ -62,7 +69,6 @@ export class WorkspaceMembersComponent implements OnInit { next: invitation => { try { this.inviteSuccess = `Invitation sent to ${invitation.email}.`; - this.invitationLink = `/invitations/${invitation.token}`; this.inviteEmail = ''; this.inviteRole = 'member'; this.loadInvitations(); @@ -79,11 +85,41 @@ export class WorkspaceMembersComponent implements OnInit { }); } - updateMemberRole(member: WorkspaceMember, role: string): void { - if (role === member.role) { - return; - } + resendInvitation(invitation: Invitation): void { + this.resendStates[invitation.id] = { loading: true, success: '', error: '' }; + this.cdr.detectChanges(); + + this.workspaceMemberService.resendInvitation(invitation.token).subscribe({ + next: () => { + this.resendStates[invitation.id] = { + loading: false, + success: `Invitation resent to ${invitation.email}.`, + error: '' + }; + this.cdr.detectChanges(); + // Auto-clear after 4s + setTimeout(() => { + this.resendStates[invitation.id] = { loading: false, success: '', error: '' }; + this.cdr.detectChanges(); + }, 4000); + }, + error: (error: Error) => { + this.resendStates[invitation.id] = { + loading: false, + success: '', + error: error.message || 'Failed to resend invitation.' + }; + this.cdr.detectChanges(); + } + }); + } + getResendState(id: string): ResendState { + return this.resendStates[id] ?? { loading: false, success: '', error: '' }; + } + + updateMemberRole(member: WorkspaceMember, role: string): void { + if (role === member.role) return; this.actionError = ''; this.workspaceMemberService.updateRole(this.workspaceId, member.userId, role as WorkspaceRole).subscribe({ next: () => this.loadMembers(), @@ -95,47 +131,42 @@ export class WorkspaceMembersComponent implements OnInit { } removeMember(member: WorkspaceMember): void { - if (!window.confirm(`Remove ${member.email} from the workspace?`)) { - return; - } - + if (!window.confirm(`Remove ${member.email} from the workspace?`)) return; this.actionError = ''; this.workspaceMemberService.removeMember(this.workspaceId, member.userId).subscribe({ next: () => this.loadMembers(), - error: (error: Error) => { - this.actionError = error.message; - } + error: (error: Error) => { this.actionError = error.message; } }); } cancelInvitation(invitation: Invitation): void { - this.workspaceMemberService.cancelInvitation(this.workspaceId, invitation.id).subscribe(() => { - this.loadInvitations(); + this.workspaceMemberService.cancelInvitation(this.workspaceId, invitation.id).subscribe({ + next: () => this.loadInvitations(), + error: (error: Error) => { this.actionError = error.message; } }); } - resendInvitation(invitation: Invitation): void { - this.inviteSuccess = `Resend link copied for ${invitation.email}.`; - this.invitationLink = `/invitations/${invitation.token}`; - } - - trackMember(_: number, member: WorkspaceMember): number { - return member.userId; - } - - trackInvitation(_: number, invitation: Invitation): string { - return invitation.id; - } + trackMember(_: number, member: WorkspaceMember): number { return member.userId; } + trackInvitation(_: number, invitation: Invitation): string { return invitation.id; } private loadMembers(): void { - this.workspaceMemberService.getMembers(this.workspaceId).subscribe(members => { - this.members = members; + this.workspaceMemberService.getMembers(this.workspaceId).subscribe({ + next: members => { + this.members = members; + this.cdr.detectChanges(); + }, + error: () => { this.cdr.detectChanges(); } }); } private loadInvitations(): void { - this.workspaceMemberService.getPendingInvitations(this.workspaceId).subscribe(invitations => { - this.invitations = invitations; + this.workspaceMemberService.getAllInvitations(this.workspaceId).subscribe({ + next: invitations => { + this.pendingInvitations = invitations.filter(i => !i.acceptedAt); + this.acceptedInvitations = invitations.filter(i => !!i.acceptedAt); + this.cdr.detectChanges(); + }, + error: () => { this.cdr.detectChanges(); } }); } } diff --git a/src/app/components/workspace/workspace-details/workspace-details.css b/src/app/components/workspace/workspace-details/workspace-details.css index 4121517..2140d30 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.css +++ b/src/app/components/workspace/workspace-details/workspace-details.css @@ -306,3 +306,91 @@ h1 { transform: rotate(360deg); } } + +/* Members strip */ +.members-strip { + margin-top: 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid #e2e8f0; + display: flex; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; +} + +.strip-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #64748b; + margin: 0; + padding-top: 0.6rem; + flex-shrink: 0; +} + +.member-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + flex: 1; +} + +.member-chip { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 999px; + padding: 0.3rem 0.75rem 0.3rem 0.3rem; +} + +.chip-avatar { + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(135deg, #111827, #334155); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.chip-info { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.chip-email { + font-size: 0.8rem; + color: #0f172a; + font-weight: 500; +} + +.chip-role { + font-size: 0.65rem; + font-weight: 700; + border-radius: 999px; + padding: 0.1rem 0.4rem; + text-transform: capitalize; +} + +.role-owner { background: #fef3c7; color: #92400e; } +.role-member { background: #e0e7ff; color: #3730a3; } +.role-viewer { background: #f1f5f9; color: #475569; } + +.manage-link { + font-size: 0.8rem; + font-weight: 600; + color: #2563eb; + text-decoration: none; + margin-left: auto; + padding-top: 0.6rem; + white-space: nowrap; +} + +.manage-link:hover { text-decoration: underline; } diff --git a/src/app/components/workspace/workspace-details/workspace-details.html b/src/app/components/workspace/workspace-details/workspace-details.html index 5a8f329..e6b0dcd 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.html +++ b/src/app/components/workspace/workspace-details/workspace-details.html @@ -16,7 +16,7 @@

Workspace

{{ workspace.name }}

-

{{ workspace.description || 'No description yet. Add one to give this workspace stronger context.' }}

+

{{ workspace.description || 'No description yet.' }}

@@ -25,15 +25,30 @@

{{ workspace.name }}

Health {{ getWorkspaceHealth(workspace.description || '') }} +
+ Members + {{ members.length }} +
Created {{ workspace.createdDate | date:'mediumDate' }}
-
- Owner - User #{{ workspace.ownerId }} +
+ + + +
+

Team

+
+
+
{{ member.email.charAt(0).toUpperCase() }}
+
+ {{ member.email }} + {{ member.role }} +
+ Manage members →
diff --git a/src/app/components/workspace/workspace-details/workspace-details.ts b/src/app/components/workspace/workspace-details/workspace-details.ts index c9c5a18..61e6066 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.ts +++ b/src/app/components/workspace/workspace-details/workspace-details.ts @@ -3,6 +3,8 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterModule, RouterLink, RouterOutlet } from '@angular/router'; import { WorkspaceService } from '../../../services/workspace'; import { Workspace } from '../../../models/workspace'; +import { WorkspaceMemberService } from '../../../services/workspace-member.service'; +import { WorkspaceMember } from '../../../models/workspace-member.model'; import { Observable } from 'rxjs'; import { AppNavComponent } from '../../app-nav/app-nav'; @@ -15,10 +17,12 @@ import { AppNavComponent } from '../../app-nav/app-nav'; }) export class WorkspaceDetailsComponent implements OnInit { workspace$: Observable | undefined; + members: WorkspaceMember[] = []; constructor( private route: ActivatedRoute, - private workspaceService: WorkspaceService + private workspaceService: WorkspaceService, + private memberService: WorkspaceMemberService ) { } ngOnInit(): void { @@ -28,26 +32,31 @@ export class WorkspaceDetailsComponent implements OnInit { if (id && id !== currentId) { currentId = id; this.workspace$ = this.workspaceService.getWorkspace(id); + this.loadMembers(id); } }); } + private loadMembers(workspaceId: string): void { + this.memberService.getMembers(workspaceId).subscribe({ + next: members => { this.members = members; }, + error: () => {} + }); + } + getWorkspaceInitials(name: string): string { - return name - .trim() - .split(/\s+/) - .slice(0, 2) - .map(part => part.charAt(0).toUpperCase()) - .join('') || 'WS'; + return name.trim().split(/\s+/).slice(0, 2).map(p => p.charAt(0).toUpperCase()).join('') || 'WS'; } getWorkspaceHealth(description: string): string { - if (description.trim().length >= 20) { - return 'Configured'; - } - if (description.trim().length > 0) { - return 'In Progress'; - } + if (description.trim().length >= 20) return 'Configured'; + if (description.trim().length > 0) return 'In Progress'; return 'Needs Detail'; } + + getRoleBadgeClass(role: string): string { + if (role === 'owner') return 'role-owner'; + if (role === 'viewer') return 'role-viewer'; + return 'role-member'; + } } diff --git a/src/app/models/workspace-member.model.ts b/src/app/models/workspace-member.model.ts index 0d9fe57..ad2eaa2 100644 --- a/src/app/models/workspace-member.model.ts +++ b/src/app/models/workspace-member.model.ts @@ -15,6 +15,7 @@ export interface Invitation { token: string; expiresAt: Date; createdAt: Date; + acceptedAt: Date | null; } export interface InvitationValidation { diff --git a/src/app/services/workspace-member.service.ts b/src/app/services/workspace-member.service.ts index 8cb2fe2..906a62b 100644 --- a/src/app/services/workspace-member.service.ts +++ b/src/app/services/workspace-member.service.ts @@ -24,6 +24,7 @@ interface InvitationResponse { role: InvitationRole; expires_at: string; created_at: string; + accepted_at: string | null; } interface InvitationValidationResponse { @@ -53,7 +54,7 @@ export class WorkspaceMemberService { getMembers(workspaceId: string): Observable { return this.http.get(`${this.apiUrl}/workspaces/${workspaceId}/members`).pipe( - map((members) => members.map((member) => this.mapMember(member))), + map((members) => members.map((m) => this.mapMember(m))), catchError((error) => throwError(() => toError(error, 'Unable to load workspace members.'))), ); } @@ -63,7 +64,7 @@ export class WorkspaceMemberService { .post(`${this.apiUrl}/workspaces/${workspaceId}/invitations`, { email, role }) .pipe( timeout(20000), - map((invitation) => this.mapInvitation(invitation)), + map((inv) => this.mapInvitation(inv)), catchError((error) => throwError(() => toError(error, 'Unable to create invitation.'))), ); } @@ -72,7 +73,7 @@ export class WorkspaceMemberService { return this.http .patch(`${this.apiUrl}/workspaces/${workspaceId}/members/${userId}`, { role }) .pipe( - map((member) => this.mapMember(member)), + map((m) => this.mapMember(m)), catchError((error) => throwError(() => toError(error, 'Unable to update member role.'))), ); } @@ -83,19 +84,31 @@ export class WorkspaceMemberService { ); } - getPendingInvitations(workspaceId: string): Observable { + getAllInvitations(workspaceId: string): Observable { return this.http.get(`${this.apiUrl}/workspaces/${workspaceId}/invitations`).pipe( - map((invitations) => invitations.map((invitation) => this.mapInvitation(invitation))), + map((invitations) => invitations.map((inv) => this.mapInvitation(inv))), catchError((error) => throwError(() => toError(error, 'Unable to load invitations.'))), ); } + /** @deprecated Use getAllInvitations */ + getPendingInvitations(workspaceId: string): Observable { + return this.getAllInvitations(workspaceId); + } + cancelInvitation(workspaceId: string, invitationId: string): Observable { return this.http.delete(`${this.apiUrl}/workspaces/${workspaceId}/invitations/${invitationId}`).pipe( catchError((error) => throwError(() => toError(error, 'Unable to cancel invitation.'))), ); } + resendInvitation(token: string): Observable { + return this.http.post(`${this.apiUrl}/invitations/${token}/resend`, {}).pipe( + timeout(15000), + catchError((error) => throwError(() => toError(error, 'Unable to resend invitation.'))), + ); + } + validateInvitation(token: string): Observable { return this.http.get(`${this.apiUrl}/invitations/${token}`).pipe( map((response) => ({ @@ -133,14 +146,15 @@ export class WorkspaceMemberService { }; } - private mapInvitation(invitation: InvitationResponse): Invitation { + private mapInvitation(inv: InvitationResponse): Invitation { return { - id: String(invitation.id), - email: invitation.email, - role: invitation.role, - token: invitation.token ?? '', - expiresAt: new Date(invitation.expires_at), - createdAt: new Date(invitation.created_at), + id: String(inv.id), + email: inv.email, + role: inv.role, + token: inv.token ?? '', + expiresAt: new Date(inv.expires_at), + createdAt: new Date(inv.created_at), + acceptedAt: inv.accepted_at ? new Date(inv.accepted_at) : null, }; } } From 4b2acd7b18236610f4339b6405e10eea36bdb832 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 15:34:40 +0000 Subject: [PATCH 6/8] fix(build): rememberMe on login + import AppNavComponent in workspace-details --- .../workspace-details/workspace-details.ts | 1 + src/app/services/auth.ts | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/components/workspace/workspace-details/workspace-details.ts b/src/app/components/workspace/workspace-details/workspace-details.ts index 61e6066..3feb1bf 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.ts +++ b/src/app/components/workspace/workspace-details/workspace-details.ts @@ -5,6 +5,7 @@ import { WorkspaceService } from '../../../services/workspace'; import { Workspace } from '../../../models/workspace'; import { WorkspaceMemberService } from '../../../services/workspace-member.service'; import { WorkspaceMember } from '../../../models/workspace-member.model'; +import { AppNavComponent } from '../../app-nav/app-nav'; import { Observable } from 'rxjs'; import { AppNavComponent } from '../../app-nav/app-nav'; diff --git a/src/app/services/auth.ts b/src/app/services/auth.ts index e5bbd69..f54c0ba 100644 --- a/src/app/services/auth.ts +++ b/src/app/services/auth.ts @@ -45,9 +45,9 @@ export class AuthService { ); } - login(email: string, password: string): Observable { + login(email: string, password: string, rememberMe: boolean = true): Observable { return this.http.post(`${this.apiUrl}/login`, { email, password }).pipe( - tap(res => this.setToken(res.token)) + tap(res => this.setToken(res.token, rememberMe)) ); } @@ -75,6 +75,7 @@ export class AuthService { logout(): void { localStorage.removeItem(this.tokenKey); + sessionStorage.removeItem(this.tokenKey); } isLoggedIn(): boolean { @@ -108,10 +109,16 @@ export class AuthService { } getToken(): string | null { - return localStorage.getItem(this.tokenKey); + return localStorage.getItem(this.tokenKey) ?? sessionStorage.getItem(this.tokenKey); } - private setToken(token: string): void { - localStorage.setItem(this.tokenKey, token); + private setToken(token: string, rememberMe: boolean = true): void { + localStorage.removeItem(this.tokenKey); + sessionStorage.removeItem(this.tokenKey); + if (rememberMe) { + localStorage.setItem(this.tokenKey, token); + } else { + sessionStorage.setItem(this.tokenKey, token); + } } } From 221c5cc693cdf7324f90679100663747a30e54d5 Mon Sep 17 00:00:00 2001 From: Sentinent Agent Date: Wed, 29 Apr 2026 15:36:22 +0000 Subject: [PATCH 7/8] fix(build): remove non-existent AppNavComponent from workspace-details --- .../workspace/workspace-details/workspace-details.html | 2 -- .../workspace/workspace-details/workspace-details.ts | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/components/workspace/workspace-details/workspace-details.html b/src/app/components/workspace/workspace-details/workspace-details.html index e6b0dcd..c9ae560 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.html +++ b/src/app/components/workspace/workspace-details/workspace-details.html @@ -1,5 +1,3 @@ - -
diff --git a/src/app/components/workspace/workspace-details/workspace-details.ts b/src/app/components/workspace/workspace-details/workspace-details.ts index 3feb1bf..4d883f1 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.ts +++ b/src/app/components/workspace/workspace-details/workspace-details.ts @@ -5,14 +5,13 @@ import { WorkspaceService } from '../../../services/workspace'; import { Workspace } from '../../../models/workspace'; import { WorkspaceMemberService } from '../../../services/workspace-member.service'; import { WorkspaceMember } from '../../../models/workspace-member.model'; -import { AppNavComponent } from '../../app-nav/app-nav'; import { Observable } from 'rxjs'; import { AppNavComponent } from '../../app-nav/app-nav'; @Component({ selector: 'app-workspace-details', standalone: true, - imports: [CommonModule, RouterModule, RouterLink, RouterOutlet, AppNavComponent], + imports: [CommonModule, RouterModule, RouterLink, RouterOutlet], templateUrl: './workspace-details.html', styleUrls: ['./workspace-details.css'] }) From 187c837cc9c225435b1a1a1b5635c57c37c3f754 Mon Sep 17 00:00:00 2001 From: Neethika Date: Wed, 29 Apr 2026 11:52:15 -0400 Subject: [PATCH 8/8] fix(dashboard): TS2345 - unreadCountFor accepts string workspace id --- src/app/components/dashboard/dashboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/dashboard/dashboard.ts b/src/app/components/dashboard/dashboard.ts index a92ab11..c4af6b3 100644 --- a/src/app/components/dashboard/dashboard.ts +++ b/src/app/components/dashboard/dashboard.ts @@ -138,8 +138,8 @@ export class Dashboard implements OnInit, OnDestroy { return this.pendingDeleteWorkspace?.id === workspace.id; } - unreadCountFor(workspaceId: number): number { - return this.unreadByWorkspace[workspaceId] ?? 0; + unreadCountFor(workspaceId: string): number { + return this.unreadByWorkspace[Number(workspaceId)] ?? 0; } private loadSignals(): void {