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 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"}
|
||||
|
|
@ -8,3 +8,6 @@ playwright
|
|||
playwright-stealth
|
||||
apscheduler
|
||||
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="/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) */}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
};
|
||||
};
|
||||
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