From b0e64f33b790ab522d3e0d3f3f0c9ca8755165a1 Mon Sep 17 00:00:00 2001 From: Oliver Pelly Date: Sat, 21 Feb 2026 22:06:09 -0800 Subject: [PATCH] test login component for testing --- ui/winstudentgoaltracker/src/app/app.html | 341 ------------------ .../src/app/app.routes.ts | 6 +- .../src/app/models/auth.models.ts | 4 +- .../src/app/pages/login/login.ts | 310 ++++++++++++++++ .../src/app/services/auth.ts | 74 ++-- .../environments/environment.development.ts | 2 +- .../src/environments/environment.ts | 2 +- 7 files changed, 351 insertions(+), 388 deletions(-) create mode 100644 ui/winstudentgoaltracker/src/app/pages/login/login.ts diff --git a/ui/winstudentgoaltracker/src/app/app.html b/ui/winstudentgoaltracker/src/app/app.html index 7528372..67e7bd4 100644 --- a/ui/winstudentgoaltracker/src/app/app.html +++ b/ui/winstudentgoaltracker/src/app/app.html @@ -1,342 +1 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - diff --git a/ui/winstudentgoaltracker/src/app/app.routes.ts b/ui/winstudentgoaltracker/src/app/app.routes.ts index dc39edb..6725dc3 100644 --- a/ui/winstudentgoaltracker/src/app/app.routes.ts +++ b/ui/winstudentgoaltracker/src/app/app.routes.ts @@ -1,3 +1,7 @@ import { Routes } from '@angular/router'; +import { Login } from './pages/login/login'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', component: Login }, + { path: 'login', component: Login }, +]; diff --git a/ui/winstudentgoaltracker/src/app/models/auth.models.ts b/ui/winstudentgoaltracker/src/app/models/auth.models.ts index 549fc81..f4f7cbc 100644 --- a/ui/winstudentgoaltracker/src/app/models/auth.models.ts +++ b/ui/winstudentgoaltracker/src/app/models/auth.models.ts @@ -51,12 +51,10 @@ export interface TokenRefreshResponse { jwtExpiresIn: number; } -// Auth state exposed by the Auth service +// Auth state derived from JWT claims export interface AuthUser { userId: string; email: string; programId: string; - programName: string; role: string; - roleDisplayName: string; } diff --git a/ui/winstudentgoaltracker/src/app/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/pages/login/login.ts new file mode 100644 index 0000000..02d31fe --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/pages/login/login.ts @@ -0,0 +1,310 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Auth } from '../../services/auth'; + +@Component({ + selector: 'app-login', + imports: [FormsModule], + template: ` + + @if (!auth.isAuthenticated() && !auth.isSelectingProgram()) { +
+

Login

+ + @if (error()) { +

{{ error() }}

+ } + +
+ + + + + +
+
+ } + + + @if (auth.isSelectingProgram()) { +
+

Select a Program

+

Choose which program to log into.

+ + @if (error()) { +

{{ error() }}

+ } + +
+ @for (program of auth.programs(); track program.programId) { + + } +
+ + +
+ } + + + @if (auth.isAuthenticated()) { +
+

Authenticated

+ + @if (auth.user(); as user) { +
+
User ID
+
{{ user.userId }}
+ +
Email
+
{{ user.email }}
+ +
Program ID
+
{{ user.programId }}
+ +
Role
+
{{ user.role }}
+
+ } + + +
+ } + `, + styles: ` + :host { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + padding: 1rem; + } + + .card { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + padding: 2rem; + width: 100%; + max-width: 420px; + } + + h2 { + margin: 0 0 0.5rem; + font-size: 1.5rem; + } + + .subtitle { + margin: 0 0 1rem; + color: #666; + font-size: 0.875rem; + } + + form { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; + } + + label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + color: #333; + } + + input { + padding: 0.625rem 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.9375rem; + outline: none; + transition: border-color 0.15s; + } + + input:focus { + border-color: #4f46e5; + } + + button[type='submit'], + button:not(.program-button):not(.link-button) { + padding: 0.625rem 1rem; + background: #4f46e5; + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; + margin-top: 0.5rem; + } + + button[type='submit']:hover, + button:not(.program-button):not(.link-button):hover { + background: #4338ca; + } + + button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .error { + background: #fef2f2; + color: #dc2626; + padding: 0.625rem 0.75rem; + border-radius: 6px; + font-size: 0.875rem; + margin: 0.5rem 0; + } + + .program-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .program-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + padding: 0.75rem 1rem; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + text-align: left; + width: 100%; + } + + .program-button:hover { + background: #eef2ff; + border-color: #4f46e5; + } + + .program-name { + font-weight: 500; + font-size: 0.9375rem; + color: #111; + } + + .program-meta { + font-size: 0.8125rem; + color: #666; + } + + .link-button { + background: none; + border: none; + color: #4f46e5; + cursor: pointer; + font-size: 0.875rem; + padding: 0.5rem 0; + margin-top: 0.75rem; + } + + .link-button:hover { + text-decoration: underline; + } + + dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1rem; + margin: 1rem 0; + font-size: 0.9375rem; + } + + dt { + font-weight: 500; + color: #555; + } + + dd { + margin: 0; + color: #111; + word-break: break-all; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; + } + `, +}) +export class Login { + protected readonly auth = inject(Auth); + + protected email = ''; + protected password = ''; + protected readonly loading = signal(false); + protected readonly error = signal(null); + + onLogin() { + this.error.set(null); + this.loading.set(true); + + this.auth.login(this.email, this.password).subscribe({ + next: (res) => { + this.loading.set(false); + if (!res.success) { + this.error.set(res.message); + } + }, + error: () => { + this.loading.set(false); + this.error.set('Unable to reach the server.'); + }, + }); + } + + onSelectProgram(programId: string) { + this.error.set(null); + this.loading.set(true); + + this.auth.selectProgram(programId).subscribe({ + next: (res) => { + this.loading.set(false); + if (!res.success) { + this.error.set(res.message); + } + }, + error: () => { + this.loading.set(false); + this.error.set('Unable to reach the server.'); + }, + }); + } + + onLogout() { + this.auth.logout().subscribe(); + } + + onBackToLogin() { + this.error.set(null); + // Clear session state so we go back to the login form + this.auth.logout().subscribe(); + } +} diff --git a/ui/winstudentgoaltracker/src/app/services/auth.ts b/ui/winstudentgoaltracker/src/app/services/auth.ts index b2c3b5d..31ff73f 100644 --- a/ui/winstudentgoaltracker/src/app/services/auth.ts +++ b/ui/winstudentgoaltracker/src/app/services/auth.ts @@ -15,7 +15,6 @@ const STORAGE_KEYS = { JWT: 'auth_jwt', REFRESH_TOKEN: 'auth_refresh_token', SESSION_TOKEN: 'auth_session_token', - USER: 'auth_user', } as const; // Refresh the JWT this many seconds before it actually expires. @@ -30,16 +29,20 @@ export class Auth { // --------------- Reactive state (signals) --------------- - private readonly _user = signal(this.loadUser()); + // Bump this to force `user` to re-derive from the JWT in localStorage. + private readonly _jwtVersion = signal(0); private readonly _sessionToken = signal(this.loadSessionToken()); private readonly _programs = signal([]); private readonly _isRefreshing = signal(false); - /** The currently authenticated user (null when logged out). */ - readonly user = this._user.asReadonly(); + /** The currently authenticated user, parsed from the JWT. Null when logged out. */ + readonly user = computed(() => { + 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 && !!this.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); @@ -98,8 +101,7 @@ export class Auth { /** * Complete login by choosing a program. On success the service stores - * the JWT + refresh token, starts the proactive refresh timer, - * and populates the `user` signal. + * the JWT + refresh token and starts the proactive refresh timer. */ selectProgram(programId: string): Observable> { return this.api.selectProgram({ programId }).pipe( @@ -155,14 +157,18 @@ export class Auth { /** Log out: revoke the refresh token on the server, then clear local state. */ logout(): Observable> { const token = this.refreshToken; - this.clearState(); if (!token) { + this.clearState(); return of({ success: true, message: 'Logged out.' }); } return this.api.logout({ refreshToken: token }).pipe( - catchError(() => of({ success: true, message: 'Logged out locally.' })), + tap(() => this.clearState()), + catchError(() => { + this.clearState(); + return of({ success: true, message: 'Logged out locally.' }); + }), ); } @@ -179,26 +185,15 @@ export class Auth { } private handleFullAuth(data: SelectProgramResponse): void { - const user: AuthUser = { - userId: data.userId, - email: data.email, - programId: data.jwt ? this.extractClaim(data.jwt, 'program_id') ?? '' : '', - programName: data.programName, - role: data.role, - roleDisplayName: data.roleDisplayName, - }; - - // Persist this.storeTokens(data.jwt, data.refreshToken); - localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(user)); // Clear phase-1 artefacts localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN); this._sessionToken.set(null); this._programs.set([]); - // Update reactive state - this._user.set(user); + // Notify signals that the JWT changed + this._jwtVersion.update((v) => v + 1); // Start proactive refresh this.scheduleRefresh(data.jwtExpiresIn); @@ -207,6 +202,7 @@ export class Auth { 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 { @@ -229,35 +225,31 @@ export class Auth { localStorage.removeItem(STORAGE_KEYS.JWT); localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN); - localStorage.removeItem(STORAGE_KEYS.USER); - this._user.set(null); + this._jwtVersion.update((v) => v + 1); this._sessionToken.set(null); this._programs.set([]); this._isRefreshing.set(false); } - private loadUser(): AuthUser | null { - try { - const raw = localStorage.getItem(STORAGE_KEYS.USER); - return raw ? (JSON.parse(raw) as AuthUser) : null; - } catch { - return null; - } - } - private loadSessionToken(): string | null { return localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN); } - /** - * Decode a single claim from a JWT without pulling in a library. - * Returns null if the claim is missing or the token is malformed. - */ - private extractClaim(jwt: string, claim: string): string | null { + /** 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 = jwt.split('.')[1]; - const decoded = JSON.parse(atob(payload)); - return decoded[claim] ?? null; + 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; } diff --git a/ui/winstudentgoaltracker/src/environments/environment.development.ts b/ui/winstudentgoaltracker/src/environments/environment.development.ts index 58d4a99..c9296a7 100644 --- a/ui/winstudentgoaltracker/src/environments/environment.development.ts +++ b/ui/winstudentgoaltracker/src/environments/environment.development.ts @@ -1,4 +1,4 @@ export const environment = { production: false, - apiBaseUrl: 'http://localhost:5104', + apiBaseUrl: 'http://localhost:5123', }; diff --git a/ui/winstudentgoaltracker/src/environments/environment.ts b/ui/winstudentgoaltracker/src/environments/environment.ts index 3ffb8c7..7c2554e 100644 --- a/ui/winstudentgoaltracker/src/environments/environment.ts +++ b/ui/winstudentgoaltracker/src/environments/environment.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiBaseUrl: 'https://api.example.com', + apiBaseUrl: 'http://localhost:5123', };