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/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 { 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(); } }); 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.

- -

{{ inviteSuccess }}

{{ inviteError }}

-
+
-
-

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.

{{ actionError }}

@@ -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 fb1ec57..e626777 100644 --- a/src/app/components/workspace-members/workspace-members.ts +++ b/src/app/components/workspace-members/workspace-members.ts @@ -1,10 +1,16 @@ 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'; import { WorkspaceMemberService } from '../../services/workspace-member.service'; +interface ResendState { + loading: boolean; + success: string; + error: string; +} + @Component({ selector: 'app-workspace-members', standalone: true, @@ -15,18 +21,24 @@ 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[] = []; - 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 { @@ -36,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; } @@ -52,32 +61,65 @@ export class WorkspaceMembersComponent implements OnInit { this.inviteError = 'Email is required.'; return; } - this.isSubmittingInvite = true; this.inviteError = ''; this.inviteSuccess = ''; 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.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(); } }); } - 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(), @@ -89,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..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 @@ - -
@@ -16,7 +14,7 @@

Workspace

{{ workspace.name }}

-

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

+

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

@@ -25,15 +23,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..4d883f1 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.ts +++ b/src/app/components/workspace/workspace-details/workspace-details.ts @@ -3,22 +3,26 @@ 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'; @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'] }) 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 8f535df..ad2eaa2 100644 --- a/src/app/models/workspace-member.model.ts +++ b/src/app/models/workspace-member.model.ts @@ -15,10 +15,12 @@ export interface Invitation { token: string; expiresAt: Date; createdAt: Date; + acceptedAt: Date | null; } 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..f54c0ba 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', }) @@ -51,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)) ); } @@ -81,6 +75,7 @@ export class AuthService { logout(): void { localStorage.removeItem(this.tokenKey); + sessionStorage.removeItem(this.tokenKey); } isLoggedIn(): boolean { @@ -89,29 +84,41 @@ 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; } } 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); + } } } diff --git a/src/app/services/workspace-member.service.ts b/src/app/services/workspace-member.service.ts index 0473972..906a62b 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, @@ -24,10 +24,12 @@ interface InvitationResponse { role: InvitationRole; expires_at: string; created_at: string; + accepted_at: string | null; } interface InvitationValidationResponse { valid: boolean; + email: string; workspace: { id: number; name: string; @@ -52,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.'))), ); } @@ -61,7 +63,8 @@ export class WorkspaceMemberService { return this.http .post(`${this.apiUrl}/workspaces/${workspaceId}/invitations`, { email, role }) .pipe( - map((invitation) => this.mapInvitation(invitation)), + timeout(20000), + map((inv) => this.mapInvitation(inv)), catchError((error) => throwError(() => toError(error, 'Unable to create invitation.'))), ); } @@ -70,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.'))), ); } @@ -81,23 +84,36 @@ 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) => ({ valid: response.valid, + email: response.email, workspace: { id: String(response.workspace.id), name: response.workspace.name, @@ -130,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, }; } }