Fix #104
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 32s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s

This commit is contained in:
2026-02-15 12:52:34 +01:00
parent f63e793c88
commit 1dc74947f4
5 changed files with 239 additions and 10 deletions

View File

@@ -1,11 +1,60 @@
import { FastifyInstance } from "fastify"
import bcrypt from "bcrypt"
import { eq } from "drizzle-orm"
import jwt from "jsonwebtoken"
import { secrets } from "../../utils/secrets"
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
export default async function authRoutesAuthenticated(server: FastifyInstance) {
server.post("/auth/refresh", {
schema: {
tags: ["Auth"],
summary: "Refresh JWT for current authenticated user",
response: {
200: {
type: "object",
properties: {
token: { type: "string" },
},
required: ["token"],
},
401: {
type: "object",
properties: {
error: { type: "string" },
},
required: ["error"],
},
},
},
}, async (req, reply) => {
if (!req.user?.user_id) {
return reply.code(401).send({ error: "Unauthorized" })
}
const token = jwt.sign(
{
user_id: req.user.user_id,
email: req.user.email,
tenant_id: req.user.tenant_id,
},
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
)
reply.setCookie("token", token, {
path: "/",
httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 6,
})
return { token }
})
server.post("/auth/password/change", {
schema: {
tags: ["Auth"],

View File

@@ -137,7 +137,7 @@ export default async function authRoutes(server: FastifyInstance) {
httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 3,
maxAge: 60 * 60 * 6,
});
return { token };

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
const auth = useAuthStore()
const remainingMinutes = computed(() => Math.floor(auth.sessionWarningRemainingSeconds / 60))
const remainingSeconds = computed(() => auth.sessionWarningRemainingSeconds % 60)
const remainingTimeLabel = computed(() => `${remainingMinutes.value}:${String(remainingSeconds.value).padStart(2, "0")}`)
const onRefresh = async () => {
await auth.refreshSession()
}
const onLogout = async () => {
auth.sessionWarningVisible = false
await auth.logout()
}
</script>
<template>
<UModal v-model="auth.sessionWarningVisible" prevent-close>
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3>
</template>
<p class="text-sm text-gray-600 dark:text-gray-300">
Deine Sitzung endet in
<span class="font-semibold">{{ remainingTimeLabel }}</span>.
Bitte bestätige, um eingeloggt zu bleiben.
</p>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="outline" color="gray" @click="onLogout">
Abmelden
</UButton>
<UButton color="primary" @click="onRefresh">
Eingeloggt bleiben
</UButton>
</div>
</template>
</UCard>
</UModal>
</template>

View File

@@ -5,6 +5,7 @@ import GlobalMessages from "~/components/GlobalMessages.vue";
import TenantDropdown from "~/components/TenantDropdown.vue";
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
import {useCalculatorStore} from '~/stores/calculator'
import SessionRefreshModal from "~/components/SessionRefreshModal.vue";
const dataStore = useDataStore()
const colorMode = useColorMode()
@@ -130,6 +131,7 @@ const footerLinks = computed(() => [
<template>
<div v-if="!auth.loading">
<SessionRefreshModal />
<div v-if="auth.activeTenantData?.locked === 'maintenance_tenant'">
<UContainer class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<UCard class="max-w-lg text-center p-10">
@@ -315,4 +317,4 @@ const footerLinks = computed(() => [
</div>
</template>
</template>

View File

@@ -12,13 +12,129 @@ export const useAuthStore = defineStore("auth", {
activeTenantData: null as any,
loading: true as boolean,
notLoggedIn: true,
sessionWarningVisible: false,
sessionWarningRemainingSeconds: 0,
sessionWarningLeadMs: 5 * 60 * 1000,
sessionWarningTimer: null as ReturnType<typeof setTimeout> | null,
sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null,
sessionCountdownTimer: null as ReturnType<typeof setInterval> | null,
}),
actions: {
async persist(token) {
decodeTokenExpiryMs(token: string) {
try {
const parts = token.split(".")
if (parts.length < 2) return null
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")))
if (!payload?.exp) return null
return Number(payload.exp) * 1000
} catch (err) {
console.error("Could not decode token expiry", err)
return null
}
},
clearSessionTimers() {
if (!process.client) return
if (this.sessionWarningTimer) {
clearTimeout(this.sessionWarningTimer)
this.sessionWarningTimer = null
}
if (this.sessionLogoutTimer) {
clearTimeout(this.sessionLogoutTimer)
this.sessionLogoutTimer = null
}
if (this.sessionCountdownTimer) {
clearInterval(this.sessionCountdownTimer)
this.sessionCountdownTimer = null
}
},
startSessionCountdown(expiresAtMs: number) {
if (!process.client) return
if (this.sessionCountdownTimer) {
clearInterval(this.sessionCountdownTimer)
}
this.sessionCountdownTimer = setInterval(() => {
const remaining = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000))
this.sessionWarningRemainingSeconds = remaining
if (remaining <= 0 && this.sessionCountdownTimer) {
clearInterval(this.sessionCountdownTimer)
this.sessionCountdownTimer = null
}
}, 1000)
},
openSessionWarning(expiresAtMs: number) {
this.sessionWarningVisible = true
this.sessionWarningRemainingSeconds = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000))
this.startSessionCountdown(expiresAtMs)
},
scheduleSessionTimers(token?: string | null) {
if (!process.client) return
const tokenToUse = token || useCookie("token").value
this.clearSessionTimers()
this.sessionWarningVisible = false
this.sessionWarningRemainingSeconds = 0
if (!tokenToUse) return
const expiresAtMs = this.decodeTokenExpiryMs(tokenToUse)
if (!expiresAtMs) return
const now = Date.now()
const msUntilLogout = expiresAtMs - now
if (msUntilLogout <= 0) {
void this.logout()
return
}
this.sessionLogoutTimer = setTimeout(() => {
this.sessionWarningVisible = false
void this.logout()
}, msUntilLogout)
const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs
if (msUntilWarning <= 0) {
this.openSessionWarning(expiresAtMs)
return
}
this.sessionWarningTimer = setTimeout(() => {
this.openSessionWarning(expiresAtMs)
}, msUntilWarning)
},
setToken(token: string | null) {
useCookie("token").value = token
if (!token) {
this.clearSessionTimers()
this.sessionWarningVisible = false
this.sessionWarningRemainingSeconds = 0
return
}
this.scheduleSessionTimers(token)
},
async persist(token: string | null) {
console.log("On Web")
useCookie("token").value = token // persistieren
this.setToken(token)
},
@@ -74,8 +190,7 @@ export const useAuthStore = defineStore("auth", {
console.log("Token: " + token)
// 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird
const tokenCookie = useCookie("token")
tokenCookie.value = token
this.setToken(token)
// 2. User Daten laden
await this.fetchMe(token)
@@ -112,19 +227,22 @@ export const useAuthStore = defineStore("auth", {
this.resetState()
// Token löschen
useCookie("token").value = null
this.setToken(null)
// Nur beim expliziten Logout navigieren wir
navigateTo("/login")
},
resetState() {
this.clearSessionTimers()
this.user = null
this.permissions = []
this.profile = null
this.activeTenant = null
this.tenants = []
this.activeTenantData = null
this.sessionWarningVisible = false
this.sessionWarningRemainingSeconds = 0
},
async fetchMe(jwt= null) {
@@ -162,6 +280,8 @@ export const useAuthStore = defineStore("auth", {
this.activeTenantData = me.tenants.find(i => i.id === me.activeTenant)
}
this.scheduleSessionTimers(tokenToUse)
console.log(this)
} catch (err: any) {
@@ -170,12 +290,27 @@ export const useAuthStore = defineStore("auth", {
// Stattdessen nur den State sauber machen und Token löschen
this.resetState()
useCookie("token").value = null
this.setToken(null)
// Wir werfen den Fehler nicht weiter, damit initStore normal durchläuft
}
},
async refreshSession() {
try {
const { token } = await useNuxtApp().$api("/api/auth/refresh", {
method: "POST",
})
this.setToken(token)
await this.fetchMe(token)
this.sessionWarningVisible = false
} catch (err) {
console.error("JWT refresh failed", err)
await this.logout()
}
},
async switchTenant(tenant_id: string) {
console.log("Auth switchTenant")
this.loading = true
@@ -188,7 +323,7 @@ export const useAuthStore = defineStore("auth", {
const {token} = res
useCookie("token").value = token // persistieren
this.setToken(token)
await this.init(token)
@@ -198,4 +333,4 @@ export const useAuthStore = defineStore("auth", {
return this.permissions.includes(key)
}
}
})
})