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/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 }}

+ + + + + + + +
+
+
+

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/guards/auth-guard.spec.ts b/src/app/guards/auth-guard.spec.ts new file mode 100644 index 0000000..982427e --- /dev/null +++ b/src/app/guards/auth-guard.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { authGuard } from './auth-guard'; +import { AuthService } from '../services/auth'; + +describe('authGuard', () => { + let mockAuthService: jasmine.SpyObj; + let mockRouter: jasmine.SpyObj; + + beforeEach(() => { + mockAuthService = jasmine.createSpyObj('AuthService', ['isLoggedIn']); + mockRouter = jasmine.createSpyObj('Router', ['navigate']); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: Router, useValue: mockRouter } + ] + }); + }); + + it('returns true when the user is logged in', () => { + mockAuthService.isLoggedIn.and.returnValue(true); + + const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never)); + + expect(result).toBeTrue(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('redirects to login when the user is not logged in', () => { + mockAuthService.isLoggedIn.and.returnValue(false); + + const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never)); + + expect(result).toBeFalse(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/login']); + }); +}); 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/integration.service.spec.ts b/src/app/services/integration.service.spec.ts new file mode 100644 index 0000000..c28f670 --- /dev/null +++ b/src/app/services/integration.service.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; +import { IntegrationService } from './integration.service'; + +describe('IntegrationService', () => { + let service: IntegrationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(IntegrationService); + }); + + it('connects Slack and exposes workspace details', () => { + let connected = false; + let workspaceName: string | undefined; + + service.connectSlack().subscribe(result => { + connected = result.connected; + }); + service.getSlackChannels().subscribe(result => { + workspaceName = result.workspaceName; + }); + + expect(connected).toBeTrue(); + expect(workspaceName).toBe('Sentinent Ops'); + }); + + it('updates Slack channel selections', () => { + let connectedChannels: string[] = []; + + service.updateSlackChannels(['C987654']).subscribe(); + service.getSlackChannels().subscribe(result => { + connectedChannels = result.channels.filter(channel => channel.isConnected).map(channel => channel.id); + }); + + expect(connectedChannels).toEqual(['C987654']); + }); + + it('disconnects Slack and clears all connected channels', () => { + let connected = true; + let connectedChannels = 1; + + service.connectSlack().subscribe(); + service.disconnectSlack().subscribe(); + service.getSlackChannels().subscribe(result => { + connected = result.connected; + connectedChannels = result.channels.filter(channel => channel.isConnected).length; + }); + + expect(connected).toBeFalse(); + expect(connectedChannels).toBe(0); + }); + + it('returns an error when GitHub sync is requested before connection', () => { + let errorMessage = ''; + + service.syncGitHub().subscribe({ + next: () => fail('expected syncGitHub to fail when GitHub is disconnected'), + error: error => { + errorMessage = error.message; + } + }); + + expect(errorMessage).toContain('Connect GitHub'); + }); + + it('syncs connected GitHub repositories and exposes the sync status', () => { + let syncId = ''; + let itemsSynced = 0; + + service.connectGitHub().subscribe(); + service.updateGitHubRepos([101, 103]).subscribe(); + service.syncGitHub().subscribe(result => { + syncId = result.syncId; + }); + service.getSyncStatus(syncId).subscribe(result => { + itemsSynced = result.itemsSynced ?? 0; + }); + + expect(syncId).toContain('sync-'); + expect(itemsSynced).toBe(12); + }); + + it('disconnects GitHub and clears the latest sync state', () => { + let status = 'unknown'; + + service.connectGitHub().subscribe(); + service.syncGitHub().subscribe(result => { + service.disconnectGitHub().subscribe(); + service.getSyncStatus(result.syncId).subscribe(syncStatus => { + status = syncStatus.status; + }); + }); + + expect(status).toBe('failed'); + }); +}); diff --git a/src/app/services/signal.service.spec.ts b/src/app/services/signal.service.spec.ts new file mode 100644 index 0000000..8ef6f89 --- /dev/null +++ b/src/app/services/signal.service.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { SignalService } from './signal.service'; +import { Signal } from '../models/signal.model'; + +describe('SignalService', () => { + let service: SignalService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SignalService); + }); + + it('filters signals by source and status', () => { + let results: Signal[] = []; + + service.getSignals({ source: 'github', status: 'unread' }).subscribe(signals => { + results = signals; + }); + + expect(results.length).toBe(2); + expect(results.every(signal => signal.sourceType === 'github')).toBeTrue(); + expect(results.every(signal => signal.status === 'unread')).toBeTrue(); + }); + + it('marks a signal as read', () => { + service.markAsRead('slack-101').subscribe(); + + let updatedSignal: Signal | undefined; + service.getSignals({ source: 'slack', status: 'read' }).subscribe(signals => { + updatedSignal = signals.find(signal => signal.id === 'slack-101'); + }); + + expect(updatedSignal?.status).toBe('read'); + }); + + it('archives a signal', () => { + service.archive('github-101').subscribe(); + + let activeSignals: Signal[] = []; + service.getSignals({ source: 'github', status: 'unread' }).subscribe(signals => { + activeSignals = signals; + }); + + expect(activeSignals.some(signal => signal.id === 'github-101')).toBeFalse(); + }); +}); diff --git a/src/app/services/workspace-member.service.spec.ts b/src/app/services/workspace-member.service.spec.ts new file mode 100644 index 0000000..ba34013 --- /dev/null +++ b/src/app/services/workspace-member.service.spec.ts @@ -0,0 +1,129 @@ +import { TestBed } from '@angular/core/testing'; +import { WorkspaceMemberService } from './workspace-member.service'; +import { Invitation, WorkspaceMember } from '../models/workspace-member.model'; + +describe('WorkspaceMemberService', () => { + let service: WorkspaceMemberService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(WorkspaceMemberService); + }); + + it('returns members for a workspace', () => { + let members: WorkspaceMember[] = []; + + service.getMembers('1').subscribe(result => { + members = result; + }); + + expect(members.length).toBe(3); + expect(members.map(member => member.email)).toContain('owner@example.com'); + }); + + it('creates an invitation for a new member', () => { + let invitation: Invitation | undefined; + + service.inviteMember('1', 'designer@example.com', 'viewer').subscribe(result => { + invitation = result; + }); + + expect(invitation?.email).toBe('designer@example.com'); + + let invitations: Invitation[] = []; + service.getPendingInvitations('1').subscribe(result => { + invitations = result; + }); + + expect(invitations.some(item => item.email === 'designer@example.com')).toBeTrue(); + }); + + it('rejects duplicate invitations or existing members', () => { + let errorMessage = ''; + + service.inviteMember('1', 'owner@example.com', 'member').subscribe({ + next: () => fail('expected duplicate invite to fail'), + error: error => { + errorMessage = error.message; + } + }); + + expect(errorMessage).toContain('already a member'); + }); + + it('updates the role for a non-owner member', () => { + let updatedRole = ''; + + service.updateRole('1', 2, 'viewer').subscribe(result => { + updatedRole = result.role; + }); + + expect(updatedRole).toBe('viewer'); + }); + + it('rejects owner role changes', () => { + let errorMessage = ''; + + service.updateRole('1', 1, 'member').subscribe({ + next: () => fail('expected owner update to fail'), + error: error => { + errorMessage = error.message; + } + }); + + expect(errorMessage).toContain("owner's role"); + }); + + it('removes a non-owner member', () => { + let members: WorkspaceMember[] = []; + + service.removeMember('1', 3).subscribe(); + service.getMembers('1').subscribe(result => { + members = result; + }); + + expect(members.some(member => member.userId === 3)).toBeFalse(); + }); + + it('rejects owner removal', () => { + let errorMessage = ''; + + service.removeMember('1', 1).subscribe({ + next: () => fail('expected owner removal to fail'), + error: error => { + errorMessage = error.message; + } + }); + + expect(errorMessage).toContain('Cannot remove workspace owner'); + }); + + it('validates an invitation token', () => { + let role = ''; + + service.validateInvitation('invite_token_member').subscribe(result => { + role = result.role; + }); + + expect(role).toBe('member'); + }); + + it('accepts a valid invitation for a new user', () => { + let acceptedWorkspaceId = ''; + + (service as any).currentUserEmail = 'newuser@example.com'; + + service.acceptInvitation('invite_token_viewer').subscribe(result => { + acceptedWorkspaceId = result.workspaceId; + }); + + expect(acceptedWorkspaceId).toBe('1'); + + let members: WorkspaceMember[] = []; + service.getMembers('1').subscribe(result => { + members = result; + }); + + expect(members.some(member => member.email === 'newuser@example.com' && member.role === 'viewer')).toBeTrue(); + }); +}); 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); + } +} diff --git a/src/app/services/workspace.spec.ts b/src/app/services/workspace.spec.ts new file mode 100644 index 0000000..a0d6ba0 --- /dev/null +++ b/src/app/services/workspace.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { WorkspaceService } from './workspace'; +import { Workspace } from '../models/workspace'; + +describe('WorkspaceService', () => { + let service: WorkspaceService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(WorkspaceService); + }); + + it('returns the seeded workspaces', () => { + let workspaces: Workspace[] = []; + + service.getWorkspaces().subscribe(result => { + workspaces = result; + }); + + expect(workspaces.length).toBe(3); + expect(workspaces.map(workspace => workspace.name)).toContain('Engineering'); + }); + + it('creates a new workspace', () => { + let createdName = ''; + let workspaceCount = 0; + + service.createWorkspace('Support', 'Support team workspace').subscribe(result => { + createdName = result.name; + }); + + service.getWorkspaces().subscribe(result => { + workspaceCount = result.length; + }); + + expect(createdName).toBe('Support'); + expect(workspaceCount).toBe(4); + }); + + it('updates an existing workspace', () => { + let updatedDescription: string | undefined; + + service.updateWorkspace('1', 'Engineering', 'Updated description').subscribe(result => { + updatedDescription = result?.description; + }); + + expect(updatedDescription).toBe('Updated description'); + }); + + it('returns undefined when updating a missing workspace', () => { + let resultValue = 'unset'; + + service.updateWorkspace('missing', 'Name', 'Description').subscribe(result => { + resultValue = result === undefined ? 'undefined' : 'defined'; + }); + + expect(resultValue).toBe('undefined'); + }); + + it('deletes an existing workspace', () => { + let deleted = false; + let workspaceCount = 0; + + service.deleteWorkspace('3').subscribe(result => { + deleted = result; + }); + + service.getWorkspaces().subscribe(result => { + workspaceCount = result.length; + }); + + expect(deleted).toBeTrue(); + expect(workspaceCount).toBe(2); + }); +});