WIP: Frontend implementation #1
8 changed files with 293 additions and 27 deletions
frontend: Owner management
commit
994ba0bc01
|
|
@ -1,3 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
59
frontend/app/composables/useAPI.ts
Normal file
59
frontend/app/composables/useAPI.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
export function useAuth() {
|
||||||
const token = useState("token", () => null)
|
const token = useState<string | null>("token", () => null)
|
||||||
|
|
||||||
function setToken(t: string) {
|
function setToken(t: string) {
|
||||||
token.value = t
|
token.value = t
|
||||||
localStorage.setItem("token", t)
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("token", t)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadToken() {
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
<div>
|
||||||
<header class="p-4 bg-white shadow">
|
<header class="p-4 bg-white shadow flex justify-between">
|
||||||
<nav class="flex gap-4">
|
<nav class="flex gap-4">
|
||||||
<NuxtLink to="/">Dashboard</NuxtLink>
|
<NuxtLink to="/">Dashboard</NuxtLink>
|
||||||
<NuxtLink to="/owners">Owners</NuxtLink>
|
<NuxtLink to="/owners">Owners</NuxtLink>
|
||||||
<NuxtLink to="/servers">Servers</NuxtLink>
|
<NuxtLink to="/servers">Servers</NuxtLink>
|
||||||
<NuxtLink to="/workentries">Work Entries</NuxtLink>
|
<NuxtLink to="/workentries">Work Entries</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
v-if="token"
|
||||||
|
@click="doLogout"
|
||||||
|
class="text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="p-6">
|
<main class="p-6">
|
||||||
|
|
@ -14,4 +36,3 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
11
frontend/app/middleware/auth.global.ts
Normal file
11
frontend/app/middleware/auth.global.ts
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
64
frontend/app/pages/login.vue
Normal file
64
frontend/app/pages/login.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
||||||
97
frontend/app/pages/owners/index.vue
Normal file
97
frontend/app/pages/owners/index.vue
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue