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',
};