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 ( +
+
+
+ TeeOff +
+

+ {step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"} +

+
+ {step === 1 ? ( + <> + setFormData({...formData, username: e.target.value})} required /> + setFormData({...formData, password: e.target.value})} required /> + + ) : ( +
+

Tast inn 6 siffer fra appen din

+ setFormData({...formData, code: e.target.value})} autoFocus required /> +
+ )} + {error &&
⚠️ {error}
} + +
+
+
+ ); +} \ 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%) */} +
+
+
+
+

Scraping Monitor

+

Sjekker status på {facilities.length} anlegg

+
+ +
+ +
+ + + + + + + + + + + {facilities.map((f: any) => ( + + + + + + + ))} + +
AnleggKonfigurasjonSiste SjekkStatus
+
{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