Add basic FastAPI auth and React login gate
CI / basic-check (push) Has been cancelled

This commit is contained in:
Perplexity Bot
2026-08-04 20:21:34 +00:00
parent 998c5246dd
commit 8aae681706
9 changed files with 139 additions and 456 deletions
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
SECRET_KEY = 'change-me-in-production'
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 120
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/auth/token')
class Token(BaseModel): access_token: str; token_type: str = 'bearer'
class TokenData(BaseModel): username: Optional[str] = None
class User(BaseModel): username: str; disabled: bool = False; role: str = 'viewer'
class UserInDB(User): hashed_password: str
fake_users_db = {'admin': {'username': 'admin', 'hashed_password': pwd_context.hash('admin123'), 'disabled': False, 'role': 'admin'}, 'viewer': {'username': 'viewer', 'hashed_password': pwd_context.hash('viewer123'), 'disabled': False, 'role': 'viewer'}}
def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password)
def get_user(username: str) -> Optional[UserInDB]:
user = fake_users_db.get(username)
return UserInDB(**user) if user else None
def authenticate_user(username: str, password: str) -> Optional[UserInDB]:
user = get_user(username)
return user if user and verify_password(password, user.hashed_password) else None
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({'exp': expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Could not validate credentials', headers={'WWW-Authenticate': 'Bearer'})
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get('sub')
if username is None: raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user(username)
if user is None: raise credentials_exception
return User(username=user.username, disabled=user.disabled, role=user.role)
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.disabled: raise HTTPException(status_code=400, detail='Inactive user')
return current_user
+6 -23
View File
@@ -1,26 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from app.routes.auth import router as auth_router
from app.routers.discoveries import router as discoveries_router app = FastAPI(title='NetMapper API')
from app.routers.health import router as health_router app.include_router(auth_router)
from app.routers.inventory import router as inventory_router
from app.routers.jobs import router as jobs_router
from app.routers.objects import router as objects_router
from app.routers.relations import router as relations_router
app = FastAPI(title='NetMapper Backend', version='0.1.0') @app.get('/health')
async def health():
app.add_middleware( return {'status': 'ok'}
CORSMiddleware,
allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.include_router(health_router)
app.include_router(objects_router)
app.include_router(jobs_router)
app.include_router(discoveries_router)
app.include_router(inventory_router)
app.include_router(relations_router)
View File
+14
View File
@@ -0,0 +1,14 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import ACCESS_TOKEN_EXPIRE_MINUTES, Token, authenticate_user, create_access_token, get_current_active_user
router = APIRouter(prefix='/auth', tags=['auth'])
@router.post('/token', response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(form_data.username, form_data.password)
if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Incorrect username or password')
access_token = create_access_token({'sub': user.username, 'role': user.role}, timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
return Token(access_token=access_token)
@router.get('/me')
async def me(current_user=Depends(get_current_active_user)):
return {'username': current_user.username, 'role': current_user.role, 'disabled': current_user.disabled}
+5 -6
View File
@@ -1,6 +1,5 @@
fastapi==0.115.0 fastapi
uvicorn[standard]==0.30.6 uvicorn[standard]
pydantic==2.9.2 python-jose[cryptography]
sqlalchemy==2.0.35 passlib[bcrypt]
psycopg[binary]==3.2.3 python-multipart
redis==5.1.1
+11 -220
View File
@@ -1,229 +1,20 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { InventoryPage } from './pages/InventoryPage'; import { InventoryPage } from './pages/InventoryPage';
import { LoginPage } from './pages/LoginPage';
import './app-shell.css'; import './app-shell.css';
type TabKey = 'overview' | 'inventory' | 'discovery' | 'topology' | 'options'; type TabKey = 'overview' | 'inventory' | 'discovery' | 'topology' | 'options';
const tabs = [
const tabs: { key: TabKey; label: string; description: string }[] = [
{ key: 'overview', label: 'Overview', description: 'Stato generale del workspace NetMapper.' }, { key: 'overview', label: 'Overview', description: 'Stato generale del workspace NetMapper.' },
{ key: 'inventory', label: 'Inventory', description: 'Tree fisico/logico e navigazione tra oggetti.' }, { key: 'inventory', label: 'Inventory', description: 'Tree fisico/logico e navigazione tra oggetti.' },
{ key: 'discovery', label: 'Discovery', description: 'Pipeline e job di raccolta dati.' }, { key: 'discovery', label: 'Discovery', description: 'Pipeline e job di raccolta dati.' },
{ key: 'topology', label: 'Topology', description: 'Vista futura della mappa multilivello.' }, { key: 'topology', label: 'Topology', description: 'Vista futura della mappa multilivello.' },
{ key: 'options', label: 'Options', description: 'Utenti, impostazioni, servizi di sistema e sicurezza.' }, { key: 'options', label: 'Options', description: 'Utenti, impostazioni, servizi di sistema e sicurezza.' },
]; ] as const;
function ShellCard({ title, children, tone = 'default' }: { title: string; children: React.ReactNode; tone?: 'default' | 'accent' | 'warning' }) { return <section className={`shell-card ${tone}`}><div className="shell-card-header"><h3>{title}</h3></div><div className="shell-card-body">{children}</div></section>; }
function ShellCard({ title, children, tone = 'default' }: { title: string; children: React.ReactNode; tone?: 'default' | 'accent' | 'warning' }) { function OverviewTab(){ return <div className="shell-grid"><ShellCard title="Workspace status" tone="accent"><ul className="shell-list"><li>Frontend inventory collegato al backend.</li><li>Tree gerarchico e context panel attivi.</li><li>Navigazione tra parent, children e relazioni disponibile.</li></ul></ShellCard><ShellCard title="Milestone attive"><ul className="shell-list"><li>Reintegro layout persistente completato.</li><li>Roadmap di security e options introdotta.</li><li>Preparazione vista topology multilivello.</li></ul></ShellCard><ShellCard title="Quick notes" tone="warning"><ul className="shell-list"><li>I moduli servizi confluiranno nel menu Options.</li><li>L'accesso dovrà essere autenticato prima delle integrazioni remote.</li><li>Serve hardening progressivo di sessione e permessi.</li></ul></ShellCard></div>; }
return ( function DiscoveryTab(){ return <div className="shell-grid two-columns"><ShellCard title="Discovery pipeline"><ul className="shell-list"><li>Seed scan subnet.</li><li>Classificazione host e object binding.</li><li>Correlazione con relazioni inventory.</li></ul></ShellCard><ShellCard title="Job queue"><ul className="shell-list"><li>SNMP collector.</li><li>LLDP ingestion.</li><li>Port mapping inference.</li></ul></ShellCard></div>; }
<section className={`shell-card ${tone}`}> function TopologyTab(){ return <div className="shell-grid two-columns"><ShellCard title="Topology roadmap"><ul className="shell-list"><li>Vista fisica dispositivi.</li><li>Vista logica host / VM / container / app.</li><li>Layer networking e dipendenze.</li></ul></ShellCard><ShellCard title="Planned interactions"><ul className="shell-list"><li>Drag & drop dei contenitori.</li><li>Link fisici e logici cliccabili.</li><li>Focus node e highlight dei vicini.</li></ul></ShellCard></div>; }
<div className="shell-card-header"> function OptionsTab(){ return <div className="shell-stack"><div className="shell-grid two-columns"><ShellCard title="User management" tone="accent"><ul className="shell-list"><li>Utenti locali e ruoli applicativi.</li><li>Disattivazione account inattivi o non più autorizzati.</li><li>Storico accessi e audit trail amministrativo.</li></ul></ShellCard><ShellCard title="System settings"><ul className="shell-list"><li>Parametri workspace e branding dell'istanza.</li><li>Configurazione discovery, inventory e policy future.</li><li>Feature flags per moduli sperimentali.</li></ul></ShellCard></div><div className="shell-grid two-columns"><ShellCard title="Services backlog"><ul className="shell-list"><li>La sezione Servizi viene assorbita nel menu Options.</li><li>Qui confluiranno terminale remoto, job runner e integrazioni di sistema.</li><li>Ogni funzione sensibile dovrà essere protetta da permessi espliciti.</li></ul></ShellCard><ShellCard title="Security roadmap" tone="warning"><ul className="shell-list"><li>Login obbligatorio per tutte le viste non pubbliche.</li><li>MFA per admin e per funzioni ad alto impatto.</li><li>Controlli server-side su ogni richiesta, deny-by-default e audit centralizzato.</li></ul></ShellCard></div></div>; }
<h3>{title}</h3> function AppShell(){ const [activeTab,setActiveTab]=useState<TabKey>('inventory'); const currentTab=tabs.find((t)=>t.key===activeTab)!; return <div className="app-shell"><aside className="app-sidebar"><div className="app-brand"><div className="app-brand-mark">NM</div><div><h1>NetMapper</h1><p>Homelab workspace</p></div></div><nav className="app-nav">{tabs.map((tab)=><button key={tab.key} className={`app-nav-item ${activeTab===tab.key?'active':''}`} onClick={()=>setActiveTab(tab.key)}><span>{tab.label}</span><small>{tab.description}</small></button>)}</nav><div className="app-sidebar-footer"><span className="shell-pill">MVP</span><span className="shell-pill">Inventory online</span><span className="shell-pill">Options added</span></div></aside><main className="app-main"><header className="app-header"><div><h2>{currentTab.label}</h2><p>{currentTab.description}</p></div><div className="app-header-actions"><span className="shell-pill">Book of truth</span><span className="shell-pill">Auth roadmap</span></div></header><section style={{ display: activeTab === 'overview' ? 'block' : 'none' }}><OverviewTab /></section><section style={{ display: activeTab === 'inventory' ? 'block' : 'none' }}><InventoryPage /></section><section style={{ display: activeTab === 'discovery' ? 'block' : 'none' }}><DiscoveryTab /></section><section style={{ display: activeTab === 'topology' ? 'block' : 'none' }}><TopologyTab /></section><section style={{ display: activeTab === 'options' ? 'block' : 'none' }}><OptionsTab /></section></main></div>; }
</div> export default function App(){ const [token,setToken]=useState<string|null>(null); useEffect(()=>{const t=window.localStorage.getItem('nm_token'); if(t) setToken(t);},[]); const login=(nextToken:string)=>{window.localStorage.setItem('nm_token', nextToken); setToken(nextToken);}; const logout=()=>{window.localStorage.removeItem('nm_token'); setToken(null);}; if(!token) return <LoginPage onLogin={login} />; return <><button onClick={logout} className="logout-button">Logout</button><AppShell /></>; }
<div className="shell-card-body">{children}</div>
</section>
);
}
function OverviewTab() {
return (
<div className="shell-grid">
<ShellCard title="Workspace status" tone="accent">
<ul className="shell-list">
<li>Frontend inventory collegato al backend.</li>
<li>Tree gerarchico e context panel attivi.</li>
<li>Navigazione tra parent, children e relazioni disponibile.</li>
</ul>
</ShellCard>
<ShellCard title="Milestone attive">
<ul className="shell-list">
<li>Reintegro layout persistente completato.</li>
<li>Roadmap di security e options introdotta.</li>
<li>Preparazione vista topology multilivello.</li>
</ul>
</ShellCard>
<ShellCard title="Quick notes" tone="warning">
<ul className="shell-list">
<li>I moduli servizi confluiranno nel menu Options.</li>
<li>L'accesso dovrà essere autenticato prima delle integrazioni remote.</li>
<li>Serve hardening progressivo di sessione e permessi.</li>
</ul>
</ShellCard>
</div>
);
}
function DiscoveryTab() {
return (
<div className="shell-grid two-columns">
<ShellCard title="Discovery pipeline">
<ul className="shell-list">
<li>Seed scan subnet.</li>
<li>Classificazione host e object binding.</li>
<li>Correlazione con relazioni inventory.</li>
</ul>
</ShellCard>
<ShellCard title="Job queue">
<ul className="shell-list">
<li>SNMP collector.</li>
<li>LLDP ingestion.</li>
<li>Port mapping inference.</li>
</ul>
</ShellCard>
</div>
);
}
function TopologyTab() {
return (
<div className="shell-grid two-columns">
<ShellCard title="Topology roadmap">
<ul className="shell-list">
<li>Vista fisica dispositivi.</li>
<li>Vista logica host / VM / container / app.</li>
<li>Layer networking e dipendenze.</li>
</ul>
</ShellCard>
<ShellCard title="Planned interactions">
<ul className="shell-list">
<li>Drag & drop dei contenitori.</li>
<li>Link fisici e logici cliccabili.</li>
<li>Focus node e highlight dei vicini.</li>
</ul>
</ShellCard>
</div>
);
}
function OptionsTab() {
return (
<div className="shell-stack">
<div className="shell-grid two-columns">
<ShellCard title="User management" tone="accent">
<ul className="shell-list">
<li>Utenti locali e ruoli applicativi.</li>
<li>Disattivazione account inattivi o non più autorizzati.</li>
<li>Storico accessi e audit trail amministrativo.</li>
</ul>
</ShellCard>
<ShellCard title="System settings">
<ul className="shell-list">
<li>Parametri workspace e branding dell'istanza.</li>
<li>Configurazione discovery, inventory e policy future.</li>
<li>Feature flags per moduli sperimentali.</li>
</ul>
</ShellCard>
</div>
<div className="shell-grid two-columns">
<ShellCard title="Services backlog">
<ul className="shell-list">
<li>La sezione Servizi viene assorbita nel menu Options.</li>
<li>Qui confluiranno terminale remoto, job runner e integrazioni di sistema.</li>
<li>Ogni funzione sensibile dovrà essere protetta da permessi espliciti.</li>
</ul>
</ShellCard>
<ShellCard title="Security roadmap" tone="warning">
<ul className="shell-list">
<li>Login obbligatorio per tutte le viste non pubbliche.</li>
<li>MFA per admin e per funzioni ad alto impatto.</li>
<li>Controlli server-side su ogni richiesta, deny-by-default e audit centralizzato.</li>
</ul>
</ShellCard>
</div>
<ShellCard title="Book of truth guardrails">
<div className="options-roadmap">
<div className="options-roadmap-item">
<span className="options-step">1</span>
<div>
<strong>Authentication foundation</strong>
<p>Sessioni sicure, logout reale, protezione delle rotte e bootstrap identità all'avvio.</p>
</div>
</div>
<div className="options-roadmap-item">
<span className="options-step">2</span>
<div>
<strong>Authorization model</strong>
<p>Ruoli base, policy per risorse sensibili e blocco di azioni per utenti non autorizzati.</p>
</div>
</div>
<div className="options-roadmap-item">
<span className="options-step">3</span>
<div>
<strong>Remote access hardening</strong>
<p>Prima di un modulo tipo Termix: MFA, audit, approvazione esplicita e tracciamento comandi.</p>
</div>
</div>
</div>
</ShellCard>
</div>
);
}
function App() {
const [activeTab, setActiveTab] = useState<TabKey>('inventory');
const currentTab = tabs.find((tab) => tab.key === activeTab)!;
return (
<div className="app-shell">
<aside className="app-sidebar">
<div className="app-brand">
<div className="app-brand-mark">NM</div>
<div>
<h1>NetMapper</h1>
<p>Homelab workspace</p>
</div>
</div>
<nav className="app-nav">
{tabs.map((tab) => (
<button
key={tab.key}
className={`app-nav-item ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
<span>{tab.label}</span>
<small>{tab.description}</small>
</button>
))}
</nav>
<div className="app-sidebar-footer">
<span className="shell-pill">MVP</span>
<span className="shell-pill">Inventory online</span>
<span className="shell-pill">Options added</span>
</div>
</aside>
<main className="app-main">
<header className="app-header">
<div>
<h2>{currentTab.label}</h2>
<p>{currentTab.description}</p>
</div>
<div className="app-header-actions">
<span className="shell-pill">Book of truth</span>
<span className="shell-pill">Auth roadmap</span>
</div>
</header>
<section style={{ display: activeTab === 'overview' ? 'block' : 'none' }}>
<OverviewTab />
</section>
<section style={{ display: activeTab === 'inventory' ? 'block' : 'none' }}>
<InventoryPage />
</section>
<section style={{ display: activeTab === 'discovery' ? 'block' : 'none' }}>
<DiscoveryTab />
</section>
<section style={{ display: activeTab === 'topology' ? 'block' : 'none' }}>
<TopologyTab />
</section>
<section style={{ display: activeTab === 'options' ? 'block' : 'none' }}>
<OptionsTab />
</section>
</main>
</div>
);
}
export default App;
+39 -207
View File
@@ -1,207 +1,39 @@
:root { :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; }
color-scheme: dark; body { margin: 0; background: linear-gradient(180deg, #0b1020 0%, #111827 100%); color: #e5e7eb; }
font-family: Inter, system-ui, sans-serif; #root { min-height: 100vh; }
background: #0b1020; .logout-button { position: fixed; top: 16px; right: 16px; z-index: 50; border: 1px solid rgba(255,255,255,0.08); background: rgba(17,24,39,0.88); color: #e5e7eb; border-radius: 999px; padding: 10px 14px; }
} .app-shell { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 100vh; }
.app-sidebar { border-right: 1px solid rgba(255,255,255,0.08); background: rgba(2, 6, 23, 0.75); backdrop-filter: blur(14px); padding: 20px; display: flex; flex-direction: column; gap: 20px; }
body { .app-brand { display: flex; align-items: center; gap: 12px; }
margin: 0; .app-brand-mark { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; background: linear-gradient(135deg, #1d4ed8, #2563eb); color: white; font-weight: 700; }
background: linear-gradient(180deg, #0b1020 0%, #111827 100%); .app-brand h1, .app-header h2, .shell-card h3 { margin: 0; }
color: #e5e7eb; .app-brand p, .app-header p { margin: 4px 0 0; color: #94a3b8; }
} .app-nav { display: flex; flex-direction: column; gap: 10px; }
.app-nav-item { border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); border-radius: 12px; padding: 12px 14px; color: #e5e7eb; text-align: left; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
#root { .app-nav-item.active { background: rgba(37, 99, 235, 0.16); border-color: rgba(96, 165, 250, 0.35); }
min-height: 100vh; .app-nav-item small { color: #94a3b8; }
} .app-sidebar-footer, .app-header-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.app-main { padding: 20px; }
.app-shell { .app-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
display: grid; .shell-pill { display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px; background: rgba(255,255,255,0.08); color: #cbd5e1; font-size: 12px; }
grid-template-columns: 280px minmax(0, 1fr); .shell-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
min-height: 100vh; .shell-grid.two-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} .shell-stack { display: grid; gap: 16px; }
.shell-card { background: rgba(17, 24, 39, 0.88); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 16px; }
.app-sidebar { .shell-card.accent { border-color: rgba(96, 165, 250, 0.35); }
border-right: 1px solid rgba(255,255,255,0.08); .shell-card.warning { border-color: rgba(251, 191, 36, 0.35); }
background: rgba(2, 6, 23, 0.75); .shell-card-body { margin-top: 12px; }
backdrop-filter: blur(14px); .shell-list { margin: 0; padding-left: 18px; display: grid; gap: 8px; color: #cbd5e1; }
padding: 20px; .options-roadmap { display: grid; gap: 12px; }
display: flex; .options-roadmap-item { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 12px; align-items: start; padding: 12px; border-radius: 12px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); }
flex-direction: column; .options-roadmap-item p { margin: 6px 0 0; color: #94a3b8; }
gap: 20px; .options-step { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; background: rgba(37, 99, 235, 0.18); color: #bfdbfe; font-weight: 700; }
} .login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.login-card { width: min(100%, 420px); background: rgba(17,24,39,0.92); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 24px; }
.app-brand { .login-eyebrow { color: #93c5fd; text-transform: uppercase; letter-spacing: 0.12em; font-size: 12px; }
display: flex; .login-copy, .login-error { color: #94a3b8; }
align-items: center; .login-form { display: grid; gap: 12px; margin-top: 16px; }
gap: 12px; .login-form label { display: grid; gap: 6px; }
} .login-form input { border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); border-radius: 12px; padding: 12px 14px; }
.login-form button { border: 0; border-radius: 12px; padding: 12px 14px; background: #2563eb; color: white; font-weight: 600; }
.app-brand-mark { @media (max-width: 1100px) { .app-shell { grid-template-columns: 1fr; } .app-sidebar { border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); } .shell-grid, .shell-grid.two-columns { grid-template-columns: 1fr; } }
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: white;
font-weight: 700;
}
.app-brand h1,
.app-header h2,
.shell-card h3 {
margin: 0;
}
.app-brand p,
.app-header p {
margin: 4px 0 0;
color: #94a3b8;
}
.app-nav {
display: flex;
flex-direction: column;
gap: 10px;
}
.app-nav-item {
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
border-radius: 12px;
padding: 12px 14px;
color: #e5e7eb;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-nav-item.active {
background: rgba(37, 99, 235, 0.16);
border-color: rgba(96, 165, 250, 0.35);
}
.app-nav-item small {
color: #94a3b8;
}
.app-sidebar-footer,
.app-header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-main {
padding: 20px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.shell-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: #cbd5e1;
font-size: 12px;
}
.shell-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.shell-grid.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.shell-stack {
display: grid;
gap: 16px;
}
.shell-card {
background: rgba(17, 24, 39, 0.88);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 16px;
}
.shell-card.accent {
border-color: rgba(96, 165, 250, 0.35);
}
.shell-card.warning {
border-color: rgba(251, 191, 36, 0.35);
}
.shell-card-body {
margin-top: 12px;
}
.shell-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 8px;
color: #cbd5e1;
}
.options-roadmap {
display: grid;
gap: 12px;
}
.options-roadmap-item {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 12px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.options-roadmap-item p {
margin: 6px 0 0;
color: #94a3b8;
}
.options-step {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(37, 99, 235, 0.18);
color: #bfdbfe;
font-weight: 700;
}
@media (max-width: 1100px) {
.app-shell {
grid-template-columns: 1fr;
}
.app-sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.shell-grid,
.shell-grid.two-columns {
grid-template-columns: 1fr;
}
}
+2
View File
@@ -0,0 +1,2 @@
export type SessionUser = { username: string; role: string } | null;
export function getSessionUser(): SessionUser { return null; }
+19
View File
@@ -0,0 +1,19 @@
import { useState } from 'react';
type Props = { onLogin: (token: string) => void };
export function LoginPage({ onLogin }: Props) {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('admin123');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault(); setLoading(true); setError('');
try {
const body = new URLSearchParams(); body.set('username', username); body.set('password', password);
const res = await fetch('/auth/token', { method: 'POST', body });
if (!res.ok) throw new Error('Login fallito');
const data = await res.json(); onLogin(data.access_token);
} catch (err) { setError(err instanceof Error ? err.message : 'Login fallito'); } finally { setLoading(false); }
};
return <main className="login-page"><section className="login-card"><p className="login-eyebrow">NetMapper auth</p><h1>Accedi al book of truth</h1><p className="login-copy">L'accesso è protetto per impedire lettura e modifica dei dati da terzi.</p><form onSubmit={submit} className="login-form"><label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label><label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>{error && <p className="login-error">{error}</p>}<button type="submit" disabled={loading}>{loading ? 'Accesso...' : 'Entra'}</button></form></section></main>;
}