diff --git a/flightscore/src/api/api.ts b/flightscore/src/api/api.ts deleted file mode 100644 index a4259ee..0000000 --- a/flightscore/src/api/api.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'QUERY'; - -export interface ApiOptions extends RequestInit { - method?: HttpMethod; - body?: any; -} - -/** - * Zentraler API-Wrapper mit typisiertem Rückgabewert und Fehlerhandling. - * Beispiele: - * const tracks = await api('/api/tracks'); - * await api('/api/tracks/1', { method: 'DELETE' }); - */ -export async function api( - path: string, - options: ApiOptions = {} -): Promise { - const config: RequestInit = { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, - }; - - if (options.body && typeof options.body !== 'string') { - config.body = JSON.stringify(options.body); - } - - const response = await fetch(path, config); - - if (!response.ok) { - let msg = `HTTP Error ${response.status}`; - try { - const text = await response.text(); - msg += `: ${text}`; - } catch { } - throw new Error(msg); - } - - if (response.status === 204) return undefined as T; - - try { - return (await response.json()) as T; - } catch { - return undefined as T; - } -} - - -export const apiGet = (path: string) => api(path, { method: 'GET' }); -export const apiPost = (path: string, body: any) => api(path, { method: 'POST', body }); -export const apiPut = (path: string, body: any) => api(path, { method: 'PUT', body }); -export const apiDelete = (path: string) => api(path, { method: 'DELETE' }); \ No newline at end of file diff --git a/flightscore/src/app-root.ts b/flightscore/src/app-root.ts index 43de679..f639d5e 100644 --- a/flightscore/src/app-root.ts +++ b/flightscore/src/app-root.ts @@ -1,92 +1,131 @@ import { LitElement, html, css } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { Router } from './router/router'; -import './pages/not-found-page'; +import { authService } from './services/auth.service'; +import type { AuthLevel } from './router/router'; +import './components/footer-bar.js'; import './components/nav-bar'; import './components/cc/cc-nav-bar'; -import './components/footer-bar'; +//import './components/tt/tt-nav-bar'; @customElement('app-root') export class AppRoot extends LitElement { private router!: Router; - - @state() private isCompetitionCenter = false; + @state() private currentPath = '/'; static styles = css` - :host { - display: flex; - flex-direction: column; - min-height: 100vh; - } - nav-bar { - flex: 0 0 auto; - } - - main { - flex: 1 0 auto; - display: flex; - flex-direction: column; - } - - footer-bar { - flex: 0 0 auto; - } -` + :host { display: flex; flex-direction: column; min-height: 100vh; } + main { flex: 1 0 auto; display: flex; flex-direction: column; } + `; firstUpdated() { - const outlet = this.shadowRoot?.getElementById('outlet') as HTMLElement; + authService.init(); + const outlet = this.shadowRoot!.getElementById('outlet') as HTMLElement; + this.router = new Router(outlet, [ + // ── Public ────────────────────────────────────────── { - path: '/', view: async () => { + path: '/', + auth: 'public', + exact: true, + view: async () => { await import('./pages/home-page.js'); return document.createElement('home-page'); - } + }, }, { - path: '/competitions', view: async () => { + path: '/competitions', + auth: 'public', + view: async () => { await import('./pages/competition/competition-page.js'); return document.createElement('competition-page'); - } + }, }, { - path: '/login', view: async () => { - await import('./pages/auth/login-page.js'); - return document.createElement('login-page') - } - }, - { - path: '/register', view: async () => { - await import('./pages/auth/register-page.js'); - return document.createElement('register-page'); - } - }, - { - path: '/cc/', view: async () => { - await import('./pages/cc/cc-home-page.js'); - return document.createElement('cc-home-page'); - } - }, - { - path: '/dev', view: async () => { + path: '/dev', + auth: 'public', + view: async () => { await import('./pages/dev-page.js'); return document.createElement('dev-page'); - } - } - ]); + }, + }, + { + path: '/login', + auth: 'public', + view: async () => { + await import('./pages/auth/login-page.js'); + return document.createElement('login-page'); + }, + }, - this.router.onRouteChange = (path: string) => { - this.isCompetitionCenter = path.startsWith('/cc'); + // ── Target Team (Code-geschützt) ───────────────────── + { + path: '/tt', + auth: 'code', + view: async () => { + await import('./pages/tt/tt-home-page.js'); + return document.createElement('tt-home-page'); + }, + }, + + // ── Competition Center (User-Login) ────────────────── + { + path: '/cc', + auth: 'user', + view: async () => { + await import('./pages/cc/cc-home-page.js'); + return document.createElement('cc-home-page'); + }, + }, + + // ── System Admin ───────────────────────────────────── + { + path: '/admin', + auth: 'admin', + view: async () => { + await import('./pages/admin/admin-home-page.js'); + return document.createElement('admin-home-page'); + }, + }, + ], + + // Guard-Logik + { + canActivate: async (auth: AuthLevel) => { + if (auth === 'public') return true; + if (auth === 'code') return authService.hasTeamCode; + if (auth === 'user') return authService.isLoggedIn; + if (auth === 'admin') return authService.isAdmin; + return false; + }, + onFail: (auth: AuthLevel, navigate) => { + if (auth === 'code') navigate('/tt/login'); // Code-Eingabe-Seite + if (auth === 'user') navigate('/login'); + if (auth === 'admin') navigate('/login'); + }, + }); + + this.router.onRouteChange = (path) => { + this.currentPath = path; }; this.router.resolve(); - this.addEventListener('nav', (e: any) => this.router.navigate(e.detail.path)); + window.addEventListener('nav', (e: any) => this.router.navigate(e.detail.path)); + } + + private get section() { + if (this.currentPath.startsWith('/cc')) return 'cc'; + if (this.currentPath.startsWith('/admin')) return 'admin'; + if (this.currentPath.startsWith('/tt')) return 'tt'; + return 'public'; } render() { return html` - ${this.isCompetitionCenter - ? html`` - : html``} + ${this.section === 'cc' ? html`` + : this.section === 'admin' ? html`` + : this.section === 'tt' ? html`` + : html``}
`; diff --git a/flightscore/src/pages/admin/admin-home-page.ts b/flightscore/src/pages/admin/admin-home-page.ts new file mode 100644 index 0000000..e4322ad --- /dev/null +++ b/flightscore/src/pages/admin/admin-home-page.ts @@ -0,0 +1,12 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('cc-home-page') +export class CCHomePage extends LitElement { + render() { + return html` +

Welcome to the Competition Center Homepage

+

Analyze your tracks visually.

+ `; + } +} \ No newline at end of file diff --git a/flightscore/src/pages/auth/login-page.ts b/flightscore/src/pages/auth/login-page.ts index d6bf27d..d376939 100644 --- a/flightscore/src/pages/auth/login-page.ts +++ b/flightscore/src/pages/auth/login-page.ts @@ -1,14 +1,14 @@ -import { LitElement, html, css } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; -import { apiPost } from '../../api/api'; -import '../../components/auth-card'; -import '../../components/form-input'; -import '../../components/ui-button'; -import '../../components/notify-bar'; -import '../../components/horizontal-divider'; -import '../../components/ui-link'; +import { LitElement, html, css } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { authService, type UserSession } from "../../services/auth.service"; +import "../../components/auth-card"; +import "../../components/form-input"; +import "../../components/ui-button"; +import "../../components/notify-bar"; +import "../../components/horizontal-divider"; +import "../../components/ui-link"; -@customElement('login-page') +@customElement("login-page") export class LoginPage extends LitElement { static styles = css` :host { @@ -24,8 +24,8 @@ export class LoginPage extends LitElement { } `; - @state() email = ''; - @state() password = ''; + @state() email = ""; + @state() password = ""; @state() error: string | null = null; @state() loading = false; @@ -33,21 +33,50 @@ export class LoginPage extends LitElement { this.error = null; this.loading = true; try { - const res = await apiPost<{ token: string }>('/api/auth/login', { - email: this.email, - password: this.password, + //todo const response = await fetch('/api/auth/login', { + const response = await fetch("http://127.0.0.1:8080/auth/login", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: this.email, password: this.password }), }); - localStorage.setItem('token', res.token); - window.dispatchEvent(new CustomEvent('auth-changed')); - dispatchEvent( - new CustomEvent('nav', { - detail: { path: '/cc/' }, + + if (!response.ok) { + this.error = + response.status === 401 + ? "E-Mail oder Passwort falsch." + : "Login fehlgeschlagen."; + return; + } + + const raw = await response.json(); + const base64url = raw.accessToken.split(".")[1]; + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(atob(base64)); + + authService.setSession( + { + id: payload.sub, + email: payload.email, + role: payload.role ?? null, + permissions: payload.permissions ?? [], + }, + raw.accessToken, + ); + + window.dispatchEvent(new CustomEvent("auth-changed")); + + const target = authService.isAdmin ? "/admin" : "/cc"; + window.dispatchEvent( + new CustomEvent("nav", { + detail: { path: target }, bubbles: true, composed: true, - }) + }), ); - } catch (e: any) { - this.error = e.message || 'Login failed'; + } catch (e) { + console.error("Fehler:", e); + this.error = "Netzwerkfehler. Bitte erneut versuchen."; } finally { this.loading = false; } @@ -64,8 +93,7 @@ export class LoginPage extends LitElement { type="email" placeholder="you@example.com" .value=${this.email} - @value-changed=${(e: CustomEvent) => - (this.email = e.detail.value)} + @value-changed=${(e: CustomEvent) => (this.email = e.detail.value)} > - (this.password = e.detail.value)} + @value-changed=${(e: CustomEvent) => (this.password = e.detail.value)} > - - ${this.loading ? 'Signing in...' : 'Sign in'} + + ${this.loading ? "Signing in..." : "Sign in"} -

Welcome to the Target Team Homepage

+

Analyze your tracks visually.

+ `; + } +} \ No newline at end of file diff --git a/flightscore/src/router/router.ts b/flightscore/src/router/router.ts index d69a9af..afc262b 100644 --- a/flightscore/src/router/router.ts +++ b/flightscore/src/router/router.ts @@ -1,40 +1,61 @@ +export type AuthLevel = "public" | "code" | "user" | "admin"; export type Route = { path: string; + auth: AuthLevel; + exact?: boolean; view: () => Promise | HTMLElement; }; +export type AuthGuard = { + canActivate: (auth: AuthLevel) => Promise; + onFail: (auth: AuthLevel, navigate: (path: string) => void) => void; +}; + export class Router { private routes: Route[]; private outlet: HTMLElement; + private guard: AuthGuard; onRouteChange?: (path: string) => void; - constructor(outlet: HTMLElement, routes: Route[]) { + constructor(outlet: HTMLElement, routes: Route[], guard: AuthGuard) { this.routes = routes; this.outlet = outlet; - window.addEventListener('popstate', () => this.resolve()); - this.resolve(); + this.guard = guard; + window.addEventListener("popstate", () => this.resolve()); } navigate(path: string) { - window.history.pushState({}, '', path); + window.history.pushState({}, "", path); this.resolve(); } async resolve() { const path = window.location.pathname; - const match = this.routes.find((r) => r.path === path); - this.outlet.innerHTML = ''; + + const match = this.routes.find((r) => + r.exact + ? r.path === path + : path === r.path || path.startsWith(r.path + "/"), + ); + + const route = match ?? { auth: "public" as AuthLevel, view: null }; + + const allowed = await this.guard.canActivate(route.auth); + if (!allowed) { + this.guard.onFail(route.auth, this.navigate.bind(this)); + return; + } + + this.outlet.innerHTML = ""; if (match) { const view = await match.view(); this.outlet.append(view); } else { - this.outlet.append(document.createElement('not-found-page')); + this.outlet.append(document.createElement("not-found-page")); } - if (this.onRouteChange) { - this.onRouteChange(path); - } + this.onRouteChange?.(path); } -} \ No newline at end of file +} diff --git a/flightscore/src/services/api.service.ts b/flightscore/src/services/api.service.ts new file mode 100644 index 0000000..11a5051 --- /dev/null +++ b/flightscore/src/services/api.service.ts @@ -0,0 +1,56 @@ +import { authService } from './auth.service'; + +class ApiService { + + async fetch(path: string, options: RequestInit = {}): Promise { + const response = await this.doFetch(path, options); + + if (response.status === 401) { + const refreshed = await this.refresh(); + if (refreshed) { + return this.doFetch(path, options); + } else { + authService.clearSession(); + window.dispatchEvent(new CustomEvent('session-expired')); + throw new Error('Session expired'); + } + } + + return response; + } + + private async doFetch(path: string, options: RequestInit): Promise { + //todo return fetch(`/api${path}`, { + return fetch(`http://127.0.0.1:8080${path}`, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(authService.accessToken + ? { Authorization: `Bearer ${authService.accessToken}` } + : {}), + ...options.headers, + }, + }); + } + + private async refresh(): Promise { + try { + //const response = await fetch('/api/auth/refresh', { + const response = await fetch('http://127.0.0.1:8080/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + + if (!response.ok) return false; + + const { accessToken } = await response.json(); + authService.setSession(authService.user!, accessToken); + return true; + } catch { + return false; + } + } +} + +export const apiService = new ApiService(); \ No newline at end of file diff --git a/flightscore/src/services/auth.service.ts b/flightscore/src/services/auth.service.ts new file mode 100644 index 0000000..8a02fd9 --- /dev/null +++ b/flightscore/src/services/auth.service.ts @@ -0,0 +1,71 @@ +export type UserSession = { + id: string; + email: string; + role: string | null; + permissions: string[]; +}; + +class AuthService { + private _user: UserSession | null = null; + private _accessToken: string | null = null; + private _teamCode: string | null = null; + + init() { + const stored = sessionStorage.getItem("session"); + if (stored) this._user = JSON.parse(stored); + this._teamCode = sessionStorage.getItem("team_code"); + } + + get user() { + return this._user; + } + get isLoggedIn() { + return this._accessToken !== null; + } + get isAdmin() { + return this._user?.role === "admin"; + } + get accessToken() { + return this._accessToken; + } + + get hasTeamCode() { + return this._teamCode !== null; + } + get teamCode() { + return this._teamCode; + } + + setSession(user: UserSession, accessToken: string) { + if (user == undefined || accessToken == undefined) { + console.warn("not saving undefined session"); + return; + } + this._user = user; + this._accessToken = accessToken; + sessionStorage.setItem("session", JSON.stringify(user)); + } + + setTeamCode(code: string) { + this._teamCode = code; + sessionStorage.setItem("team_code", code); + } + + clearTeamCode() { + this._teamCode = null; + sessionStorage.removeItem("team_code"); + } + + clearSession() { + this._user = null; + this._accessToken = null; + this._teamCode = null; + sessionStorage.clear(); + } + + hasPermission(permission: string) { + return this._user?.permissions.includes(permission) ?? false; + } +} + +export const authService = new AuthService();