From e5b76a7477ac1fc1059e26490d9a540099d2d838 Mon Sep 17 00:00:00 2001 From: Erol Date: Sat, 11 Apr 2026 09:54:54 +0200 Subject: [PATCH] Codex ferdig med ymse frontend --- .gitignore | 3 + backend/__pycache__/main.cpython-311.pyc | Bin 44533 -> 0 bytes backend/main.py | 119 +++- backend/requirements.txt | 3 +- frontend/src/app/admin/greenfee/page.tsx | 33 +- frontend/src/app/admin/medlemskap/page.tsx | 35 +- frontend/src/app/admin/page.tsx | 646 +++++++++++++++++- .../rediger/[slug]/EditFacilityClient.tsx | 93 ++- frontend/src/app/admin/vtg/page.tsx | 29 +- frontend/src/components/AdminMobileMenu.tsx | 141 ++++ frontend/src/components/Header.tsx | 4 +- .../src/components/ScrapeMethodSelect.tsx | 5 +- frontend/src/config/adminFetch.ts | 10 + 13 files changed, 1025 insertions(+), 96 deletions(-) create mode 100755 .gitignore delete mode 100644 backend/__pycache__/main.cpython-311.pyc create mode 100755 frontend/src/components/AdminMobileMenu.tsx create mode 100755 frontend/src/config/adminFetch.ts diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3bbe7b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc deleted file mode 100644 index 630d2597ca1ee8827bc66e967d77d71e69abb9e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44533 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 diff --git a/backend/main.py b/backend/main.py index 7fb9d7e..2393a5e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet". from fastapi import FastAPI, HTTPException, Response, Request, Query from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from contextlib import asynccontextmanager import asyncpg import json @@ -20,6 +21,8 @@ from datetime import datetime, date, timedelta from jose import jwt, JWTError from passlib.context import CryptContext from dotenv import load_dotenv +import qrcode +import qrcode.image.svg from pydantic import BaseModel from typing import Optional, List, Any @@ -40,6 +43,17 @@ ALGORITHM = "HS256" pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + +async def validate_admin_session_token(token: str) -> str: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if not username: + raise JWTError() + return username + except JWTError as exc: + raise HTTPException(status_code=401, detail="Ugyldig eller utløpt admin-sesjon") from exc + # --- PYDANTIC MODELLER --- class CourseStatusUpdate(BaseModel): id: int @@ -84,6 +98,10 @@ class VtgApproval(BaseModel): class BulkVtgRequest(BaseModel): approvals: List[VtgApproval] + + +class AdminPasswordConfirm(BaseModel): + password: str # --- FUNKSJONER --- def format_row(row): """ @@ -141,13 +159,23 @@ def format_row(row): return d -async def queue_scrape_job(job_type: str, facility_ids: List[int]): + +def generate_totp_qr_svg(provisioning_uri: str) -> str: + image = qrcode.make( + provisioning_uri, + image_factory=qrcode.image.svg.SvgPathImage, + box_size=8, + border=3, + ) + return image.to_string(encoding="unicode") + +async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None): if job_type not in SCRAPE_JOB_TYPES: raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}") if not facility_ids: raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") - job, was_created = await enqueue_scrape_job(app.state.pool, job_type, facility_ids) + job, was_created = await enqueue_scrape_job(app.state.pool, job_type, facility_ids, requested_by=requested_by) status = "queued" if was_created else "already_queued" message = ( f"{job_type.capitalize()}-skraping for {len(job['facility_ids'])} anlegg ble lagt i kø." @@ -197,6 +225,23 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def require_admin_session_for_admin_routes(request: Request, call_next): + if request.url.path.startswith("/api/admin"): + token = request.cookies.get("admin_session") + if not token: + return JSONResponse(status_code=401, content={"detail": "Admin-innlogging kreves"}) + + try: + username = await validate_admin_session_token(token) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + request.state.admin_username = username + + return await call_next(request) + # --- AUTH ENDPOINTS --- @app.post("/api/auth/login") @@ -268,6 +313,17 @@ async def verify_2fa(data: dict, response: Response): ) return {"status": "success"} +@app.post("/api/auth/logout") +async def logout(response: Response): + """Logger ut admin ved å slette sesjonscookien.""" + response.delete_cookie( + key="admin_session", + httponly=True, + samesite="lax", + secure=False, + ) + return {"status": "success"} + # --- DATA ENDPOINTS --- @app.get("/api/facilities") @@ -464,18 +520,61 @@ async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit: return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit) +@app.post("/api/admin/2fa/setup") +async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request): + """Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator.""" + username = getattr(http_request.state, "admin_username", None) + if not username: + raise HTTPException(status_code=401, detail="Admin-innlogging kreves") + + async with app.state.pool.acquire() as conn: + admin = await conn.fetchrow( + "SELECT username, email, password_hash, otp_secret FROM admins WHERE username = $1", + username, + ) + + if not admin: + raise HTTPException(status_code=404, detail="Admin-bruker ble ikke funnet") + + try: + password_is_valid = pwd_context.verify(request.password, admin["password_hash"]) + except Exception as exc: + raise HTTPException(status_code=500, detail="Kunne ikke verifisere passordet") from exc + + if not password_is_valid: + raise HTTPException(status_code=401, detail="Feil passord") + + if not admin["otp_secret"]: + raise HTTPException(status_code=400, detail="Denne kontoen har ikke 2FA-nøkkel ennå") + + issuer_name = "TeeOff.no" + account_name = admin["email"] or admin["username"] + provisioning_uri = pyotp.TOTP(admin["otp_secret"]).provisioning_uri( + name=account_name, + issuer_name=issuer_name, + ) + + return { + "issuer": issuer_name, + "account_name": account_name, + "otp_secret": admin["otp_secret"], + "provisioning_uri": provisioning_uri, + "qr_svg": generate_totp_qr_svg(provisioning_uri), + } + + @app.post("/api/admin/run-scraper") -async def run_scraper_endpoint(request: ScrapeRunRequest): +async def run_scraper_endpoint(request: ScrapeRunRequest, http_request: Request): """Legger banestatus-skraping i en persistent jobbkø.""" print(f"📡 API mottok forespørsel om å kjøre banestatus-skraping for IDer: {request.facility_ids}") - return await queue_scrape_job("banestatus", request.facility_ids) + return await queue_scrape_job("banestatus", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) @app.post("/api/admin/run-membership-scraper") -async def run_membership_scraper_endpoint(request: ScrapeRunRequest): +async def run_membership_scraper_endpoint(request: ScrapeRunRequest, http_request: Request): """Tar imot IDer for medlemskapsskraping og legger jobben i kø.""" print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}") - return await queue_scrape_job("medlemskap", request.facility_ids) + return await queue_scrape_job("medlemskap", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) @app.get("/api/health") async def health_check(): @@ -578,10 +677,10 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest): return {"status": "success"} @app.post("/api/admin/run-greenfee-scraper") -async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest): +async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, http_request: Request): """Tar imot IDer for greenfeeskraping og legger jobben i kø.""" print(f"📡 API mottok forespørsel om greenfee-skraping for IDer: {request.facility_ids}") - return await queue_scrape_job("greenfee", request.facility_ids) + return await queue_scrape_job("greenfee", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) # --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER --- @@ -617,10 +716,10 @@ async def approve_vtg_bulk(request: BulkVtgRequest): return {"status": "success"} @app.post("/api/admin/run-vtg-scraper") -async def run_vtg_scraper_endpoint(request: ScrapeRunRequest): +async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, http_request: Request): """Tar imot IDer for VTG-skraping og legger jobben i kø.""" print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}") - return await queue_scrape_job("vtg", request.facility_ids) + return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) if __name__ == "__main__": import uvicorn diff --git a/backend/requirements.txt b/backend/requirements.txt index 6204e3c..73796e3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,5 +11,6 @@ python-dotenv python-jose[cryptography] passlib[bcrypt] pyotp +qrcode google-genai -google-generativeai \ No newline at end of file +google-generativeai diff --git a/frontend/src/app/admin/greenfee/page.tsx b/frontend/src/app/admin/greenfee/page.tsx index ed8a782..82fb4cc 100644 --- a/frontend/src/app/admin/greenfee/page.tsx +++ b/frontend/src/app/admin/greenfee/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect } from 'react'; import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; +import AdminMobileMenu from "@/components/AdminMobileMenu"; import Link from 'next/link'; export default function GreenfeeWasher() { @@ -11,7 +13,7 @@ export default function GreenfeeWasher() { const fetchDrafts = () => { setLoading(true); - fetch(`${API_URL}/admin/greenfee/drafts`) + adminFetch(`${API_URL}/admin/greenfee/drafts`) .then(res => res.json()) .then(data => { const editableDrafts = data.map((f: any) => { @@ -87,7 +89,7 @@ export default function GreenfeeWasher() { setSaving(true); try { - const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, { + const res = await adminFetch(`${API_URL}/admin/greenfee/approve-bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approvals: toApprove }) @@ -110,13 +112,14 @@ export default function GreenfeeWasher() { return (
-
+ +
← Tilbake til oversikten

Greenfee-Vaskeriet

Sjekk at prisene gir mening før publisering.

-
@@ -133,14 +136,14 @@ export default function GreenfeeWasher() { Velg Alle
- {drafts.map(draft => ( -
+ {drafts.map((draft, index) => ( +
toggleOne(draft.id)} />
-
+ {draft.greenfee_draft?.ai_begrunnelse && ( @@ -172,12 +175,12 @@ export default function GreenfeeWasher() {

Nytt forslag å godkjenne:

{draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => ( -
- updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" /> - updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" /> - updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" /> - updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" /> - +
+ updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" /> + updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" /> + updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" /> + updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" /> +
))}
); -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/medlemskap/page.tsx b/frontend/src/app/admin/medlemskap/page.tsx index 7c16052..f413caa 100644 --- a/frontend/src/app/admin/medlemskap/page.tsx +++ b/frontend/src/app/admin/medlemskap/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect } from 'react'; import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; +import AdminMobileMenu from "@/components/AdminMobileMenu"; import Link from 'next/link'; export default function MembershipWasher() { @@ -11,7 +13,7 @@ export default function MembershipWasher() { const fetchDrafts = () => { setLoading(true); - fetch(`${API_URL}/admin/membership/drafts`) + adminFetch(`${API_URL}/admin/membership/drafts`) .then(res => res.json()) .then(data => { // Konverter innkommende drafts til editerbare felter lokalt @@ -61,7 +63,7 @@ export default function MembershipWasher() { setSaving(true); try { - const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, { + const res = await adminFetch(`${API_URL}/admin/membership/approve-bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approvals: toApprove }) @@ -84,7 +86,8 @@ export default function MembershipWasher() { return (
-
+ +
← Tilbake til oversikten

Medlemskaps-Vaskeriet

@@ -93,7 +96,7 @@ export default function MembershipWasher() { @@ -117,8 +120,8 @@ export default function MembershipWasher() { Velg Alle
- {drafts.map(draft => ( -
+ {drafts.map((draft, index) => ( +
{/* OPPDATERT: Navn + ID Badge */} -
-

+
+

{draft.name} ID: {draft.id}

- Sjekk Klubbens Nettside ↗ + Sjekk Klubbens Nettside ↗
{draft.membership_draft?.ai_begrunnelse && ( @@ -149,9 +152,9 @@ export default function MembershipWasher() { {/* Standard */}

Standard Medlemskap (Ubegrenset)

-
- updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" /> - updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" /> +
+ updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" /> + updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />

Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}

@@ -160,9 +163,9 @@ export default function MembershipWasher() { {/* Rimeligste */}

Rimeligste (Betaler Greenfee)

-
- updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" /> - updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" /> +
+ updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" /> + updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />

Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}

@@ -176,4 +179,4 @@ export default function MembershipWasher() {
); -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 51d3a5f..7a8286b 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; import Link from 'next/link'; @@ -25,6 +26,14 @@ type ScrapeJob = { finished_at?: string | null; }; +type TwoFactorSetupResponse = { + issuer: string; + account_name: string; + otp_secret: string; + provisioning_uri: string; + qr_svg: string; +}; + const JOB_LABELS: Record = { banestatus: 'Banestatus', medlemskap: 'Medlemskap', @@ -79,12 +88,19 @@ export default function AdminDashboard() { const [scrapeJobs, setScrapeJobs] = useState([]); const [isQueueing, setIsQueueing] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false); const [editingFacility, setEditingFacility] = useState(null); const [activeTab, setActiveTab] = useState('banestatus'); const [statusFilter, setStatusFilter] = useState('alle'); const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] }); const [isSaving, setIsSaving] = useState(false); const latestJobStateRef = useRef(null); + const [showTwoFactorModal, setShowTwoFactorModal] = useState(false); + const [twoFactorPassword, setTwoFactorPassword] = useState(''); + const [twoFactorError, setTwoFactorError] = useState(''); + const [isLoadingTwoFactor, setIsLoadingTwoFactor] = useState(false); + const [twoFactorSetup, setTwoFactorSetup] = useState(null); + const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null); const fetchFacilities = () => { fetch(`${API_URL}/facilities`) @@ -97,7 +113,7 @@ export default function AdminDashboard() { }; const fetchScrapeJobs = (tab: AdminTab = activeTab) => { - fetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`) + adminFetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`) .then(res => res.json()) .then(data => { setScrapeJobs(Array.isArray(data) ? data : []); @@ -149,6 +165,32 @@ export default function AdminDashboard() { } }, [activeTab, latestJob]); + useEffect(() => { + if (!showTwoFactorModal) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeTwoFactorModal(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showTwoFactorModal]); + + useEffect(() => { + if (!showMobileAdminMenu) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowMobileAdminMenu(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showMobileAdminMenu]); + const filteredFacilities = useMemo(() => { if (statusFilter === 'alle') return facilities; return facilities.map(facility => { @@ -187,7 +229,7 @@ export default function AdminDashboard() { const handleQuickEdit = async (id: number, field: string, value: string) => { setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f)); try { - const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, { + const res = await adminFetch(`${API_URL}/admin/facilities/${id}/quick-edit`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field, value }) @@ -207,7 +249,7 @@ export default function AdminDashboard() { activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' : '/admin/run-vtg-scraper'; try { - const response = await fetch(`${API_URL}${endpoint}`, { + const response = await adminFetch(`${API_URL}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ facility_ids: selectedFacilities }) @@ -237,7 +279,7 @@ export default function AdminDashboard() { const handleSaveEdit = async () => { setIsSaving(true); try { - const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, { + const response = await adminFetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editForm) @@ -250,10 +292,72 @@ export default function AdminDashboard() { } finally { setIsSaving(false); } }; + const openTwoFactorModal = () => { + setShowTwoFactorModal(true); + setTwoFactorPassword(''); + setTwoFactorError(''); + setTwoFactorSetup(null); + setCopiedTwoFactorField(null); + }; + + const closeTwoFactorModal = () => { + setShowTwoFactorModal(false); + setTwoFactorPassword(''); + setTwoFactorError(''); + setTwoFactorSetup(null); + setCopiedTwoFactorField(null); + }; + + const handleLoadTwoFactorSetup = async (event: React.FormEvent) => { + event.preventDefault(); + setTwoFactorError(''); + setIsLoadingTwoFactor(true); + setCopiedTwoFactorField(null); + + try { + const response = await adminFetch(`${API_URL}/admin/2fa/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: twoFactorPassword }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente 2FA-oppsett'); + setTwoFactorSetup(data); + setTwoFactorPassword(''); + } catch (error) { + if (error instanceof Error && error.message === 'ADMIN_UNAUTHORIZED') return; + setTwoFactorSetup(null); + setTwoFactorError(error instanceof Error ? error.message : 'Kunne ikke hente 2FA-oppsett'); + } finally { + setIsLoadingTwoFactor(false); + } + }; + + const copyTwoFactorValue = async (field: 'secret' | 'uri', value: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedTwoFactorField(field); + window.setTimeout(() => setCopiedTwoFactorField(null), 2000); + } catch { + setCopiedTwoFactorField(null); + } + }; + + const handleLogout = async () => { + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include' + }); + } finally { + window.location.href = '/'; + } + }; + if (loading) return
LASTER KONTROLLPANEL...
; return ( -
+
{/* REDIGER-MODAL FOR BANESTATUS */} {editingFacility && ( @@ -323,6 +427,216 @@ export default function AdminDashboard() {
)} + {showTwoFactorModal && ( +
{ + if (event.target === event.currentTarget) { + closeTwoFactorModal(); + } + }} + > +
+ + +
+
+

1Password og TOTP

+

Sett opp 2FA i 1Password

+

+ Bekreft passordet ditt på nytt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan brukes i 1Password. +

+
+ +
+ +
+ + +
+
+
+

Sikker bekreftelse

+

Vis QR-kode

+
+ +
+ + + {twoFactorError && ( +
+ {twoFactorError} +
+ )} + + +
+
+ +
+
+

2FA-oppsett

+

QR-kode og nøkkel

+
+ + {!twoFactorSetup ? ( +
+
+

Ingen QR-kode vist ennå

+

+ Bekreft passordet ditt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan legges inn i 1Password. +

+
+
+ ) : ( +
+
+
+

+ Skann i 1Password +

+
+ +
+
+

Konto

+

{twoFactorSetup.account_name}

+

Issuer: {twoFactorSetup.issuer}

+
+ +
+

Manuell oppsettsnøkkel

+

{twoFactorSetup.otp_secret}

+ +
+ +
+

Avansert URI

+

{twoFactorSetup.provisioning_uri}

+ +
+
+
+ )} +
+
+
+
+
+ )} + + {showMobileAdminMenu && ( +
{ + if (event.target === event.currentTarget) { + setShowMobileAdminMenu(false); + } + }} + > +
+
+
+

Adminmeny

+

TeeOff Admin

+
+ +
+ + + +
+ +
+
+
+ )} + {/* SIDEBAR */} {/* HOVEDINNHOLD */} -
+
+
+ +
+

Kontrollpanel

Oversikt over {filteredFacilities.length} anlegg

- +
+ + +
{latestJob && latestJob.job_type === activeTab && ( @@ -424,7 +766,7 @@ export default function AdminDashboard() { )} {/* VELDIG SYNLIGE FANER */} -
+
@@ -444,13 +786,263 @@ export default function AdminDashboard() {
)} -
- +
+
+

Arbeidsvisning

+

+ Hvert anlegg vises som et eget arbeidskort, slik at du ser innhold, status og handlinger samlet uten sideveis scrolling. +

+
+ +
+ +
+ {filteredFacilities.map((f: any, index: number) => { + const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0; + const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0; + const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0; + const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft); + const accentStyles = [ + 'bg-white border-gray-100', + 'bg-[#fbfdf8] border-[#e3edd7]', + 'bg-[#f8fbff] border-[#dbe7f5]', + ]; + const accentStyle = isHighlighted ? 'bg-[#f3f9ea] border-[#b9d88d]' : accentStyles[index % accentStyles.length]; + + return ( +
+
+
+
+ handleSelectOne(f.id, e.target.checked)} + /> +
+
+

{f.name}

+ ID {f.id} + {isHighlighted && ( + + Trenger oppmerksomhet + + )} +
+
+ {f.city || 'Ukjent sted'} + {activeTab === 'banestatus' && ( + {f.status_updated_at ? `Sjekket ${new Date(f.status_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri sjekket'} + )} + {activeTab === 'medlemskap' && ( + {f.membership_updated_at ? `Vasket ${new Date(f.membership_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'} + )} + {activeTab === 'greenfee' && ( + {f.greenfee_updated_at ? `Vasket ${new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'} + )} + {activeTab === 'vtg' && ( + {f.vtg_updated_at ? `Vasket ${new Date(f.vtg_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'} + )} +
+
+
+ +
+ {activeTab === 'banestatus' && ( + + )} + {activeTab === 'medlemskap' && hasMemDraft && ( + + Til vaskeri + + )} + {activeTab === 'greenfee' && hasGfDraft && ( + + Til vaskeri + + )} + {activeTab === 'vtg' && hasVtgDraft && ( + + Til vaskeri + + )} + + Rediger alt + +
+
+ + {activeTab === 'banestatus' && ( +
+
+

Kilde og metode

+
+
+ +

{f.scrape_status_selector || 'Ingen selector lagret'}

+
+
+

Metode

+ +
+
+
+ +
+

Banestatus

+
+ {f.course_statuses && f.course_statuses.length > 0 ? f.course_statuses.map((cs: any, idx: number) => { + let badgeColor = "bg-gray-100 text-gray-500"; + if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700"; + if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700"; + if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700"; + return ( +
+ {cs.name} + {cs.status || 'UKJENT'} +
+ ); + }) : ( +

Ingen baner registrert.

+ )} +
+
+
+ )} + + {activeTab === 'medlemskap' && ( +
+
+

Kilde

+
+ +
+
+
+

Aktuelle priser

+
+
+ Standard + {f.standard_medlemskap ? `${f.standard_medlemskap},-` : 'Ikke registrert'} +
+
+ Rimeligste + {f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : 'Ikke registrert'} +
+
+
+
+

Status

+
+ + {hasMemDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'} + +
+
+
+ )} + + {activeTab === 'greenfee' && ( +
+
+

Kilde

+
+ +
+
+
+

Aktive priser

+
+ {f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => ( +
+
+

{g.banenavn || 'Uten banenavn'}

+

{g.priskategori || 'Standard'}

+
+

V: {g.pris_voksne || '-'} J: {g.pris_junior || '-'}

+
+ )) : ( +

Ingen priser registrert.

+ )} +
+
+
+

Status

+
+ + {hasGfDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'} + +
+
+
+ )} + + {activeTab === 'vtg' && ( +
+
+

Kilde

+
+ +
+
+
+

Registrert informasjon

+
+
+ Pris + {f.vtg_pris ? `${f.vtg_pris},-` : 'Ikke registrert'} +
+
+ Beskrivelse + {f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'} +
+
+ {f.vtg_datoer && f.vtg_datoer.length > 0 ? `${f.vtg_datoer.length} kursdatoer` : 'Ingen kursdatoer'} +
+
+
+
+

Status

+
+ + {hasVtgDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'} + +
+
+
+ )} +
+
+ ); + })} +
+ +
+ Scroll sidelengs for flere kolonner + Venstre og høyre kant er låst +
+ +
+
- - - + + + {activeTab === 'banestatus' && ( <> @@ -484,10 +1076,10 @@ export default function AdminDashboard() { )} - + - + {filteredFacilities.map((f: any) => { const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0; @@ -497,9 +1089,9 @@ export default function AdminDashboard() { return ( - - - + + @@ -580,7 +1172,7 @@ export default function AdminDashboard() { )} -
0} onChange={handleSelectAll} />IDAnlegg 0} onChange={handleSelectAll} />IDAnleggSist VasketHandlingHandling
handleSelectOne(f.id, e.target.checked)} />#{f.id} + handleSelectOne(f.id, e.target.checked)} />#{f.id}
{f.name}
{f.city}
+
{activeTab === 'banestatus' && } {activeTab === 'medlemskap' && hasMemDraft && Gå til Vaskeri} diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index 3904d16..59f7d31 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { adminFetch } from "@/config/adminFetch"; +import AdminMobileMenu from "@/components/AdminMobileMenu"; // KOMPONENT 1: MultiSelect for samarbeidende klubber const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => { @@ -60,9 +62,9 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
{entries.map(([k, v]) => ( -
+
updateKey(k, e.target.value, v)} @@ -73,7 +75,7 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, value={String(v)} onChange={e => updateVal(k, e.target.value)} /> - +
))}
@@ -234,7 +236,79 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any ))}
-
+
+
+

Herrer

+
+ {activeKeys.map(k => ( +
+

{k}

+
+ updateTee('herrer', k, 'navn_utslag', e.target.value)} /> + updateTee('herrer', k, 'baneverdi', e.target.value)} /> + updateTee('herrer', k, 'slopeverdi', e.target.value)} /> +
+
+ ))} +
+
+ +
+

Damer

+
+ {activeKeys.map(k => ( +
+

{k}

+
+ updateTee('damer', k, 'navn_utslag_damer', e.target.value)} /> + updateTee('damer', k, 'baneverdi_damer', e.target.value)} /> + updateTee('damer', k, 'slopeverdi_damer', e.target.value)} /> +
+
+ ))} +
+
+
+ +
+
+
+

Hull for hull

+

Hvert hull er et eget kort med par, hcp og lengder per aktiv utslagskolonne.

+
+ {holes.length} hull +
+
+ {holes.map((h, idx) => ( +
+
+

Hull {h.hole_number}

+ {activeKeys.length} utslag +
+
+
+ + updateHole(idx, 'par', e.target.value)} /> +
+
+ + updateHole(idx, 'hcp_index', e.target.value)} /> +
+
+
+ {activeKeys.map(k => ( +
+ + updateHole(idx, 'lengths', e.target.value, k)} /> +
+ ))} +
+
+ ))} +
+
+ +
@@ -295,9 +369,9 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any
-
- - +
+ +
); @@ -326,7 +400,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini const handleSave = async () => { setSaving(true); try { - const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, { + const res = await adminFetch(`/api/admin/facilities/${initialData.id}/full`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) @@ -363,6 +437,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini return (
+
← Tilbake til oversikten @@ -634,4 +709,4 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
); -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/vtg/page.tsx b/frontend/src/app/admin/vtg/page.tsx index 11303a1..de98679 100644 --- a/frontend/src/app/admin/vtg/page.tsx +++ b/frontend/src/app/admin/vtg/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect } from 'react'; import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; +import AdminMobileMenu from "@/components/AdminMobileMenu"; import Link from 'next/link'; export default function VtgWasher() { @@ -11,7 +13,7 @@ export default function VtgWasher() { const fetchDrafts = () => { setLoading(true); - fetch(`${API_URL}/admin/vtg/drafts`) + adminFetch(`${API_URL}/admin/vtg/drafts`) .then(res => res.json()) .then(data => { const editableDrafts = data.map((f: any) => { @@ -94,7 +96,7 @@ export default function VtgWasher() { setSaving(true); try { - const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, { + const res = await adminFetch(`${API_URL}/admin/vtg/approve-bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approvals: toApprove }) @@ -117,13 +119,14 @@ export default function VtgWasher() { return (
-
+ +
← Tilbake til oversikten

VTG-Vaskeriet

Gå gjennom og godkjenn kursinformasjon for Veien til Golf.

-
@@ -140,14 +143,14 @@ export default function VtgWasher() { Velg Alle
- {drafts.map(draft => ( -
+ {drafts.map((draft, index) => ( +
toggleOne(draft.id)} />
-
+

{draft.name} ID: {draft.id}

- Sjekk Nettside ↗ + Sjekk Nettside ↗
{draft.vtg_draft?.ai_begrunnelse && ( @@ -178,15 +181,15 @@ export default function VtgWasher() {
Fant ingen spesifikke kursdatoer.
) : ( draft.edit_datoer.map((row: any, idx: number) => ( -
- updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" /> - updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" /> + - +
)) )} @@ -205,4 +208,4 @@ export default function VtgWasher() {
); -} \ No newline at end of file +} diff --git a/frontend/src/components/AdminMobileMenu.tsx b/frontend/src/components/AdminMobileMenu.tsx new file mode 100755 index 0000000..8752b3f --- /dev/null +++ b/frontend/src/components/AdminMobileMenu.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { API_URL } from "@/config/constants"; + +type AdminMobileMenuProps = { + onOpenTwoFactor?: () => void; +}; + +const NAV_ITEMS = [ + { href: '/admin', label: 'Kontrollpanel', match: (pathname: string) => pathname === '/admin' }, + { href: '/admin/medlemskap', label: 'Medlemskap', match: (pathname: string) => pathname.startsWith('/admin/medlemskap') }, + { href: '/admin/greenfee', label: 'Greenfee', match: (pathname: string) => pathname.startsWith('/admin/greenfee') }, + { href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') }, +]; + +export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProps) { + const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + const handleLogout = async () => { + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include' + }); + } finally { + window.location.href = '/'; + } + }; + + return ( + <> +
+ +
+ + {isOpen && ( +
{ + if (event.target === event.currentTarget) { + setIsOpen(false); + } + }} + > +
+
+
+

Adminmeny

+

TeeOff Admin

+
+ +
+ + + +
+ +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 48bf3de..9319346 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -20,7 +20,6 @@ export default function Header() { Finn Bane Medlemskap Om oss - Admin {/* HAMBURGER (Mobil) */} @@ -37,9 +36,8 @@ export default function Header() { setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap - setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn
)} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/ScrapeMethodSelect.tsx b/frontend/src/components/ScrapeMethodSelect.tsx index 8b93e85..0656438 100644 --- a/frontend/src/components/ScrapeMethodSelect.tsx +++ b/frontend/src/components/ScrapeMethodSelect.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from 'react'; +import { adminFetch } from "@/config/adminFetch"; // Tilpass interface til de dataene du allerede har i frontend interface Facility { @@ -23,7 +24,7 @@ export default function ScrapeMethodSelect({ facility }: { facility: Facility }) try { // Husk å endre URL-en hvis API-et ditt ligger på et annet domene - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, { + const response = await adminFetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -68,4 +69,4 @@ export default function ScrapeMethodSelect({ facility }: { facility: Facility }) ); -} \ No newline at end of file +} diff --git a/frontend/src/config/adminFetch.ts b/frontend/src/config/adminFetch.ts new file mode 100755 index 0000000..57e368c --- /dev/null +++ b/frontend/src/config/adminFetch.ts @@ -0,0 +1,10 @@ +export async function adminFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); + + if (response.status === 401 && typeof window !== 'undefined') { + window.location.href = '/admin/login'; + throw new Error('ADMIN_UNAUTHORIZED'); + } + + return response; +}