diff --git a/backend/app/auth.py b/backend/app/auth.py index 4fbe17b..15905e0 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index 17bf487..e530c49 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 ( diff --git a/backend/app/main.py b/backend/app/main.py index 4159e8d..d5b64ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ec80298..2d88580 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -21,6 +21,9 @@ class ServerBase(BaseModel): class ServerCreate(ServerBase): pass +class ServerUpdate(ServerBase): + pass + class ServerRead(ServerBase): id: int owner: OwnerRead | None = None diff --git a/frontend/app/app.vue b/frontend/app/app.vue index ea4ef5c..54b6347 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,3 +1,18 @@ + + + + + diff --git a/frontend/app/composables/useAPI.ts b/frontend/app/composables/useAPI.ts new file mode 100644 index 0000000..cb93681 --- /dev/null +++ b/frontend/app/composables/useAPI.ts @@ -0,0 +1,59 @@ +export function useApi() { + const config = useRuntimeConfig() + const { token, logout } = useAuth() + + async function apiFetch(url: string, options: any = {}) { + try { + return await $fetch(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 } +} diff --git a/frontend/app/composables/useAuth.ts b/frontend/app/composables/useAuth.ts index 3acb2c5..15e3cd2 100644 --- a/frontend/app/composables/useAuth.ts +++ b/frontend/app/composables/useAuth.ts @@ -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("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 } +} diff --git a/frontend/app/composables/useOwners.ts b/frontend/app/composables/useOwners.ts new file mode 100644 index 0000000..6af05a0 --- /dev/null +++ b/frontend/app/composables/useOwners.ts @@ -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 + } +} diff --git a/frontend/app/composables/useServers.ts b/frontend/app/composables/useServers.ts new file mode 100644 index 0000000..63408e9 --- /dev/null +++ b/frontend/app/composables/useServers.ts @@ -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 + } +} diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue index 1a115bb..54ae58a 100644 --- a/frontend/app/layouts/default.vue +++ b/frontend/app/layouts/default.vue @@ -1,12 +1,48 @@ + + - - - + + + Dashboard Owners Servers - Work Entries + + + + Work Entries + + + + + Neu erstellen + + + + Suchen + + + + + + + + Logout + + @@ -14,4 +50,3 @@ - diff --git a/frontend/app/middleware/auth.global.ts b/frontend/app/middleware/auth.global.ts new file mode 100644 index 0000000..2fcc50d --- /dev/null +++ b/frontend/app/middleware/auth.global.ts @@ -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") + } +}) diff --git a/frontend/app/pages/login.vue b/frontend/app/pages/login.vue new file mode 100644 index 0000000..de69707 --- /dev/null +++ b/frontend/app/pages/login.vue @@ -0,0 +1,64 @@ + + + + + Login + + + + + + + + Login + + + {{ error }} + + + diff --git a/frontend/app/pages/owners.vue b/frontend/app/pages/owners.vue deleted file mode 100644 index 1a6bf69..0000000 --- a/frontend/app/pages/owners.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Owners - - - - {{ o.name }} - - - - - diff --git a/frontend/app/pages/owners/index.vue b/frontend/app/pages/owners/index.vue new file mode 100644 index 0000000..d03b97c --- /dev/null +++ b/frontend/app/pages/owners/index.vue @@ -0,0 +1,96 @@ + + + + + Owner Verwaltung + + + Neuen Owner anlegen + + + + + + Speichern + + + + + + Owner Liste + + Lade Owner… + {{ error }} + + + + + ID + Name + + + + + {{ owner.id }} + {{ owner.name }} + + + + + + diff --git a/frontend/app/pages/servers/index.vue b/frontend/app/pages/servers/index.vue new file mode 100644 index 0000000..f4c51be --- /dev/null +++ b/frontend/app/pages/servers/index.vue @@ -0,0 +1,234 @@ + + + + + Server Verwaltung + + + + Server Liste + + Lade Server… + {{ error }} + + + + + ID + Name + Beschreibung + Eigentümer + + + + + {{ server.id }} + + + + + + {{ server.hostname }} + + + + + + + + + {{ server.description }} + + + + + + + Kein Owner + + {{ owner.name }} + + + + + {{ server.owner ? server.owner.name : "—" }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Neuen Server anlegen + + + + + + + + Kein Owner + + {{ owner.name }} + + + + + + Speichern + + + + + + diff --git a/frontend/app/pages/workentries/create.vue b/frontend/app/pages/workentries/create.vue new file mode 100644 index 0000000..c8e990d --- /dev/null +++ b/frontend/app/pages/workentries/create.vue @@ -0,0 +1,149 @@ + + + + + Neuen Worklog-Eintrag erstellen + + {{ error }} + + + + Eintrag erstellt + + Titel: {{ createdEntry.title }} + Beschreibung: {{ createdEntry.description || "—" }} + Dauer: {{ createdEntry.duration_minutes || "—" }} + + + Server: + + {{ s.hostname }} + + + + + Weiteren Eintrag erstellen + + + + + + + + + + + + + Server auswählen + + + + {{ server.hostname }} + + + + + + Speichern + + + + diff --git a/frontend/app/pages/workentries/search.vue b/frontend/app/pages/workentries/search.vue new file mode 100644 index 0000000..059742b --- /dev/null +++ b/frontend/app/pages/workentries/search.vue @@ -0,0 +1,147 @@ + + + + + Worklog-Suche + + + + Server auswählen… + + {{ s.hostname }} + + + + + + + Suchen + + + + + + Suchergebnisse + + Lade… + {{ error }} + + + + + Titel + Dauer + Erstellt am + Server + + + + + + {{ entry.title }} + {{ entry.duration_minutes ?? "—" }} + {{ new Date(entry.created_at).toLocaleString() }} + + + {{ s.hostname }} + + + + + + + Beschreibung + + {{ entry.description || "Keine Beschreibung vorhanden." }} + + + + + + + + + + Keine Einträge gefunden. + + + + diff --git a/frontend/app/utils/auth.ts b/frontend/app/utils/auth.ts new file mode 100644 index 0000000..a73d282 --- /dev/null +++ b/frontend/app/utils/auth.ts @@ -0,0 +1,3 @@ +export function isUnauthorized(result: any) { + return result && result.unauthorized === true +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 0ebc993..c22ab71 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -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" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a30548..d31a986 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e29a932..def62e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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"
{{ error }}
Titel: {{ createdEntry.title }}
Beschreibung: {{ createdEntry.description || "—" }}
Dauer: {{ createdEntry.duration_minutes || "—" }}
+ Server: + + {{ s.hostname }} + +
+ {{ entry.description || "Keine Beschreibung vorhanden." }} +