Før mandagens forsøk på å løse innlogging til kontrollpanelet

This commit is contained in:
Erol 2026-03-02 09:05:18 +01:00
parent c94665333a
commit ebd4e40a41
9 changed files with 399 additions and 58 deletions

44
backend/create_admin.py Normal file
View file

@ -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()

View file

@ -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)
return {"status": "healthy"}

View file

@ -8,3 +8,6 @@ playwright
playwright-stealth
apscheduler
python-dotenv
python-jose[cryptography]
passlib[bcrypt]
pyotp

View file

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
<div className="flex justify-center mb-10">
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
</div>
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
</h2>
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
{step === 1 ? (
<>
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData({...formData, username: e.target.value})} required />
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData({...formData, password: e.target.value})} required />
</>
) : (
<div className="space-y-4">
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
</div>
)}
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100"> {error}</div>}
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
</button>
</form>
</div>
</div>
);
}

View file

@ -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 <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return (
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
{/* SIDEBAR (22%) */}
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
<div className="text-white border-l-4 border-[#8bc34a] pl-4 py-1">Scraping Monitor</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Medlemskap</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Bildegalleri</div>
</nav>
<div className="mt-auto pt-10 border-t border-white/10">
<button onClick={() => window.location.href='/'} className="text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300">Logg ut</button>
</div>
</aside>
{/* HOVEDINNHOLD (78%) */}
<main className="lg:w-[78%] p-6 md:p-12">
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
<header className="flex justify-between items-center mb-12">
<div>
<h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status {facilities.length} anlegg</p>
</div>
<button className="bg-[#8bc34a] text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-105 transition-all">Kjør alle skrapere</button>
</header>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-6">Anlegg</th>
<th className="pb-6">Konfigurasjon</th>
<th className="pb-6">Siste Sjekk</th>
<th className="pb-6 text-right">Status</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-8">
<div className="font-black text-lg">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
<td className="py-8">
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
</td>
<td className="py-8 text-gray-400 font-mono text-xs">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td>
<td className="py-8 text-right">
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</div>
);
}

View file

@ -20,7 +20,7 @@ export default function Header() {
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
<Link href="/logg-inn" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-[#e64a19] transition-all">Admin</Link>
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
</nav>
{/* HAMBURGER (Mobil) */}

View file

@ -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<string, string[]> = {
"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"]
};
};

View file

@ -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*'],
};