Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down
114 changes: 114 additions & 0 deletions src/app/components/accept-invitation/accept-invitation.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
38 changes: 38 additions & 0 deletions src/app/components/accept-invitation/accept-invitation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<section class="accept-shell">
<article class="accept-card" *ngIf="invitation && !error; else errorState">
<p class="eyebrow">Invitation</p>
<h1>Join {{ invitation.workspace.name }}</h1>
<p class="description">
{{ invitation.invitedBy.email }} invited you to join this workspace as a <strong>{{ invitation.role }}</strong>.
</p>

<div class="summary">
<div>
<span>Workspace</span>
<strong>{{ invitation.workspace.name }}</strong>
</div>
<div>
<span>Role</span>
<strong>{{ invitation.role }}</strong>
</div>
</div>

<p class="feedback success" *ngIf="success">{{ success }}</p>

<div class="actions">
<button type="button" class="primary-btn" [disabled]="isSubmitting" (click)="joinWorkspace()">
{{ isSubmitting ? 'Joining...' : 'Join Workspace' }}
</button>
<a routerLink="/dashboard" class="secondary-btn">Back to dashboard</a>
</div>
</article>

<ng-template #errorState>
<article class="accept-card error-card">
<p class="eyebrow">Invitation</p>
<h1>Invitation unavailable</h1>
<p class="description">{{ error || 'Invitation expired or invalid' }}</p>
<a routerLink="/dashboard" class="secondary-btn">Go to dashboard</a>
</article>
</ng-template>
</section>
74 changes: 74 additions & 0 deletions src/app/components/accept-invitation/accept-invitation.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AcceptInvitationComponent>;
let mockWorkspaceMemberService: jasmine.SpyObj<WorkspaceMemberService>;
let mockAuthService: jasmine.SpyObj<AuthService>;
let router: Router;

beforeEach(async () => {
mockWorkspaceMemberService = jasmine.createSpyObj<WorkspaceMemberService>('WorkspaceMemberService', [
'validateInvitation',
'acceptInvitation'
]);
mockAuthService = jasmine.createSpyObj<AuthService>('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();
});
});
59 changes: 59 additions & 0 deletions src/app/components/accept-invitation/accept-invitation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
}
}
7 changes: 4 additions & 3 deletions src/app/components/login/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ <h2>Welcome back</h2>
<form *ngIf="isLoginVisible" (ngSubmit)="handleLogin()" class="form-section" novalidate>
<label class="input-label" for="loginEmail">Email</label>
<input id="loginEmail" name="loginEmail" class="input-field" type="email" autocomplete="email"
[(ngModel)]="loginEmail" (input)="onLoginEmailInput()" placeholder="you@email.com">
[class.error]="!!loginError" [(ngModel)]="loginEmail" (input)="onLoginEmailInput()" placeholder="you@email.com">

<label class="input-label" for="loginPassword">Password</label>
<input id="loginPassword" name="loginPassword" class="input-field" type="password"
Expand All @@ -73,7 +73,7 @@ <h2>Welcome back</h2>
<form *ngIf="isRegisterVisible" (ngSubmit)="handleRegister()" class="form-section" novalidate>
<label class="input-label" for="regEmail">Email</label>
<input id="regEmail" name="regEmail" class="input-field" type="email"
autocomplete="email" [(ngModel)]="regEmail" (input)="onRegisterEmailInput()"
[class.error]="!!registerError" autocomplete="email" [(ngModel)]="regEmail" (input)="onRegisterEmailInput()"
placeholder="you@email.com">

<label class="input-label" for="regPassword">Password</label>
Expand All @@ -88,9 +88,10 @@ <h2>Welcome back</h2>

<form *ngIf="showForgot" (ngSubmit)="handleForgot()" class="form-section">
<label class="input-label" for="forgotEmail">Work Email</label>
<input id="forgotEmail" name="forgotEmail" class="input-field" type="email" required [(ngModel)]="forgotEmail"
<input id="forgotEmail" name="forgotEmail" class="input-field" type="email" [class.error]="!!forgotError" required [(ngModel)]="forgotEmail"
(input)="onForgotEmailInput()" placeholder="you@company.com">
<p class="subtle-copy">We'll send a password reset link to your email address.</p>
<p *ngIf="forgotError" class="error-text">{{ forgotError }}</p>

<button type="submit" class="btn-primary" [disabled]="isForgotSubmitting">
Send Reset Link
Expand Down
Loading
Loading