Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add password reset #171

Merged
merged 3 commits into from
Dec 18, 2024
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
12 changes: 12 additions & 0 deletions api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,15 @@ func (r *AuthRegistrationRequest) Validate() error {
return nil
}
}

type AuthResetPasswordRequest struct {
Email string `json:"email"`
}

func (r *AuthResetPasswordRequest) Validate() error {
if r.Email == "" {
return errors.New("email is empty")
} else {
return nil
}
}
15 changes: 13 additions & 2 deletions frontend/cloud-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ import {InviteComponent} from './invite/invite.component';
import {UsersComponent} from './components/users/users.component';
import {DeploymentsPageComponent} from './deployments/deployments-page.component';
import {VerifyComponent} from './verify/verify.component';
import {ForgotComponent} from './forgot/forgot.component';
import {PasswordResetComponent} from './password-reset/password-reset.component';

export const routes: Routes = [
{path: 'login', component: LoginComponent},
{path: 'register', component: RegisterComponent},
{path: 'forgot', component: ForgotComponent},
{
path: '',
canActivate: [
Expand All @@ -41,14 +44,21 @@ export const routes: Routes = [
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated) {
if (!auth.getClaims().email_verified) {
if (auth.getClaims().password_reset) {
if (state.url === '/reset') {
return true;
} else {
return router.createUrlTree(['/reset']);
}
} else if (!auth.getClaims().email_verified) {
if (state.url === '/verify') {
return true;
} else {
return router.createUrlTree(['/verify']);
}
} else {
return true;
}
return true;
} else {
return router.createUrlTree(['/login']);
}
Expand All @@ -57,6 +67,7 @@ export const routes: Routes = [
children: [
{path: '', pathMatch: 'full', redirectTo: 'dashboard'},
{path: 'verify', component: VerifyComponent},
{path: 'reset', component: PasswordResetComponent},
{path: 'join', component: InviteComponent},
{
path: '',
Expand Down
57 changes: 57 additions & 0 deletions frontend/cloud-ui/src/app/forgot/forgot.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<section class="bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a href="#" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
<img class="w-8 h-8 mr-2" src="glasskube-logo.svg" alt="Glasskube" />
Glasskube
</a>
<div
class="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
@if (success) {
<h1
class="text-xl text-center font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Check your inbox…
</h1>
} @else {
<h1 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Request password reset
</h1>
<form class="space-y-4 md:space-y-6" [formGroup]="formGroup" (ngSubmit)="submit()">
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<input
type="email"
formControlName="email"
id="email"
class="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="name@company.com"
required />
@if (formGroup.controls.email.touched && formGroup.controls.email.errors) {
<div class="text-sm text-red-500">Please enter a valid email address</div>
}
</div>
@if (errorMessage) {
<div class="text-sm text-red-500">{{ errorMessage }}</div>
}
<button
type="submit"
class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
Send E-Mail
</button>
<p class="text-sm text-center font-light text-gray-500 dark:text-gray-400">
<a routerLink="/login" class="font-medium text-primary-600 hover:underline dark:text-primary-500"
>Back to login</a
>
</p>
<p class="text-sm text-center font-light text-gray-500 dark:text-gray-400">
Don’t have an account yet?<br />
<a routerLink="/register" class="font-medium text-primary-600 hover:underline dark:text-primary-500"
>Sign up</a
>
</p>
</form>
}
</div>
</div>
</div>
</section>
60 changes: 60 additions & 0 deletions frontend/cloud-ui/src/app/forgot/forgot.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {FormGroup, FormControl, Validators, ReactiveFormsModule} from '@angular/forms';
import {Router, ActivatedRoute, RouterLink} from '@angular/router';
import {distinctUntilChanged, filter, lastValueFrom, map, Subject, takeUntil} from 'rxjs';
import {AuthService} from '../services/auth.service';
import {HttpErrorResponse} from '@angular/common/http';

@Component({
selector: 'app-forgot',
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './forgot.component.html',
})
export class ForgotComponent implements OnInit, OnDestroy {
public readonly formGroup = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
});
public errorMessage?: string;
public success = false;
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyed$ = new Subject<void>();

public ngOnInit(): void {
this.route.queryParams
.pipe(
map((params) => params['email']),
filter((email) => email),
distinctUntilChanged(),
takeUntil(this.destroyed$)
)
.subscribe((email) => {
this.formGroup.patchValue({email});
});
}

public ngOnDestroy(): void {
this.destroyed$.next();
}

public async submit(): Promise<void> {
this.formGroup.markAllAsTouched();
this.errorMessage = undefined;
if (this.formGroup.valid) {
const value = this.formGroup.value;
try {
await lastValueFrom(this.auth.resetPassword(value.email!));
} catch (e) {
if (e instanceof HttpErrorResponse && e.status < 500 && typeof e.error === 'string') {
this.errorMessage = e.error;
} else {
console.error(e);
this.errorMessage = 'something went wrong';
}
return;
}
this.success = true;
}
}
}
43 changes: 22 additions & 21 deletions frontend/cloud-ui/src/app/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,31 @@ <h1 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-
}
</div>
@if (errorMessage) {
<div class="flex items-center justify-between">
<span class="text-sm text-red-500">{{ errorMessage }}</span>
<!-- TODO: enable "remember me" feature if implemented
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="remember"
aria-describedby="remember"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800"
required="" />
</div>
<div class="ml-3 text-sm">
<label for="remember" class="text-gray-500 dark:text-gray-300">Remember me</label>
<div class="text-sm text-red-500">{{ errorMessage }}</div>
}
<div class="flex items-center justify-between">
<!-- TODO: enable "remember me" feature if implemented
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="remember"
aria-describedby="remember"
type="checkbox"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800"
required="" />
</div>
<div class="ml-3 text-sm">
<label for="remember" class="text-gray-500 dark:text-gray-300">Remember me</label>
</div>
-->
<!-- TODO: enable "forgot password" feature if implemented
<a href="#" class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-500"
>Forgot password?</a
>
-->
</div>
}
-->
<a
routerLink="/forgot"
[queryParams]="{email: formGroup.controls.email.valid ? formGroup.value.email : undefined}"
class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-500">
Forgot password?
</a>
</div>
<button
type="submit"
class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<section class="bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a href="#" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
<img class="w-8 h-8 mr-2" src="https://flowbite.s3.amazonaws.com/blocks/marketing-ui/logo.svg" alt="logo" />
Flowbite
</a>
<div
class="w-full p-6 bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md dark:bg-gray-800 dark:border-gray-700 sm:p-8">
<h2 class="mb-1 text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Change Password
</h2>
<form class="mt-4 space-y-4 lg:mt-5 md:space-y-5" [formGroup]="form" (ngSubmit)="submit()">
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<input
type="email"
[value]="email"
id="email"
class="opacity-60 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="name@company.com"
required
readonly
disabeld />
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<input
type="password"
formControlName="password"
id="password"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required="" />
@if (form.controls.password.touched && form.controls.password.errors) {
<span class="text-sm text-red-500">Password is required and must have at leat 8 characters</span>
}
</div>
<div>
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Confirm password</label
>
<input
type="password"
formControlName="passwordConfirm"
id="confirm-password"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required="" />
@if (form.controls.passwordConfirm.touched && form.errors?.['passwordMismatch']) {
<span class="text-sm text-red-500">Password mismatch</span>
}
</div>
@if (errorMessage) {
<div class="text-sm text-red-500">{{ errorMessage }}</div>
}
<button
type="submit"
class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
Reset passwod
</button>
</form>
</div>
</div>
</section>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {HttpErrorResponse} from '@angular/common/http';
import {Component, inject} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {Router} from '@angular/router';
import {lastValueFrom} from 'rxjs';
import {AuthService} from '../services/auth.service';
import {SettingsService} from '../services/settings.service';

@Component({
selector: 'app-password-reset',
imports: [ReactiveFormsModule],
templateUrl: './password-reset.component.html',
})
export class PasswordResetComponent {
private readonly settings = inject(SettingsService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);

public readonly form = new FormGroup(
{
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
passwordConfirm: new FormControl('', [Validators.required]),
},
(control) => (control.value.password === control.value.passwordConfirm ? null : {passwordMismatch: 'error'})
);
public readonly email = this.auth.getClaims().email;
public errorMessage?: string;

public async submit() {
this.form.markAllAsTouched();
this.errorMessage = undefined;
if (this.form.valid) {
try {
await lastValueFrom(this.settings.updateUserSettings({password: this.form.value.password!}));
} catch (e) {
if (e instanceof HttpErrorResponse && e.status < 500 && typeof e.error === 'string') {
this.errorMessage = e.error;
} else {
this.errorMessage = 'something went wrong';
}
console.error(e);
}
await lastValueFrom(this.auth.logout());
location.assign(`/login?email=${encodeURIComponent(this.email)}`);
}
}
}
7 changes: 6 additions & 1 deletion frontend/cloud-ui/src/app/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface JWTClaims {
sub: string;
org: string;
email: string;
password_reset: boolean;
email_verified: boolean;
name: string;
exp: string;
Expand Down Expand Up @@ -51,6 +52,10 @@ export class AuthService {
);
}

public resetPassword(email: string): Observable<void> {
return this.httpClient.post<void>(`${this.baseUrl}/reset`, {email});
}

public register(email: string, name: string | null | undefined, password: string): Observable<void> {
let body: any = {email, password};
if (name) {
Expand All @@ -75,7 +80,7 @@ export class AuthService {

export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
if (req.url !== '/api/v1/auth/login' && req.url !== '/api/v1/auth/register') {
if (!req.url.startsWith('/api/v1/auth/')) {
const token = auth.token;
try {
if (dayjs.unix(parseInt(auth.getClaims().exp)).isAfter(dayjs())) {
Expand Down
Loading
Loading