DEV Community

Sakshi
Sakshi

Posted on

Angular 22 Authentication: Building Login Pages for Admin Dashboards

Authentication is the first thing users see when they open an admin dashboard. It's also the part most tutorials get wrong — either oversimplifying to the point of being unusable in production or overcomplicating with libraries that add more problems than they solve.

Here's how to build authentication for an Angular 22 admin dashboard properly — JWT tokens, route guards, HTTP interceptors, and a login page that actually looks and works correctly.

The Authentication Architecture

Angular 22 authentication involves four pieces:

AuthService — manages token storage, user state, login/logout logic. Everything authentication-related goes here.

authGuard — functional route guard that protects routes. Redirects to login if not authenticated.

authInterceptor — functional HTTP interceptor that adds JWT token to every outgoing request automatically.

Login component — the UI. Signal Forms, Bootstrap 5.3, proper error handling.

AuthService

// core/services/auth.service.ts
import { Injectable, signal, computed } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Router } from '@angular/router'
import { tap, catchError } from 'rxjs/operators'
import { throwError } from 'rxjs'

export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
  avatar?: string
}

export interface LoginCredentials {
  email: string
  password: string
}

export interface AuthResponse {
  token: string
  refreshToken: string
  user: User
  expiresIn: number
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly TOKEN_KEY = 'auth_token'
  private readonly REFRESH_KEY = 'refresh_token'
  private readonly USER_KEY = 'auth_user'

  // Signal-based state
  private _user = signal<User | null>(this.loadStoredUser())
  private _isLoading = signal(false)
  private _error = signal<string | null>(null)

  // Public read-only signals
  user = this._user.asReadonly()
  isLoading = this._isLoading.asReadonly()
  error = this._error.asReadonly()

  // Derived state
  isAuthenticated = computed(() => this._user() !== null)
  isAdmin = computed(() => this._user()?.role === 'admin')
  userName = computed(() => this._user()?.name ?? '')

  constructor(
    private http: HttpClient,
    private router: Router
  ) {}

  login(credentials: LoginCredentials) {
    this._isLoading.set(true)
    this._error.set(null)

    return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
      tap(response => {
        this.storeTokens(response.token, response.refreshToken)
        this.storeUser(response.user)
        this._user.set(response.user)
        this._isLoading.set(false)
      }),
      catchError(error => {
        this._error.set(
          error.error?.message ?? 'Invalid email or password'
        )
        this._isLoading.set(false)
        return throwError(() => error)
      })
    )
  }

  logout() {
    localStorage.removeItem(this.TOKEN_KEY)
    localStorage.removeItem(this.REFRESH_KEY)
    localStorage.removeItem(this.USER_KEY)
    this._user.set(null)
    this.router.navigate(['/auth/login'])
  }

  getToken(): string | null {
    return localStorage.getItem(this.TOKEN_KEY)
  }

  hasPermission(permission: string): boolean {
    const role = this._user()?.role
    const permissions: Record<string, string[]> = {
      admin: ['read', 'write', 'delete', 'admin'],
      editor: ['read', 'write'],
      viewer: ['read']
    }
    return role ? permissions[role]?.includes(permission) ?? false : false
  }

  private storeTokens(token: string, refreshToken: string) {
    localStorage.setItem(this.TOKEN_KEY, token)
    localStorage.setItem(this.REFRESH_KEY, refreshToken)
  }

  private storeUser(user: User) {
    localStorage.setItem(this.USER_KEY, JSON.stringify(user))
  }

  private loadStoredUser(): User | null {
    try {
      const stored = localStorage.getItem(this.USER_KEY)
      return stored ? JSON.parse(stored) : null
    } catch {
      return null
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Signal-based state throughout. isAuthenticated and isAdmin computed from user Signal. hasPermission method for role-based access control.

authGuard

// core/guards/auth.guard.ts
import { inject } from '@angular/core'
import { CanActivateFn, Router } from '@angular/router'
import { AuthService } from '../services/auth.service'

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService)
  const router = inject(Router)

  if (authService.isAuthenticated()) {
    return true
  }

  return router.createUrlTree(['/auth/login'], {
    queryParams: { returnUrl: state.url }
  })
}

// Admin-only guard
export const adminGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService)
  const router = inject(Router)

  if (!authService.isAuthenticated()) {
    return router.createUrlTree(['/auth/login'], {
      queryParams: { returnUrl: state.url }
    })
  }

  if (!authService.isAdmin()) {
    return router.createUrlTree(['/unauthorized'])
  }

  return true
}
Enter fullscreen mode Exit fullscreen mode

Two guards — authGuard for any authenticated user, adminGuard for admin-only routes. Functional guards — no class, no interface. inject() for dependencies.

authInterceptor

// core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'
import { inject } from '@angular/core'
import { catchError, throwError } from 'rxjs'
import { AuthService } from '../services/auth.service'
import { Router } from '@angular/router'

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService)
  const router = inject(Router)

  const token = authService.getToken()

  // Skip auth header for login/register endpoints
  const isAuthEndpoint = req.url.includes('/auth/login') ||
                          req.url.includes('/auth/register')

  const authReq = token && !isAuthEndpoint
    ? req.clone({
        headers: req.headers.set('Authorization', `Bearer ${token}`)
      })
    : req

  return next(authReq).pipe(
    catchError((error: HttpErrorResponse) => {
      // Auto-logout on 401 Unauthorized
      if (error.status === 401) {
        authService.logout()
        router.navigate(['/auth/login'])
      }
      return throwError(() => error)
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

Skips auth header for auth endpoints. Adds Bearer token to everything else. Auto-logout on 401 response — handles expired tokens without manual checking.

Routes With Guards Applied

// app.routes.ts
export const routes: Routes = [
  {
    path: 'auth',
    children: [
      {
        path: 'login',
        loadComponent: () =>
          import('./features/auth/login/login.component')
            .then(m => m.LoginComponent)
      },
      {
        path: 'forgot-password',
        loadComponent: () =>
          import('./features/auth/forgot-password/forgot-password.component')
            .then(m => m.ForgotPasswordComponent)
      }
    ]
  },
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./features/dashboard/dashboard.component')
        .then(m => m.DashboardComponent),
    canActivate: [authGuard]
  },
  {
    path: 'admin',
    loadComponent: () =>
      import('./features/admin/admin.component')
        .then(m => m.AdminComponent),
    canActivate: [adminGuard] // admin only
  },
  {
    path: '',
    redirectTo: 'dashboard',
    pathMatch: 'full'
  }
]
Enter fullscreen mode Exit fullscreen mode

Auth routes unguarded. Dashboard guarded by authGuard. Admin section guarded by adminGuard. All lazy loaded.

Login Component With Signal Forms

// features/auth/login/login.component.ts
import { Component, inject, computed, signal } from '@angular/core'
import { Router, ActivatedRoute } from '@angular/router'
import { FormGroup, FormControl, Validators } from '@angular/forms/signals'
import { AuthService } from '../../../core/services/auth.service'

@Component({
  selector: 'app-login',
  standalone: true,
  templateUrl: './login.component.html'
})
export class LoginComponent {
  private authService = inject(AuthService)
  private router = inject(Router)
  private route = inject(ActivatedRoute)

  // Signal Forms
  form = new FormGroup({
    email: new FormControl('', [
      Validators.required,
      Validators.email
    ]),
    password: new FormControl('', [
      Validators.required,
      Validators.minLength(8)
    ]),
    rememberMe: new FormControl(false)
  })

  // State from service
  isLoading = this.authService.isLoading
  serverError = this.authService.error
  passwordVisible = signal(false)

  // Validation signals
  emailError = computed(() => {
    const control = this.form.controls.email
    if (!control.touched()) return null
    if (control.hasError('required')) return 'Email is required'
    if (control.hasError('email')) return 'Enter a valid email address'
    return null
  })

  passwordError = computed(() => {
    const control = this.form.controls.password
    if (!control.touched()) return null
    if (control.hasError('required')) return 'Password is required'
    if (control.hasError('minlength')) return 'Password must be at least 8 characters'
    return null
  })

  isFormValid = computed(() => this.form.valid())

  togglePassword() {
    this.passwordVisible.update(v => !v)
  }

  onSubmit() {
    if (!this.form.valid()) {
      Object.values(this.form.controls).forEach(control => {
        control.markAsTouched()
      })
      return
    }

    const { email, password } = this.form.value()

    this.authService.login({ email, password }).subscribe({
      next: () => {
        const returnUrl = this.route.snapshot
          .queryParams['returnUrl'] || '/dashboard'
        this.router.navigateByUrl(returnUrl)
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- features/auth/login/login.component.html -->
<div class="auth-wrapper min-vh-100 d-flex align-items-center
            justify-content-center bg-light">
  <div class="container">
    <div class="row justify-content-center">
      <div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">

        <!-- Logo -->
        <div class="text-center mb-4">
          <a href="/" class="d-inline-block">
            <img src="assets/images/logo.svg" alt="Logo" height="40">
          </a>
        </div>

        <!-- Card -->
        <div class="card border-0 shadow-sm">
          <div class="card-body p-4 p-md-5">

            <!-- Header -->
            <div class="text-center mb-4">
              <h1 class="h4 fw-bold mb-1">Welcome back</h1>
              <p class="text-muted small mb-0">Sign in to your account</p>
            </div>

            <!-- Server Error -->
            @if (serverError()) {
              <div class="alert alert-danger d-flex align-items-center
                          gap-2 py-2 mb-4" role="alert">
                <i class="bx bx-error-circle fs-5"></i>
                <span class="small">{{ serverError() }}</span>
              </div>
            }

            <!-- Form -->
            <form (ngSubmit)="onSubmit()" novalidate>

              <!-- Email -->
              <div class="mb-3">
                <label for="email" class="form-label fw-medium small">
                  Email address
                </label>
                <input
                  type="email"
                  id="email"
                  class="form-control"
                  [class.is-invalid]="emailError()"
                  [class.is-valid]="!emailError() &&
                                    form.controls.email.touched()"
                  placeholder="you@example.com"
                  [value]="form.controls.email.value()"
                  (input)="form.controls.email
                    .setValue($any($event.target).value)"
                  (blur)="form.controls.email.markAsTouched()"
                  autocomplete="email">
                @if (emailError()) {
                  <div class="invalid-feedback">{{ emailError() }}</div>
                }
              </div>

              <!-- Password -->
              <div class="mb-3">
                <div class="d-flex justify-content-between mb-1">
                  <label for="password"
                         class="form-label fw-medium small mb-0">
                    Password
                  </label>
                  <a href="/auth/forgot-password"
                     class="small text-primary text-decoration-none">
                    Forgot password?
                  </a>
                </div>
                <div class="input-group">
                  <input
                    [type]="passwordVisible() ? 'text' : 'password'"
                    id="password"
                    class="form-control border-end-0"
                    [class.is-invalid]="passwordError()"
                    placeholder="••••••••"
                    [value]="form.controls.password.value()"
                    (input)="form.controls.password
                      .setValue($any($event.target).value)"
                    (blur)="form.controls.password.markAsTouched()"
                    autocomplete="current-password">
                  <button type="button"
                          class="input-group-text bg-white border-start-0"
                          (click)="togglePassword()">
                    <i class="bx fs-5"
                       [class.bx-show]="!passwordVisible()"
                       [class.bx-hide]="passwordVisible()"></i>
                  </button>
                </div>
                @if (passwordError()) {
                  <div class="text-danger small mt-1">
                    {{ passwordError() }}
                  </div>
                }
              </div>

              <!-- Remember Me -->
              <div class="mb-4">
                <div class="form-check">
                  <input type="checkbox"
                         class="form-check-input"
                         id="rememberMe">
                  <label class="form-check-label small" for="rememberMe">
                    Remember me for 30 days
                  </label>
                </div>
              </div>

              <!-- Submit -->
              <button
                type="submit"
                class="btn btn-primary w-100 py-2"
                [disabled]="isLoading()">
                @if (isLoading()) {
                  <span class="spinner-border spinner-border-sm me-2"
                        role="status"></span>
                  Signing in...
                } @else {
                  Sign In
                }
              </button>

            </form>

            <!-- Divider -->
            <div class="d-flex align-items-center my-4">
              <hr class="flex-grow-1">
              <span class="px-3 text-muted small">or</span>
              <hr class="flex-grow-1">
            </div>

            <!-- Sign Up Link -->
            <p class="text-center text-muted small mb-0">
              Don't have an account?
              <a href="/auth/register"
                 class="text-primary fw-medium text-decoration-none">
                Create account
              </a>
            </p>

          </div>
        </div>

        <!-- Footer -->
        <p class="text-center text-muted small mt-4">
          Protected by SSL encryption
        </p>

      </div>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Forgot Password Component

// features/auth/forgot-password/forgot-password.component.ts
import { Component, inject, signal } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { FormControl, Validators } from '@angular/forms/signals'

@Component({
  selector: 'app-forgot-password',
  standalone: true,
  template: `
    <div class="auth-wrapper min-vh-100 d-flex align-items-center
                justify-content-center bg-light">
      <div class="container">
        <div class="row justify-content-center">
          <div class="col-12 col-sm-10 col-md-8 col-lg-5">
            <div class="card border-0 shadow-sm">
              <div class="card-body p-4 p-md-5">

                @if (!submitted()) {
                  <div class="text-center mb-4">
                    <div class="bg-primary-subtle rounded-circle
                                d-inline-flex p-3 mb-3">
                      <i class="bx bx-lock-open text-primary fs-3"></i>
                    </div>
                    <h1 class="h5 fw-bold mb-1">Forgot your password?</h1>
                    <p class="text-muted small">
                      Enter your email and we'll send a reset link.
                    </p>
                  </div>

                  <form (ngSubmit)="onSubmit()">
                    <div class="mb-3">
                      <input type="email"
                             class="form-control"
                             placeholder="your@email.com"
                             [value]="email.value()"
                             (input)="email.setValue(
                               $any($event.target).value
                             )"
                             required>
                    </div>
                    <button type="submit"
                            class="btn btn-primary w-100"
                            [disabled]="isLoading()">
                      @if (isLoading()) {
                        <span class="spinner-border spinner-border-sm me-2">
                        </span>
                        Sending...
                      } @else {
                        Send Reset Link
                      }
                    </button>
                  </form>
                } @else {
                  <div class="text-center">
                    <div class="bg-success-subtle rounded-circle
                                d-inline-flex p-3 mb-3">
                      <i class="bx bx-check text-success fs-3"></i>
                    </div>
                    <h2 class="h5 fw-bold mb-2">Check your email</h2>
                    <p class="text-muted small">
                      We sent a reset link to
                      <strong>{{ email.value() }}</strong>
                    </p>
                    <a href="/auth/login"
                       class="btn btn-outline-primary mt-2">
                      Back to login
                    </a>
                  </div>
                }

              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  `
})
export class ForgotPasswordComponent {
  email = new FormControl('', [Validators.required, Validators.email])
  isLoading = signal(false)
  submitted = signal(false)

  private http = inject(HttpClient)

  onSubmit() {
    if (!this.email.valid()) return

    this.isLoading.set(true)
    this.http.post('/api/auth/forgot-password', {
      email: this.email.value()
    }).subscribe({
      next: () => {
        this.submitted.set(true)
        this.isLoading.set(false)
      },
      error: () => this.isLoading.set(false)
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Two states — form and success confirmation. Clean UX for password reset flows.

Register appConfig With Interceptor

// app.config.ts
import { ApplicationConfig } from '@angular/core'
import { provideRouter } from '@angular/router'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { routes } from './app.routes'
import { authInterceptor } from './core/interceptors/auth.interceptor'

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor]))
  ]
}
Enter fullscreen mode Exit fullscreen mode

One line to register the interceptor. Every HTTP request in the app automatically gets the JWT token attached.

Browse Angular 22 admin dashboard templates with authentication already implemented at LettStart Design — auth guards, interceptors, login pages, Signal Forms, Bootstrap 5.3. Use FIRST30 for 30% off.

Top comments (0)