mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 06:27:37 +00:00
Desktop UI updates
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Api } from './api';
|
||||
|
||||
describe('Api', () => {
|
||||
let service: Api;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Api);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
ResponseResult,
|
||||
SelectProgramRequest,
|
||||
SelectProgramResponse,
|
||||
TokenRefreshResponse,
|
||||
} from '../models/auth.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Api {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = environment.apiBaseUrl;
|
||||
|
||||
// Phase 1 — verify credentials, receive session token + program list
|
||||
login(request: LoginRequest): Observable<ResponseResult<LoginResponse>> {
|
||||
return this.http.post<ResponseResult<LoginResponse>>(
|
||||
`${this.base}/api/Auth/Login`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2 — select a program, receive JWT + refresh token
|
||||
selectProgram(request: SelectProgramRequest): Observable<ResponseResult<SelectProgramResponse>> {
|
||||
return this.http.post<ResponseResult<SelectProgramResponse>>(
|
||||
`${this.base}/api/Auth/SelectProgram`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// Exchange a refresh token for a new JWT + rotated refresh token
|
||||
refreshToken(request: RefreshTokenRequest): Observable<ResponseResult<TokenRefreshResponse>> {
|
||||
return this.http.post<ResponseResult<TokenRefreshResponse>>(
|
||||
`${this.base}/api/Auth/RefreshToken`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// Revoke the refresh token and log out
|
||||
logout(request: RefreshTokenRequest): Observable<ResponseResult<object>> {
|
||||
return this.http.post<ResponseResult<object>>(
|
||||
`${this.base}/api/Auth/Logout`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Auth } from './auth';
|
||||
|
||||
describe('Auth', () => {
|
||||
let service: Auth;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Auth);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, EMPTY, Observable, of, Subject, tap } from 'rxjs';
|
||||
import {
|
||||
AuthUser,
|
||||
LoginResponse,
|
||||
ResponseResult,
|
||||
SelectProgramResponse,
|
||||
TokenRefreshResponse,
|
||||
UserProgramSummary,
|
||||
} from '../models/auth.models';
|
||||
import { Api } from './api';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
JWT: 'auth_jwt',
|
||||
REFRESH_TOKEN: 'auth_refresh_token',
|
||||
SESSION_TOKEN: 'auth_session_token',
|
||||
} as const;
|
||||
|
||||
// Refresh the JWT this many seconds before it actually expires.
|
||||
const REFRESH_BUFFER_SECONDS = 60;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Auth {
|
||||
private readonly api = inject(Api);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// --------------- Reactive state (signals) ---------------
|
||||
|
||||
// Bump this to force `user` to re-derive from the JWT in localStorage.
|
||||
private readonly _jwtVersion = signal(0);
|
||||
private readonly _sessionToken = signal<string | null>(this.loadSessionToken());
|
||||
private readonly _programs = signal<UserProgramSummary[]>([]);
|
||||
private readonly _isRefreshing = signal(false);
|
||||
|
||||
/** The currently authenticated user, parsed from the JWT. Null when logged out. */
|
||||
readonly user = computed<AuthUser | null>(() => {
|
||||
this._jwtVersion(); // subscribe to token changes
|
||||
return this.parseUserFromJwt();
|
||||
});
|
||||
|
||||
/** True when the user has completed both login phases and holds a valid JWT. */
|
||||
readonly isAuthenticated = computed(() => this.user() !== null);
|
||||
|
||||
/** True while login phase 1 has succeeded and the user is choosing a program. */
|
||||
readonly isSelectingProgram = computed(() => this._sessionToken() !== null);
|
||||
|
||||
/** Programs returned by phase 1 for the user to choose from. */
|
||||
readonly programs = this._programs.asReadonly();
|
||||
|
||||
/** Emits when a token refresh fails and the user is forced to re-login. */
|
||||
readonly sessionExpired$ = new Subject<void>();
|
||||
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// --------------- Accessors ---------------
|
||||
|
||||
/** Current JWT (access token). */
|
||||
get jwt(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEYS.JWT);
|
||||
}
|
||||
|
||||
/** Current session token (phase 1 only). */
|
||||
get sessionToken(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN);
|
||||
}
|
||||
|
||||
/** Current refresh token. */
|
||||
get refreshToken(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
/** Whether a token refresh is currently in flight. */
|
||||
get isRefreshing(): boolean {
|
||||
return this._isRefreshing();
|
||||
}
|
||||
|
||||
// --------------- Phase 1: Login ---------------
|
||||
|
||||
/**
|
||||
* Verify credentials. On success the response contains a short-lived
|
||||
* session token and the list of programs the user belongs to.
|
||||
*
|
||||
* After calling this, present the program list and call `selectProgram()`.
|
||||
*/
|
||||
login(email: string, password: string): Observable<ResponseResult<LoginResponse>> {
|
||||
return this.api.login({ email, password }).pipe(
|
||||
tap((res) => {
|
||||
if (res.success && res.data) {
|
||||
localStorage.setItem(STORAGE_KEYS.SESSION_TOKEN, res.data.sessionToken);
|
||||
this._sessionToken.set(res.data.sessionToken);
|
||||
this._programs.set(res.data.programs);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- Phase 2: Select Program ---------------
|
||||
|
||||
/**
|
||||
* Complete login by choosing a program. On success the service stores
|
||||
* the JWT + refresh token and starts the proactive refresh timer.
|
||||
*/
|
||||
selectProgram(programId: string): Observable<ResponseResult<SelectProgramResponse>> {
|
||||
return this.api.selectProgram({ programId }).pipe(
|
||||
tap((res) => {
|
||||
if (res.success && res.data) {
|
||||
this.handleFullAuth(res.data);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- Token Refresh ---------------
|
||||
|
||||
/**
|
||||
* Manually trigger a token refresh. Normally you don't need this —
|
||||
* the proactive timer and the 401 interceptor handle it automatically.
|
||||
*
|
||||
* Returns the API response so callers can react to failures.
|
||||
*/
|
||||
doRefresh(): Observable<ResponseResult<TokenRefreshResponse>> {
|
||||
const token = this.refreshToken;
|
||||
if (!token) {
|
||||
this.forceLogout();
|
||||
return of({ success: false, message: 'No refresh token.' });
|
||||
}
|
||||
|
||||
if (this._isRefreshing()) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
this._isRefreshing.set(true);
|
||||
|
||||
return this.api.refreshToken({ refreshToken: token }).pipe(
|
||||
tap((res) => {
|
||||
this._isRefreshing.set(false);
|
||||
if (res.success && res.data) {
|
||||
this.storeTokens(res.data.jwt, res.data.newRefreshToken);
|
||||
this.scheduleRefresh(res.data.jwtExpiresIn);
|
||||
} else {
|
||||
this.forceLogout();
|
||||
}
|
||||
}),
|
||||
catchError(() => {
|
||||
this._isRefreshing.set(false);
|
||||
this.forceLogout();
|
||||
return of({ success: false, message: 'Token refresh failed.' } as ResponseResult<TokenRefreshResponse>);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- Logout ---------------
|
||||
|
||||
/** Log out: revoke the refresh token on the server, then clear local state. */
|
||||
logout(): Observable<ResponseResult<object>> {
|
||||
const token = this.refreshToken;
|
||||
|
||||
if (!token) {
|
||||
this.clearState();
|
||||
return of({ success: true, message: 'Logged out.' });
|
||||
}
|
||||
|
||||
return this.api.logout({ refreshToken: token }).pipe(
|
||||
tap(() => this.clearState()),
|
||||
catchError(() => {
|
||||
this.clearState();
|
||||
return of({ success: true, message: 'Logged out locally.' });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- Internals ---------------
|
||||
|
||||
/**
|
||||
* Called by the 401 interceptor when a refresh fails irrecoverably.
|
||||
* Clears all auth state and redirects to login.
|
||||
*/
|
||||
forceLogout(): void {
|
||||
this.clearState();
|
||||
this.sessionExpired$.next();
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
|
||||
private handleFullAuth(data: SelectProgramResponse): void {
|
||||
this.storeTokens(data.jwt, data.refreshToken);
|
||||
|
||||
// Clear phase-1 artefacts
|
||||
localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);
|
||||
this._sessionToken.set(null);
|
||||
this._programs.set([]);
|
||||
|
||||
// Notify signals that the JWT changed
|
||||
this._jwtVersion.update((v) => v + 1);
|
||||
|
||||
// Start proactive refresh
|
||||
this.scheduleRefresh(data.jwtExpiresIn);
|
||||
}
|
||||
|
||||
private storeTokens(jwt: string, refreshToken: string): void {
|
||||
localStorage.setItem(STORAGE_KEYS.JWT, jwt);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
this._jwtVersion.update((v) => v + 1);
|
||||
}
|
||||
|
||||
private scheduleRefresh(expiresInSeconds: number): void {
|
||||
this.clearRefreshTimer();
|
||||
const delayMs = Math.max((expiresInSeconds - REFRESH_BUFFER_SECONDS) * 1000, 0);
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.doRefresh().subscribe();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private clearRefreshTimer(): void {
|
||||
if (this.refreshTimer !== null) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearState(): void {
|
||||
this.clearRefreshTimer();
|
||||
localStorage.removeItem(STORAGE_KEYS.JWT);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);
|
||||
this._jwtVersion.update((v) => v + 1);
|
||||
this._sessionToken.set(null);
|
||||
this._programs.set([]);
|
||||
this._isRefreshing.set(false);
|
||||
}
|
||||
|
||||
private loadSessionToken(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN);
|
||||
}
|
||||
|
||||
/** Parse user info directly from the JWT in localStorage. Returns null if no valid JWT. */
|
||||
private parseUserFromJwt(): AuthUser | null {
|
||||
const token = this.jwt;
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return {
|
||||
userId: payload['user_id'] ?? '',
|
||||
email: payload['email'] ?? '',
|
||||
programId: payload['program_id'] ?? '',
|
||||
role: payload[
|
||||
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role'
|
||||
] ?? payload['role'] ?? '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
export type FormFactor = 'mobile' | 'desktop';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlatformService {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// ──── Raw Hardware Signals ────
|
||||
|
||||
private readonly isCoarsePointer =
|
||||
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
private readonly screenWidth =
|
||||
typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
|
||||
private readonly screenHeight =
|
||||
typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||
|
||||
private readonly minDimension = Math.min(this.screenWidth, this.screenHeight);
|
||||
private readonly maxDimension = Math.max(this.screenWidth, this.screenHeight);
|
||||
|
||||
// ──── Override Layer ────
|
||||
|
||||
/** When non-null, overrides auto-detection. */
|
||||
private readonly formFactorOverride = signal<FormFactor | null>(null);
|
||||
|
||||
// ──── Public API ────
|
||||
|
||||
/** The resolved form factor — auto-detected or overridden. */
|
||||
readonly formFactor = computed<FormFactor>(() =>
|
||||
this.formFactorOverride() ?? this.resolveFormFactor(),
|
||||
);
|
||||
|
||||
/** True when the user has manually toggled away from auto-detection. */
|
||||
readonly isOverridden = computed(() => this.formFactorOverride() !== null);
|
||||
|
||||
/**
|
||||
* Switch to a specific form factor at runtime.
|
||||
* Forces a route re-evaluation so the user immediately sees the other experience.
|
||||
*/
|
||||
switchTo(target: FormFactor): void {
|
||||
this.formFactorOverride.set(target);
|
||||
this.router.navigateByUrl(this.router.url);
|
||||
}
|
||||
|
||||
/** Clear the override and return to auto-detected form factor. */
|
||||
resetToAuto(): void {
|
||||
this.formFactorOverride.set(null);
|
||||
this.router.navigateByUrl(this.router.url);
|
||||
}
|
||||
|
||||
// ──── Device Classification Policy ────
|
||||
|
||||
private resolveFormFactor(): FormFactor {
|
||||
// Non-touch devices are always desktop
|
||||
if (!this.isCoarsePointer) return 'desktop';
|
||||
|
||||
// Large tablets (iPad Pro 12.9" portrait = 1024px) → desktop
|
||||
if (this.minDimension >= 1024) return 'desktop';
|
||||
|
||||
// Medium tablets in landscape with sufficient width → desktop
|
||||
if (this.maxDimension >= 1180 && this.screenWidth > this.screenHeight) {
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
// Phones and small/portrait tablets → mobile
|
||||
return 'mobile';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { StudentCardDto } from '../models/dto/student-card.dto';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StudentService {
|
||||
|
||||
// ************************** Constructor **************************
|
||||
|
||||
// ************************** Declarations *************************
|
||||
|
||||
// ************************** Properties ***************************
|
||||
|
||||
// ************************ Public Methods *************************
|
||||
|
||||
// *****************************************************************
|
||||
// Returns student card summaries. Currently returns dummy data
|
||||
// until the API endpoint is available.
|
||||
// *****************************************************************
|
||||
getStudentCards(): Observable<StudentCardDto[]> {
|
||||
return of([
|
||||
{
|
||||
studentId: '1',
|
||||
identifier: 'J.B',
|
||||
age: 21,
|
||||
lastEntryDate: '2026-02-21',
|
||||
goalCount: 3,
|
||||
progressEventCount: 5,
|
||||
},
|
||||
{
|
||||
studentId: '2',
|
||||
identifier: 'M.K',
|
||||
age: 19,
|
||||
lastEntryDate: '2026-02-25',
|
||||
goalCount: 4,
|
||||
progressEventCount: 8,
|
||||
},
|
||||
{
|
||||
studentId: '3',
|
||||
identifier: 'A.R',
|
||||
age: 22,
|
||||
lastEntryDate: null,
|
||||
goalCount: 2,
|
||||
progressEventCount: 0,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// ************************ Event Handlers *************************
|
||||
|
||||
// ********************** Support Procedures ***********************
|
||||
}
|
||||
Reference in New Issue
Block a user