Compare commits
10 commits
d4921043c2
...
43de7bc9b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
43de7bc9b5 |
|||
|
9eb9be7318 |
|||
|
c24a5ce480 |
|||
|
9fda6b5a5c |
|||
|
c8b6e4757f |
|||
|
ffbbd3d0ac |
|||
|
034d676463 |
|||
|
f4ef04dbf7 |
|||
|
994ba0bc01 |
|||
|
9df2ee08d2 |
21 changed files with 1043 additions and 31 deletions
|
|
@ -13,7 +13,7 @@ from .config import settings
|
|||
|
||||
SECRET_KEY = settings.secret_key
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minuted
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt_sha256"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ class Settings(BaseModel):
|
|||
|
||||
secret_key: str = os.getenv("SECRET_KEY", "CHANGE_ME")
|
||||
|
||||
access_token_expire_minuted: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
||||
|
||||
@property
|
||||
def database_url(self):
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,12 +10,24 @@ from .auth import (
|
|||
create_access_token, get_password_hash,
|
||||
)
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@app.options("/{path:path}")
|
||||
def preflight_handler(path: str):
|
||||
return {}
|
||||
|
||||
@app.post("/auth/register", response_model=schemas.UserRead)
|
||||
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()
|
||||
|
|
@ -86,6 +98,25 @@ def create_server(
|
|||
db.refresh(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])
|
||||
def list_servers(
|
||||
db: Session = Depends(get_db),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class ServerBase(BaseModel):
|
|||
class ServerCreate(ServerBase):
|
||||
pass
|
||||
|
||||
class ServerUpdate(ServerBase):
|
||||
pass
|
||||
|
||||
class ServerRead(ServerBase):
|
||||
id: int
|
||||
owner: OwnerRead | None = None
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
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()
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
33
frontend/app/composables/useOwners.ts
Normal file
33
frontend/app/composables/useOwners.ts
Normal 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
|
||||
}
|
||||
}
|
||||
33
frontend/app/composables/useServers.ts
Normal file
33
frontend/app/composables/useServers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||
<header class="p-4 bg-white shadow">
|
||||
<nav class="flex gap-4">
|
||||
<div>
|
||||
<header class="p-4 bg-white shadow flex justify-between">
|
||||
<nav class="flex gap-4 items-center">
|
||||
<NuxtLink to="/">Dashboard</NuxtLink>
|
||||
<NuxtLink to="/owners">Owners</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>
|
||||
|
||||
|
||||
<div>
|
||||
<button v-if="token" @click="doLogout" class="text-red-600 hover:underline">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="p-6">
|
||||
|
|
@ -14,4 +50,3 @@
|
|||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
15
frontend/app/middleware/auth.global.ts
Normal file
15
frontend/app/middleware/auth.global.ts
Normal 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")
|
||||
}
|
||||
})
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
|
||||
96
frontend/app/pages/owners/index.vue
Normal file
96
frontend/app/pages/owners/index.vue
Normal 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>
|
||||
234
frontend/app/pages/servers/index.vue
Normal file
234
frontend/app/pages/servers/index.vue
Normal 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>
|
||||
149
frontend/app/pages/workentries/create.vue
Normal file
149
frontend/app/pages/workentries/create.vue
Normal 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>
|
||||
147
frontend/app/pages/workentries/search.vue
Normal file
147
frontend/app/pages/workentries/search.vue
Normal 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>
|
||||
3
frontend/app/utils/auth.ts
Normal file
3
frontend/app/utils/auth.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isUnauthorized(result: any) {
|
||||
return result && result.unauthorized === true
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
modules: [ "@nuxtjs/tailwindcss" ],
|
||||
modules: [
|
||||
"@nuxtjs/tailwindcss",
|
||||
"@nuxt/icon"
|
||||
],
|
||||
srr: true,
|
||||
nitro: {
|
||||
preset: "static"
|
||||
|
|
|
|||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"name": "frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"nuxt": "^4.3.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^4.6.4"
|
||||
|
|
@ -28,6 +29,19 @@
|
|||
"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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
|
|
@ -992,6 +1006,47 @@
|
|||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
|
|
@ -1421,6 +1476,28 @@
|
|||
"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": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"nuxt": "^4.3.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^4.6.4"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue