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 }}
+
+ {{ signInError }}
+
+
+
+
+
+
+ Create account to accept
+ Join {{ invitation.workspace.name }}
+
+ {{ signUpError }}
+
+
+
+
+
+
+ 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 }}
- {{ success }}
-
-
-
-
- Invitation
- Invitation unavailable
- {{ error || 'Invitation expired or invalid' }}
- Go to dashboard
-
-
+
+
+ Success
+ You're in!
+ {{ successMessage }} Redirecting you now…
+
+
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.
-
-
- {{ isSubmittingInvite ? 'Sending...' : 'Invite Member' }}
+ {{ isSubmittingInvite ? 'Sending…' : 'Invite' }}
{{ inviteSuccess }}
{{ inviteError }}
-
- Invitation link
- {{ invitationLink }}
-
+
-
-
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' }}
+
-
-
-
Resend
-
Cancel
+
+
+
+ {{ getResendState(invitation.id).loading ? 'Sending…' : 'Resend' }}
+
+ Cancel
+
+
+
+ {{ getResendState(invitation.id).success }}
+
+
+ {{ getResendState(invitation.id).error }}
+
@@ -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,
};
}
}