From fdf98a70e5928434a86698068269510dca2a1543 Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 12:55:29 +0000 Subject: [PATCH] Smart-Lock-Gastcodes zu Smart-Lock/Alarmanlagen-Code umgebaut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statt zeitlich begrenzter Gästecodes, die jeder selbst ausstellen konnte, gibt es jetzt einen dauerhaften Code je Mieter, den ausschließlich der Vermieter vergibt/widerruft. Der Mieter sieht nur seinen eigenen Code (read-only). --- backend/src/app.ts | 4 +- backend/src/routes/accessCodes.ts | 105 ++++++++++++++++++++++++++++++ backend/src/routes/guestCodes.ts | 96 --------------------------- prisma/schema.prisma | 32 +++++---- web-dashboard/index.html | 103 +++++++++++++++++------------ 5 files changed, 189 insertions(+), 151 deletions(-) create mode 100644 backend/src/routes/accessCodes.ts delete mode 100644 backend/src/routes/guestCodes.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index d3b720e..6b5a44c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,7 +10,7 @@ import { cleaningTasksRouter } from './routes/cleaningTasks'; import { expensesRouter } from './routes/expenses'; import { inventoryRouter } from './routes/inventory'; import { handoverProtocolsRouter } from './routes/handoverProtocols'; -import { guestCodesRouter } from './routes/guestCodes'; +import { accessCodesRouter } from './routes/accessCodes'; import { storageRouter } from './routes/storage'; import { documentsRouter } from './routes/documents'; import { contractsRouter } from './routes/contracts'; @@ -49,7 +49,7 @@ export function createApp() { app.use('/v1', expensesRouter); app.use('/v1', inventoryRouter); app.use('/v1', handoverProtocolsRouter); - app.use('/v1', guestCodesRouter); + app.use('/v1', accessCodesRouter); app.use('/v1', storageRouter); app.use('/v1', documentsRouter); app.use('/v1', contractsRouter); diff --git a/backend/src/routes/accessCodes.ts b/backend/src/routes/accessCodes.ts new file mode 100644 index 0000000..df5dff9 --- /dev/null +++ b/backend/src/routes/accessCodes.ts @@ -0,0 +1,105 @@ +import { Router, Response } from 'express'; +import { PrismaClient, AccessCodeStatus } from '@prisma/client'; +import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; + +const prisma = new PrismaClient(); + +/** + * Router für den Smart-Lock/Alarmanlagen-Code je Mieter. + * + * Zugriffsregel: nur Vermieter/Admin vergeben oder ändern einen Code für + * einen bestimmten Mieter. Der Mieter selbst kann seinen eigenen Code nur + * einsehen (lesend), nicht anlegen, ändern oder widerrufen. + */ +export const accessCodesRouter = Router(); + +const DEFAULT_LOCK_DEVICE_ID = 'front-door-01'; + +const ACCESS_CODE_SELECT = { + id: true, + code: true, + lockDeviceId: true, + status: true, + createdAt: true, + updatedAt: true, + user: { select: { id: true, fullName: true } }, + assignedBy: { select: { id: true, fullName: true } }, +}; + +// POST /access-codes { userId, code, lockDeviceId? } (nur Vermieter/Admin) +// Vergibt/ersetzt den Code für den angegebenen Mieter. Ein vorhandener +// aktiver Code desselben Mieters wird dabei automatisch widerrufen. +accessCodesRouter.post( + '/access-codes', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { userId, code, lockDeviceId } = req.body || {}; + + if (!userId || typeof userId !== 'string') { + return res.status(400).json({ error: 'userId (Mieter) ist erforderlich' }); + } + if (!code || !String(code).trim()) { + return res.status(400).json({ error: 'Code ist erforderlich' }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return res.status(400).json({ error: 'Mieter nicht gefunden' }); + } + + await prisma.accessCode.updateMany({ + where: { userId, status: AccessCodeStatus.ACTIVE }, + data: { status: AccessCodeStatus.REVOKED }, + }); + + const accessCode = await prisma.accessCode.create({ + data: { + userId, + assignedById: req.user!.id, + code: String(code).trim(), + lockDeviceId: typeof lockDeviceId === 'string' && lockDeviceId.trim() ? lockDeviceId.trim() : DEFAULT_LOCK_DEVICE_ID, + status: AccessCodeStatus.ACTIVE, + }, + select: ACCESS_CODE_SELECT, + }); + + res.status(201).json({ accessCode }); + }, +); + +// GET /access-codes — Vermieter/Admin sehen alle Codes, ein Mieter sieht nur +// seinen eigenen (aktiven) Code. +accessCodesRouter.get('/access-codes', requireAuth, async (req: AuthedRequest, res: Response) => { + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + const where: Record = {}; + if (!isLandlord) { + where.userId = req.user!.id; + where.status = AccessCodeStatus.ACTIVE; + } + + const accessCodes = await prisma.accessCode.findMany({ + where, + orderBy: { createdAt: 'desc' }, + select: ACCESS_CODE_SELECT, + }); + + res.status(200).json({ accessCodes }); +}); + +// DELETE /access-codes/:id — nur Vermieter/Admin widerrufen einen Code. +accessCodesRouter.delete( + '/access-codes/:id', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const accessCode = await prisma.accessCode.findUnique({ where: { id: req.params.id } }); + if (!accessCode) return res.status(404).json({ error: 'Code nicht gefunden' }); + + await prisma.accessCode.update({ + where: { id: req.params.id }, + data: { status: AccessCodeStatus.REVOKED }, + }); + res.status(204).send(); + }, +); diff --git a/backend/src/routes/guestCodes.ts b/backend/src/routes/guestCodes.ts deleted file mode 100644 index 39059f2..0000000 --- a/backend/src/routes/guestCodes.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Router, Response } from 'express'; -import { PrismaClient, GuestCodeStatus } from '@prisma/client'; -import { AuthedRequest, requireAuth } from '../middleware/auth'; - -const prisma = new PrismaClient(); - -/** - * Router für Smart-Lock-Gastcodes (Nuki/Tuya). Jeder eingeloggte Nutzer - * (Mieter oder Vermieter) kann einen zeitlich begrenzten Gäste-Code - * generieren. Ein Mieter sieht/verwaltet nur seine EIGENEN ausgestellten - * Codes (wer wann Besuch bekommt, ist privat) — Vermieter/Admin sehen alle. - */ -export const guestCodesRouter = Router(); - -const DEFAULT_LOCK_DEVICE_ID = 'front-door-01'; - -function generateCode(): string { - return String(Math.floor(100000 + Math.random() * 900000)); // 6-stelliger PIN -} - -const GUEST_CODE_SELECT = { - id: true, - code: true, - lockDeviceId: true, - validFrom: true, - validUntil: true, - status: true, - createdAt: true, - issuedBy: { select: { id: true, fullName: true } }, -}; - -guestCodesRouter.post('/guest-codes', requireAuth, async (req: AuthedRequest, res: Response) => { - const { validFrom, validUntil, lockDeviceId } = req.body || {}; - - if (!validFrom || Number.isNaN(Date.parse(validFrom))) { - return res.status(400).json({ error: 'validFrom (Datum/Zeit) ist erforderlich' }); - } - if (!validUntil || Number.isNaN(Date.parse(validUntil))) { - return res.status(400).json({ error: 'validUntil (Datum/Zeit) ist erforderlich' }); - } - if (new Date(validUntil) <= new Date(validFrom)) { - return res.status(400).json({ error: 'validUntil muss nach validFrom liegen' }); - } - - let code = generateCode(); - for (let attempt = 0; attempt < 5; attempt += 1) { - const existing = await prisma.guestCode.findUnique({ where: { code } }); - if (!existing) break; - code = generateCode(); - } - - const guestCode = await prisma.guestCode.create({ - data: { - issuedById: req.user!.id, - code, - lockDeviceId: typeof lockDeviceId === 'string' && lockDeviceId.trim() ? lockDeviceId.trim() : DEFAULT_LOCK_DEVICE_ID, - validFrom: new Date(validFrom), - validUntil: new Date(validUntil), - status: GuestCodeStatus.ACTIVE, - }, - select: GUEST_CODE_SELECT, - }); - - res.status(201).json({ guestCode }); -}); - -guestCodesRouter.get('/guest-codes', requireAuth, async (req: AuthedRequest, res: Response) => { - const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; - const where: Record = {}; - if (!isLandlord) where.issuedById = req.user!.id; - - const guestCodes = await prisma.guestCode.findMany({ - where, - orderBy: { validFrom: 'desc' }, - select: GUEST_CODE_SELECT, - }); - - res.status(200).json({ guestCodes }); -}); - -// Widerrufen — eigener Code oder Vermieter/Admin. -guestCodesRouter.delete('/guest-codes/:id', requireAuth, async (req: AuthedRequest, res: Response) => { - const guestCode = await prisma.guestCode.findUnique({ where: { id: req.params.id } }); - if (!guestCode) return res.status(404).json({ error: 'Gästecode nicht gefunden' }); - - const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; - if (!isLandlord && guestCode.issuedById !== req.user!.id) { - return res.status(403).json({ error: 'Du kannst nur deine eigenen Gästecodes widerrufen' }); - } - - await prisma.guestCode.update({ - where: { id: req.params.id }, - data: { status: GuestCodeStatus.REVOKED }, - }); - res.status(204).send(); -}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39bef31..e2e3355 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -109,9 +109,8 @@ enum HandoverType { MOVE_OUT } -enum GuestCodeStatus { +enum AccessCodeStatus { ACTIVE - EXPIRED REVOKED } @@ -187,7 +186,8 @@ model User { expensesPaid Expense[] expenseShares ExpenseShare[] handoverProtocols HandoverProtocol[] - guestCodesIssued GuestCode[] + accessCodeAssigned AccessCode[] @relation("AccessCodeUser") + accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy") notifications Notification[] invitationsSent Invitation[] @relation("InvitationsSent") storageSlots StorageSlot[] @@ -540,20 +540,26 @@ model MeterReading { // SMART LOCK / GUEST CODES // ---------------------------------------------------------------------------- -model GuestCode { - id String @id @default(uuid()) - issuedById String @map("issued_by_id") - issuedBy User @relation(fields: [issuedById], references: [id]) +// Persönlicher Smart-Lock/Alarmanlagen-Code je Mieter. Anders als ein +// klassischer zeitlich begrenzter Gästecode ist das ein dauerhafter Code für +// den festen Zugang/Alarm-Ein-/Ausschalten des jeweiligen Mieters — daher +// nur vom Vermieter/Admin vergebbar, der Mieter kann seinen eigenen Code nur +// einsehen (nicht selbst anlegen oder ändern). +model AccessCode { + id String @id @default(uuid()) + userId String @map("user_id") + user User @relation("AccessCodeUser", fields: [userId], references: [id]) + assignedById String @map("assigned_by_id") + assignedBy User @relation("AccessCodeAssignedBy", fields: [assignedById], references: [id]) - code String @unique - lockDeviceId String @map("lock_device_id") // Nuki/Tuya Device-ID - validFrom DateTime @map("valid_from") - validUntil DateTime @map("valid_until") - status GuestCodeStatus @default(ACTIVE) + code String + lockDeviceId String @map("lock_device_id") // Nuki/Tuya/HmIP Device-ID + status AccessCodeStatus @default(ACTIVE) createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - @@map("guest_codes") + @@map("access_codes") } // ---------------------------------------------------------------------------- diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 45d8226..846a517 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -926,23 +926,26 @@
-
-

Smart-Lock-Gastcodes

-
- -
+
+

Smart-Lock/Alarmanlagen-Code

+
@@ -1136,7 +1139,7 @@ const EXPENSE_CATEGORY_LABEL = { HAUSHALT: 'Haushalt', LEBENSMITTEL_GEMEINSAM: 'Lebensmittel (gemeinsam)', REPARATUR_VORAUSLAGE: 'Reparatur-Vorauslage', SONSTIGES: 'Sonstiges' }; const ITEM_CONDITION_LABEL = { NEW: 'Neu', GOOD: 'Gut', WEAR_AND_TEAR: 'Abgenutzt', DAMAGED: 'Beschädigt' }; const HANDOVER_TYPE_LABEL = { MOVE_IN: 'Einzug', MOVE_OUT: 'Auszug' }; - const GUEST_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', EXPIRED: 'Abgelaufen', REVOKED: 'Widerrufen' }; + const ACCESS_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', REVOKED: 'Widerrufen' }; const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' }; const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' }; const TRASH_TYPE_COLOR = { RESTMUELL: '#4A4A4A', BIOMUELL: '#8B5E34', GELBER_SACK: '#E0B400', PAPIER: '#2F80ED', GLAS: '#27AE60' }; @@ -1393,7 +1396,7 @@ initExpenseForm(); if (isLandlord) initInventoryForm(); if (isLandlord) initHandoverForm(); - initGuestCodeForm(); + initAccessCodeForm(); if (isLandlord) initStorageForms(); if (isLandlord) initDocumentForm(); if (isLandlord) initUtilityStatementForm(); @@ -1407,7 +1410,7 @@ loadExpenses(); loadInventory(); loadHandoverProtocols(); - loadGuestCodes(); + loadAccessCodes(); loadStorage(); loadDocuments(); loadNoticeDeadlines(); @@ -1423,7 +1426,7 @@ setInterval(loadExpenses, REFRESH_INTERVAL_MS); setInterval(loadInventory, REFRESH_INTERVAL_MS); setInterval(loadHandoverProtocols, REFRESH_INTERVAL_MS); - setInterval(loadGuestCodes, REFRESH_INTERVAL_MS); + setInterval(loadAccessCodes, REFRESH_INTERVAL_MS); setInterval(loadStorage, REFRESH_INTERVAL_MS); setInterval(loadDocuments, REFRESH_INTERVAL_MS); setInterval(loadNoticeDeadlines, REFRESH_INTERVAL_MS); @@ -2561,49 +2564,69 @@ } // --------------------------------------------------------------------- - // SMART-LOCK-GASTCODES + // SMART-LOCK/ALARMANLAGEN-CODE // --------------------------------------------------------------------- - function initGuestCodeForm() { - const form = document.getElementById('guestCodeForm'); - const errorBox = document.getElementById('guestCodeFormError'); + async function initAccessCodeForm() { + const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; + document.getElementById('accessCodeAssignCard').style.display = isLandlord ? 'block' : 'none'; + document.getElementById('accessCodeTenantHint').style.display = isLandlord ? 'none' : 'block'; + if (!isLandlord) return; + + const userSelect = document.getElementById('accessCodeUser'); + userSelect.innerHTML = ''; + try { + const data = await apiFetch('/tenants'); + data.tenants.forEach(t => { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName; + userSelect.appendChild(opt); + }); + } catch (err) { + console.error('[access-codes] Mieterliste laden fehlgeschlagen:', err.message); + } + + const form = document.getElementById('accessCodeForm'); + const errorBox = document.getElementById('accessCodeFormError'); form.onsubmit = async (e) => { e.preventDefault(); errorBox.style.display = 'none'; - const submitBtn = document.getElementById('guestCodeSubmitBtn'); + const submitBtn = document.getElementById('accessCodeSubmitBtn'); submitBtn.disabled = true; - submitBtn.textContent = 'Generiert…'; + submitBtn.textContent = 'Vergibt…'; try { - const validFrom = document.getElementById('guestCodeFrom').value; - const validUntil = document.getElementById('guestCodeUntil').value; - await apiFetch('/guest-codes', { method: 'POST', body: JSON.stringify({ validFrom, validUntil }) }); + const userId = userSelect.value; + const code = document.getElementById('accessCodeValue').value; + await apiFetch('/access-codes', { method: 'POST', body: JSON.stringify({ userId, code }) }); form.reset(); - loadGuestCodes(); + loadAccessCodes(); } catch (err) { errorBox.textContent = err.message; errorBox.style.display = 'block'; } finally { submitBtn.disabled = false; - submitBtn.textContent = 'Code generieren'; + submitBtn.textContent = 'Code vergeben'; } }; } - async function loadGuestCodes() { + async function loadAccessCodes() { if (!currentUser) return; try { - const data = await apiFetch('/guest-codes'); - renderGuestCodes(data.guestCodes); + const data = await apiFetch('/access-codes'); + renderAccessCodes(data.accessCodes); } catch (err) { - console.error('[guest-codes] Laden fehlgeschlagen:', err.message); + console.error('[access-codes] Laden fehlgeschlagen:', err.message); } } - function renderGuestCodes(codes) { - const list = document.getElementById('guestCodeList'); + function renderAccessCodes(codes) { + const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; + const list = document.getElementById('accessCodeList'); list.innerHTML = ''; if (!codes.length) { - list.appendChild(el('div', 'empty-state', 'Noch keine Gästecodes ausgestellt.')); + list.appendChild(el('div', 'empty-state', isLandlord ? 'Noch keine Codes vergeben.' : 'Dir wurde noch kein Code zugewiesen.')); return; } codes.forEach(g => { @@ -2611,23 +2634,23 @@ const left = el('div'); left.appendChild(el('div', 'title', `Code: ${g.code}`)); left.appendChild(el('div', 'meta-text', - `${g.issuedBy.fullName} · ${new Date(g.validFrom).toLocaleString('de-DE')} – ${new Date(g.validUntil).toLocaleString('de-DE')} · ${GUEST_CODE_STATUS_LABEL[g.status] || g.status}`)); + `${isLandlord && g.user ? g.user.fullName + ' · ' : ''}vergeben von ${g.assignedBy.fullName} · ${ACCESS_CODE_STATUS_LABEL[g.status] || g.status}`)); row.appendChild(left); - if (g.status === 'ACTIVE') { + if (isLandlord && g.status === 'ACTIVE') { const btn = document.createElement('button'); btn.className = 'btn-secondary'; btn.textContent = 'Widerrufen'; - btn.onclick = () => revokeGuestCode(g.id); + btn.onclick = () => revokeAccessCode(g.id); row.appendChild(btn); } list.appendChild(row); }); } - async function revokeGuestCode(id) { + async function revokeAccessCode(id) { try { - await apiFetch(`/guest-codes/${id}`, { method: 'DELETE' }); - loadGuestCodes(); + await apiFetch(`/access-codes/${id}`, { method: 'DELETE' }); + loadAccessCodes(); } catch (err) { alert(err.message); }