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 630d259..0000000 Binary files a/backend/__pycache__/main.cpython-311.pyc and /dev/null differ 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.name} ID: {draft.id}

- Sjekk Nettside ↗ + Sjekk Nettside ↗
{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; +}