M8: add operational authentication mode
This commit is contained in:
@@ -21,6 +21,12 @@ DEMO_ORGANIZATION_NAME=Northstar Mobility
|
||||
DEMO_TIMEZONE=Europe/Brussels
|
||||
DEMO_ALLOW_RESET=true
|
||||
|
||||
# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager.
|
||||
# Keep these values in a secret store or an untracked production .env file.
|
||||
INITIAL_ADMIN_EMAIL=
|
||||
INITIAL_ADMIN_PASSWORD=
|
||||
INITIAL_ADMIN_DISPLAY_NAME=Operations Manager
|
||||
|
||||
# n8n
|
||||
N8N_BASE_URL=http://n8n:5678
|
||||
N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""operational user credentials
|
||||
|
||||
Revision ID: b7c7b536df85
|
||||
Revises: 799d8800e241
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "b7c7b536df85"
|
||||
down_revision = "799d8800e241"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("email", sa.String(length=320), nullable=True))
|
||||
op.add_column("users", sa.Column("password_hash", sa.String(length=512), nullable=True))
|
||||
op.create_unique_constraint("uq_users_email", "users", ["email"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("uq_users_email", "users", type_="unique")
|
||||
op.drop_column("users", "password_hash")
|
||||
op.drop_column("users", "email")
|
||||
+12
-3
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.security import SessionPayload, read_session_token
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, Role
|
||||
|
||||
settings = get_settings()
|
||||
@@ -22,13 +23,21 @@ def get_db() -> Generator[Session, None, None]:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> CurrentUser:
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload: SessionPayload | None = read_session_token(token) if token else None
|
||||
if payload is None or payload.role not in _VALID_ROLES:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
role: Role = payload.role # type: ignore[assignment]
|
||||
return CurrentUser(public_ref=payload.public_ref, display_name=payload.display_name, role=role)
|
||||
user = db.get(User, payload.user_id)
|
||||
if (
|
||||
user is None
|
||||
or not user.active
|
||||
or user.public_ref != payload.public_ref
|
||||
or user.role not in _VALID_ROLES
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
role: Role = user.role # type: ignore[assignment]
|
||||
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=role)
|
||||
|
||||
|
||||
def require_operations_manager(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import (
|
||||
SessionPayload,
|
||||
create_session_token,
|
||||
hash_password,
|
||||
read_session_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, PasswordLoginRequest
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _current_user_out(user: User) -> CurrentUser:
|
||||
return CurrentUser(
|
||||
public_ref=user.public_ref,
|
||||
display_name=user.display_name,
|
||||
role=user.role, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _set_session(response: Response, user: User) -> None:
|
||||
token = create_session_token(
|
||||
SessionPayload(
|
||||
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
|
||||
display_name=user.display_name, issued_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name, token, httponly=True, samesite="lax",
|
||||
secure=settings.session_cookie_secure, max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_initial_admin(db: Session) -> None:
|
||||
"""Create or rotate the explicitly configured first manager in operational mode."""
|
||||
if (
|
||||
settings.mobilityops_demo_mode
|
||||
or not settings.initial_admin_email
|
||||
or not settings.initial_admin_password
|
||||
):
|
||||
return
|
||||
email = settings.initial_admin_email.strip().lower()
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is None:
|
||||
user = User(
|
||||
public_ref="USR-ADMIN",
|
||||
email=email,
|
||||
password_hash=hash_password(settings.initial_admin_password),
|
||||
display_name=settings.initial_admin_display_name,
|
||||
role="operations_manager",
|
||||
active=True,
|
||||
)
|
||||
db.add(user)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="system",
|
||||
actor_label="bootstrap",
|
||||
action="operational_admin_created",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def password_login(
|
||||
body: PasswordLoginRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CurrentUser:
|
||||
if settings.mobilityops_demo_mode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Password login is unavailable in demo mode",
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
|
||||
if user is None or not user.active or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
_set_session(response, user)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=user.id,
|
||||
actor_label=user.display_name,
|
||||
action="password_login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return _current_user_out(user)
|
||||
|
||||
|
||||
@router.get("/session", response_model=CurrentUser)
|
||||
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=uuid.UUID(payload.user_id),
|
||||
actor_label=payload.display_name,
|
||||
action="logout",
|
||||
entity_type="user",
|
||||
)
|
||||
db.commit()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {"status": "logged_out"}
|
||||
@@ -22,6 +22,8 @@ settings = get_settings()
|
||||
|
||||
@router.get("/manifest", response_model=DemoManifestOut)
|
||||
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||
if not settings.mobilityops_demo_mode:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
|
||||
# both need this before any session exists. Nothing here is sensitive — it's the same
|
||||
# honest "what is this demo" summary a logged-in user would see.
|
||||
@@ -32,6 +34,8 @@ def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||
def demo_login(
|
||||
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||
) -> CurrentUser:
|
||||
if not settings.mobilityops_demo_mode:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
|
||||
user = db.scalar(select(User).where(User.public_ref == public_ref))
|
||||
if user is None:
|
||||
|
||||
@@ -48,6 +48,9 @@ class Settings(BaseSettings):
|
||||
demo_organization_name: str = "Northstar Mobility"
|
||||
demo_timezone: str = "Europe/Brussels"
|
||||
demo_allow_reset: bool = True
|
||||
initial_admin_email: str = ""
|
||||
initial_admin_password: str = ""
|
||||
initial_admin_display_name: str = "Operations Manager"
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -50,3 +51,31 @@ def read_session_token(token: str) -> SessionPayload | None:
|
||||
if time.time() - payload.issued_at > settings.session_ttl_seconds:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
derived = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32)
|
||||
encoded_salt = base64.urlsafe_b64encode(salt).decode()
|
||||
encoded_hash = base64.urlsafe_b64encode(derived).decode()
|
||||
return f"scrypt$16384$8$1${encoded_salt}${encoded_hash}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str | None) -> bool:
|
||||
if not encoded:
|
||||
return False
|
||||
try:
|
||||
algorithm, n, r, p, salt, expected = encoded.split("$")
|
||||
if algorithm != "scrypt":
|
||||
return False
|
||||
derived = hashlib.scrypt(
|
||||
password.encode(),
|
||||
salt=base64.urlsafe_b64decode(salt.encode()),
|
||||
n=int(n),
|
||||
r=int(r),
|
||||
p=int(p),
|
||||
dklen=32,
|
||||
)
|
||||
return hmac.compare_digest(derived, base64.urlsafe_b64decode(expected.encode()))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.routers import (
|
||||
audit,
|
||||
auth,
|
||||
bookings,
|
||||
dashboard,
|
||||
data_quality,
|
||||
@@ -19,7 +20,9 @@ from app.api.routers import (
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
from app.api.routers.auth import bootstrap_initial_admin
|
||||
from app.core.config import PRODUCT_NAME, get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.errors import AppError, error_body
|
||||
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||
|
||||
@@ -28,6 +31,8 @@ settings = get_settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
with SessionLocal() as db:
|
||||
bootstrap_initial_admin(db)
|
||||
start_background_dispatcher()
|
||||
yield
|
||||
stop_background_dispatcher()
|
||||
@@ -81,6 +86,7 @@ def system_status() -> dict[str, object]:
|
||||
|
||||
|
||||
app.include_router(demo.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(vehicles.router)
|
||||
app.include_router(bookings.router)
|
||||
|
||||
@@ -11,6 +11,8 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
@@ -12,6 +12,11 @@ class DemoLoginRequest(BaseModel):
|
||||
role: Role
|
||||
|
||||
|
||||
class PasswordLoginRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
public_ref: str
|
||||
display_name: str
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from app.api.routers import auth as auth_router
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def test_password_login_is_available_only_outside_demo_mode(ops_client, monkeypatch):
|
||||
user = User(
|
||||
public_ref="USR-REAL",
|
||||
email="manager@example.test",
|
||||
password_hash=hash_password("correct-horse-battery-staple"),
|
||||
display_name="Real Manager",
|
||||
role="operations_manager",
|
||||
active=True,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
db.add(user)
|
||||
db.commit()
|
||||
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
|
||||
|
||||
response = ops_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "manager@example.test", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["public_ref"] == "USR-REAL"
|
||||
assert ops_client.get("/api/v1/auth/session").json()["display_name"] == "Real Manager"
|
||||
|
||||
|
||||
def test_password_login_rejects_invalid_credentials(ops_client, monkeypatch):
|
||||
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
|
||||
response = ops_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "unknown@example.test", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -30,6 +30,21 @@ docker compose run --rm api pytest -q # all tests pass
|
||||
docker compose run --rm api ruff check . # clean
|
||||
```
|
||||
|
||||
## Operational mode (non-demo login)
|
||||
|
||||
Keep the current demonstration environment on `MOBILITYOPS_DEMO_MODE=true`. For an
|
||||
operational deployment, set `MOBILITYOPS_DEMO_MODE=false`, `DEMO_ALLOW_RESET=false` and
|
||||
provide `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD` (at least 8 characters) and an
|
||||
optional `INITIAL_ADMIN_DISPLAY_NAME` in the deployment's untracked secret environment.
|
||||
On startup the API creates the first active Operations Manager only when no user with that
|
||||
email exists. The sign-in page then accepts email/password instead of exposing demo roles;
|
||||
demo reset, the guided tour and the synthetic-data badge are hidden.
|
||||
|
||||
Use a long unique `APP_SECRET`, set `SESSION_COOKIE_SECURE=true` once the public endpoint
|
||||
uses HTTPS, and keep `INITIAL_ADMIN_PASSWORD` out of Git and logs. Existing sessions are
|
||||
revalidated against the current user record on every request, so deactivating an account
|
||||
invalidates its next request.
|
||||
|
||||
## n8n automation (one-time per environment)
|
||||
|
||||
The n8n image used here (n8nio/n8n:latest, 2.x) requires an owner account before any
|
||||
|
||||
@@ -6,6 +6,13 @@ export interface CurrentUser {
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
service: string;
|
||||
environment: string;
|
||||
demo_mode: boolean;
|
||||
knowledge_provider: string;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
public_ref: string;
|
||||
make: string;
|
||||
|
||||
@@ -75,7 +75,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
|
||||
export function Layout() {
|
||||
const { t } = useTranslation(["navigation", "common", "auth"]);
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, demoMode } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
|
||||
const navigate = useNavigate();
|
||||
@@ -231,7 +231,7 @@ export function Layout() {
|
||||
<span className="environment-dot" />
|
||||
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
|
||||
</div>
|
||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
{demoMode && user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
<div className="sidebar-reset">
|
||||
<ApiErrorNotice error={resetError} />
|
||||
{!resetConfirming ? (
|
||||
@@ -313,8 +313,8 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<DemoGuideTrigger />
|
||||
<DemoBadge />
|
||||
{demoMode && <DemoGuideTrigger />}
|
||||
{demoMode && <DemoBadge />}
|
||||
{user && (
|
||||
<details className="operator-menu">
|
||||
<summary className="operator">
|
||||
@@ -350,7 +350,7 @@ export function Layout() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<DemoGuide />
|
||||
{demoMode && <DemoGuide />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api, onUnauthorized } from "../api/client";
|
||||
import type { CurrentUser, Role } from "../api/types";
|
||||
import type { CurrentUser, Role, SystemStatus } from "../api/types";
|
||||
|
||||
interface AuthState {
|
||||
user: CurrentUser | null;
|
||||
loading: boolean;
|
||||
demoMode: boolean;
|
||||
loginAs: (role: Role) => Promise<void>;
|
||||
loginWithPassword: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -32,11 +34,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// confirmed (or rejected) the session cookie.
|
||||
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [demoMode, setDemoMode] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.get<CurrentUser>("/api/v1/demo/session")
|
||||
api.get<SystemStatus>("/api/v1/system/status")
|
||||
.then((system) => setDemoMode(system.demo_mode))
|
||||
.catch(() => setDemoMode(true))
|
||||
.finally(() => api
|
||||
.get<CurrentUser>("/api/v1/auth/session")
|
||||
.then((confirmed) => {
|
||||
if (cancelled) return;
|
||||
setUser(confirmed);
|
||||
@@ -49,7 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -75,11 +81,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginWithPassword = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const loggedIn = await api.post<CurrentUser>("/api/v1/auth/login", { email, password });
|
||||
setUser(loggedIn);
|
||||
cacheUser(loggedIn);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setUser(null);
|
||||
cacheUser(null);
|
||||
try {
|
||||
await api.post("/api/v1/demo/logout");
|
||||
await api.post("/api/v1/auth/logout");
|
||||
} catch {
|
||||
// Best effort: the cookie is cleared server-side when it works, and the client has
|
||||
// already dropped its own state either way.
|
||||
@@ -87,7 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>{children}</AuthContext.Provider>
|
||||
<AuthContext.Provider value={{ user, loading, demoMode, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,5 +19,9 @@
|
||||
"roleOperationsManager": "Operations manager",
|
||||
"roleRentalEmployee": "Rental employee",
|
||||
"switchRole": "Switch role",
|
||||
"logout": "Log out"
|
||||
"logout": "Log out",
|
||||
"emailLabel": "Email address",
|
||||
"passwordLabel": "Password",
|
||||
"signIn": "Sign in",
|
||||
"passwordLoginFailed": "Sign-in failed. Check your credentials."
|
||||
}
|
||||
|
||||
@@ -19,5 +19,9 @@
|
||||
"roleOperationsManager": "Responsable des opérations",
|
||||
"roleRentalEmployee": "Collaborateur de location",
|
||||
"switchRole": "Changer de rôle",
|
||||
"logout": "Déconnexion"
|
||||
"logout": "Déconnexion",
|
||||
"emailLabel": "Adresse e-mail",
|
||||
"passwordLabel": "Mot de passe",
|
||||
"signIn": "Se connecter",
|
||||
"passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants."
|
||||
}
|
||||
|
||||
@@ -19,5 +19,9 @@
|
||||
"roleOperationsManager": "Operationsmanager",
|
||||
"roleRentalEmployee": "Verhuurmedewerker",
|
||||
"switchRole": "Wissel van rol",
|
||||
"logout": "Uitloggen"
|
||||
"logout": "Uitloggen",
|
||||
"emailLabel": "E-mailadres",
|
||||
"passwordLabel": "Wachtwoord",
|
||||
"signIn": "Aanmelden",
|
||||
"passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Dashboard() {
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
|
||||
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
|
||||
const greetingPeriod = useGreetingPeriod();
|
||||
const { user } = useAuth();
|
||||
const { user, demoMode } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -114,7 +114,7 @@ export function Dashboard() {
|
||||
<div className="page dashboard-page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
|
||||
|
||||
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
|
||||
{demoMode && <section className="demo-start-panel" aria-label={t("demoStart.title")}>
|
||||
<div>
|
||||
<Icon name="spark" />
|
||||
<div>
|
||||
@@ -132,7 +132,7 @@ export function Dashboard() {
|
||||
)}
|
||||
<Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <Icon name="chevron" /></Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
<section className="readiness-band" aria-labelledby="readiness-heading">
|
||||
<div className="readiness-label">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
@@ -10,10 +10,12 @@ import { PRODUCT_NAME } from "../product";
|
||||
|
||||
export function Login() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const { loginAs, loading } = useAuth();
|
||||
const { loginAs, loginWithPassword, loading, demoMode } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
async function handleLogin(role: Role, guided = false) {
|
||||
setError(null);
|
||||
@@ -25,6 +27,17 @@ export function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordLogin(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await loginWithPassword(email, password);
|
||||
navigate("/dashboard");
|
||||
} catch {
|
||||
setError(t("passwordLoginFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const orgName = manifest?.organization_name ?? t("common:orgName");
|
||||
const description = t("defaultDescription", { productName: PRODUCT_NAME });
|
||||
|
||||
@@ -61,7 +74,7 @@ export function Login() {
|
||||
<p className="login-intro">{t("accessIntro")}</p>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<button
|
||||
{demoMode ? <><button
|
||||
type="button"
|
||||
className="button button-primary login-guide-cta"
|
||||
disabled={loading}
|
||||
@@ -82,7 +95,11 @@ export function Login() {
|
||||
<span><strong>{t("exploreAsRentalEmployee")}</strong><small>{t("exploreAsRentalEmployeeDetail")}</small></span>
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
</div>
|
||||
</div></> : <form className="login-options" onSubmit={handlePasswordLogin}>
|
||||
<label>{t("emailLabel")}<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required /></label>
|
||||
<label>{t("passwordLabel")}<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required /></label>
|
||||
<button type="submit" className="button button-primary" disabled={loading}>{t("signIn")}</button>
|
||||
</form>}
|
||||
<div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user