add login + invite UI to web dashboard

This commit is contained in:
Giuseppe Lombardo 2026-08-12 18:03:24 +00:00
parent 00eaa3d7d6
commit 6d8cbd7f42

View File

@ -156,36 +156,249 @@
font-size: 14px; font-size: 14px;
} }
.error-banner code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px; } .error-banner code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px; }
/* --- Auth / Invite views --- */
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
background: var(--card-bg);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 32px 28px;
width: 100%;
max-width: 380px;
}
.auth-card h1 { font-size: 19px; margin: 0 0 4px; }
.auth-card p.sub { color: var(--text-muted); font-size: 13.5px; margin: 0 0 22px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; }
.field input, .field select {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 14px;
font-family: inherit;
background: #fff;
color: var(--text);
}
.field input:focus, .field select:focus { outline: 2px solid var(--green); outline-offset: -1px; }
.btn-primary {
width: 100%;
background: var(--green);
color: #fff;
border: none;
border-radius: 8px;
padding: 11px 14px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
}
.btn-primary:hover { opacity: 0.92; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
background: #fff;
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
}
.form-error {
background: var(--red-bg);
color: var(--red);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 14px;
}
.form-success {
background: var(--green-bg);
color: var(--green);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 14px;
}
.view { display: none; }
.view.active { display: block; }
.topbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 12px 32px 0;
font-size: 13px;
color: var(--text-muted);
}
.invite-card {
background: var(--card-bg);
border-radius: 14px;
padding: 20px;
box-shadow: var(--shadow);
}
.invite-form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
}
.invite-form-row .field { flex: 1 1 200px; margin-bottom: 0; }
.invite-form-row button { flex: 0 0 auto; height: 40px; padding: 0 18px; }
.invite-link-box {
display: flex;
gap: 8px;
align-items: center;
background: var(--bg);
border: 1px dashed var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-top: 14px;
font-size: 13px;
word-break: break-all;
}
.invite-link-box code { flex: 1; }
.invite-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 13.5px; }
.invite-table th, .invite-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.invite-table th { color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
.invite-status-chip {
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
text-transform: uppercase;
}
.invite-status-chip.PENDING { background: var(--yellow-bg); color: var(--yellow); }
.invite-status-chip.ACCEPTED { background: var(--green-bg); color: var(--green); }
.invite-status-chip.EXPIRED, .invite-status-chip.REVOKED { background: #EEF0F2; color: var(--text-muted); }
</style> </style>
</head> </head>
<body> <body>
<header> <!-- ================= LOGIN VIEW ================= -->
<div> <div id="loginView" class="view">
<h1>WG Nackenheim — Vermieter-Cockpit</h1> <div class="auth-wrap">
<p>170 qm · 4 Zimmer · Einzelzimmervermietung</p> <div class="auth-card">
<h1>WG Nackenheim — Anmelden</h1>
<p class="sub">Vermieter-Cockpit &amp; Mieterbereich</p>
<div id="loginError" style="display:none" class="form-error"></div>
<form id="loginForm">
<div class="field">
<label for="loginEmail">E-Mail-Adresse</label>
<input type="email" id="loginEmail" required autocomplete="email" />
</div>
<div class="field">
<label for="loginPassword">Passwort</label>
<input type="password" id="loginPassword" required autocomplete="current-password" />
</div>
<button type="submit" class="btn-primary" id="loginSubmitBtn">Anmelden</button>
</form>
</div>
</div> </div>
<div style="display:flex; align-items:center; gap:12px;"> </div>
<div class="meta" id="lastUpdated"></div>
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button> <!-- ================= ACCEPT INVITE VIEW ================= -->
<div id="acceptInviteView" class="view">
<div class="auth-wrap">
<div class="auth-card">
<h1>Einladung annehmen</h1>
<p class="sub" id="acceptInviteSub">Lade Einladung…</p>
<div id="acceptInviteError" style="display:none" class="form-error"></div>
<form id="acceptInviteForm" style="display:none">
<div class="field">
<label for="acceptEmail">E-Mail-Adresse</label>
<input type="email" id="acceptEmail" disabled />
</div>
<div class="field">
<label for="acceptFullName">Vollständiger Name</label>
<input type="text" id="acceptFullName" required autocomplete="name" />
</div>
<div class="field">
<label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label>
<input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" />
</div>
<button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen &amp; anmelden</button>
</form>
</div>
</div> </div>
</header> </div>
<main> <!-- ================= APP VIEW ================= -->
<div id="errorBanner" style="display:none" class="error-banner"></div> <div id="appView" class="view">
<div class="stat-row" id="statRow"></div> <div class="topbar">
<span id="whoAmI"></span>
<button class="btn-secondary" onclick="logout()">Abmelden</button>
</div>
<section> <header>
<h2>Miet-Ampel je Zimmer</h2> <div>
<div class="room-grid" id="roomGrid"></div> <h1>WG Nackenheim — Vermieter-Cockpit</h1>
</section> <p>170 qm · 4 Zimmer · Einzelzimmervermietung</p>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<div class="meta" id="lastUpdated"></div>
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button>
</div>
</header>
<section> <main>
<h2>Offene Schadensmeldungen</h2> <div id="errorBanner" style="display:none" class="error-banner"></div>
<div class="ticket-list" id="ticketList"></div>
</section> <div class="stat-row" id="statRow"></div>
</main>
<section id="inviteSection" style="display:none">
<h2>Mieter einladen</h2>
<div class="invite-card">
<div id="inviteFormError" style="display:none" class="form-error"></div>
<form id="inviteForm" class="invite-form-row">
<div class="field">
<label for="inviteEmail">E-Mail-Adresse des Mieters</label>
<input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" />
</div>
<div class="field">
<label for="inviteRoom">Zimmer (optional)</label>
<select id="inviteRoom">
<option value=""> kein Zimmer zuordnen </option>
</select>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button>
</form>
<div id="inviteLinkResult" style="display:none" class="invite-link-box">
<code id="inviteLinkText"></code>
<button class="btn-secondary" onclick="copyInviteLink()">Kopieren</button>
</div>
<table class="invite-table" id="inviteTable" style="display:none">
<thead>
<tr><th>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr>
</thead>
<tbody id="inviteTableBody"></tbody>
</table>
</div>
</section>
<section>
<h2>Miet-Ampel je Zimmer</h2>
<div class="room-grid" id="roomGrid"></div>
</section>
<section>
<h2>Offene Schadensmeldungen</h2>
<div class="ticket-list" id="ticketList"></div>
</section>
</main>
</div>
<script> <script>
const API_BASE_URL = window.WG_API_BASE_URL || const API_BASE_URL = window.WG_API_BASE_URL ||
@ -194,6 +407,8 @@
const STATUS_LABEL = { GREEN: 'Bezahlt', YELLOW: 'Fällig', RED: 'Überfällig' }; const STATUS_LABEL = { GREEN: 'Bezahlt', YELLOW: 'Fällig', RED: 'Überfällig' };
const PRIORITY_LABEL = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall' }; const PRIORITY_LABEL = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall' };
const ROLE_LABEL = { LANDLORD: 'Vermieter', TENANT: 'Mieter', CRAFTSMAN: 'Handwerker', ADMIN: 'Verwaltung' };
const INVITE_STATUS_LABEL = { PENDING: 'Offen', ACCEPTED: 'Angenommen', EXPIRED: 'Abgelaufen', REVOKED: 'Zurückgezogen' };
function formatEuro(n) { function formatEuro(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0); return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
@ -206,18 +421,282 @@
return e; return e;
} }
// ---------------------------------------------------------------------
// AUTH — Login, Session-Wiederherstellung, Einladungen annehmen
// ---------------------------------------------------------------------
let currentUser = null;
function getToken() { return localStorage.getItem('wg_token'); }
function setSession(token, user) {
localStorage.setItem('wg_token', token);
localStorage.setItem('wg_user', JSON.stringify(user));
currentUser = user;
}
function clearSession() {
localStorage.removeItem('wg_token');
localStorage.removeItem('wg_user');
currentUser = null;
}
function authHeaders() {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
async function apiFetch(path, options = {}) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(options.headers || {}),
},
});
let body = null;
try { body = await res.json(); } catch { /* kein JSON-Body */ }
if (!res.ok) {
const message = (body && body.error) || `Server antwortete mit Status ${res.status}`;
throw new Error(message);
}
return body;
}
async function bootstrap() {
const inviteToken = new URLSearchParams(location.search).get('invite');
if (inviteToken) {
showView('acceptInviteView');
initAcceptInvite(inviteToken);
return;
}
const storedUser = localStorage.getItem('wg_user');
if (getToken() && storedUser) {
try {
const data = await apiFetch('/auth/me');
currentUser = data.user;
enterApp();
return;
} catch {
clearSession();
}
}
showView('loginView');
initLogin();
}
function initLogin() {
const form = document.getElementById('loginForm');
const errorBox = document.getElementById('loginError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('loginSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Anmelden…';
try {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const data = await apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setSession(data.token, data.user);
enterApp();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Anmelden';
}
};
}
async function initAcceptInvite(token) {
const sub = document.getElementById('acceptInviteSub');
const errorBox = document.getElementById('acceptInviteError');
const form = document.getElementById('acceptInviteForm');
try {
const preview = await apiFetch(`/invitations/${token}/preview`);
sub.textContent = `Einladung als ${ROLE_LABEL[preview.role] || preview.role}` +
(preview.room ? ` für ${preview.room.roomNumber}` : '') + '. Bitte Passwort festlegen.';
document.getElementById('acceptEmail').value = preview.email;
form.style.display = 'flex';
form.style.flexDirection = 'column';
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('acceptSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird erstellt…';
try {
const fullName = document.getElementById('acceptFullName').value.trim();
const password = document.getElementById('acceptPassword').value;
const data = await apiFetch(`/invitations/${token}/accept`, {
method: 'POST',
body: JSON.stringify({ fullName, password }),
});
setSession(data.token, data.user);
history.replaceState(null, '', location.pathname);
enterApp();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen & anmelden';
}
};
} catch (err) {
sub.textContent = '';
errorBox.textContent = err.message;
errorBox.style.display = 'block';
}
}
function logout() {
clearSession();
history.replaceState(null, '', location.pathname);
location.reload();
}
function enterApp() {
showView('appView');
document.getElementById('whoAmI').textContent =
`${currentUser.fullName} · ${ROLE_LABEL[currentUser.role] || currentUser.role}`;
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('inviteSection').style.display = isLandlord ? 'block' : 'none';
if (isLandlord) initInvitePanel();
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
// MIETER EINLADEN (nur Vermieter/Admin)
// ---------------------------------------------------------------------
let lastRooms = [];
function populateRoomSelect() {
const select = document.getElementById('inviteRoom');
select.innerHTML = '<option value=""> kein Zimmer zuordnen </option>';
lastRooms.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
select.appendChild(opt);
});
}
function initInvitePanel() {
const form = document.getElementById('inviteForm');
const errorBox = document.getElementById('inviteFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('inviteSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Lädt ein…';
try {
const email = document.getElementById('inviteEmail').value.trim();
const roomId = document.getElementById('inviteRoom').value || undefined;
const data = await apiFetch('/invitations', {
method: 'POST',
body: JSON.stringify({ email, roomId }),
});
document.getElementById('inviteLinkText').textContent = data.inviteLink;
document.getElementById('inviteLinkResult').style.display = 'flex';
document.getElementById('inviteEmail').value = '';
loadInvitations();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Einladen';
}
};
loadInvitations();
}
function copyInviteLink() {
const text = document.getElementById('inviteLinkText').textContent;
navigator.clipboard?.writeText(text);
}
async function loadInvitations() {
try {
const data = await apiFetch('/invitations');
const table = document.getElementById('inviteTable');
const body = document.getElementById('inviteTableBody');
body.innerHTML = '';
if (!data.invitations.length) {
table.style.display = 'none';
return;
}
table.style.display = 'table';
data.invitations.forEach(inv => {
const tr = document.createElement('tr');
tr.appendChild(el('td', '', inv.email));
tr.appendChild(el('td', '', inv.room ? inv.room.roomNumber : ''));
tr.appendChild(el('td', '', `<span class="invite-status-chip ${inv.status}">${INVITE_STATUS_LABEL[inv.status] || inv.status}</span>`));
tr.appendChild(el('td', '', new Date(inv.createdAt).toLocaleDateString('de-DE')));
const actionsTd = el('td', '');
if (inv.status === 'PENDING') {
const revokeBtn = document.createElement('button');
revokeBtn.className = 'btn-secondary';
revokeBtn.textContent = 'Zurückziehen';
revokeBtn.onclick = () => revokeInvitation(inv.id);
actionsTd.appendChild(revokeBtn);
}
tr.appendChild(actionsTd);
body.appendChild(tr);
});
} catch (err) {
// Einladungsliste ist nicht kritisch für die Kernansicht — still fehlschlagen lassen.
console.error('[invitations] Laden fehlgeschlagen:', err.message);
}
}
async function revokeInvitation(id) {
try {
await apiFetch(`/invitations/${id}`, { method: 'DELETE' });
loadInvitations();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// COCKPIT-DATEN
// ---------------------------------------------------------------------
async function loadData() { async function loadData() {
const errorBanner = document.getElementById('errorBanner'); const errorBanner = document.getElementById('errorBanner');
errorBanner.style.display = 'none'; errorBanner.style.display = 'none';
try { try {
const res = await fetch(`${API_BASE_URL}/cockpit/summary`); const data = await apiFetch('/cockpit/summary');
if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`); lastRooms = data.rooms || [];
const data = await res.json(); populateRoomSelect();
render(data); render(data);
} catch (err) { } catch (err) {
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
clearSession();
location.reload();
return;
}
errorBanner.style.display = 'block'; errorBanner.style.display = 'block';
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. ` + errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. (${err.message})`;
`Läuft <code>setup-and-start.bat</code> noch? (${err.message})`;
} }
} }
@ -288,8 +767,7 @@
}); });
} }
loadData(); bootstrap();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script> </script>
</body> </body>