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
12 changes: 0 additions & 12 deletions src/app/app.component.ts

This file was deleted.

16 changes: 8 additions & 8 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { JwtService } from './core/auth/services/jwt.service';
import { UserService, AuthState } from './core/auth/services/user.service';
import { apiInterceptor } from './core/interceptors/api.interceptor';
import { tokenInterceptor } from './core/interceptors/token.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
import { Jwt } from './core/auth/services/jwt';
import { UserAuth, AuthState } from './core/auth/services/user-auth';
import { apiInterceptor } from './core/interceptors/api-interceptor';
import { tokenInterceptor } from './core/interceptors/token-interceptor';
import { errorInterceptor } from './core/interceptors/error-interceptor';
import { EMPTY } from 'rxjs';
import { User } from './core/auth/user.model';

Expand All @@ -30,7 +30,7 @@ declare global {
/**
* Sets up the debug interface on window.__conduit_debug__
*/
function setupDebugInterface(jwtService: JwtService, userService: UserService): void {
function setupDebugInterface(jwtService: Jwt, userService: UserAuth): void {
let currentAuthState: AuthState = 'loading';
let currentUser: User | null = null;

Expand All @@ -53,7 +53,7 @@ function setupDebugInterface(jwtService: JwtService, userService: UserService):
* - 4XX → 'unauthenticated' (invalid token, cleared)
* - 5XX → 'unavailable' (server down, token kept, auto-retry)
*/
export function initAuth(jwtService: JwtService, userService: UserService) {
export function initAuth(jwtService: Jwt, userService: UserAuth) {
return () => {
setupDebugInterface(jwtService, userService);

Expand All @@ -72,7 +72,7 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes),
provideHttpClient(withInterceptors([apiInterceptor, tokenInterceptor, errorInterceptor])),
provideAppInitializer(() => {
const initializerFn = initAuth(inject(JwtService), inject(UserService));
const initializerFn = initAuth(inject(Jwt), inject(UserAuth));
return initializerFn();
}),
],
Expand Down
File renamed without changes.
24 changes: 12 additions & 12 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
import { Router, Routes } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './core/auth/services/user.service';
import { UserAuth } from './core/auth/services/user-auth';
import { map } from 'rxjs/operators';

/**
* Guard that requires authentication. Redirects to /login if not authenticated.
*/
const requireAuth = () => {
const router = inject(Router);
return inject(UserService).isAuthenticated.pipe(map(isAuth => isAuth || router.createUrlTree(['/login'])));
return inject(UserAuth).isAuthenticated.pipe(map(isAuth => isAuth || router.createUrlTree(['/login'])));
};

export const routes: Routes = [
{
path: '',
loadComponent: () => import('./features/article/pages/home/home.component'),
loadComponent: () => import('./features/article/pages/home/home'),
},
{
path: 'tag/:tag',
loadComponent: () => import('./features/article/pages/home/home.component'),
loadComponent: () => import('./features/article/pages/home/home'),
},
{
path: 'login',
loadComponent: () => import('./core/auth/auth.component'),
canActivate: [() => inject(UserService).isAuthenticated.pipe(map(isAuth => !isAuth))],
loadComponent: () => import('./core/auth/auth'),
canActivate: [() => inject(UserAuth).isAuthenticated.pipe(map(isAuth => !isAuth))],
},
{
path: 'register',
loadComponent: () => import('./core/auth/auth.component'),
canActivate: [() => inject(UserService).isAuthenticated.pipe(map(isAuth => !isAuth))],
loadComponent: () => import('./core/auth/auth'),
canActivate: [() => inject(UserAuth).isAuthenticated.pipe(map(isAuth => !isAuth))],
},
{
path: 'settings',
loadComponent: () => import('./features/settings/settings.component'),
loadComponent: () => import('./features/settings/settings'),
canActivate: [requireAuth],
},
{
Expand All @@ -44,18 +44,18 @@ export const routes: Routes = [
children: [
{
path: '',
loadComponent: () => import('./features/article/pages/editor/editor.component'),
loadComponent: () => import('./features/article/pages/editor/editor'),
canActivate: [requireAuth],
},
{
path: ':slug',
loadComponent: () => import('./features/article/pages/editor/editor.component'),
loadComponent: () => import('./features/article/pages/editor/editor'),
canActivate: [requireAuth],
},
],
},
{
path: 'article/:slug',
loadComponent: () => import('./features/article/pages/article/article.component'),
loadComponent: () => import('./features/article/pages/article/article'),
},
];
12 changes: 12 additions & 0 deletions src/app/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Header } from './core/layout/header';
import { RouterOutlet } from '@angular/router';
import { Footer } from './core/layout/footer';

@Component({
selector: 'app-root',
templateUrl: './app.html',
imports: [Header, RouterOutlet, Footer],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,22 @@
<h1 class="text-xs-center">{{ title }}</h1>
<p class="text-xs-center">
@if (authType === 'register') {
<a [routerLink]="['/login']">Have an account?</a>
}

@if (authType === 'login') {
<a [routerLink]="['/register']">Need an account?</a>
<a [routerLink]="['/login']">Have an account?</a>
} @if (authType === 'login') {
<a [routerLink]="['/register']">Need an account?</a>
}
</p>
<app-list-errors [errors]="errors()" />
<form [formGroup]="authForm" (ngSubmit)="submitForm()">
<fieldset [disabled]="isSubmitting()">
<fieldset class="form-group">
@if (authType === 'register') {
<input
formControlName="username"
placeholder="Username"
class="form-control form-control-lg"
type="text"
/>
<input
formControlName="username"
placeholder="Username"
class="form-control form-control-lg"
type="text"
/>
}
</fieldset>
<fieldset class="form-group">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { Validators, FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ListErrorsComponent } from '../../shared/components/list-errors.component';
import { ListErrors } from '../../shared/components/list-errors';
import { Errors } from '../models/errors.model';
import { UserService } from './services/user.service';
import { UserAuth } from './services/user-auth';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

interface AuthForm {
Expand All @@ -14,11 +14,11 @@ interface AuthForm {

@Component({
selector: 'app-auth-page',
templateUrl: './auth.component.html',
imports: [RouterLink, ListErrorsComponent, ReactiveFormsModule],
templateUrl: './auth.html',
imports: [RouterLink, ListErrors, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AuthComponent implements OnInit {
export default class Auth implements OnInit {
authType = '';
title = '';
errors = signal<Errors>({ errors: {} });
Expand All @@ -29,7 +29,7 @@ export default class AuthComponent implements OnInit {
constructor(
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly userService: UserService,
private readonly userService: UserAuth,
) {
this.authForm = new FormGroup<AuthForm>({
email: new FormControl('', {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { DestroyRef, Directive, inject, Input, OnInit, signal, TemplateRef, ViewContainerRef } from '@angular/core';
import { UserService } from './services/user.service';
import { UserAuth } from './services/user-auth';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Directive({
selector: '[ifAuthenticated]',
standalone: true,
})
export class IfAuthenticatedDirective<T> implements OnInit {
export class IfAuthenticated<T> implements OnInit {
destroyRef = inject(DestroyRef);
constructor(
private templateRef: TemplateRef<T>,
private userService: UserService,
private userService: UserAuth,
private viewContainer: ViewContainerRef,
) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import 'zone.js/testing';
import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vitest';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
import { JwtService } from './jwt.service';
import { Jwt } from './jwt';

describe('JwtService', () => {
let service: JwtService;
describe('Jwt', () => {
let service: Jwt;
let localStorageSpy: any;

beforeAll(() => {
Expand All @@ -30,10 +30,10 @@ describe('JwtService', () => {
});

TestBed.configureTestingModule({
providers: [JwtService],
providers: [Jwt],
});

service = TestBed.inject(JwtService);
service = TestBed.inject(Jwt);
});

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class JwtService {
export class Jwt {
getToken(): string {
return window.localStorage['jwtToken'];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@ang
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { UserService } from './user.service';
import { JwtService } from './jwt.service';
import { UserAuth } from './user-auth';
import { Jwt } from './jwt';
import { User } from '../user.model';

describe('UserService', () => {
describe('UserAuth', () => {
beforeAll(() => {
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
});

let service: UserService;
let service: UserAuth;
let httpMock: HttpTestingController;
let jwtService: any;
let router: any;
Expand All @@ -40,10 +40,10 @@ describe('UserService', () => {

TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService, { provide: JwtService, useValue: jwtService }, { provide: Router, useValue: router }],
providers: [UserAuth, { provide: Jwt, useValue: jwtService }, { provide: Router, useValue: router }],
});

service = TestBed.inject(UserService);
service = TestBed.inject(UserAuth);
httpMock = TestBed.inject(HttpTestingController);
});

Expand Down Expand Up @@ -286,7 +286,7 @@ describe('UserService', () => {
});

describe('setAuth', () => {
it('should save token to JwtService', () => {
it('should save token to Jwt', () => {
service.setAuth(mockUser);
expect(jwtService.saveToken).toHaveBeenCalledWith(mockUser.token);
});
Expand All @@ -305,7 +305,7 @@ describe('UserService', () => {
});

describe('purgeAuth', () => {
it('should destroy token in JwtService', () => {
it('should destroy token in Jwt', () => {
service.purgeAuth();
expect(jwtService.destroyToken).toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Observable, BehaviorSubject, EMPTY, Subscription, timer } from 'rxjs';

import { JwtService } from './jwt.service';
import { Jwt } from './jwt';
import { map, distinctUntilChanged, tap, shareReplay, catchError } from 'rxjs/operators';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { User } from '../user.model';
Expand All @@ -10,13 +10,13 @@ import { Router } from '@angular/router';
export type AuthState = 'authenticated' | 'unauthenticated' | 'unavailable' | 'loading';

/**
* UserService - Manages authentication state for the current user.
* UserAuth - Manages authentication state for the current user.
*
* ## Endpoints
*
* This service uses GET /user (not /users/:id or /profiles/:username):
* - GET /user → Returns the authenticated user's own data (JWT token identifies who you are)
* - GET /profiles/:username → Different endpoint for viewing any user's public profile (see ProfileService)
* - GET /profiles/:username → Different endpoint for viewing any user's public profile (see Profile)
*
* ## Auth States
*
Expand Down Expand Up @@ -45,7 +45,7 @@ export type AuthState = 'authenticated' | 'unauthenticated' | 'unavailable' | 'l
* which calls purgeAuth() - this handles "token expired mid-session" scenarios.
*/
@Injectable({ providedIn: 'root' })
export class UserService {
export class UserAuth {
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser = this.currentUserSubject.asObservable().pipe(distinctUntilChanged());

Expand All @@ -67,7 +67,7 @@ export class UserService {

constructor(
private readonly http: HttpClient,
private readonly jwtService: JwtService,
private readonly jwtService: Jwt,
private readonly router: Router,
) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { UserService } from '../auth/services/user.service';
import { UserAuth } from '../auth/services/user-auth';

/**
* Global HTTP error interceptor.
Expand All @@ -12,7 +12,7 @@ import { UserService } from '../auth/services/user.service';
* There are two layers of 401 handling:
*
* 1. GET /user endpoint (auth initialization):
* - Handled by UserService.getCurrentUser() with 4XX vs 5XX logic
* - Handled by User.getCurrentUser() with 4XX vs 5XX logic
* - This interceptor SKIPS /user to avoid double-handling
*
* 2. All OTHER endpoints (articles, comments, profiles, etc.):
Expand All @@ -30,15 +30,15 @@ import { UserService } from '../auth/services/user.service';
* - Network errors get a user-friendly fallback message
*/
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const userService = inject(UserService);
const userAuth = inject(UserAuth);

return next(req).pipe(
catchError((err: HttpErrorResponse) => {
// Global 401 handling for all endpoints EXCEPT /user
// (token expired mid-session → logout)
// /user is handled by UserService.getCurrentUser() with 4XX vs 5XX logic
// /user is handled by User.getCurrentUser() with 4XX vs 5XX logic
if (err.status === 401 && !req.url.endsWith('/user')) {
userService.purgeAuth();
userAuth.purgeAuth();
}

// Normalize error format: { errors: {...}, status: number }
Expand Down
Loading