WIP: Frontend implementation #1

Draft
fager wants to merge 19 commits from ch-frontend into main
8 changed files with 293 additions and 27 deletions
Showing only changes of commit 994ba0bc01 - Show all commits

frontend: Owner management

Frank Agerholm 2026-01-25 17:00:59 +01:00
No known key found for this signature in database

View file

@ -1,3 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View file

@ -0,0 +1,59 @@
export function useApi() {
const config = useRuntimeConfig()
const { token, logout } = useAuth()
async function apiFetch<T>(url: string, options: any = {}) {
try {
return await $fetch<T>(url, {
baseURL: config.public.apiBase,
headers: {
...(options.headers || {}),
...(token.value ? { Authorization: `Bearer ${token.value}` } : {})
},
...options
})
} catch (error: any) {
// Netzwerkfehler oder Server nicht erreichbar
if (!error.response) {
console.error("API unreachable:", error)
throw new Error("Der Server ist nicht erreichbar.")
}
const status = error.response.status
const data = error.response._data
// 401 → Token ungültig oder abgelaufen
if (status === 401) {
console.warn("API returned 401 — logging out")
logout()
throw new Error("Deine Sitzung ist abgelaufen. Bitte erneut einloggen.")
}
// 403 → keine Berechtigung
if (status === 403) {
throw new Error("Du hast keine Berechtigung für diese Aktion.")
}
// 400 → Validierungsfehler
if (status === 400) {
throw new Error(data?.detail || "Ungültige Eingabe.")
}
// 404 → nicht gefunden
if (status === 404) {
throw new Error("Die angeforderte Ressource wurde nicht gefunden.")
}
// 500 → Serverfehler
if (status >= 500) {
console.error("Server error:", data)
throw new Error("Interner Serverfehler. Bitte später erneut versuchen.")
}
// Fallback
throw new Error(data?.detail || "Unbekannter Fehler.")
}
}
return { apiFetch }
}

View file

@ -1,15 +1,46 @@
function decodeJwt(token: string) {
try {
const payload = token.split(".")[1]
const decoded = JSON.parse(atob(payload))
return decoded
} catch {
return null
}
}
export function useAuth() {
const token = useState("token", () => null)
const token = useState<string | null>("token", () => null)
function setToken(t: string) {
token.value = t
localStorage.setItem("token", t)
if (typeof window !== "undefined") {
localStorage.setItem("token", t)
}
}
function loadToken() {
token.value = localStorage.getItem("token")
if (typeof window !== "undefined") {
const stored = localStorage.getItem("token")
if (!stored) return
const payload = decodeJwt(stored)
if( !payload || !payload.exp || payload.exp * 1000 < Date.now()) {
console.info("Token expired - removing it from storage")
localStorage.removeItem("token")
token.value = null
}
token.value = stored
}
}
return { token, setToken, loadToken }
}
function logout() {
token.value = null
if (typeof window !== "undefined") {
localStorage.removeItem("token")
}
}
return { token, setToken, loadToken, logout }
}

View file

@ -1,12 +1,34 @@
<script setup>
const { token, loadToken, logout } = useAuth()
const router = useRouter()
if (process.client) {
loadToken()
}
function doLogout() {
logout()
router.push("/login")
}
</script>
<template>
<div class="min-h-screen bg-gray-100 text-gray-900">
<header class="p-4 bg-white shadow">
<div>
<header class="p-4 bg-white shadow flex justify-between">
<nav class="flex gap-4">
<NuxtLink to="/">Dashboard</NuxtLink>
<NuxtLink to="/owners">Owners</NuxtLink>
<NuxtLink to="/servers">Servers</NuxtLink>
<NuxtLink to="/workentries">Work Entries</NuxtLink>
</nav>
<div>
<button
v-if="token"
@click="doLogout"
class="text-red-600 hover:underline"
>
Logout
</button>
</div>
</header>
<main class="p-6">
@ -14,4 +36,3 @@
</main>
</div>
</template>

View file

@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware((to) => {
if (import.meta.server) return // SSR ignorieren
// allow login-page
const { token, loadToken } = useAuth()
loadToken()
if (!token.value && to.path !== "/login") {
return navigateTo("/login")
}
})

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from "vue"
const username = ref("")
const password = ref("")
const error = ref("")
const { setToken } = useAuth()
const { apiFetch } = useApi()
const router = useRouter()
async function login() {
error.value = ""
try {
const data = await apiFetch("/auth/token", {
method: "POST",
body: new URLSearchParams({
username: username.value,
password: password.value
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
})
setToken(data.access_token)
router.push("/")
} catch (e: any) {
error.value = "Login fehlgeschlagen"
}
}
</script>
<template>
<div class="max-w-sm mx-auto mt-20 p-6 bg-white shadow rounded">
<h1 class="text-2xl font-bold mb-4">Login</h1>
<div class="space-y-4">
<input
v-model="username"
type="text"
placeholder="Username"
class="w-full p-2 border rounded"
/>
<input
v-model="password"
type="password"
placeholder="Password"
class="w-full p-2 border rounded"
/>
<button
@click="login"
class="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700"
>
Login
</button>
<p v-if="error" class="text-red-600">{{ error }}</p>
</div>
</div>
</template>

View file

@ -1,19 +0,0 @@
<script setup lang="ts">
const config = useRuntimeConfig()
const owners = await $fetch("/owners", {
baseURL: config.public.apiBase
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold mb-4">Owners</h1>
<ul class="space-y-2">
<li v-for="o in owners" :key="o.id" class="p-3 bg-gray-100 rounded">
{{ o.name }}
</li>
</ul>
</div>
</template>

View file

@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, onMounted } from "vue"
const { loadToken } = useAuth()
const { apiFetch } = useApi()
const owners = ref([])
const newOwnerName = ref("")
const loading = ref(false)
const error = ref("")
async function loadOwners() {
loading.value = true
try {
owners.value = await apiFetch("/owners")
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createOwner() {
if (!newOwnerName.value.trim()) return
try {
const owner = await apiFetch("/owners", {
method: "POST",
body: {
name: newOwnerName.value
}
})
owners.value.push(owner)
newOwnerName.value = ""
} catch (e: any) {
error.value = e.message
}
}
onMounted(() => {
loadToken()
loadOwners()
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Owner Verwaltung</h1>
<div class="p-4 bg-white shadow rounded space-y-4">
<h2 class="text-xl font-semibold">Neuen Owner anlegen</h2>
<div class="flex gap-4">
<input
v-model="newOwnerName"
type="text"
placeholder="Owner Name"
class="border p-2 rounded flex-1"
/>
<button
@click="createOwner"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Speichern
</button>
</div>
</div>
<div class="p-4 bg-white shadow rounded">
<h2 class="text-xl font-semibold mb-4">Owner Liste</h2>
<div v-if="loading">Lade Owner</div>
<div v-if="error" class="text-red-600">{{ error }}</div>
<table class="w-full border-collapse">
<thead>
<tr class="border-b">
<th class="text-left p-2">ID</th>
<th class="text-left p-2">Name</th>
</tr>
</thead>
<tbody>
<tr
v-for="owner in owners"
:key="owner.id"
class="border-b hover:bg-gray-50"
>
<td class="p-2">{{ owner.id }}</td>
<td class="p-2">{{ owner.name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>