From ebd4e40a414136dabbfaf18814db0e1177de82d7 Mon Sep 17 00:00:00 2001 From: Erol Date: Mon, 2 Mar 2026 09:05:18 +0100 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20mandagens=20fors=C3=B8k=20p=C3=A5=20?= =?UTF-8?q?=C3=A5=20l=C3=B8se=20innlogging=20til=20kontrollpanelet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/__pycache__/main.cpython-311.pyc | Bin 7814 -> 11659 bytes backend/create_admin.py | 44 +++++++ backend/main.py | 154 +++++++++++++++-------- backend/requirements.txt | 3 + frontend/src/app/admin/login/page.tsx | 102 +++++++++++++++ frontend/src/app/admin/page.tsx | 91 ++++++++++++++ frontend/src/components/Header.tsx | 2 +- frontend/src/config/constants.ts | 25 +++- frontend/src/middleware.ts | 36 ++++++ 9 files changed, 399 insertions(+), 58 deletions(-) create mode 100644 backend/create_admin.py create mode 100644 frontend/src/app/admin/login/page.tsx create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/middleware.ts diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 105c763d842569c4043ce85db90262b102ffdf4f..0cdd5f3aec057c109504798b6dba0a7fa4f5fa10 100644 GIT binary patch literal 11659 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 OT0I_d8-U;U-G2aUgJ>!M 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