Compare commits

..

10 commits

21 changed files with 1043 additions and 31 deletions

View file

@ -13,7 +13,7 @@ from .config import settings
SECRET_KEY = settings.secret_key SECRET_KEY = settings.secret_key
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minuted
pwd_context = CryptContext(schemes=["bcrypt_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt_sha256"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

View file

@ -14,6 +14,8 @@ class Settings(BaseModel):
secret_key: str = os.getenv("SECRET_KEY", "CHANGE_ME") secret_key: str = os.getenv("SECRET_KEY", "CHANGE_ME")
access_token_expire_minuted: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
@property @property
def database_url(self): def database_url(self):
return ( return (

View file

@ -10,12 +10,24 @@ from .auth import (
create_access_token, get_password_hash, create_access_token, get_password_hash,
) )
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Server Worklog") app = FastAPI(title="Server Worklog")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # oder deine echte Domain
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Nur für lokale Entwicklung, in Produktion via Alembic # Nur für lokale Entwicklung, in Produktion via Alembic
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@app.options("/{path:path}")
def preflight_handler(path: str):
return {}
@app.post("/auth/register", response_model=schemas.UserRead) @app.post("/auth/register", response_model=schemas.UserRead)
def register_user(user_in: schemas.UserCreate, db: Session = Depends(get_db)): def register_user(user_in: schemas.UserCreate, db: Session = Depends(get_db)):
existing = db.query(models.User).filter(models.User.username == user_in.username).first() existing = db.query(models.User).filter(models.User.username == user_in.username).first()
@ -86,6 +98,25 @@ def create_server(
db.refresh(server) db.refresh(server)
return server return server
@app.put("/servers/{server_id}", response_model=schemas.ServerRead)
def update_server(
server_id: int,
server: schemas.ServerUpdate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
db_server = db.query(models.Server).filter(models.Server.id == server_id).first()
if not db_server:
raise HTTPException(status_code=404, detail="Server not found")
db_server.hostname = server.hostname
db_server.description = server.description
db_server.owner_id = server.owner_id
db.commit()
db.refresh(db_server)
return db_server
@app.get("/servers", response_model=List[schemas.ServerRead]) @app.get("/servers", response_model=List[schemas.ServerRead])
def list_servers( def list_servers(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View file

@ -21,6 +21,9 @@ class ServerBase(BaseModel):
class ServerCreate(ServerBase): class ServerCreate(ServerBase):
pass pass
class ServerUpdate(ServerBase):
pass
class ServerRead(ServerBase): class ServerRead(ServerBase):
id: int id: int
owner: OwnerRead | None = None owner: OwnerRead | None = None

View file

@ -1,3 +1,18 @@
<template> <template>
<NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout>
</template> </template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity .2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

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()
return { unauthorized: true }
}
// 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() { 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
if (typeof window !== "undefined") {
localStorage.setItem("token", t) 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 }
}

View file

@ -0,0 +1,33 @@
import { ref } from "vue"
import { isUnauthorized } from "~/utils/auth"
export function useOwners() {
const owners = ref([])
const loading = ref(false)
const error = ref("")
const { apiFetch } = useApi()
async function loadOwners() {
loading.value = true
error.value = ""
const result = await apiFetch("/owners")
if (isUnauthorized(result)) {
loading.value = false
return result
}
owners.value = result
loading.value = false
return result
}
return {
owners,
loading,
error,
loadOwners
}
}

View file

@ -0,0 +1,33 @@
import { ref } from "vue"
import { isUnauthorized } from "~/utils/auth"
export function useServers() {
const servers = ref([])
const loading = ref(false)
const error = ref("")
const { apiFetch } = useApi()
async function loadServers() {
loading.value = true
error.value = ""
const result = await apiFetch("/servers")
if (isUnauthorized(result)) {
loading.value = false
return result
}
servers.value = result
loading.value = false
return result
}
return {
servers,
loading,
error,
loadServers
}
}

View file

@ -1,12 +1,48 @@
<script setup>
const { token, loadToken, logout } = useAuth()
const router = useRouter()
const showWorkMenu = ref(false)
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 items-center">
<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>
<div class="relative">
<button @click="showWorkMenu = !showWorkMenu" class="cursor-pointer">
Work Entries
</button>
<div v-if="showWorkMenu" class="absolute left-0 mt-2 bg-white shadow rounded p-2 space-y-1 z-50">
<NuxtLink @click="showWorkMenu = false" to="/workentries/create" class="block px-3 py-1 hover:bg-gray-100 rounded">
Neu erstellen
</NuxtLink>
<NuxtLink @click="showWorkMenu = false" to="/workentries/search" class="block px-3 py-1 hover:bg-gray-100 rounded">
Suchen
</NuxtLink>
</div>
</div>
</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 +50,3 @@
</main> </main>
</div> </div>
</template> </template>

View file

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
if (import.meta.server) return // SSR ignorieren
// allow login-page
if (to.meta.requiresAuth !== true) {
return
}
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>
<form @submit.prevent="login" 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
type="submit"
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>
</form>
</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,96 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { isUnauthorized } from "~/utils/auth"
import { ref, onMounted } from "vue"
const { loadToken } = useAuth()
const { apiFetch } = useApi()
const { owners, loadOwners } = useOwners()
const newOwnerName = ref("")
const loading = ref(false)
const error = ref("")
async function createOwner() {
if (!newOwnerName.value.trim()) return
try {
const result = await apiFetch("/owners", {
method: "POST",
body: {
name: newOwnerName.value
}
})
if (isUnauthorized(result)) {
return result
}
owners.value.push(result)
newOwnerName.value = ""
return result
} catch (e: any) {
error.value = e.message
}
}
onMounted( async () => {
loadToken()
if (isUnauthorized(await loadOwners())) return
})
</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>

View file

@ -0,0 +1,234 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const { servers, loadServers } = useServers()
const { owners, loadOwners } = useOwners()
const newServer = ref({
hostname: "",
description: "",
owner_id: null
})
const loading = ref(false)
const error = ref("")
const showCreateForm = ref(false)
const editingId = ref<number | null>(null)
const editServer = ref({
hostname: "",
description: "",
owner_id: null
})
function startEdit(server: any) {
editingId.value = Number(server.id)
editServer.value = {
hostname: server.hostname,
description: server.description,
owner_id: server.owner_id
}
}
function cancelEdit() {
editingId.value = null
}
async function saveEdit(serverId: number) {
try {
const updated = await apiFetch(`/servers/${serverId}`, {
method: "PUT",
body: {
hostname: editServer.value.hostname,
description: editServer.value.description,
owner_id: editServer.value.owner_id
}
})
if (isUnauthorized(updated)) {
return updated
}
// Liste aktualisieren
const idx = servers.value.findIndex(s => s.id === serverId)
if (idx !== -1) servers.value[idx] = updated
editingId.value = null
} catch (e: any) {
error.value = e.message
}
}
async function createServer() {
if (!newServer.value.hostname.trim()) return
try {
const server = await apiFetch("/servers", {
method: "POST",
body: {
hostname: newServer.value.hostname,
description: newServer.value.description,
owner_id: newServer.value.owner_id
}
})
if (isUnauthorized(server)) {
return server
}
servers.value.push(server)
newServer.value = { hostname: "", description: "" }
} catch (e: any) {
error.value = e.message
}
}
onMounted(async () => {
if (isUnauthorized(await loadOwners())) return
if (isUnauthorized(await loadServers())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Server Verwaltung</h1>
<!-- Server-Liste -->
<div class="p-4 bg-white shadow rounded">
<h2 class="text-xl font-semibold mb-4">Server Liste</h2>
<div v-if="loading">Lade Server</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>
<th class="text-left p-2">Beschreibung</th>
<th class="text-left p-2">Eigentümer</th>
</tr>
</thead>
<tbody>
<tr v-for="server in servers" :key="server.id" class="border-b hover:bg-gray-50">
<td class="p-2">{{ server.id }}</td>
<td class="p-2">
<template v-if="editingId === server.id">
<input
v-model="editServer.hostname"
class="border p-1 rounded w-full"
/>
</template>
<template v-else>
{{ server.hostname }}
</template>
</td>
<td class="p-2">
<template v-if="editingId === server.id">
<textarea
v-model="editServer.description"
class="border p-1 rounded w-full"
/>
</template>
<template v-else>
{{ server.description }}
</template>
</td>
<td class="p-2">
<template v-if="editingId === server.id">
<select v-model="editServer.owner_id" class="border p-1 rounded">
<option :value="null">Kein Owner</option>
<option
v-for="owner in owners"
:key="owner.id"
:value="owner.id"
>
{{ owner.name }}
</option>
</select>
</template>
<template v-else>
{{ server.owner ? server.owner.name : "—" }}
</template>
</td>
<td class="p-2">
<template v-if="editingId === server.id">
<button
@click="saveEdit(server.id)"
class="bg-green-600 text-white px-2 py-1 rounded mr-2"
>
<Icon name="heroicons:check" class="w-5 h-5" />
</button>
<button
@click="cancelEdit"
class="bg-gray-400 text-white px-2 py-1 rounded"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</template>
<template v-else>
<button
@click="startEdit(server)"
class="bg-blue-600 text-white px-2 py-1 rounded"
>
<Icon name="heroicons:pencil-square" class="w-5 h-5" />
</button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<button
@click="showCreateForm = !showCreateForm"
class="flex items-center gap-2 text-blue-600 hover:text-blue-800"
>
<Icon
v-if="showCreateForm"
name="heroicons:eye-slash"
class="w-5 h-5"
/>
<Icon
v-else
name="heroicons:document-plus"
class="w-5 h-5"
/>
</button>
<!-- Neues Server-Formular -->
<Transition name="fade">
<div v-if="showCreateForm" class="p-4 bg-white shadow rounded space-y-4">
<h2 class="text-xl font-semibold">Neuen Server anlegen</h2>
<div class="flex flex-col gap-4">
<input v-model="newServer.hostname" type="text" placeholder="Server Name" class="border p-2 rounded" />
<textarea v-model="newServer.description" placeholder="Beschreibung (optional)"
class="border p-2 rounded" />
<select v-model="newServer.owner_id" class="border p-2 rounded">
<option :value="null">Kein Owner</option>
<option v-for="owner in owners" :key="owner.id" :value="owner.id">
{{ owner.name }}
</option>
</select>
<button @click="createServer"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 self-start">
Speichern
</button>
</div>
</div>
</Transition>
</div>
</template>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const servers = ref([])
const loading = ref(false)
const error = ref("")
const createdEntry = ref(null)
const form = ref({
title: "",
description: "",
duration_minutes: null,
server_ids: []
})
async function loadServers() {
try {
const result = await apiFetch("/servers")
if (isUnauthorized(result)) {
return result
}
servers.value = result
} catch (e: any) {
error.value = e.message
}
}
async function createEntry() {
try {
const entry = await apiFetch("/work-entries", {
method: "POST",
body: form.value
})
if (isUnauthorized(entry)) {
return entry
}
createdEntry.value = entry
// Formular zurücksetzen
form.value = {
title: "",
description: "",
duration_minutes: null,
server_ids: []
}
} catch (e: any) {
error.value = e.message
}
}
function resetForm() {
createdEntry.value = null
}
onMounted(async () => {
if (isUnauthorized(await loadServers())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Neuen Worklog-Eintrag erstellen</h1>
<div v-if="error" class="text-red-600">{{ error }}</div>
<!-- Anzeige des erstellten Eintrags -->
<div v-if="createdEntry" class="p-4 bg-green-50 border border-green-300 rounded">
<h2 class="text-xl font-semibold mb-2">Eintrag erstellt</h2>
<p><strong>Titel:</strong> {{ createdEntry.title }}</p>
<p><strong>Beschreibung:</strong> {{ createdEntry.description || "—" }}</p>
<p><strong>Dauer:</strong> {{ createdEntry.duration_minutes || "—" }}</p>
<p class="mt-2">
<strong>Server:</strong>
<span
v-for="s in createdEntry.servers"
:key="s.id"
class="inline-block bg-gray-200 px-2 py-1 rounded mr-1"
>
{{ s.hostname }}
</span>
</p>
<button
@click="resetForm"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Weiteren Eintrag erstellen
</button>
</div>
<!-- Formular -->
<div v-else class="p-4 bg-white shadow rounded space-y-4">
<input
v-model="form.title"
type="text"
placeholder="Titel"
class="border p-2 rounded w-full"
/>
<textarea
v-model="form.description"
placeholder="Beschreibung (optional)"
class="border p-2 rounded w-full"
/>
<input
v-model.number="form.duration_minutes"
type="number"
placeholder="Dauer in Minuten (optional)"
class="border p-2 rounded w-full"
/>
<div>
<label class="font-semibold">Server auswählen</label>
<div class="flex flex-col gap-1 mt-1">
<label
v-for="server in servers"
:key="server.id"
class="flex items-center gap-2"
>
<input
type="checkbox"
:value="server.id"
v-model="form.server_ids"
/>
{{ server.hostname }}
</label>
</div>
</div>
<button
@click="createEntry"
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
>
Speichern
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,147 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const { servers, loadServers } = useServers()
const selectedServer = ref(null)
const query = ref("")
const results = ref([])
const loading = ref(false)
const error = ref("")
const expandedRow = ref<number | null>(null)
function toggleRow(id: number) {
expandedRow.value = expandedRow.value === id ? null : id
}
async function search() {
if (!selectedServer.value && !query.value) {
results.value = []
return
}
loading.value = true
error.value = ""
try {
const params: any = {}
if (selectedServer.value) {
params.server_id = selectedServer.value
}
if (query.value) {
params.q = query.value
}
const result = await apiFetch("/work-entries", {
query: params
})
if (isUnauthorized(result)) {
return result
}
results.value = result
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(async () => {
if (isUnauthorized(await loadServers())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Worklog-Suche</h1>
<form @submit.prevent="search" class="p-4 bg-white shadow rounded space-y-4">
<select
v-model="selectedServer"
class="border p-2 rounded w-full"
>
<option :value="null">Server auswählen</option>
<option
v-for="s in servers"
:key="s.id"
:value="s.id"
>
{{ s.hostname }}
</option>
</select>
<input
v-model="query"
type="text"
placeholder="Freitextsuche (optional)"
class="border p-2 rounded w-full"
/>
<button
type="submit"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Suchen
</button>
</form>
<!-- Nur anzeigen, wenn ein Server gewählt wurde -->
<div v-if="selectedServer || query" class="p-4 bg-white shadow rounded">
<h2 class="text-xl font-semibold mb-4">Suchergebnisse</h2>
<div v-if="loading">Lade</div>
<div v-if="error" class="text-red-600">{{ error }}</div>
<table v-if="results.length" class="w-full border-collapse">
<thead>
<tr class="border-b">
<th class="text-left p-2">Titel</th>
<th class="text-left p-2">Dauer</th>
<th class="text-left p-2">Erstellt am</th>
<th class="text-left p-2">Server</th>
</tr>
</thead>
<tbody>
<template v-for="entry in results" :key="entry.id">
<tr
@click="toggleRow(entry.id)"
class="border-b hover:bg-gray-50 cursor-pointer"
>
<td class="p-2">{{ entry.title }}</td>
<td class="p-2">{{ entry.duration_minutes ?? "—" }}</td>
<td class="p-2">{{ new Date(entry.created_at).toLocaleString() }}</td>
<td class="p-2">
<span
v-for="s in entry.servers"
:key="s.id"
class="inline-block bg-gray-200 px-2 py-1 rounded mr-1 text-sm"
>
{{ s.hostname }}
</span>
</td>
</tr>
<!-- Detailzeile -->
<tr v-if="expandedRow === entry.id" class="bg-gray-50">
<td colspan="4" class="p-4">
<h3 class="font-semibold mb-2">Beschreibung</h3>
<p class="whitespace-pre-line">
{{ entry.description || "Keine Beschreibung vorhanden." }}
</p>
</td>
</tr>
</template>
</tbody>
</table>
<div v-else class="text-gray-500">
Keine Einträge gefunden.
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,3 @@
export function isUnauthorized(result: any) {
return result && result.unauthorized === true
}

View file

@ -1,6 +1,9 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: [ "@nuxtjs/tailwindcss" ], modules: [
"@nuxtjs/tailwindcss",
"@nuxt/icon"
],
srr: true, srr: true,
nitro: { nitro: {
preset: "static" preset: "static"

View file

@ -7,6 +7,7 @@
"name": "frontend", "name": "frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1",
"nuxt": "^4.3.0", "nuxt": "^4.3.0",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
@ -28,6 +29,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@ -992,6 +1006,47 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@iconify/collections": {
"version": "1.0.642",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.642.tgz",
"integrity": "sha512-SjQpWEQDG8votqjmF/G0CCmFFYaQfm+GECNjAEhzyIGB/RpzOUWcGCIBnFQA/YCg3I+wC5sZpWF/Mko6KWeMwg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
@ -1421,6 +1476,28 @@
"devtools-wizard": "cli.mjs" "devtools-wizard": "cli.mjs"
} }
}, },
"node_modules/@nuxt/icon": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": { "node_modules/@nuxt/kit": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",

View file

@ -10,6 +10,7 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1",
"nuxt": "^4.3.0", "nuxt": "^4.3.0",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"