Add local username/password login as fallback when Microsoft login isn't available
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Successful in 21s
Build and Deploy / deploy (push) Successful in 1s

Neues "users"-Table (bcrypt-Hash, nie Klartext), POST /api/auth/login gibt ein
selbst-signiertes JWT (HS256) aus. requireAuth unterscheidet MSAL- (RS256) und
lokale Tokens (HS256) anhand des alg-Headers. Server legt beim Start optional
ein erstes lokales Konto an, wenn SEED_ADMIN_USERNAME/SEED_ADMIN_PASSWORD als
Stack-Env gesetzt sind (idempotent, Klartext-Passwort landet nie im Repo).

Frontend: Login-Screen hat jetzt einen Alternativ-Link zu Benutzername/Passwort,
App.tsx kombiniert MSAL- und lokalen Auth-Status.

Temporärer test-build-only.yml Workflow zur Docker-Build-Verifikation ohne
Registry-Push (wird nach dem Test wieder entfernt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fidelis Huber
2026-08-21 17:56:40 +02:00
co-authored by Claude Sonnet 5
parent 7bdf4f2897
commit 97c4aa1c68
14 changed files with 263 additions and 32 deletions
+27 -18
View File
@@ -1,29 +1,38 @@
import { useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react';
import { useIsAuthenticated } from '@azure/msal-react';
import Login from './pages/Login';
import Header from './components/Header';
import Dashboard from './pages/Dashboard';
import ProjektDetail from './pages/ProjektDetail';
import Kabelrechner from './pages/Kabelrechner';
import { clearLocalAuth, getLocalUser } from './lib/localAuth';
export default function App() {
const msalAuthenticated = useIsAuthenticated();
const [localUser, setLocalUser] = useState<string | null>(getLocalUser());
const isAuthenticated = msalAuthenticated || !!localUser;
if (!isAuthenticated) {
return <Login onLocalLogin={setLocalUser} />;
}
function handleLocalLogout() {
clearLocalAuth();
setLocalUser(null);
}
return (
<>
<UnauthenticatedTemplate>
<Login />
</UnauthenticatedTemplate>
<AuthenticatedTemplate>
<BrowserRouter>
<Header />
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/projekte/:id" element={<ProjektDetail />} />
<Route path="/kabelrechner" element={<Kabelrechner />} />
</Routes>
</main>
</BrowserRouter>
</AuthenticatedTemplate>
</>
<BrowserRouter>
<Header localUser={localUser} onLocalLogout={handleLocalLogout} />
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/projekte/:id" element={<ProjektDetail />} />
<Route path="/kabelrechner" element={<Kabelrechner />} />
</Routes>
</main>
</BrowserRouter>
);
}
+10 -4
View File
@@ -1,14 +1,20 @@
import { Link, useLocation } from 'react-router-dom';
import { useMsal } from '@azure/msal-react';
export default function Header() {
export default function Header({ localUser, onLocalLogout }: { localUser: string | null; onLocalLogout: () => void }) {
const { instance, accounts } = useMsal();
const location = useLocation();
const onKabelrechner = location.pathname.startsWith('/kabelrechner');
const account = accounts[0];
const displayName = account?.name || account?.username || '';
const displayName = localUser ?? account?.name ?? account?.username ?? '';
const roleLine = localUser ? 'Lokales Konto' : (account?.username ?? '');
const initials = displayName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
function logout() {
if (account) instance.logoutPopup();
onLocalLogout();
}
return (
<>
<header>
@@ -22,10 +28,10 @@ export default function Header() {
<div className="hdr-av">{initials}</div>
<div>
<div className="hdr-name">{displayName.split(' ')[0]}</div>
<div className="hdr-role">{account?.username}</div>
<div className="hdr-role">{roleLine}</div>
</div>
</div>
<button className="btn-logout" onClick={() => instance.logoutPopup()}>Abmelden</button>
<button className="btn-logout" onClick={logout}>Abmelden</button>
</header>
<div className="tabs">
<Link to="/" className={'tab' + (!onKabelrechner ? ' active' : '')}>📋 MATERIALSCHEIN</Link>
+5
View File
@@ -1,7 +1,12 @@
import { msalInstance } from './authConfig';
import { getLocalToken } from './localAuth';
import type { MaterialItem, Projekt } from './types';
async function authHeader(): Promise<HeadersInit> {
const localToken = getLocalToken();
if (localToken) {
return { Authorization: `Bearer ${localToken}`, 'Content-Type': 'application/json' };
}
const account = msalInstance.getAllAccounts()[0];
if (!account) throw new Error('Nicht angemeldet.');
const result = await msalInstance.acquireTokenSilent({ scopes: ['openid', 'profile'], account });
+31
View File
@@ -0,0 +1,31 @@
const TOKEN_KEY = 'bv_local_token';
const NAME_KEY = 'bv_local_name';
export function getLocalToken(): string | null {
return sessionStorage.getItem(TOKEN_KEY);
}
export function getLocalUser(): string | null {
return sessionStorage.getItem(NAME_KEY);
}
export async function localLogin(username: string, password: string): Promise<string> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Anmeldung fehlgeschlagen.');
}
const data = await res.json();
sessionStorage.setItem(TOKEN_KEY, data.token);
sessionStorage.setItem(NAME_KEY, data.name);
return data.name as string;
}
export function clearLocalAuth() {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(NAME_KEY);
}
+40 -4
View File
@@ -1,12 +1,16 @@
import { useState } from 'react';
import { useState, type FormEvent } from 'react';
import { useMsal } from '@azure/msal-react';
import { localLogin } from '../lib/localAuth';
export default function Login() {
export default function Login({ onLocalLogin }: { onLocalLogin: (name: string) => void }) {
const { instance } = useMsal();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [showLocal, setShowLocal] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
async function login() {
async function loginMicrosoft() {
setBusy(true);
setErr('');
try {
@@ -17,6 +21,19 @@ export default function Login() {
setBusy(false);
}
async function submitLocal(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr('');
try {
const name = await localLogin(username, password);
onLocalLogin(name);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
setBusy(false);
}
return (
<div className="login-screen">
<div className="lcard">
@@ -24,7 +41,7 @@ export default function Login() {
<div className="lsub">Elektrotechnik · Materialschein</div>
<div className="ltitle">Willkommen</div>
<div className="ldesc">Melde dich mit deinem BergVOLT<br />Office 365 Konto an.</div>
<button className="btn-ms" onClick={login} disabled={busy}>
<button className="btn-ms" onClick={loginMicrosoft} disabled={busy}>
<svg viewBox="0 0 21 21" width="18" height="18">
<rect x="0" y="0" width="10" height="10" fill="#f25022" />
<rect x="11" y="0" width="10" height="10" fill="#7fba00" />
@@ -33,6 +50,25 @@ export default function Login() {
</svg>
Mit Microsoft anmelden
</button>
{!showLocal && (
<button className="btn btn-ghost" style={{ width: '100%', marginTop: 14 }} onClick={() => setShowLocal(true)}>
Alternativ mit Benutzername anmelden
</button>
)}
{showLocal && (
<form onSubmit={submitLocal} style={{ marginTop: 18, textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 12 }}>
<label className="field">Benutzername
<input value={username} onChange={e => setUsername(e.target.value)} autoComplete="username" autoFocus />
</label>
<label className="field">Passwort
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" />
</label>
<button className="btn btn-cyan" style={{ width: '100%' }} disabled={busy} type="submit">Anmelden</button>
</form>
)}
{err && <div className="lerr">{err}</div>}
</div>
</div>