Added new Routing System

This commit is contained in:
Jan Meinl
2026-04-11 21:22:18 +02:00
parent ee1fa0d822
commit b47c9ce5db
8 changed files with 335 additions and 156 deletions
-54
View File
@@ -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<Track[]>('/api/tracks');
* await api<void>('/api/tracks/1', { method: 'DELETE' });
*/
export async function api<T = any>(
path: string,
options: ApiOptions = {}
): Promise<T> {
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 = <T>(path: string) => api<T>(path, { method: 'GET' });
export const apiPost = <T>(path: string, body: any) => api<T>(path, { method: 'POST', body });
export const apiPut = <T>(path: string, body: any) => api<T>(path, { method: 'PUT', body });
export const apiDelete = <T>(path: string) => api<T>(path, { method: 'DELETE' });
+95 -56
View File
@@ -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`<cc-nav-bar></cc-nav-bar>`
: html`<nav-bar></nav-bar>`}
${this.section === 'cc' ? html`<cc-nav-bar></cc-nav-bar>`
: this.section === 'admin' ? html`<admin-nav-bar></admin-nav-bar>`
: this.section === 'tt' ? html`<tt-nav-bar></tt-nav-bar>`
: html`<nav-bar></nav-bar>`}
<main id="outlet"></main>
<footer-bar></footer-bar>
`;
@@ -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`
<h1>Welcome to the Competition Center Homepage</h1>
<p>Analyze your tracks visually.</p>
`;
}
}
+56 -34
View File
@@ -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)}
></form-input>
<form-input
@@ -73,21 +101,15 @@ export class LoginPage extends LitElement {
type="password"
placeholder="Enter your password"
.value=${this.password}
@value-changed=${(e: CustomEvent) =>
(this.password = e.detail.value)}
@value-changed=${(e: CustomEvent) => (this.password = e.detail.value)}
></form-input>
<notify-bar type="error" .message=${this.error}></notify-bar>
<ui-button
full
?disabled=${this.loading}
@click=${this.handleLogin}
>
${this.loading ? 'Signing in...' : 'Sign in'}
<ui-button full ?disabled=${this.loading} @click=${this.handleLogin}>
${this.loading ? "Signing in..." : "Sign in"}
</ui-button>
<horizontal-divider></horizontal-divider>
<p class="footer">
+12
View File
@@ -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`
<h1>Welcome to the Target Team Homepage</h1>
<p>Analyze your tracks visually.</p>
`;
}
}
+31 -10
View File
@@ -1,40 +1,61 @@
export type AuthLevel = "public" | "code" | "user" | "admin";
export type Route = {
path: string;
auth: AuthLevel;
exact?: boolean;
view: () => Promise<HTMLElement> | HTMLElement;
};
export type AuthGuard = {
canActivate: (auth: AuthLevel) => Promise<boolean>;
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);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { authService } from './auth.service';
class ApiService {
async fetch(path: string, options: RequestInit = {}): Promise<Response> {
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<Response> {
//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<boolean> {
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();
+71
View File
@@ -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();