Før mandagens forsøk på å løse innlogging til kontrollpanelet
This commit is contained in:
parent
c94665333a
commit
ebd4e40a41
9 changed files with 399 additions and 58 deletions
Binary file not shown.
44
backend/create_admin.py
Normal file
44
backend/create_admin.py
Normal 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()
|
||||||
154
backend/main.py
154
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import json
|
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 ---
|
# --- 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):
|
def format_row(row):
|
||||||
"""
|
"""
|
||||||
Vasker data fra databasen:
|
Vasker data fra databasen:
|
||||||
1. Konverterer datoer til ISO-format.
|
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:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
|
|
||||||
# 1. Håndter dato- og tidsformater for JSON-serialisering
|
# 1. Datoer
|
||||||
for key in ['status_updated_at', 'created_at']:
|
for key in ['status_updated_at', 'created_at']:
|
||||||
if isinstance(d.get(key), (date, datetime)):
|
if isinstance(d.get(key), (date, datetime)):
|
||||||
d[key] = d[key].isoformat()
|
d[key] = d[key].isoformat()
|
||||||
|
|
||||||
# 2. Definer alle felter som inneholder JSON-data
|
# 2. JSON-felter (Lister)
|
||||||
# Disse må parses manuelt hvis de kommer som strenger fra Postgres
|
|
||||||
json_list_fields = [
|
json_list_fields = [
|
||||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
'course_statuses', 'courses', 'gallery', 'greenfee',
|
||||||
'faqs', 'shotzoom', 'social_links', 'holes'
|
'faqs', 'shotzoom', 'social_links', 'holes'
|
||||||
]
|
]
|
||||||
json_dict_fields = [
|
|
||||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Vask list-felter
|
|
||||||
for field in json_list_fields:
|
for field in json_list_fields:
|
||||||
if field in d:
|
if field in d:
|
||||||
val = d[field]
|
val = d[field]
|
||||||
if val is None:
|
if val is None: d[field] = []
|
||||||
d[field] = []
|
|
||||||
elif isinstance(val, str):
|
elif isinstance(val, str):
|
||||||
try:
|
try: d[field] = json.loads(val)
|
||||||
d[field] = json.loads(val)
|
except: d[field] = []
|
||||||
except:
|
elif not isinstance(val, list): d[field] = []
|
||||||
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:
|
for field in json_dict_fields:
|
||||||
if field in d:
|
if field in d:
|
||||||
val = d[field]
|
val = d[field]
|
||||||
if val is None:
|
if val is None: d[field] = {}
|
||||||
d[field] = {}
|
|
||||||
elif isinstance(val, str):
|
elif isinstance(val, str):
|
||||||
try:
|
try: d[field] = json.loads(val)
|
||||||
d[field] = json.loads(val)
|
except: d[field] = {}
|
||||||
except:
|
elif not isinstance(val, dict): d[field] = {}
|
||||||
d[field] = {}
|
|
||||||
elif not isinstance(val, dict):
|
|
||||||
d[field] = {}
|
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Opprett database-pool ved start
|
# Opprett database-pool
|
||||||
try:
|
try:
|
||||||
app.state.pool = await asyncpg.create_pool(
|
app.state.pool = await asyncpg.create_pool(
|
||||||
DB_URL,
|
DB_URL, min_size=5, max_size=20, command_timeout=60
|
||||||
min_size=5,
|
|
||||||
max_size=20,
|
|
||||||
command_timeout=60
|
|
||||||
)
|
)
|
||||||
print("✅ Database tilkoblet og pool opprettet")
|
print("✅ Database pool opprettet")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Databasefeil under oppstart: {e}")
|
print(f"❌ Databasefeil: {e}")
|
||||||
raise e
|
raise e
|
||||||
yield
|
yield
|
||||||
# Lukk pool ved avslutning
|
|
||||||
await app.state.pool.close()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=[
|
||||||
|
"https://nye.teeoff.no",
|
||||||
|
"http://nye.teeoff.no",
|
||||||
|
"http://localhost:3000"
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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")
|
@app.get("/api/facilities")
|
||||||
async def get_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:
|
async with app.state.pool.acquire() as conn:
|
||||||
rows = await conn.fetch("""
|
rows = await conn.fetch("""
|
||||||
SELECT f.*, (
|
SELECT f.*, (
|
||||||
|
|
@ -111,7 +167,7 @@ async def get_facilities():
|
||||||
|
|
||||||
@app.get("/api/facilities/{slug}")
|
@app.get("/api/facilities/{slug}")
|
||||||
async def get_facility(slug: str):
|
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:
|
async with app.state.pool.acquire() as conn:
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow("""
|
||||||
SELECT f.*, (
|
SELECT f.*, (
|
||||||
|
|
@ -130,20 +186,10 @@ async def get_facility(slug: str):
|
||||||
""", slug)
|
""", slug)
|
||||||
|
|
||||||
if not row:
|
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)
|
return format_row(row)
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Enkel sjekk for å se at API og DB lever"""
|
return {"status": "healthy"}
|
||||||
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)
|
|
||||||
|
|
@ -8,3 +8,6 @@ playwright
|
||||||
playwright-stealth
|
playwright-stealth
|
||||||
apscheduler
|
apscheduler
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
python-jose[cryptography]
|
||||||
|
passlib[bcrypt]
|
||||||
|
pyotp
|
||||||
102
frontend/src/app/admin/login/page.tsx
Normal file
102
frontend/src/app/admin/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/app/admin/page.tsx
Normal file
91
frontend/src/app/admin/page.tsx
Normal 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 på {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ export default function Header() {
|
||||||
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
||||||
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
||||||
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</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>
|
</nav>
|
||||||
|
|
||||||
{/* HAMBURGER (Mobil) */}
|
{/* HAMBURGER (Mobil) */}
|
||||||
|
|
|
||||||
|
|
@ -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 FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
|
||||||
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
|
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
|
||||||
|
|
||||||
|
|
|
||||||
36
frontend/src/middleware.ts
Normal file
36
frontend/src/middleware.ts
Normal 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*'],
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue