Skip to content
Closed
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ ng serve

Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.

## Supabase setup

This frontend now uses Supabase for authentication.

Update the following files with your Supabase project values before testing login or signup:

```text
src/environments/environment.ts
src/environments/environment.development.ts
```

Set:

```ts
supabaseUrl: 'https://your-project-ref.supabase.co'
supabaseAnonKey: 'your-supabase-anon-key'
passwordResetRedirectUrl: 'http://localhost:4200/login'
```

In your Supabase dashboard, enable Email authentication for the project and make sure the redirect URL matches your local frontend URL.

## Code scaffolding

Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
Expand Down
12 changes: 12 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.ts"
}
],
"budgets": [
{
"type": "initial",
Expand All @@ -71,6 +77,12 @@
"outputHashing": "all"
},
"development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
],
"optimization": false,
"extractLicenses": false,
"sourceMap": true
Expand Down
101 changes: 96 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@angular/forms": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0",
"@supabase/supabase-js": "^2.100.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "^0.16.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('AcceptInvitationComponent', () => {
role: 'member'
}));
mockWorkspaceMemberService.acceptInvitation.and.returnValue(of({ workspaceId: '1', role: 'member' }));
mockAuthService.isLoggedIn.and.returnValue(true);
mockAuthService.isLoggedIn.and.returnValue(of(true));

await TestBed.configureTestingModule({
imports: [AcceptInvitationComponent, RouterTestingModule],
Expand Down Expand Up @@ -65,7 +65,7 @@ describe('AcceptInvitationComponent', () => {

it('should redirect to login when not authenticated', () => {
spyOn(router, 'navigate');
mockAuthService.isLoggedIn.and.returnValue(false);
mockAuthService.isLoggedIn.and.returnValue(of(false));

component.joinWorkspace();

Expand Down
40 changes: 24 additions & 16 deletions src/app/components/accept-invitation/accept-invitation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { InvitationValidation } from '../../models/workspace-member.model';
import { AuthService } from '../../services/auth';
import { WorkspaceMemberService } from '../../services/workspace-member.service';
Expand Down Expand Up @@ -37,23 +38,30 @@ export class AcceptInvitationComponent implements OnInit {
}

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;
this.authService.isLoggedIn().subscribe(isLoggedIn => {
if (!isLoggedIn) {
this.router.navigate(['/login'], { queryParams: { redirectTo: `/invitations/${this.token}` } });
return;
}

this.isSubmitting = true;
this.workspaceMemberService.acceptInvitation(this.token).pipe(
finalize(() => {
if (!this.success) {
this.isSubmitting = false;
}
})
).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.error = error.message;
}
});
});
}
}
47 changes: 42 additions & 5 deletions src/app/components/login/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { DOCUMENT } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Session } from '@supabase/supabase-js';
import { of, throwError } from 'rxjs';
import { Login } from './login';
import { AuthService } from '../../services/auth';
Expand All @@ -15,7 +15,7 @@ describe('Login', () => {
let document: Document;

beforeEach(async () => {
mockAuthService = jasmine.createSpyObj<AuthService>('AuthService', ['login', 'signup']);
mockAuthService = jasmine.createSpyObj<AuthService>('AuthService', ['login', 'signup', 'resetPassword']);

spyOn(localStorage, 'getItem').and.returnValue(null);
spyOn(localStorage, 'setItem');
Expand Down Expand Up @@ -92,7 +92,21 @@ describe('Login', () => {

it('shows a success message and redirects after login', fakeAsync(() => {
spyOn(router, 'navigate');
mockAuthService.login.and.returnValue(of({ token: 'token-1' }));
const session = {
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
expires_at: 9999999999,
token_type: 'bearer',
user: {
id: 'user-1',
app_metadata: {},
user_metadata: {},
aud: 'authenticated',
created_at: '2026-03-25T00:00:00.000Z'
}
} as Session;
mockAuthService.login.and.returnValue(of(session));
component.loginEmail = 'user@example.com';
component.loginPassword = 'secret';

Expand All @@ -107,7 +121,7 @@ describe('Login', () => {

it('maps a 401 login response to invalid credentials', () => {
mockAuthService.login.and.returnValue(
throwError(() => new HttpErrorResponse({ status: 401, error: 'invalid credentials' }))
throwError(() => new Error('Invalid login credentials'))
);
component.loginEmail = 'user@example.com';
component.loginPassword = 'wrong';
Expand Down Expand Up @@ -148,7 +162,7 @@ describe('Login', () => {

it('shows a duplicate email message when signup returns a conflict', () => {
mockAuthService.signup.and.returnValue(
throwError(() => new HttpErrorResponse({ status: 409, error: 'already exists' }))
throwError(() => new Error('User already registered'))
);
component.activeTab = 'register';
component.regEmail = 'user@example.com';
Expand Down Expand Up @@ -184,4 +198,27 @@ describe('Login', () => {
expect(component.forgotError).toBe('Enter a valid email address');
expect(component.isForgotSubmitting).toBeFalse();
});

it('shows a success message after a password reset request', () => {
mockAuthService.resetPassword.and.returnValue(of(void 0));
component.showForgotPassword();
component.forgotEmail = 'user@example.com';

component.handleForgot();

expect(mockAuthService.resetPassword).toHaveBeenCalledWith('user@example.com');
expect(component.showSuccess).toBeTrue();
expect(component.successTitle).toBe('Check your email');
});

it('shows an error when the password reset request fails', () => {
mockAuthService.resetPassword.and.returnValue(throwError(() => new Error('Reset failed')));
component.showForgotPassword();
component.forgotEmail = 'user@example.com';

component.handleForgot();

expect(component.forgotError).toBe('Unable to send reset link. Please try again.');
expect(component.isForgotSubmitting).toBeFalse();
});
});
Loading
Loading