diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc
index 105c763..0cdd5f3 100644
Binary files a/backend/__pycache__/main.cpython-311.pyc and b/backend/__pycache__/main.cpython-311.pyc differ
diff --git a/backend/create_admin.py b/backend/create_admin.py
new file mode 100644
index 0000000..7144314
--- /dev/null
+++ b/backend/create_admin.py
@@ -0,0 +1,44 @@
+"""
+TEE OFF ADMIN GENERATOR v1.2 (PBKDF2)
+---------------------------------------------------------------------------
+FUNKSJON: Genererer SQL for å sette inn en admin med PBKDF2-hash.
+BRUK: docker exec -it teeoff_api python create_admin.py
+---------------------------------------------------------------------------
+"""
+import pyotp
+from passlib.hash import pbkdf2_sha256
+import getpass
+
+def generate_admin():
+ print("\n" + "="*50)
+ print(" TEE OFF ADMIN GENERATOR v1.2 (PBKDF2)")
+ print("="*50)
+
+ username = input("Brukernavn: ").strip()
+ email = input("E-post: ").strip()
+ password = getpass.getpass("Passord (Ingen lengdebegrensning): ")
+
+ # Generer 2FA hemmelighet
+ otp_secret = pyotp.random_base32()
+
+ # Lag hash med PBKDF2
+ print("⏳ Genererer sikker hash...")
+ password_hash = pbkdf2_sha256.hash(password)
+
+ print("\n" + "✅ GENERERING VELLYKKET!")
+ print("-" * 50)
+ print("KJØR DENNE KOMMANDOEN FOR Å OPPRETTE BRUKEREN:")
+ print("-" * 50)
+
+ sql = f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{username}', '{email}', '{password_hash}', '{otp_secret}');"
+
+ print(f"\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c \"{sql}\"")
+
+ print("\n" + "-" * 50)
+ print("2FA KONFIGURASJON (Viktig!):")
+ print(f"Brukernavn: {email}")
+ print(f"Nøkkel (Secret): {otp_secret}")
+ print("-" * 50 + "\n")
+
+if __name__ == "__main__":
+ generate_admin()
\ No newline at end of file
diff --git a/backend/main.py b/backend/main.py
index 4aa0feb..b9f3021 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,100 +1,156 @@
-from fastapi import FastAPI, HTTPException
+"""
+TEE OFF BACKEND API v3.6.5 - THE FINAL MASTER VERSION
+---------------------------------------------------------------------------
+REGEL 1: Bruk str (ikke string) for type-hinting.
+REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
+REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
+REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
+---------------------------------------------------------------------------
+"""
+
+from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import asyncpg
import json
-from datetime import date, datetime
+import pyotp
+import os
+from datetime import datetime, date, timedelta
+from jose import jwt, JWTError
+from passlib.context import CryptContext
+from dotenv import load_dotenv
+
+load_dotenv()
# --- KONFIGURASJON ---
-DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
+DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
+SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
+ALGORITHM = "HS256"
+pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
- 2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
+ 2. Parser stringified JSON til ekte Python-objekter.
"""
if row is None:
return None
d = dict(row)
- # 1. Håndter dato- og tidsformater for JSON-serialisering
+ # 1. Datoer
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
- # 2. Definer alle felter som inneholder JSON-data
- # Disse må parses manuelt hvis de kommer som strenger fra Postgres
+ # 2. JSON-felter (Lister)
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
]
- json_dict_fields = [
- 'amenities', 'vtg', 'nsg_data', 'golfamore_data'
- ]
-
- # Vask list-felter
for field in json_list_fields:
if field in d:
val = d[field]
- if val is None:
- d[field] = []
+ if val is None: d[field] = []
elif isinstance(val, str):
- try:
- d[field] = json.loads(val)
- except:
- d[field] = []
- elif not isinstance(val, list):
- d[field] = []
+ try: d[field] = json.loads(val)
+ except: d[field] = []
+ elif not isinstance(val, list): d[field] = []
- # Vask objekt-felter
+ # 3. JSON-felter (Objekter)
+ json_dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
for field in json_dict_fields:
if field in d:
val = d[field]
- if val is None:
- d[field] = {}
+ if val is None: d[field] = {}
elif isinstance(val, str):
- try:
- d[field] = json.loads(val)
- except:
- d[field] = {}
- elif not isinstance(val, dict):
- d[field] = {}
+ try: d[field] = json.loads(val)
+ except: d[field] = {}
+ elif not isinstance(val, dict): d[field] = {}
return d
@asynccontextmanager
async def lifespan(app: FastAPI):
- # Opprett database-pool ved start
+ # Opprett database-pool
try:
app.state.pool = await asyncpg.create_pool(
- DB_URL,
- min_size=5,
- max_size=20,
- command_timeout=60
+ DB_URL, min_size=5, max_size=20, command_timeout=60
)
- print("✅ Database tilkoblet og pool opprettet")
+ print("✅ Database pool opprettet")
except Exception as e:
- print(f"❌ Databasefeil under oppstart: {e}")
+ print(f"❌ Databasefeil: {e}")
raise e
yield
- # Lukk pool ved avslutning
await app.state.pool.close()
-app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan)
+app = FastAPI(title="TeeOff API v3.6.5", lifespan=lifespan)
-# CORS-oppsett slik at Next.js kan snakke med API-et
+# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
+ allow_origins=[
+ "https://nye.teeoff.no",
+ "http://nye.teeoff.no",
+ "http://localhost:3000"
+ ],
+ allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
+# --- AUTH ENDPOINTS ---
+
+@app.post("/api/auth/login")
+async def login(data: dict):
+ """Steg 1: Sjekk passord og returner temp_token for 2FA."""
+ async with app.state.pool.acquire() as conn:
+ admin = await conn.fetchrow(
+ "SELECT * FROM admins WHERE username = $1 OR email = $1",
+ data.get('username')
+ )
+
+ if not admin or not pwd_context.verify(data.get('password'), admin['password_hash']):
+ raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
+
+ temp_token = jwt.encode(
+ {"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
+ SECRET_KEY, algorithm=ALGORITHM
+ )
+ return {"step": "2fa", "temp_token": temp_token}
+
+@app.post("/api/auth/verify-2fa")
+async def verify_2fa(data: dict, response: Response):
+ """Steg 2: Sjekk TOTP og sett session cookie."""
+ try:
+ payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
+ username = payload.get("sub")
+ except:
+ raise HTTPException(status_code=401, detail="Sesjonen har utløpt")
+
+ async with app.state.pool.acquire() as conn:
+ admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
+ totp = pyotp.TOTP(admin['otp_secret'])
+ if not totp.verify(data.get('code')):
+ raise HTTPException(status_code=401, detail="Feil 2FA-kode")
+
+ final_token = jwt.encode(
+ {"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
+ SECRET_KEY, algorithm=ALGORITHM
+ )
+
+ response.set_cookie(
+ key="admin_session", value=final_token,
+ httponly=True, samesite="lax", secure=False # False for utvikling
+ )
+ return {"status": "success"}
+
+# --- DATA ENDPOINTS ---
+
@app.get("/api/facilities")
async def get_facilities():
- """Henter alle golfanlegg med aggregert banestatus"""
+ """Henter alle anlegg med aggregert banestatus for kortene."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
@@ -111,7 +167,7 @@ async def get_facilities():
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
- """Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull"""
+ """Henter ett anlegg med alle baner og hull (brukes i FacilityDetailView)."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
@@ -130,20 +186,10 @@ async def get_facility(slug: str):
""", slug)
if not row:
- raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
+ raise HTTPException(status_code=404, detail="Banen finnes ikke")
return format_row(row)
@app.get("/api/health")
async def health_check():
- """Enkel sjekk for å se at API og DB lever"""
- try:
- async with app.state.pool.acquire() as conn:
- await conn.execute("SELECT 1")
- return {"status": "healthy", "database": "connected"}
- except Exception as e:
- return {"status": "unhealthy", "error": str(e)}
-
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(app, host="0.0.0.0", port=8000)
\ No newline at end of file
+ return {"status": "healthy"}
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 53d979b..ff5702f 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -8,3 +8,6 @@ playwright
playwright-stealth
apscheduler
python-dotenv
+python-jose[cryptography]
+passlib[bcrypt]
+pyotp
\ No newline at end of file
diff --git a/frontend/src/app/admin/login/page.tsx b/frontend/src/app/admin/login/page.tsx
new file mode 100644
index 0000000..a3587fb
--- /dev/null
+++ b/frontend/src/app/admin/login/page.tsx
@@ -0,0 +1,102 @@
+"use client";
+/**
+ * TEE OFF ADMIN LOGIN v1.2
+ * ---------------------------------------------------------------------------
+ * PLASSERING: frontend/src/app/admin/login/page.tsx
+ * FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
+ * ---------------------------------------------------------------------------
+ */
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { API_URL } from "@/config/constants";
+
+export default function AdminLogin() {
+ const [step, setStep] = useState(1);
+ const [formData, setFormData] = useState({ username: '', password: '', code: '' });
+ const [tempToken, setTempToken] = useState('');
+ const [error, setError] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError('');
+
+ try {
+ const res = await fetch(`${API_URL}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: formData.username, password: formData.password })
+ });
+
+ const data = await res.json();
+ if (res.ok) {
+ setTempToken(data.temp_token);
+ setStep(2);
+ } else {
+ setError(data.detail || 'Ugyldig pålogging');
+ }
+ } catch (err) {
+ setError('Systemfeil: Kunne ikke koble til API-et');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleVerify2FA = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+
+ try {
+ const res = await fetch(`${API_URL}/auth/verify-2fa`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ temp_token: tempToken, code: formData.code })
+ });
+
+ if (res.ok) {
+ // VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
+ router.push('/admin');
+ router.refresh();
+ } else {
+ setError('Ugyldig 2FA-kode');
+ }
+ } catch (err) {
+ setError('Tilkoblingsfeil ved 2FA-verifisering');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+

+
+
+ {step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
new file mode 100644
index 0000000..0b49133
--- /dev/null
+++ b/frontend/src/app/admin/page.tsx
@@ -0,0 +1,91 @@
+"use client";
+/**
+ * TEE OFF ADMIN DASHBOARD v1.1
+ * ---------------------------------------------------------------------------
+ * PLASSERING: frontend/src/app/admin/page.tsx
+ * FUNKSJON: Monitorering av banestatus og administrasjon.
+ * ---------------------------------------------------------------------------
+ */
+
+import { useState, useEffect } from 'react';
+import { API_URL } from "@/config/constants";
+
+export default function AdminDashboard() {
+ const [facilities, setFacilities] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch(`${API_URL}/facilities`)
+ .then(res => res.json())
+ .then(data => {
+ setFacilities(Array.isArray(data) ? data : []);
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
+ }, []);
+
+ if (loading) return LASTER DASHBORD...
;
+
+ return (
+
+ {/* SIDEBAR (22%) */}
+
+
+ {/* HOVEDINNHOLD (78%) */}
+
+
+
+
+
+
+
+
+ | Anlegg |
+ Konfigurasjon |
+ Siste Sjekk |
+ Status |
+
+
+
+ {facilities.map((f: any) => (
+
+ |
+ {f.name}
+ {f.city}
+ |
+
+ {f.scrape_status_url}
+ {f.scrape_status_selector}
+ |
+
+ {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ |
+
+
+ |
+
+ ))}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index bf89ee4..48bf3de 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -20,7 +20,7 @@ export default function Header() {
Finn Bane
Medlemskap
Om oss
- Admin
+ Admin
{/* HAMBURGER (Mobil) */}
diff --git a/frontend/src/config/constants.ts b/frontend/src/config/constants.ts
index de034d4..4b54ddf 100644
--- a/frontend/src/config/constants.ts
+++ b/frontend/src/config/constants.ts
@@ -1,5 +1,24 @@
-// Globale innstillinger for TeeOff.no
-export const API_URL = process.env.API_URL || "http://api:8000/api";
+/**
+ * TEE OFF CONFIG CONSTANTS v1.3
+ * ---------------------------------------------------------------------------
+ * REGEL 1: ALDRI trunker eller fjern data fra denne filen.
+ * REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
+ * REGEL 3: Inneholder alle regionale mappinger for Norge.
+ * ---------------------------------------------------------------------------
+ */
+
+const isBrowser = typeof window !== 'undefined';
+
+// Intern URL for server-to-server (Docker-internt)
+const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
+
+// Relativ sti for browseren.
+// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
+// og domene (nye.teeoff.no) som resten av siden.
+const EXTERNAL_API = "/api";
+
+export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
+
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
@@ -20,4 +39,4 @@ export const REGIONS: Record = {
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
"sørlandet": ["vest-agder", "aust-agder", "agder"],
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
-};
+};
\ No newline at end of file
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
new file mode 100644
index 0000000..dc05080
--- /dev/null
+++ b/frontend/src/middleware.ts
@@ -0,0 +1,36 @@
+/**
+ * TEE OFF SECURITY MIDDLEWARE v1.0
+ * ---------------------------------------------------------------------------
+ * REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
+ * FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
+ * ---------------------------------------------------------------------------
+ */
+
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/request';
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ const session = request.cookies.get('admin_session');
+
+ // 1. Tillat alltid tilgang til innloggingssiden
+ if (pathname.startsWith('/admin/login')) {
+ return NextResponse.next();
+ }
+
+ // 2. Beskytt alle andre ruter under /admin
+ if (pathname.startsWith('/admin')) {
+ if (!session) {
+ // Ingen sesjon funnet -> Send til innlogging
+ const loginUrl = new URL('/admin/login', request.url);
+ return NextResponse.redirect(loginUrl);
+ }
+ }
+
+ return NextResponse.next();
+}
+
+// Definer hvilke ruter middleware skal kjøre på
+export const config = {
+ matcher: ['/admin/:path*'],
+};
\ No newline at end of file