Added new Routing System
This commit is contained in:
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
@@ -97,4 +119,4 @@ export class LoginPage extends LitElement {
|
||||
</auth-card>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user