diff --git a/astro-i18n.config.ts b/astro-i18n.config.ts index c1a4391..1ee359d 100644 --- a/astro-i18n.config.ts +++ b/astro-i18n.config.ts @@ -21,6 +21,7 @@ export default defineAstroI18nConfig({ statistiken: "stats", ankuendigungen: "announcements", datenschutzerklaerung: "privacy-policy", + "passwort-zuruecksetzen": "reset-password", "passwort-setzen": "set-password", }, }, diff --git a/src/components/Login.svelte b/src/components/Login.svelte index 3a86569..70c72e3 100644 --- a/src/components/Login.svelte +++ b/src/components/Login.svelte @@ -31,8 +31,7 @@ let error: string = $state(""); async function login() { - let {tokenStore} = await import("./repo/repo.ts"); - let {authRepo} = await import("./repo/auth.ts"); + let {authV2Repo} = await import("./repo/authv2.ts"); if (username === "" || pw === "") { pw = ""; error = t("login.error"); @@ -40,15 +39,14 @@ } try { - let auth = await get(authRepo).login(username, pw); - if (auth == undefined) { + let auth = await get(authV2Repo).login(username, pw); + if (!auth) { pw = ""; error = t("login.error"); return; } - tokenStore.set(auth); - navigate(l("/dashboard")); + await navigate(l("/dashboard")); } catch (e: any) { pw = ""; error = t("login.error"); diff --git a/src/components/Navbar.svelte b/src/components/Navbar.svelte index 3e0194c..6cc047a 100644 --- a/src/components/Navbar.svelte +++ b/src/components/Navbar.svelte @@ -23,24 +23,35 @@ import {t} from "astro-i18n"; import {l} from "../util/util"; import {onMount} from "svelte"; + import {loggedIn} from "@repo/authv2.ts"; interface Props { logo?: import('svelte').Snippet; } let { logo }: Props = $props(); - let navbar: HTMLDivElement = $state(); + let navbar = $state(); let searchOpen = $state(false); + let accountBtn = $state(); + + $effect(() => { + if ($loggedIn) { + accountBtn!.href = l("/dashboard"); + } else { + accountBtn!.href = l("/login"); + } + }) + onMount(() => { handleScroll(); }) function handleScroll() { if (window.scrollY > 0) { - navbar.classList.add("before:scale-y-100"); + navbar!.classList.add("before:scale-y-100"); } else { - navbar.classList.remove("before:scale-y-100"); + navbar!.classList.remove("before:scale-y-100"); } } @@ -106,7 +117,7 @@ --> - + {t("navbar.links.account")} + + + + +
+

{t("login.title")}

+
+ + + + + + +
+

+ {t("login.setPassword")}

+ + {#if error} +

{error}

+ {/if} + +
+ + \ No newline at end of file diff --git a/src/components/admin/App.svelte b/src/components/admin/App.svelte index 9923513..be1e844 100644 --- a/src/components/admin/App.svelte +++ b/src/components/admin/App.svelte @@ -22,38 +22,40 @@ import wrap from "svelte-spa-router/wrap"; import Router, {replace} from "svelte-spa-router"; import {get} from "svelte/store"; - import {tokenStore} from "@repo/repo"; + import {loggedIn} from "@repo/authv2.ts"; const routes: RouteDefinition = { - "/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(tokenStore) != ""}), + "/": wrap({asyncComponent: () => import("./pages/Home.svelte"), conditions: detail => get(loggedIn)}), "/perms": wrap({ asyncComponent: () => import("./pages/Perms.svelte"), - conditions: detail => get(tokenStore) != "" + conditions: detail => get(loggedIn) }), "/login": wrap({ asyncComponent: () => import("./pages/Login.svelte"), - conditions: detail => get(tokenStore) == "" + conditions: detail => !get(loggedIn) }), "/event/:id": wrap({ asyncComponent: () => import("./pages/Event.svelte"), - conditions: detail => get(tokenStore) != "" + conditions: detail => get(loggedIn) }), "/event/:id/generate": wrap({ asyncComponent: () => import("./pages/Generate.svelte"), - conditions: detail => get(tokenStore) != "" + conditions: detail => get(loggedIn) }), "/edit": wrap({ asyncComponent: () => import("./pages/Edit.svelte"), - conditions: detail => get(tokenStore) != "" + conditions: detail => get(loggedIn) }), "/display/:event": wrap({ asyncComponent: () => import("./pages/Display.svelte"), - conditions: detail => get(tokenStore) != "" + conditions: detail => get(loggedIn) }), "*": wrap({asyncComponent: () => import("./pages/NotFound.svelte")}) }; function conditionsFailed(event: ConditionsFailedEvent) { + console.log(event) + if (event.detail.location === "/login") { replace("/"); } else { diff --git a/src/components/dashboard/UserInfo.svelte b/src/components/dashboard/UserInfo.svelte index e88c9c4..aeb8a20 100644 --- a/src/components/dashboard/UserInfo.svelte +++ b/src/components/dashboard/UserInfo.svelte @@ -22,9 +22,9 @@ import type {Player} from "@type/data.ts"; import {l} from "@utils/util.ts"; import Statistics from "./Statistics.svelte"; - import {authRepo} from "@repo/auth.ts"; - import {tokenStore} from "@repo/repo.ts"; + import {authV2Repo} from "@repo/authv2.ts"; import Card from "@components/Card.svelte"; + import {navigate} from "astro:transitions/client"; interface Props { user: Player; @@ -33,9 +33,8 @@ let { user }: Props = $props(); async function logout() { - await $authRepo.logout() - tokenStore.set("") - window.location.href = l("/login") + await $authV2Repo.logout(); + await navigate(l("/login")); } diff --git a/src/components/repo/auth.ts b/src/components/repo/auth.ts deleted file mode 100644 index 0f38493..0000000 --- a/src/components/repo/auth.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * This file is a part of the SteamWar software. - * - * Copyright (C) 2023 SteamWar.de-Serverteam - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import {fetchWithToken, tokenStore} from "./repo.ts"; -import {derived} from "svelte/store"; - -export class AuthRepo { - constructor(private token: string) { - } - - public async login(username: string, password: string): Promise { - return await fetchWithToken(this.token, "/auth/login", { - body: JSON.stringify({ - username, - password, - }), - method: "POST", - }).then(value => value.json()).then(value => value.token); - } - - public async logout(): Promise { - await fetchWithToken(this.token, "/auth/tokens/logout", { - method: "POST", - }); - } -} - -export const authRepo = derived(tokenStore, ($token) => new AuthRepo($token)); \ No newline at end of file diff --git a/src/components/repo/authv2.ts b/src/components/repo/authv2.ts new file mode 100644 index 0000000..ab17fc3 --- /dev/null +++ b/src/components/repo/authv2.ts @@ -0,0 +1,183 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2025 SteamWar.de-Serverteam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {readable, writable} from "svelte/store"; +import {tokenStore} from "@repo/repo.ts"; +import dayjs, {type Dayjs} from "dayjs"; +import {type AuthToken, AuthTokenSchema} from "@type/auth.ts"; + +export class AuthV2Repo { + private accessToken: string | undefined; + private accessTokenExpires: Dayjs | undefined; + private refreshToken: string | undefined; + private refreshTokenExpires: Dayjs | undefined; + + constructor() { + if (typeof localStorage === "undefined") { + return; + } + + this.accessToken = localStorage.getItem("sw-access-token") ?? undefined; + if (this.accessToken) { + this.accessTokenExpires = dayjs(localStorage.getItem("sw-access-token-expires") ?? ""); + } + + this.refreshToken = localStorage.getItem("sw-refresh-token") ?? undefined; + if (this.refreshToken) { + loggedIn.set(true); + this.refreshTokenExpires = dayjs(localStorage.getItem("sw-refresh-token-expires") ?? ""); + } + } + + async login(name: string, password: string) { + if (this.accessToken !== undefined || this.refreshToken !== undefined) { + throw new Error("Already logged in"); + } + + try { + const login = await this.request("/v2/auth/state", { + method: "POST", + body: JSON.stringify({ + name, + password, + keepLoggedIn: true, + }), + }).then(value => value.json()).then(value => AuthTokenSchema.parse(value)); + + this.setLoginState(login); + + return true; + } catch (e) { + return false; + } + } + + async resetPassword(name: string, password: string, token: string) { + return await this.requestWithToken(token, "/v2/auth/register", { + method: "POST", + body: JSON.stringify({ + name, + password, + }), + }); + } + + async logout() { + if (this.accessToken === undefined) { + return; + } + + await this.request("/v2/auth/state", { + method: "DELETE", + }); + + this.resetAccessToken(); + this.resetRefreshToken(); + } + + private setLoginState(tokens: AuthToken) { + this.setAccessToken(tokens.accessToken.token, dayjs(tokens.accessToken.expires)); + this.setRefreshToken(tokens.refreshToken.token, dayjs(tokens.refreshToken.expires)); + loggedIn.set(true); + } + + private setAccessToken(token: string, expires: Dayjs) { + this.accessToken = token; + this.accessTokenExpires = expires; + localStorage.setItem("sw-access-token", token); + localStorage.setItem("sw-access-token-expires", expires.toString()); + } + + private resetAccessToken() { + if (this.accessToken === undefined) { + return; + } + this.accessToken = undefined; + this.accessTokenExpires = undefined; + localStorage.removeItem("sw-access-token"); + localStorage.removeItem("sw-access-token-expires"); + } + + private setRefreshToken(token: string, expires: Dayjs) { + this.refreshToken = token; + this.refreshTokenExpires = expires; + localStorage.setItem("sw-refresh-token", token); + localStorage.setItem("sw-refresh-token-expires", expires.toString()); + } + + private resetRefreshToken() { + if (this.refreshToken === undefined) { + return; + } + this.refreshToken = undefined; + this.refreshTokenExpires = undefined; + localStorage.removeItem("sw-refresh-token"); + localStorage.removeItem("sw-refresh-token-expires"); + + loggedIn.set(false); + } + + private async refresh() { + if (this.refreshToken === undefined || this.refreshTokenExpires === undefined || this.refreshTokenExpires.isBefore(dayjs().add(10, "seconds"))) { + this.resetRefreshToken(); + this.resetAccessToken(); + + return; + } + + try { + const response = await this.requestWithToken(this.refreshToken!, "/v2/auth/state", { + method: "PUT", + }).then(value => value.json()).then(value => AuthTokenSchema.parse(value)); + + this.setLoginState(response); + } catch (e) { + this.resetRefreshToken(); + this.resetAccessToken(); + return; + } + } + + async request(url: string, params: RequestInit = {}) { + if (this.accessToken !== undefined && this.accessTokenExpires !== undefined && this.accessTokenExpires.isBefore(dayjs())) { + await this.refresh(); + } + + return this.requestWithToken(this.accessToken ?? "", url, params); + } + + private async requestWithToken(token: string, url: string, params: RequestInit = {}) { + return fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params, + headers: { + ...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}), + "Content-Type": "application/json", ...params.headers, + }, + }) + .then(value => { + if (value.status === 401) { + tokenStore.set(""); + } + return value; + }); + } +} + +export const loggedIn = writable(false); + +export const authV2Repo = readable(new AuthV2Repo()); \ No newline at end of file diff --git a/src/components/repo/repo.ts b/src/components/repo/repo.ts index 7b75d3c..c759338 100644 --- a/src/components/repo/repo.ts +++ b/src/components/repo/repo.ts @@ -17,31 +17,9 @@ * along with this program. If not, see . */ -import {writable} from "svelte/store"; +import {get, writable} from "svelte/store"; +import {authV2Repo} from "@repo/authv2.ts"; -export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => - fetch(`${import.meta.env.PUBLIC_API_SERVER}${url}`, {...params, - headers: { - ...(token !== "" ? {"Authorization": "Bearer " + (token)} : {}), - "Content-Type": "application/json", ...params.headers, - }, - }) - .then(value => { - if (value.status === 401) { - tokenStore.set(""); - } - return value; - }); +export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => get(authV2Repo).request(url, params); -export function getLocalStorage() { - if (typeof localStorage === "undefined") { - return { - getItem: () => "", - setItem: () => {}, - }; - } - return localStorage; -} - -export const tokenStore = writable((getLocalStorage().getItem("sw-session") ?? "")); -tokenStore.subscribe((value) => getLocalStorage().setItem("sw-session", value)); +export const tokenStore = writable(""); diff --git a/src/components/types/auth.ts b/src/components/types/auth.ts new file mode 100644 index 0000000..0f2a749 --- /dev/null +++ b/src/components/types/auth.ts @@ -0,0 +1,34 @@ +/* + * This file is a part of the SteamWar software. + * + * Copyright (C) 2025 SteamWar.de-Serverteam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {z} from "zod"; + +export const TokenSchema = z.object({ + token: z.string(), + expires: z.string(), +}); + +export type Token = z.infer; + +export const AuthTokenSchema = z.object({ + accessToken: TokenSchema, + refreshToken: TokenSchema, +}); + +export type AuthToken = z.infer; \ No newline at end of file diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index ba8656e..770d08b 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -8,9 +8,11 @@ import {t} from "astro-i18n";