Skip to content
Open
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
9,961 changes: 9,961 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ApplicationConfig, inject, provideAppInitializer, provideZonelessChangeDetection } from '@angular/core';
import {
ApplicationConfig,
inject,
isDevMode,
provideAppInitializer,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
Expand Down Expand Up @@ -55,7 +61,9 @@ function setupDebugInterface(jwtService: JwtService, userService: UserService):
*/
export function initAuth(jwtService: JwtService, userService: UserService) {
return () => {
setupDebugInterface(jwtService, userService);
if (isDevMode()) {
setupDebugInterface(jwtService, userService);
}

if (jwtService.getToken()) {
return userService.getCurrentUser();
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/auth/auth.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ <h1 class="text-xs-center">{{ title }}</h1>
name="email"
placeholder="Email"
class="form-control form-control-lg"
type="text"
type="email"
/>
</fieldset>
<fieldset class="form-group">
Expand Down
20 changes: 19 additions & 1 deletion src/app/core/auth/auth.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface AuthForm {
username?: FormControl<string>;
}

const STRONG_PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{8,}$/;

@Component({
selector: 'app-auth-page',
templateUrl: './auth.component.html',
Expand All @@ -33,7 +35,7 @@ export default class AuthComponent implements OnInit {
) {
this.authForm = new FormGroup<AuthForm>({
email: new FormControl('', {
validators: [Validators.required],
validators: [Validators.required, Validators.email],
nonNullable: true,
}),
password: new FormControl('', {
Expand All @@ -46,6 +48,17 @@ export default class AuthComponent implements OnInit {
ngOnInit(): void {
this.authType = this.route.snapshot.url.at(-1)!.path;
this.title = this.authType === 'login' ? 'Sign in' : 'Sign up';
const passwordControl = this.authForm.controls.password;
if (this.authType === 'register') {
passwordControl.setValidators([
Validators.required,
Validators.minLength(6),
Validators.pattern(STRONG_PASSWORD_PATTERN),
]);
} else {
passwordControl.setValidators([Validators.required, Validators.minLength(6)]);
}
passwordControl.updateValueAndValidity({ emitEvent: false });
if (this.authType === 'register') {
this.authForm.addControl(
'username',
Expand All @@ -58,6 +71,11 @@ export default class AuthComponent implements OnInit {
}

submitForm(): void {
if (this.authForm.invalid) {
this.authForm.markAllAsTouched();
return;
}

this.isSubmitting.set(true);
this.errors.set({ errors: {} });

Expand Down
35 changes: 25 additions & 10 deletions src/app/core/auth/if-authenticated.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,40 @@ export class IfAuthenticatedDirective<T> implements OnInit {
private viewContainer: ViewContainerRef,
) {}

isAuthenticated = signal<boolean | null>(null);
condition = signal(false);
hasView = signal(false);

ngOnInit() {
this.userService.isAuthenticated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isAuthenticated: boolean) => {
const authRequired = isAuthenticated && this.condition();
const unauthRequired = !isAuthenticated && !this.condition();

if ((authRequired || unauthRequired) && !this.hasView()) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView.set(true);
} else if (this.hasView()) {
this.viewContainer.clear();
this.hasView.set(false);
}
this.isAuthenticated.set(isAuthenticated);
this.updateView();
});
}

@Input() set ifAuthenticated(condition: boolean) {
this.condition.set(condition);
this.updateView();
}

private updateView(): void {
const isAuthenticated = this.isAuthenticated();

if (isAuthenticated === null) {
return;
}

const shouldShow = (isAuthenticated && this.condition()) || (!isAuthenticated && !this.condition());

if (shouldShow && !this.hasView()) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView.set(true);
return;
}

if (!shouldShow && this.hasView()) {
this.viewContainer.clear();
this.hasView.set(false);
}
}
}
8 changes: 4 additions & 4 deletions src/app/core/auth/services/jwt.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ describe('JwtService', () => {
expect(token).toBe(mockToken);
});

it('should return undefined when no token exists', () => {
it('should return null when no token exists', () => {
const token = service.getToken();
expect(token).toBeUndefined();
expect(token).toBeNull();
});

it('should handle empty string token', () => {
Expand Down Expand Up @@ -168,7 +168,7 @@ describe('JwtService', () => {
service.destroyToken();
delete localStorageSpy['jwtToken'];
const token = service.getToken();
expect(token).toBeUndefined();
expect(token).toBeNull();
});

it('should be idempotent', () => {
Expand Down Expand Up @@ -305,7 +305,7 @@ describe('JwtService', () => {
// User logs out
service.destroyToken();
delete localStorageSpy['jwtToken'];
expect(service.getToken()).toBeUndefined();
expect(service.getToken()).toBeNull();
});

it('should support session management', () => {
Expand Down
28 changes: 23 additions & 5 deletions src/app/core/auth/services/jwt.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } from '@angular/core';

const TOKEN_KEY = 'jwtToken';

@Injectable({ providedIn: 'root' })
export class JwtService {
getToken(): string {
return window.localStorage['jwtToken'];
private inMemoryToken: string | null = null;

getToken(): string | null {
if (isDevMode()) {
return window.localStorage[TOKEN_KEY] ?? null;
}

return this.inMemoryToken;
}

saveToken(token: string): void {
window.localStorage['jwtToken'] = token;
if (isDevMode()) {
window.localStorage[TOKEN_KEY] = token;
return;
}

this.inMemoryToken = token;
}

destroyToken(): void {
window.localStorage.removeItem('jwtToken');
if (isDevMode()) {
window.localStorage.removeItem(TOKEN_KEY);
return;
}

this.inMemoryToken = null;
}
}
5 changes: 5 additions & 0 deletions src/app/features/article/pages/article/article.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ export default class ArticleComponent implements OnInit {
const article = this.article();
if (!article) return;

if (this.isDeleting()) return;

const confirmed = window.confirm('Are you sure you want to delete this article?');
if (!confirmed) return;

this.isDeleting.set(true);

this.articleService
Expand Down
14 changes: 12 additions & 2 deletions src/app/features/settings/settings.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SettingsForm {
password: FormControl<string>;
}

const STRONG_PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{8,}$/;

@Component({
selector: 'app-settings-page',
templateUrl: './settings.component.html',
Expand All @@ -27,9 +29,12 @@ export default class SettingsComponent implements OnInit {
image: new FormControl('', { nonNullable: true }),
username: new FormControl('', { nonNullable: true }),
bio: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true }),
email: new FormControl('', {
validators: [Validators.required, Validators.email],
nonNullable: true,
}),
password: new FormControl('', {
validators: [Validators.required],
validators: [Validators.pattern(STRONG_PASSWORD_PATTERN)],
nonNullable: true,
}),
});
Expand Down Expand Up @@ -58,6 +63,11 @@ export default class SettingsComponent implements OnInit {
}

submitForm() {
if (this.settingsForm.invalid) {
this.settingsForm.markAllAsTouched();
return;
}

this.isSubmitting.set(true);

const payload = { ...this.settingsForm.value };
Expand Down