From ed9c19f4b0a62eef6a2eec42197280a169d7ddce Mon Sep 17 00:00:00 2001 From: Neethika Date: Tue, 24 Mar 2026 22:54:55 -0400 Subject: [PATCH 1/3] feat(members): add invitation and member management UI (ref Sentinent-AI/Sentinent#15) --- src/app/app.routes.ts | 10 + .../accept-invitation/accept-invitation.css | 114 ++++++++++ .../accept-invitation/accept-invitation.html | 38 ++++ .../accept-invitation.spec.ts | 74 ++++++ .../accept-invitation/accept-invitation.ts | 59 +++++ .../workspace-members/workspace-members.css | 212 ++++++++++++++++++ .../workspace-members/workspace-members.html | 119 ++++++++++ .../workspace-members.spec.ts | 98 ++++++++ .../workspace-members/workspace-members.ts | 124 ++++++++++ .../workspace-details/workspace-details.html | 1 + src/app/models/workspace-member.model.ts | 30 +++ src/app/services/workspace-member.service.ts | 158 +++++++++++++ 12 files changed, 1037 insertions(+) create mode 100644 src/app/components/accept-invitation/accept-invitation.css create mode 100644 src/app/components/accept-invitation/accept-invitation.html create mode 100644 src/app/components/accept-invitation/accept-invitation.spec.ts create mode 100644 src/app/components/accept-invitation/accept-invitation.ts create mode 100644 src/app/components/workspace-members/workspace-members.css create mode 100644 src/app/components/workspace-members/workspace-members.html create mode 100644 src/app/components/workspace-members/workspace-members.spec.ts create mode 100644 src/app/components/workspace-members/workspace-members.ts create mode 100644 src/app/models/workspace-member.model.ts create mode 100644 src/app/services/workspace-member.service.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f0e64c2..e5fb9e1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,10 +3,15 @@ import { Login } from './components/login/login'; import { authGuard } from './guards/auth-guard'; import { CreateWorkspace } from './components/workspace/create-workspace'; import { WorkspaceIntegrationsComponent } from './components/workspace-integrations/workspace-integrations'; +import { WorkspaceMembersComponent } from './components/workspace-members/workspace-members'; export const routes: Routes = [ { path: 'login', component: Login }, { path: 'signup', component: Login }, + { + path: 'invitations/:token', + loadComponent: () => import('./components/accept-invitation/accept-invitation').then(m => m.AcceptInvitationComponent) + }, { path: 'dashboard', loadComponent: () => import('./components/dashboard/dashboard').then(m => m.Dashboard), @@ -17,6 +22,11 @@ export const routes: Routes = [ component: CreateWorkspace, canActivate: [authGuard] }, + { + path: 'workspaces/:id/settings/members', + component: WorkspaceMembersComponent, + canActivate: [authGuard] + }, { path: 'workspaces/:id/settings/integrations', component: WorkspaceIntegrationsComponent, diff --git a/src/app/components/accept-invitation/accept-invitation.css b/src/app/components/accept-invitation/accept-invitation.css new file mode 100644 index 0000000..252e233 --- /dev/null +++ b/src/app/components/accept-invitation/accept-invitation.css @@ -0,0 +1,114 @@ +.accept-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 2rem; + background: radial-gradient(circle at top, rgba(37, 99, 235, 0.12), transparent 45%), #f8fafc; +} + +.accept-card { + width: min(100%, 620px); + background: #ffffff; + border: 1px solid #dbe4f0; + border-radius: 24px; + padding: 2rem; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08); +} + +.eyebrow { + margin: 0 0 0.35rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.75rem; + color: #2563eb; + font-weight: 700; +} + +.accept-card h1 { + margin: 0; + font-size: 2rem; +} + +.description { + margin: 0.9rem 0 0; + color: #475569; + line-height: 1.6; +} + +.summary { + margin: 1.5rem 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.summary div { + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 1rem; + background: #f8fafc; +} + +.summary span { + display: block; + color: #64748b; + margin-bottom: 0.35rem; +} + +.actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.primary-btn, +.secondary-btn { + display: inline-flex; + justify-content: center; + align-items: center; + border-radius: 999px; + padding: 0.85rem 1.15rem; + font-weight: 700; + text-decoration: none; +} + +.primary-btn { + border: 1px solid #111827; + background: #111827; + color: #ffffff; + cursor: pointer; +} + +.secondary-btn { + border: 1px solid #cbd5e1; + background: #ffffff; + color: #0f172a; +} + +.feedback.success { + margin: 0 0 1rem; + padding: 0.9rem 1rem; + border-radius: 14px; + background: #ecfdf5; + color: #166534; + font-weight: 600; +} + +.error-card { + text-align: left; +} + +@media (max-width: 640px) { + .accept-shell { + padding: 1rem; + } + + .summary { + grid-template-columns: 1fr; + } + + .actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/app/components/accept-invitation/accept-invitation.html b/src/app/components/accept-invitation/accept-invitation.html new file mode 100644 index 0000000..30cec1f --- /dev/null +++ b/src/app/components/accept-invitation/accept-invitation.html @@ -0,0 +1,38 @@ +
+
+

Invitation

+

Join {{ invitation.workspace.name }}

+

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

+ +
+
+ Workspace + {{ invitation.workspace.name }} +
+
+ Role + {{ invitation.role }} +
+
+ + + +
+ + Back to dashboard +
+
+ + +
+

Invitation

+

Invitation unavailable

+

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

+ Go to dashboard +
+
+
diff --git a/src/app/components/accept-invitation/accept-invitation.spec.ts b/src/app/components/accept-invitation/accept-invitation.spec.ts new file mode 100644 index 0000000..d0743c9 --- /dev/null +++ b/src/app/components/accept-invitation/accept-invitation.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { AcceptInvitationComponent } from './accept-invitation'; +import { AuthService } from '../../services/auth'; +import { WorkspaceMemberService } from '../../services/workspace-member.service'; + +describe('AcceptInvitationComponent', () => { + let component: AcceptInvitationComponent; + let fixture: ComponentFixture; + let mockWorkspaceMemberService: jasmine.SpyObj; + let mockAuthService: jasmine.SpyObj; + let router: Router; + + beforeEach(async () => { + mockWorkspaceMemberService = jasmine.createSpyObj('WorkspaceMemberService', [ + 'validateInvitation', + 'acceptInvitation' + ]); + mockAuthService = jasmine.createSpyObj('AuthService', ['isLoggedIn']); + + mockWorkspaceMemberService.validateInvitation.and.returnValue(of({ + valid: true, + workspace: { id: '1', name: 'Engineering' }, + invitedBy: { email: 'owner@example.com' }, + role: 'member' + })); + mockWorkspaceMemberService.acceptInvitation.and.returnValue(of({ workspaceId: '1', role: 'member' })); + mockAuthService.isLoggedIn.and.returnValue(true); + + await TestBed.configureTestingModule({ + imports: [AcceptInvitationComponent, RouterTestingModule], + providers: [ + { provide: WorkspaceMemberService, useValue: mockWorkspaceMemberService }, + { provide: AuthService, useValue: mockAuthService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ token: 'token-1' }) + } + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(AcceptInvitationComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + fixture.detectChanges(); + }); + + it('should render invitation details', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Join Engineering'); + expect(compiled.textContent).toContain('owner@example.com'); + }); + + it('should accept invitation when authenticated', () => { + component.joinWorkspace(); + + expect(mockWorkspaceMemberService.acceptInvitation).toHaveBeenCalledWith('token-1'); + }); + + it('should redirect to login when not authenticated', () => { + spyOn(router, 'navigate'); + mockAuthService.isLoggedIn.and.returnValue(false); + + component.joinWorkspace(); + + expect(router.navigate).toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/accept-invitation/accept-invitation.ts b/src/app/components/accept-invitation/accept-invitation.ts new file mode 100644 index 0000000..51439e1 --- /dev/null +++ b/src/app/components/accept-invitation/accept-invitation.ts @@ -0,0 +1,59 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { InvitationValidation } from '../../models/workspace-member.model'; +import { AuthService } from '../../services/auth'; +import { WorkspaceMemberService } from '../../services/workspace-member.service'; + +@Component({ + selector: 'app-accept-invitation', + standalone: true, + imports: [CommonModule, RouterLink], + templateUrl: './accept-invitation.html', + styleUrl: './accept-invitation.css' +}) +export class AcceptInvitationComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + private readonly workspaceMemberService = inject(WorkspaceMemberService); + + token = ''; + invitation?: InvitationValidation; + error = ''; + success = ''; + isSubmitting = false; + + ngOnInit(): void { + this.token = this.route.snapshot.paramMap.get('token') ?? ''; + this.workspaceMemberService.validateInvitation(this.token).subscribe({ + next: invitation => { + this.invitation = invitation; + }, + error: (error: Error) => { + this.error = error.message; + } + }); + } + + joinWorkspace(): void { + if (!this.authService.isLoggedIn()) { + this.router.navigate(['/login'], { queryParams: { redirectTo: `/invitations/${this.token}` } }); + return; + } + + this.isSubmitting = true; + this.workspaceMemberService.acceptInvitation(this.token).subscribe({ + next: response => { + this.success = `You joined the workspace as ${response.role}. Redirecting now.`; + setTimeout(() => { + this.router.navigate(['/workspaces', response.workspaceId, 'decisions']); + }, 900); + }, + error: (error: Error) => { + this.isSubmitting = false; + this.error = error.message; + } + }); + } +} diff --git a/src/app/components/workspace-members/workspace-members.css b/src/app/components/workspace-members/workspace-members.css new file mode 100644 index 0000000..13705db --- /dev/null +++ b/src/app/components/workspace-members/workspace-members.css @@ -0,0 +1,212 @@ +.members-shell { + display: grid; + gap: 1.5rem; + padding: 1rem 0 2rem; +} + +.members-header, +.invite-card, +.members-card, +.invitations-card { + background: #ffffff; + border: 1px solid #dbe4f0; + border-radius: 20px; + padding: 1.5rem; + 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; +} + +.eyebrow { + margin: 0 0 0.35rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.75rem; + color: #2563eb; + font-weight: 700; +} + +.members-header h2, +.section-head h3, +.member-row h4, +.invitation-row h4, +.empty-state h4 { + margin: 0; +} + +.members-header p, +.section-head p, +.member-row p, +.invitation-row p, +.empty-state p, +.invite-link span { + color: #475569; + margin: 0.35rem 0 0; +} + +.back-link { + color: #0f172a; + text-decoration: none; + border: 1px solid #cbd5e1; + border-radius: 999px; + padding: 0.75rem 1rem; + font-weight: 600; +} + +.section-head { + margin-bottom: 1rem; +} + +.invite-form { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr) auto; + gap: 1rem; + align-items: end; +} + +.invite-form label { + display: grid; + gap: 0.4rem; + font-weight: 600; + color: #0f172a; +} + +.invite-form input, +.invite-form select, +.member-actions select { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 12px; + padding: 0.8rem 0.9rem; + font: inherit; + background: #ffffff; +} + +.primary-btn, +.secondary-btn, +.danger-btn { + border-radius: 999px; + padding: 0.8rem 1rem; + font-weight: 700; + cursor: pointer; +} + +.primary-btn { + border: 1px solid #111827; + background: #111827; + color: #ffffff; +} + +.secondary-btn { + border: 1px solid #cbd5e1; + background: #ffffff; + color: #0f172a; +} + +.danger-btn { + border: 1px solid #ef4444; + background: #ffffff; + color: #b91c1c; +} + +.feedback { + margin: 1rem 0 0; + padding: 0.9rem 1rem; + border-radius: 14px; + font-weight: 600; +} + +.feedback.success { + background: #ecfdf5; + color: #166534; +} + +.feedback.error { + background: #fef2f2; + color: #991b1b; +} + +.invite-link { + margin-top: 1rem; + display: grid; + gap: 0.35rem; +} + +.invite-link code { + display: block; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 0.9rem; + overflow-wrap: anywhere; +} + +.member-list, +.invitation-list { + display: grid; + gap: 1rem; +} + +.member-row, +.invitation-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #f8fafc; + padding: 1rem; +} + +.member-meta { + display: flex; + gap: 0.9rem; + align-items: center; +} + +.member-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + display: grid; + place-items: center; + background: linear-gradient(135deg, #111827, #334155); + color: #ffffff; + font-weight: 700; +} + +.member-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.empty-state { + border: 1px dashed #cbd5e1; + border-radius: 16px; + padding: 1.25rem; + text-align: center; + background: #f8fafc; +} + +@media (max-width: 860px) { + .members-header, + .member-row, + .invitation-row, + .invite-form { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .member-actions { + width: 100%; + flex-direction: column; + } +} diff --git a/src/app/components/workspace-members/workspace-members.html b/src/app/components/workspace-members/workspace-members.html new file mode 100644 index 0000000..87d26a1 --- /dev/null +++ b/src/app/components/workspace-members/workspace-members.html @@ -0,0 +1,119 @@ +
+
+
+

Workspace Access

+

Members and Invitations

+

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

+
+ Back to workspace +
+ +
+
+
+

Invite Member

+

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

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

Workspace Members

+

Update roles or remove members while keeping the owner protected.

+
+
+ + + +
+
+
+
{{ member.email.charAt(0).toUpperCase() }}
+
+

{{ member.email }}

+

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

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

No members found

+

Invite teammates to start collaborating in this workspace.

+
+
+
+ +
+
+
+

Pending Invitations

+

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

+
+
+ +
+
+
+

{{ invitation.email }}

+

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

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

No pending invitations

+

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

+
+
+
+
diff --git a/src/app/components/workspace-members/workspace-members.spec.ts b/src/app/components/workspace-members/workspace-members.spec.ts new file mode 100644 index 0000000..51cf9c8 --- /dev/null +++ b/src/app/components/workspace-members/workspace-members.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { WorkspaceMembersComponent } from './workspace-members'; +import { WorkspaceMemberService } from '../../services/workspace-member.service'; + +describe('WorkspaceMembersComponent', () => { + let component: WorkspaceMembersComponent; + let fixture: ComponentFixture; + let mockWorkspaceMemberService: jasmine.SpyObj; + + beforeEach(async () => { + mockWorkspaceMemberService = jasmine.createSpyObj('WorkspaceMemberService', [ + 'getMembers', + 'inviteMember', + 'updateRole', + 'removeMember', + 'getPendingInvitations', + 'cancelInvitation' + ]); + + mockWorkspaceMemberService.getMembers.and.returnValue(of([ + { userId: 1, email: 'owner@example.com', role: 'owner', joinedAt: new Date('2026-03-01T00:00:00Z') } + ])); + mockWorkspaceMemberService.getPendingInvitations.and.returnValue(of([ + { + id: 'invite-1', + email: 'newhire@example.com', + role: 'member', + token: 'token-1', + expiresAt: new Date('2026-04-01T00:00:00Z'), + createdAt: new Date('2026-03-24T00:00:00Z') + } + ])); + mockWorkspaceMemberService.inviteMember.and.returnValue(of({ + id: 'invite-2', + email: 'invitee@example.com', + role: 'viewer', + token: 'token-2', + expiresAt: new Date('2026-04-03T00:00:00Z'), + createdAt: new Date('2026-03-24T00:00:00Z') + })); + mockWorkspaceMemberService.updateRole.and.returnValue(of({ + userId: 2, + email: 'member@example.com', + role: 'viewer', + joinedAt: new Date('2026-03-21T00:00:00Z') + })); + mockWorkspaceMemberService.removeMember.and.returnValue(of(void 0)); + mockWorkspaceMemberService.cancelInvitation.and.returnValue(of(void 0)); + + await TestBed.configureTestingModule({ + imports: [WorkspaceMembersComponent], + providers: [ + { provide: WorkspaceMemberService, useValue: mockWorkspaceMemberService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ id: '1' }) + } + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(WorkspaceMembersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render members and invitations', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('owner@example.com'); + expect(compiled.textContent).toContain('newhire@example.com'); + }); + + it('should create invitation and show generated link', () => { + component.inviteEmail = 'invitee@example.com'; + component.inviteRole = 'viewer'; + + component.inviteMember(); + fixture.detectChanges(); + + expect(mockWorkspaceMemberService.inviteMember).toHaveBeenCalled(); + expect(component.invitationLink).toContain('/invitations/token-2'); + }); + + it('should show invitation errors', () => { + mockWorkspaceMemberService.inviteMember.and.returnValue(throwError(() => new Error('Only workspace owners can invite members'))); + component.inviteEmail = 'invitee@example.com'; + + component.inviteMember(); + fixture.detectChanges(); + + expect(component.inviteError).toContain('Only workspace owners'); + }); +}); diff --git a/src/app/components/workspace-members/workspace-members.ts b/src/app/components/workspace-members/workspace-members.ts new file mode 100644 index 0000000..3102faf --- /dev/null +++ b/src/app/components/workspace-members/workspace-members.ts @@ -0,0 +1,124 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Invitation, InvitationRole, WorkspaceMember, WorkspaceRole } from '../../models/workspace-member.model'; +import { WorkspaceMemberService } from '../../services/workspace-member.service'; + +@Component({ + selector: 'app-workspace-members', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink], + templateUrl: './workspace-members.html', + styleUrl: './workspace-members.css' +}) +export class WorkspaceMembersComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly workspaceMemberService = inject(WorkspaceMemberService); + + workspaceId = ''; + members: WorkspaceMember[] = []; + invitations: Invitation[] = []; + inviteEmail = ''; + inviteRole: InvitationRole = 'member'; + inviteSuccess = ''; + inviteError = ''; + actionError = ''; + invitationLink = ''; + isSubmittingInvite = false; + + readonly availableRoles: WorkspaceRole[] = ['owner', 'member', 'viewer']; + + ngOnInit(): void { + this.workspaceId = this.route.snapshot.paramMap.get('id') ?? ''; + this.loadMembers(); + this.loadInvitations(); + } + + inviteMember(): void { + const trimmedEmail = this.inviteEmail.trim().toLowerCase(); + if (!trimmedEmail) { + 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(); + }, + error: (error: Error) => { + this.isSubmittingInvite = false; + this.inviteError = error.message; + } + }); + } + + 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(), + error: (error: Error) => { + this.actionError = error.message; + this.loadMembers(); + } + }); + } + + removeMember(member: WorkspaceMember): void { + 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; + } + }); + } + + cancelInvitation(invitation: Invitation): void { + this.workspaceMemberService.cancelInvitation(this.workspaceId, invitation.id).subscribe(() => { + this.loadInvitations(); + }); + } + + 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; + } + + private loadMembers(): void { + this.workspaceMemberService.getMembers(this.workspaceId).subscribe(members => { + this.members = members; + }); + } + + private loadInvitations(): void { + this.workspaceMemberService.getPendingInvitations(this.workspaceId).subscribe(invitations => { + this.invitations = invitations; + }); + } +} diff --git a/src/app/components/workspace/workspace-details/workspace-details.html b/src/app/components/workspace/workspace-details/workspace-details.html index f864e8f..5e5c26e 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.html +++ b/src/app/components/workspace/workspace-details/workspace-details.html @@ -9,6 +9,7 @@

{{ workspace.name }}

diff --git a/src/app/models/workspace-member.model.ts b/src/app/models/workspace-member.model.ts new file mode 100644 index 0000000..8f535df --- /dev/null +++ b/src/app/models/workspace-member.model.ts @@ -0,0 +1,30 @@ +export type WorkspaceRole = 'owner' | 'member' | 'viewer'; +export type InvitationRole = 'member' | 'viewer'; + +export interface WorkspaceMember { + userId: number; + email: string; + role: WorkspaceRole; + joinedAt: Date; +} + +export interface Invitation { + id: string; + email: string; + role: InvitationRole; + token: string; + expiresAt: Date; + createdAt: Date; +} + +export interface InvitationValidation { + valid: boolean; + workspace: { + id: string; + name: string; + }; + invitedBy: { + email: string; + }; + role: InvitationRole; +} diff --git a/src/app/services/workspace-member.service.ts b/src/app/services/workspace-member.service.ts new file mode 100644 index 0000000..2fb79ae --- /dev/null +++ b/src/app/services/workspace-member.service.ts @@ -0,0 +1,158 @@ +import { Injectable } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { Invitation, InvitationRole, InvitationValidation, WorkspaceMember, WorkspaceRole } from '../models/workspace-member.model'; + +@Injectable({ + providedIn: 'root' +}) +export class WorkspaceMemberService { + private membersByWorkspace: Record = { + '1': [ + { userId: 1, email: 'owner@example.com', role: 'owner', joinedAt: new Date('2026-03-01T00:00:00Z') }, + { userId: 2, email: 'member@example.com', role: 'member', joinedAt: new Date('2026-03-20T00:00:00Z') }, + { userId: 3, email: 'viewer@example.com', role: 'viewer', joinedAt: new Date('2026-03-21T00:00:00Z') } + ] + }; + + private invitationsByWorkspace: Record = { + '1': [ + { + id: 'invite-1', + email: 'newhire@example.com', + role: 'member', + token: 'invite_token_member', + expiresAt: new Date('2026-04-01T00:00:00Z'), + createdAt: new Date('2026-03-23T00:00:00Z') + }, + { + id: 'invite-2', + email: 'observer@example.com', + role: 'viewer', + token: 'invite_token_viewer', + expiresAt: new Date('2026-04-03T00:00:00Z'), + createdAt: new Date('2026-03-24T00:00:00Z') + } + ] + }; + + private currentUserEmail = 'owner@example.com'; + + getMembers(workspaceId: string): Observable { + return of([...(this.membersByWorkspace[workspaceId] ?? [])]); + } + + inviteMember(workspaceId: string, email: string, role: InvitationRole): Observable { + const existingMember = (this.membersByWorkspace[workspaceId] ?? []).find(member => member.email.toLowerCase() === email.toLowerCase()); + const existingInvitation = (this.invitationsByWorkspace[workspaceId] ?? []).find(invitation => invitation.email.toLowerCase() === email.toLowerCase()); + + if (existingMember || existingInvitation) { + return throwError(() => new Error('User is already a member or has pending invitation')); + } + + const invitation: Invitation = { + id: `invite-${Date.now()}`, + email, + role, + token: `token-${Date.now()}`, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + createdAt: new Date() + }; + + this.invitationsByWorkspace[workspaceId] = [...(this.invitationsByWorkspace[workspaceId] ?? []), invitation]; + return of(invitation); + } + + updateRole(workspaceId: string, userId: number, role: WorkspaceRole): Observable { + const members = this.membersByWorkspace[workspaceId] ?? []; + const targetMember = members.find(member => member.userId === userId); + + if (!targetMember) { + return throwError(() => new Error('Member not found')); + } + + if (targetMember.role === 'owner') { + return throwError(() => new Error("Cannot modify owner's role")); + } + + const updatedMember = { ...targetMember, role }; + this.membersByWorkspace[workspaceId] = members.map(member => member.userId === userId ? updatedMember : member); + return of(updatedMember); + } + + removeMember(workspaceId: string, userId: number): Observable { + const members = this.membersByWorkspace[workspaceId] ?? []; + const targetMember = members.find(member => member.userId === userId); + + if (targetMember?.role === 'owner') { + return throwError(() => new Error('Cannot remove workspace owner')); + } + + this.membersByWorkspace[workspaceId] = members.filter(member => member.userId !== userId); + return of(void 0); + } + + getPendingInvitations(workspaceId: string): Observable { + return of([...(this.invitationsByWorkspace[workspaceId] ?? [])]); + } + + cancelInvitation(workspaceId: string, invitationId: string): Observable { + this.invitationsByWorkspace[workspaceId] = (this.invitationsByWorkspace[workspaceId] ?? []).filter(invitation => invitation.id !== invitationId); + return of(void 0); + } + + validateInvitation(token: string): Observable { + const invitation = this.findInvitationByToken(token); + + if (!invitation) { + return throwError(() => new Error('Invitation expired or invalid')); + } + + return of({ + valid: true, + workspace: { + id: '1', + name: 'Engineering' + }, + invitedBy: { + email: 'owner@example.com' + }, + role: invitation.role + }); + } + + acceptInvitation(token: string): Observable<{ workspaceId: string; role: WorkspaceRole }> { + const invitation = this.findInvitationByToken(token); + + if (!invitation) { + return throwError(() => new Error('Invitation expired or invalid')); + } + + const workspaceId = '1'; + const members = this.membersByWorkspace[workspaceId] ?? []; + + if (members.some(member => member.email.toLowerCase() === this.currentUserEmail.toLowerCase())) { + return throwError(() => new Error('You are already a member of this workspace')); + } + + const nextUserId = members.reduce((highest, member) => Math.max(highest, member.userId), 0) + 1; + const memberRole: WorkspaceRole = invitation.role === 'viewer' ? 'viewer' : 'member'; + + this.membersByWorkspace[workspaceId] = [ + ...members, + { + userId: nextUserId, + email: this.currentUserEmail, + role: memberRole, + joinedAt: new Date() + } + ]; + + this.invitationsByWorkspace[workspaceId] = (this.invitationsByWorkspace[workspaceId] ?? []).filter(item => item.token !== token); + + return of({ workspaceId, role: memberRole }); + } + + private findInvitationByToken(token: string): Invitation | undefined { + return Object.values(this.invitationsByWorkspace).flat().find(invitation => invitation.token === token); + } +} From 9842c345bbca591bde01406ee76d350d35d66e3c Mon Sep 17 00:00:00 2001 From: Neethika Date: Wed, 25 Mar 2026 18:19:09 -0400 Subject: [PATCH 2/3] test(frontend): add Angular unit test coverage --- src/app/components/login/login.html | 7 +- src/app/components/login/login.spec.ts | 187 ++++++++++++++++++ src/app/components/login/login.ts | 25 ++- src/app/guards/auth-guard.spec.ts | 39 ++++ src/app/services/integration.service.spec.ts | 96 +++++++++ src/app/services/signal.service.spec.ts | 46 +++++ .../services/workspace-member.service.spec.ts | 129 ++++++++++++ src/app/services/workspace.spec.ts | 75 +++++++ 8 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 src/app/components/login/login.spec.ts create mode 100644 src/app/guards/auth-guard.spec.ts create mode 100644 src/app/services/integration.service.spec.ts create mode 100644 src/app/services/signal.service.spec.ts create mode 100644 src/app/services/workspace-member.service.spec.ts create mode 100644 src/app/services/workspace.spec.ts diff --git a/src/app/components/login/login.html b/src/app/components/login/login.html index 8911f46..caf3036 100644 --- a/src/app/components/login/login.html +++ b/src/app/components/login/login.html @@ -48,7 +48,7 @@

Welcome back

+ [class.error]="!!loginError" [(ngModel)]="loginEmail" (input)="onLoginEmailInput()" placeholder="you@email.com"> Welcome back @@ -88,9 +88,10 @@

Welcome back

-

We'll send a password reset link to your email address.

+

{{ forgotError }}