Compare commits

...
5 Commits
73 changed files with 716 additions and 4585 deletions

No files matched your search

@@ -0,0 +1,167 @@
# Admin-Panel ins Ticketsystem – Etappe 4 (Abschalten) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** project.webklar.com ist nur noch Kundenoberfläche: Admin-Panel, Rechnungsanalyse, `/api/portal-admin`, Projekt-Admin-Wechsel und der Mitarbeiter-Login im Portal entfallen; Team-Mails verlinken ins Ticketsystem.
**Architecture:** Reiner Rückbau plus drei kleine Umstellungen: Login lehnt Mitarbeiter mit Hinweis ab, `/me` beendet alte Mitarbeiter- und Alt-Ansichts-Sitzungen mit Weiterleitung ins WOMS, Mail-Vorlagen zeigen auf WOMS-Seiten. Alles, was das WOMS über `/api/team` nutzt (Router, Chat-Baustein, Kundenansicht, Preview-Links), bleibt.
**Tech Stack:** Portal Express 4 (ESM, Node 20), node:test + `test/support/*`, Playwright-UI-Checks. WOMS React 18.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 4 „Was in Etappe 4 wegfällt“, Etappe 4 in Abschnitt 5; E1, E4, E10, E11)
## Global Constraints
- Arbeitsorte wie Etappe 3 (Portal Stand `40f0a04`, WOMS `feda436`); vorher `git fetch` + `git merge --ff-only`.
- **Livegang-Reihenfolge: WOMS zuerst** (Knopf „Projekt-Admin“ ruft `/api/admin/switch-link`), dann Portal. Nutzer pusht.
- Nie `git add -A` (Löschungen mit `git rm <datei>`), nichts pushen. `command grep -a`.
- **Bleibt:** `public/chat-composer.js/.css` (WOMS lädt sie), `GET /api/auth/staff-preview` und `/kundenansicht*`, `isAdminSession` in `/preview-access`, `services/staffMfaAdmin.js` + `scripts/mitarbeiter-2fa.mjs` (WOMS-2FA), `services/customerActivity.js`, `services/invoiceNinja.js`, `services/staffAccount.js`, `middleware/appwriteStaff.js`, alle Router unter `/api/team`.
- Fester Text Login-Sperre: `Mitarbeiter arbeiten im Ticketsystem.` + Link „Zum Ticketsystem“ auf `config.womsBaseUrl`.
- Mail-Texte und Design bleiben; nur Links, Linktext „Admin-Bereich“ → „Ticketsystem“ und Sätze, die den Admin-Bereich nennen.
## Review Focus
1. **Mitarbeiter mit alter Portal-Sitzung (Cookie bis 7 Tage):** öffnet `/dashboard.html` oder `/login.html`. Erwartung: Weiterleitung ins Ticketsystem bzw. normale Login-Seite, keine Fehlerseite, kein Zugriff auf Kundendaten (Task 1).
2. **Alte „Als Kunde ansehen“-Sitzung ohne Kennung:** Erwartung: wie abgelaufen behandelt (Cookie weg, Hinweis), keine Lese- oder Schreibzugriffe mehr (Task 2).
3. **Mitarbeiter mit 2FA meldet sich im Portal an:** Erwartung: Hinweis „Mitarbeiter arbeiten im Ticketsystem.“ ohne Code-Mail (Task 1).
4. **Team-Mail-Links:** jeder Link in T01–T18 öffnet eine bestehende WOMS-Seite; kein `admin.html` mehr (Task 3).
5. **Kunden-Login und Kundenansicht unverändert** (Task 1/2: bestehende Tests grün, UI-Check Kundenansicht).
---
## Portal
### Task 1: Mitarbeiter-Login sperren, alte Mitarbeiter-Sitzungen beenden
**Files:**
- Modify: `server/routes/auth.js` (`POST /login` :163-250: Mitarbeiter → Appwrite-Sitzung best effort beenden, `403 { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: config.womsBaseUrl }`; entfernen: `/login/mfa`, `/login/mfa/resend`, `startStaffPortalSession`, `mfaCookieOptions`, `MFA_GONE`, `GET /staff-switch`, Import `staffMfa`; `GET /me` :445-475: Rolle admin ohne Kundenansicht-Kennung → Portal- und Preview-Cookie löschen, `{ authenticated: false, reason: 'staff', redirect: config.womsBaseUrl }`)
- Delete: `server/services/staffMfa.js`, `test/staffMfa.test.js`
- Modify: `server/services/appwriteClient.js` (`sessionNeedsSecondFactor` entfernen; `appwriteSessionFetch` bleibt für `DELETE /account/sessions/current`)
- Modify: `public/login.html` (MFA-Formular :75-99 raus; unter `#login-error` ein verstecktes `<a id="login-staff-link">Zum Ticketsystem</a>`), `public/login.css` (MFA-Stile :658-671 raus), `public/app.js` (`showMfaStep`, `adminLoginTarget`, `mfaRequired`-Zweig raus; Login: bei 403 mit `ticketUrl` Text + Link zeigen; schon angemeldet: `me.reason === 'staff'` bleibt auf der Login-Seite; Dashboard: `me.reason === 'staff'` → `location.href = me.redirect`; `api()` reicht `ticketUrl` am Fehler weiter)
- Modify: `test/support/appwriteMock.js` (`DELETE /v1/account/sessions/current` mit Sitzungs-Cookie → 204, gezählt in `sessionDeletes()`)
- Test: `test/staffLogin.test.js` (umschreiben), `test/kundenansichtEnde.test.js` (Alt-Test `/me` mit `viewAsCookie` → neues Verhalten)
**Interfaces:**
- Produces: `POST /api/auth/login` für Mitarbeiter (employees-Eintrag oder Label admin, mit oder ohne 2FA) → 403 `{ error, ticketUrl }`, keine Code-Mail, keine Portal-Sitzung.
- Produces: `GET /api/auth/me` für Sitzungen `role: 'admin'` ohne `viewId` (reine Mitarbeiter-Sitzung und Alt-Ansicht) → `{ authenticated: false, reason: 'staff', redirect }` + Cookies gelöscht.
- [ ] **Step 1: Failing tests**
```js
test('Kunde meldet sich weiter normal an', async () => { /* bestehender Test :31-36 bleibt */ })
test('Mitarbeiter ohne 2FA: 403 mit Link ins Ticketsystem, keine Sitzung, Appwrite-Sitzung beendet', async () => {
const res = await post('/api/auth/login', { email: 'andrej@webklar.com', password: 'richtig' })
assert.equal(res.status, 403)
assert.deepEqual(res.body, { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' })
assert.equal(setCookies(res).filter((c) => c.startsWith('webklar_portal_session=') && !/Expires=Thu, 01 Jan 1970/.test(c)).length, 0)
assert.equal(aw.sessionDeletes(), 1)
})
test('Mitarbeiter mit 2FA: dieselbe Antwort, keine Code-Mail', async () => { /* aw.addUser mfa: true; mailsSent(userId) === 0 */ })
test('/me mit alter Mitarbeiter-Sitzung: reason staff, Weiterleitung, Cookies geloescht', async () => {
const res = await rawGet('/api/auth/me', { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' }) })
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
})
test('/me mit Alt-Ansicht (viewAs ohne viewId): ebenso reason staff', async () => {})
test('/login/mfa und /staff-switch gibt es nicht mehr (404)', async () => {})
```
- [ ] **Step 2:** Run `node --test test/staffLogin.test.js test/kundenansichtEnde.test.js` → FAIL
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** UI-Check `test/ui/kundenansicht.ui.mjs`: Szenario „alt“ ersetzen durch „staff“ (`/me` → `reason: 'staff'`) mit Erwartung Weiterleitung auf `redirect`; neuer Check Login-Seite: 403 mit `ticketUrl` zeigt Text und sichtbaren Link „Zum Ticketsystem“ mit `href` = `ticketUrl`. Run → alle ok
- [ ] **Step 5:** Run `npm test` → PASS
- [ ] **Step 6: Commit** — „Portal: Mitarbeiter-Login gesperrt (Hinweis aufs Ticketsystem), alte Mitarbeiter-Sitzungen enden“
### Task 2: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernen
**Files:**
- Delete (`git rm`): `public/admin.html`, `public/admin.js`, `public/kalkulation.html`, `public/kalkulation.js`, `public/kalkulation.css`, `server/routes/portalAdmin.js`, `server/routes/admin/switchLink.js`, `server/services/appointments.js`, `test/staffSwitch.test.js`, `test/ui/admin-funktionen.ui.mjs`
- Create: `server/services/staffPreviewLink.js` (`buildStaffPreviewLink` samt `safePreviewUrl`/`previewUrlForSubdomain` aus `server/routes/admin/previewOpen.js` verschoben); Delete `server/routes/admin/previewOpen.js`; Modify `server/routes/team/preview.js` (Import)
- Modify: `server/index.js` (Imports/Mounts `/api/portal-admin`, `/api/admin/preview-open`, `/api/admin/switch-link` raus), `server/middleware/session.js` (`requireAdminSession` raus), `server/services/staffPreviewTokens.js` (Doku ohne `switch`), `server/middleware/kundenansichtGuard.js` (`LEGACY_ALLOWED` und Präfix `/api/portal-admin/` raus; Sitzung mit `viewAs` ohne `viewId` → Cookies löschen, 401 `view_expired` wie abgelaufene Kundenansicht), `public/app.js` (Alt-Zweig in `renderViewAsBanner` raus)
- Modify Kommentare/Texte, die das Admin-Panel nennen (Liste: Bestandsaufnahme Abschnitt 6): Server-Meldungen „Im "Als Kunde ansehen"-Modus …“ → „In der Kundenansicht …“; Kommentare auf „Ticketsystem“ bzw. „früheres Admin-Panel“.
- Modify Tests: `test/storageTasks.test.js` (auf `/api/team/storage-tasks` mit `Bearer jwt-staff`), `test/funktionenRoutes.test.js` (Admin-Teil auf `/api/team/funktionen`), `test/teamChat.test.js` (Admin-Panel-Test raus), `test/kundenansichtGuard.test.js` (Alt-Ansicht: jetzt 401 `view_expired`; Untergrenze geprüfter Schreib-Routen `>= 18`), `test/newsRoutes.test.js` (Alt-Ansicht → 401), `test/support/fixtures.js` (`adminCookie`/`viewAsCookie` nur noch, wo Tests sie als „alte Sitzung“ brauchen; sonst `kundenansichtCookie`), `test/ui/kundenansicht.ui.mjs` (Alt-Banner-Szenario bereits in Task 1 ersetzt)
**Interfaces:**
- Consumes: Task 1.
- Produces: `buildStaffPreviewLink({ userId, email, name, subdomain, previewUrl })` unverändert, neuer Ort `server/services/staffPreviewLink.js`.
- Produces: 404 für `/admin.html`, `/kalkulation.html`, `/api/portal-admin/*`, `/api/admin/switch-link`, `/api/admin/preview-open`.
- [ ] **Step 1: Failing test** `test/adminPanelGone.test.js`
```js
test('Admin-Panel und alte Admin-Schnittstellen sind weg', async () => {
// App wie server/index.js aufbauen ist zu gross: stattdessen Dateien und Router-Mounts pruefen
for (const f of ['public/admin.html', 'public/admin.js', 'public/kalkulation.html', 'public/kalkulation.js', 'public/kalkulation.css', 'server/routes/portalAdmin.js', 'server/routes/admin/switchLink.js', 'server/routes/admin/previewOpen.js']) {
assert.equal(existsSync(new URL(`../${f}`, import.meta.url)), false, f)
}
const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
for (const mount of ['/api/portal-admin', '/api/admin/switch-link', '/api/admin/preview-open']) assert.ok(!index.includes(`'${mount}'`), mount)
for (const f of ['public/chat-composer.js', 'public/chat-composer.css', 'public/kundenansicht.js']) assert.ok(existsSync(new URL(`../${f}`, import.meta.url)), `${f} muss bleiben`)
})
test('keine Verweise mehr auf admin.html, kalkulation.html oder /api/portal-admin in server/ und public/', () => {
// rekursiv lesen (node:fs), Fundstellen auflisten -> []
})
test('Alt-Ansicht ohne Kennung: Lesen und Schreiben 401 view_expired, Cookies geloescht', async () => {})
```
- [ ] **Step 2:** Run → FAIL
- [ ] **Step 3:** Löschen, verschieben, umstellen; Tests anpassen
- [ ] **Step 4:** Run `npm test` → PASS; `node test/ui/kundenansicht.ui.mjs` und `node test/ui/composer-headers.ui.mjs` → alle ok
- [ ] **Step 5: Commit** — „Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt“
### Task 3: Team-Mails verlinken ins Ticketsystem
**Files:**
- Modify: 15 Vorlagen mit Fußzeilen-Link (`server/mail-templates/T01,T02,T03,T04,T05,T06,T07,T08,T09,T10,T11,T12,T13,T14,T16 *.html`): `href="https://project.webklar.com/admin.html"` → `https://ticket.webklar.com/customers`, Linktext „Admin-Bereich“ → „Ticketsystem“
- Modify: `T01 Neue Chatnachricht.html` (Knopf „Im Ticketsystem antworten →“ auf `{{team_link}}`, Platzhalter im Kopfkommentar ergänzen, Satz :39 anpassen), `T10`/`T11` (Knopftexte bleiben, `{{admin_link}}` bleibt als Name), Sätze mit „Admin-Bereich“ in `T09:64`, `T13:41,45,53` → „Ticketsystem (Kunden → …)“
- Modify: `server/services/mailer.js` (`sendChatNotification`: `team_link: ${config.womsBaseUrl}/customers/chat?kunde=<customer.$id>`), `server/services/takeoverCredits.js:460,:489` (`admin_link: ${config.womsBaseUrl}/customers/<customerId>?tab=gutschriften&antrag=<id>`)
- Test: `test/mailTemplates.test.js` (ergänzen)
**Interfaces:**
- Consumes: WOMS-Routen `/customers`, `/customers/chat?kunde=`, `/customers/<id>?tab=gutschriften&antrag=` (live seit Etappe 2/3).
- [ ] **Step 1: Failing tests**
```js
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const t of allTemplates()) {
assert.ok(!t.html.includes('admin.html'), t.id)
assert.ok(!t.html.includes('Admin-Bereich'), t.id)
}
})
test('Fusszeile der Team-Mails: Ticketsystem', () => { /* die 15 Vorlagen enthalten href="https://ticket.webklar.com/customers" und den Text "Ticketsystem" */ })
test('T01 antwortet im Ticketsystem beim Kunden', async () => {
// sendChatNotification({ $id: 'c1', name: 'Berta' }, 'Hallo') ueber mailSink: html enthaelt href="https://ticket.webklar.com/customers/chat?kunde=c1" und "Im Ticketsystem antworten"
})
test('T10/T11 oeffnen den Antrag im Kunden-Reiter Gutschriften', async () => {
// Antrag anlegen bzw. beantworten wie in test/takeoverCredits*: Link https://ticket.webklar.com/customers/c1?tab=gutschriften&antrag=<id>
})
```
- [ ] **Step 2:** Run `node --test test/mailTemplates.test.js` → FAIL
- [ ] **Step 3:** Implementieren (Platzhalter-Prüfung `declared == used` aus `mailTemplates.test.js` muss grün bleiben)
- [ ] **Step 4:** Run `npm test` → PASS
- [ ] **Step 5: Commit** — „Team-Mails: Links ins Ticketsystem statt Admin-Bereich“
---
## WOMS
### Task 4: Knopf „Projekt-Admin“ entfernen
**Files:**
- Modify: `src/components/Navbar.jsx` (Knopf :142-151, `handleProjectAdmin`, `switching`, Import `openProjectAdmin`, `IconExternalLink`), `src/lib/employeeAdminApi.js` (`openProjectAdmin` raus), `src/styles/global.css` (`.side-link-button` raus), Kommentare mit `public/admin.js`-Zeilen → „wie im früheren Admin-Panel“ (`storageFormat.js`, `creditFormat.js`, `offerFormat.js`, `OfferForm.jsx`, `CreditDetail.jsx`)
- Test: `tests/unit/navLinks.test.mjs` (unverändert grün), Harness `test-e3.mjs` Sidebar-Check um „kein Projekt-Admin“ ergänzen
- [ ] **Step 1:** Harness-Check `sidebar_ohne_projekt_admin` ergänzen → Run → FAIL
- [ ] **Step 2:** Implementieren; `npm test` → PASS; Vite-Build → „built in“; Harness → alle ok
- [ ] **Step 3: Commit** — „Sidebar: Projekt-Admin entfernt (Admin-Panel abgeschaltet)“
---
## Abschluss
### Task 5: Gesamtprüfung und Übergabe
- [ ] Portal `npm test` grün, neue/geänderte Tests unter Node 20 grün; UI-Checks grün. WOMS `npm test`, Build, Harnesses (Etappe 2 und 3) grün.
- [ ] Abschlussreview beider Branches (frischer Reviewer, sonnet).
- [ ] Übergabe, **WOMS zuerst**: 1. `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 2. Live-Check (ich): Bundle ohne „Projekt-Admin“; 3. `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`; 4. Live-Check: `/admin.html` 404, `/api/portal-admin/chats` 404, Mitarbeiter-Login 403 mit Hinweis (ohne echtes Passwort: nur Routen), `/dashboard.html` und Kundenansicht weiter ok, `chat-composer.js` 200; 5. Nutzer-Test: eine Team-Mail (z. B. Chat) öffnen und Link prüfen.
-309
View File
@@ -1,309 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin – Webklar Kundenbereich</title>
<link rel="stylesheet" href="/login.css">
<style>
.admin-tabs { display: flex; gap: 8px; margin: 18px 0; flex-wrap: wrap; }
.admin-tab {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
color: inherit; padding: 8px 16px; border-radius: 999px; cursor: pointer; font: inherit;
}
.admin-tab.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
.admin-section { display: none; }
.admin-section.active { display: block; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; }
.stat-tile { padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); }
.stat-tile .num { font-size: 26px; font-weight: 700; display: block; }
.stat-tile .lbl { font-size: 12px; opacity: 0.7; }
.admin-search { width: 100%; max-width: 380px; padding: 9px 14px; border-radius: 10px;
border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; margin-bottom: 12px; }
.admin-table-wrap { overflow-x: auto; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); }
/* Kundentabelle: volle Breite, kein Links-/Rechts-Scroll */
.admin-table-wrap.no-scroll { overflow-x: visible; }
.admin-table-wrap.no-scroll table.admin-table { table-layout: fixed; }
.admin-table-wrap.no-scroll .admin-table td { word-break: break-word; overflow-wrap: anywhere; }
.cust-actions { display: flex; flex-wrap: wrap; gap: 6px; }
table.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.admin-table th, .admin-table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); vertical-align: middle; }
.admin-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
.admin-table tr:last-child td { border-bottom: none; }
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; }
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
.pill-warn { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.8; }
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
.mini-btn { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.18); color: inherit;
padding: 5px 11px; border-radius: 8px; cursor: pointer; font-size: 13px; white-space: nowrap; }
.mini-btn:hover { background: rgba(255,255,255,0.14); }
a.mini-btn { display: inline-flex; align-items: center; text-decoration: none; }
.mini-btn.primary { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
.code-input { width: 100%; min-width: 60px; max-width: 90px; padding: 4px 8px; border-radius: 7px; border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 13px; }
.detail-row td { background: rgba(255,255,255,0.03); }
.detail-box { padding: 6px 4px; }
.detail-box h4 { margin: 8px 0 4px; font-size: 13px; }
.detail-box .muted { opacity: 0.65; }
.ws-line { font-size: 13px; opacity: 0.85; margin: 2px 0 2px 14px; }
a.plain { color: #7dd3fc; text-decoration: none; }
a.plain:hover { text-decoration: underline; }
.admin-note { font-size: 13px; opacity: 0.65; margin: 8px 0 0; }
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.recent-list { list-style: none; margin: 0; padding: 0; }
.recent-list li { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; }
.recent-list li:last-child { border-bottom: none; }
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
/* Chat */
.badge-pill {
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
align-items: center; justify-content: center; border-radius: 999px;
background: #f87171; color: #fff; font-size: 12px; font-weight: 700;
}
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 14px; min-height: 480px; }
@media (max-width: 760px) { .chat-layout { grid-template-columns: 1fr; } }
.chat-side { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.chat-list { border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow-y: auto; max-height: 560px; }
.chat-list-item { padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
.chat-list-item:hover { background: rgba(255,255,255,0.05); }
.chat-list-item.active { background: rgba(94,234,212,0.1); }
.chat-list-item .last { font-size: 12px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-thread { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; }
.chat-messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding: 4px; max-height: 440px; }
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
.chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
.chat-bubble.mine { align-self: flex-end; background: rgba(94,234,212,0.18); border: 1px solid rgba(94,234,212,0.45); border-bottom-right-radius: 4px; }
.chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; }
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
.chat-input-row textarea {
flex: 1; resize: none; min-height: 44px; max-height: 120px; padding: 10px 12px;
border-radius: 12px; border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
/* Anhaenge und Sprachnachrichten (chat-composer.css) im Tuerkis des Admin-Panels */
body {
--chat-accent: #2dd4bf; --chat-accent-hover: #5eead4; --chat-accent-soft: rgba(94,234,212,0.16);
--chat-accent-line: rgba(94,234,212,0.5); --chat-accent-text: #5eead4; --chat-on-accent: #042f2e;
}
/* Übernahmegutschriften */
.credit-filter { display: flex; gap: 8px; margin-bottom: 12px; }
.credit-filter button {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
color: inherit; padding: 5px 13px; border-radius: 999px; cursor: pointer; font: inherit; font-size: 13px;
}
.credit-filter button.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
tr.credit-active td { background: rgba(94,234,212,0.07); }
.credit-detail { margin-top: 18px; }
.credit-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.credit-head h3 { margin: 0; }
.credit-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 22px; }
.credit-cols h4 { margin: 16px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
.credit-cols h4:first-child { margin-top: 4px; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 14px; margin: 0; }
.kv dt { opacity: 0.6; }
.kv dd { margin: 0; overflow-wrap: anywhere; }
.pre { white-space: pre-wrap; font-size: 14px; margin: 0; overflow-wrap: anywhere; }
.check-list { list-style: none; margin: 0; padding: 0; font-size: 14px; }
.check-list li { padding: 5px 0 5px 26px; position: relative; border-bottom: 1px solid rgba(255,255,255,0.06); }
.check-list li:last-child { border-bottom: none; }
.check-list li::before { position: absolute; left: 2px; top: 5px; font-weight: 700; }
.check-list li.ok::before { content: '✓'; color: #4ade80; }
.check-list li.warn::before { content: '!'; color: #fbbf24; left: 6px; }
.check-list li.info::before { content: 'i'; color: #7dd3fc; left: 6px; font-style: italic; }
.check-list small { display: block; opacity: 0.6; font-size: 12px; overflow-wrap: anywhere; }
.manual-checks label { display: flex; gap: 9px; align-items: flex-start; font-size: 14px; padding: 4px 0; cursor: pointer; }
.manual-checks input { margin-top: 3px; width: 16px; height: 16px; accent-color: #5eead4; flex: none; }
.calc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
.calc-grid label, .credit-field label { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 4px; }
.credit-input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 9px; border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 14px; }
.credit-input option { color: #111; }
textarea.credit-input { min-height: 80px; resize: vertical; }
.calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); }
/* E-Mail-Speicher: ab der Warnschwelle des Hetzner-Pakets rot */
.calc-result.warn { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.45); color: #fca5a5; }
.storage-task strong { font-size: 15px; }
.storage-partial { color: #fca5a5; }
.storage-failed { margin: 14px 0 0; }
.storage-failed li { margin: 4px 0; font-size: 14px; }
.credit-field { margin-top: 10px; }
.credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.mini-btn:disabled { opacity: 0.45; cursor: default; }
.mini-btn.danger { border-color: rgba(248,113,113,0.5); }
.credit-msg { margin-top: 10px; font-size: 14px; }
.history-item { font-size: 13px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.history-item:last-child { border-bottom: none; }
</style>
<link rel="stylesheet" href="/chat-composer.css">
</head>
<body class="portal-page dashboard-page">
<div class="dot-background" aria-hidden="true">
<div class="dot-pattern"></div>
<div class="dot-fade"></div>
</div>
<header class="portal-header">
<a class="portal-logo" href="/admin.html">Webklar <span style="opacity:.6">Admin</span></a>
<div class="portal-header-actions">
<span class="portal-meta" id="admin-meta"></span>
<a class="portal-btn-outline" href="https://ticket.webklar.com/tickets">← Ticketsystem</a>
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
</div>
</header>
<main class="portal-main dashboard-content-reveal">
<h1 class="portal-title">Admin-Übersicht</h1>
<p id="admin-error" class="error hidden"></p>
<div class="admin-tabs">
<button class="admin-tab active" data-tab="overview">Übersicht</button>
<button class="admin-tab" data-tab="projects">Projekte</button>
<button class="admin-tab" data-tab="customers">Kunden</button>
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
<button class="admin-tab" data-tab="storage">E-Mail-Speicher</button>
</div>
<!-- Übersicht -->
<section class="admin-section active" id="tab-overview">
<div class="stat-grid" id="stat-grid"><span class="portal-status">Lade…</span></div>
<div class="two-col">
<div class="portal-card">
<h3>Letzte Portal-Logins</h3>
<ul class="recent-list" id="recent-logins"><li class="muted">Lade…</li></ul>
</div>
<div class="portal-card">
<h3>Zuletzt aktualisierte Projekte</h3>
<ul class="recent-list" id="recent-projects"><li class="muted">Lade…</li></ul>
</div>
</div>
</section>
<!-- Projekte -->
<section class="admin-section" id="tab-projects">
<input class="admin-search" id="project-search" type="search"
placeholder="Projekte suchen (Name, Subdomain, Repo, Kunde)…">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Projekt</th><th>Subdomain</th><th>Kunde</th><th>Status</th><th>Typ</th><th>Links</th></tr>
</thead>
<tbody id="project-rows"><tr><td colspan="6" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<p class="admin-note">Als Admin kannst du jede Projekt-Vorschau direkt öffnen – ohne Kunden-Login.</p>
</section>
<!-- Kunden -->
<section class="admin-section" id="tab-customers">
<input class="admin-search" id="customer-search" type="search"
placeholder="Kunden suchen (Kundennr., Name, E-Mail, Ort)…">
<div class="admin-table-wrap no-scroll">
<table class="admin-table">
<colgroup>
<col style="width:9%"><col style="width:23%"><col style="width:11%"><col style="width:11%">
<col style="width:13%"><col style="width:8%"><col style="width:25%">
</colgroup>
<thead>
<tr><th>Kundennr.</th><th>Kunde</th><th>Status</th><th>Portal</th><th>Letzter Login</th><th>Projekte</th><th>Aktionen</th></tr>
</thead>
<tbody id="customer-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<p class="admin-note">Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.</p>
</section>
<!-- Chat -->
<section class="admin-section" id="tab-chat">
<div class="chat-layout">
<div class="chat-side">
<input class="credit-input" id="admin-chat-new" list="admin-chat-new-list" autocomplete="off"
placeholder="Neue Nachricht an Kunde…" aria-label="Neue Nachricht an Kunde">
<datalist id="admin-chat-new-list"></datalist>
<div class="chat-list" id="admin-chat-list">
<div class="chat-list-item"><span class="muted">Lade Unterhaltungen…</span></div>
</div>
</div>
<div class="chat-thread" id="admin-chat-thread">
<div class="chat-empty">Unterhaltung links auswählen</div>
</div>
</div>
</section>
<!-- Übernahmegutschriften -->
<section class="admin-section" id="tab-credits">
<div class="credit-filter">
<button type="button" class="active" data-credit-filter="open">Offen</button>
<button type="button" data-credit-filter="all">Alle</button>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Eingang</th><th>Kunde</th><th>Leistung / bisher bei</th><th>Rechnung</th><th>Bezahlt bis</th><th>Status</th><th></th></tr>
</thead>
<tbody id="credit-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<div id="credit-detail" class="portal-card credit-detail hidden"></div>
<p class="admin-note">Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.</p>
</section>
<!-- Abo-Angebote -->
<section class="admin-section" id="tab-offers">
<div class="flex-between" style="margin-bottom:12px">
<div class="credit-filter" style="margin:0">
<button type="button" class="active" data-offer-filter="open">Offen &amp; aktiv</button>
<button type="button" data-offer-filter="all">Alle</button>
</div>
<button type="button" class="mini-btn primary" id="offer-new">Neues Angebot</button>
</div>
<div id="offer-message" class="calc-result hidden"></div>
<div id="offer-form" class="portal-card hidden" style="margin-bottom:16px"></div>
<div class="admin-table-wrap no-scroll">
<table class="admin-table">
<colgroup>
<col style="width:15%"><col style="width:19%"><col style="width:33%"><col style="width:15%"><col style="width:18%">
</colgroup>
<thead>
<tr><th>Angelegt</th><th>Kunde</th><th>Angebot</th><th>Gültig bis</th><th>Status</th></tr>
</thead>
<tbody id="offer-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<p class="admin-note">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
</section>
<!-- E-Mail-Speicher (Zusatzspeicher-Aufgaben fuers Team, Hetzner-Paket) -->
<section class="admin-section" id="tab-storage">
<div id="storage-summary" class="calc-result">Lade…</div>
<div class="admin-table-wrap no-scroll">
<table class="admin-table">
<colgroup>
<col style="width:14%"><col style="width:22%"><col style="width:34%"><col style="width:18%"><col style="width:12%">
</colgroup>
<thead>
<tr><th>Fällig</th><th>Kunde</th><th>Aufgabe in konsoleH</th><th>Bestellt</th><th></th></tr>
</thead>
<tbody id="storage-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<div id="storage-failed" class="storage-failed hidden">
<h4>Gescheiterte Änderungen (letzte 30 Tage)</h4>
<ul class="recent-list" id="storage-failed-list"></ul>
</div>
<p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p>
</section>
</main>
<script src="/chat-composer.js"></script>
<script src="/admin.js"></script>
<script>
initAdminPage();
</script>
</body>
</html>
-1418
View File
File diff suppressed because it is too large. Load diff
+21 -94
View File
@@ -17,6 +17,8 @@ async function api(path, options = {}) {
err.status = response.status
if (data.code) err.code = data.code
if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds
// Mitarbeiter-Login gesperrt: Adresse des Ticketsystems fuer den Link unter dem Hinweis
if (data.ticketUrl) err.ticketUrl = data.ticketUrl
// Passwort auf einem anderen Gerät geändert: diese Sitzung gilt nicht mehr
if (data.code === 'session_revoked' && document.body.classList.contains('dashboard-page') && !sessionEndRedirected) {
sessionEndRedirected = true
@@ -473,17 +475,12 @@ async function postLoginRedirect() {
window.location.href = await resolvePostLoginTarget()
}
// Links aus Admin-Mails (z. B. /admin.html?gutschrift=<id>) nach dem Login wieder aufnehmen
function adminLoginTarget() {
const next = getQueryParam('next')
return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html'
}
/* -------------------------------------------------------------- Login-Page --- */
async function initLoginPage() {
const form = document.getElementById('login-form')
const errorEl = document.getElementById('login-error')
const btn = document.getElementById('login-btn')
const staffLink = document.getElementById('login-staff-link')
const errorFromQuery = getQueryParam('error')
if (errorFromQuery) {
@@ -523,11 +520,8 @@ async function initLoginPage() {
}
try {
// Alte Mitarbeiter-Sitzung beendet /me selbst (reason staff): dann bleibt die Anmeldeseite stehen
const me = await api('/api/auth/me')
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
window.location.href = adminLoginTarget()
return
}
if (me.authenticated && me.customer) {
await postLoginRedirect()
return
@@ -540,6 +534,7 @@ async function initLoginPage() {
e.preventDefault()
if (btn.disabled) return
errorEl.classList.add('hidden')
staffLink?.classList.add('hidden')
btn.disabled = true
btn.classList.add('is-loading')
btn.setAttribute('aria-busy', 'true')
@@ -552,18 +547,14 @@ async function initLoginPage() {
password: document.getElementById('password').value,
}),
})
if (result.mfaRequired) {
showMfaStep(result.email)
return
}
if (result.role === 'admin') {
window.location.href = adminLoginTarget()
return
}
await postLoginRedirect()
return
} catch (err) {
showError(errorEl, err.message)
if (err.ticketUrl && staffLink) {
staffLink.href = err.ticketUrl
staffLink.classList.remove('hidden')
}
if (err.status === 429) {
cooldownActive = true
await applyCooldown(err.retryAfterSeconds || 900)
@@ -576,84 +567,19 @@ async function initLoginPage() {
})
}
/* Mitarbeiter-2FA: Code aus der E-Mail (POST /api/auth/login/mfa) */
function showMfaStep(maskedEmail) {
const loginForm = document.getElementById('login-form')
const form = document.getElementById('mfa-form')
const input = document.getElementById('mfa-code')
const errorEl = document.getElementById('mfa-error')
const btn = document.getElementById('mfa-btn')
const resend = document.getElementById('mfa-resend')
loginForm?.classList.add('hidden')
form?.classList.remove('hidden')
document.getElementById('mfa-hint').textContent = `Wir haben dir einen Code an ${maskedEmail} geschickt.`
input?.focus()
form?.addEventListener('submit', async (e) => {
e.preventDefault()
if (btn.disabled) return
hideError(errorEl)
btn.disabled = true
btn.classList.add('is-loading')
try {
await api('/api/auth/login/mfa', { method: 'POST', body: JSON.stringify({ otp: input.value.trim() }) })
window.location.href = adminLoginTarget()
} catch (err) {
if (err.status === 410) {
window.location.href = `/login.html?error=${encodeURIComponent(err.message)}`
return
}
showError(errorEl, err.message)
input.select()
} finally {
btn.disabled = false
btn.classList.remove('is-loading')
}
})
resend?.addEventListener('click', async () => {
hideError(errorEl)
resend.disabled = true
try {
await api('/api/auth/login/mfa/resend', { method: 'POST' })
document.getElementById('mfa-hint').textContent = `Neuer Code ist unterwegs an ${maskedEmail}.`
} catch (err) {
if (err.status === 410) {
window.location.href = `/login.html?error=${encodeURIComponent(err.message)}`
return
}
showError(errorEl, err.message)
} finally {
resend.disabled = false
}
})
}
/* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
if (!banner || !me.viewAs) return
if (!banner || !me.viewAs || !me.viewId) return
banner.classList.remove('hidden')
// Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
if (me.viewId) {
banner.innerHTML = `
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
<button type="button" id="exit-viewas-btn">Beenden</button>
`
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
window.location.href = redirect || 'https://ticket.webklar.com/customers'
})
return
}
// Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
// Kundenansicht aus dem Ticketsystem: Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
banner.innerHTML = `
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
<button type="button" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
<button type="button" id="exit-viewas-btn">Beenden</button>
`
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
window.location.href = '/admin.html'
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
window.location.href = redirect || 'https://ticket.webklar.com/customers'
})
}
@@ -2035,7 +1961,7 @@ function showView(name, { updateHash = true } = {}) {
}
/* Profilbild (DiceBear Big Smile vom eigenen Server); die Initialen bleiben stehen, bis das Bild geladen ist,
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass "Als Kunde ansehen" nicht das gecachte Bild zeigt. */
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass die Kundenansicht nicht das gecachte Bild zeigt. */
function setAvatar(el, initials, seed) {
if (!el) return
el.textContent = initials
@@ -2088,16 +2014,17 @@ async function initDashboardPage() {
const me = await api('/api/auth/me')
if (!me.authenticated) {
// Alte Mitarbeiter-Sitzung (Mitarbeiter arbeiten im Ticketsystem): dorthin weiter
if (me.reason === 'staff' && me.redirect) {
window.location.href = me.redirect
return
}
const target = new URL(loginUrl, window.location.origin)
// Passwort auf einem anderen Gerät geändert oder Kundenansicht abgelaufen: Hinweis auf der Anmeldeseite
if ((me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) target.searchParams.set('error', me.message)
window.location.href = target.pathname + target.search
return
}
if (me.role === 'admin' && !me.customer) {
window.location.href = '/admin.html'
return
}
if (!me.customer) {
window.location.href = loginUrl
return
+2 -2
View File
@@ -1,5 +1,5 @@
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Admin-Panel, siehe chat-composer.js).
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, admin.html setzt Tuerkis. */
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Ticketsystem, siehe chat-composer.js).
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, das Ticketsystem setzt eigene (styles/chat.css). */
:root {
--chat-accent: #9d3dfe;
--chat-accent-hover: #8a25f0;
+2 -2
View File
@@ -1,4 +1,4 @@
/* Chat im Kundenbereich und im Admin-Panel: Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten.
/* Chat im Kundenbereich und im Ticketsystem (laedt diese Datei zur Laufzeit): Anhaenge (Bild, Kamera, Datei) und Sprachnachrichten.
ChatComposer.create({...}) baut die Eingabezeile, ChatComposer.renderMessage(...) eine Sprechblase.
Erlaubte Endungen und Grenzen prueft der Server noch einmal (server/services/chatAttachments.js). */
(function () {
@@ -468,7 +468,7 @@
/**
* Sprechblase: Anhaenge, Text, Absender/Zeit.
* fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Admin: /api/portal-admin/...).
* fileUrl(att) liefert die Adresse des Anhangs (Kunde: /api/chat/..., Ticketsystem: signierter Link unter /api/team/...).
*/
function renderMessage(msg, { mine, meta, fileUrl }) {
const atts = Array.isArray(msg.attachments) ? msg.attachments : []
-372
View File
@@ -1,372 +0,0 @@
/* Rechnungsanalyse / Projektkalkulation.
Bewusst eigenes helles "Organic"-Design (nicht das dunkle Portal-Theme),
weil die Seite im Kundengespraech gezeigt wird. Fonts liegen lokal unter
/fonts (kein Google-CDN-Abruf). */
@font-face {
font-family: 'Caprasimo'; font-style: normal; font-weight: 400; font-display: swap;
src: url(/fonts/caprasimo-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Caprasimo'; font-style: normal; font-weight: 400; font-display: swap;
src: url(/fonts/caprasimo-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Figtree ist ein Variable Font – eine Datei deckt 400–700 ab. */
@font-face {
font-family: 'Figtree'; font-style: normal; font-weight: 400 700; font-display: swap;
src: url(/fonts/figtree-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Figtree'; font-style: normal; font-weight: 400 700; font-display: swap;
src: url(/fonts/figtree-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--color-bg: #f5ead8;
--color-surface: #ebddc5;
--color-text: #201e1d;
--color-accent: #c67139;
--color-accent-2: #7a8a5e;
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
--color-accent-100: #fff2eb;
--color-accent-200: #ffe1d0;
--color-accent-700: #8c491a;
--color-accent-800: #643312;
--color-accent-2-100: #f0fae1;
--color-accent-2-200: #e1eecc;
--color-accent-2-500: #8fa073;
--color-accent-2-600: #728157;
--color-accent-2-700: #56633f;
--color-accent-2-800: #3d472b;
--font-heading: 'Caprasimo', Georgia, serif;
--font-body: 'Figtree', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--radius-md: 16px;
--radius-lg: 28px;
--shadow-sm: 0 1px 2px rgba(46, 43, 37, 0.14);
--shadow-md: 0 3px 10px rgba(46, 43, 37, 0.16);
--shadow-lg: 0 12px 32px rgba(46, 43, 37, 0.22);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.55;
}
h1, h2, h3 { font-family: var(--font-heading); font-weight: 400; line-height: 1.12; margin: 0; }
a { color: var(--color-accent-700); }
a:hover { color: var(--color-accent-800); }
button { font: inherit; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: var(--color-accent-200); }
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type=number] { -moz-appearance: textfield; }
/* ------------------------------------------------------------- Kopfzeile --- */
.kal-header {
position: sticky; top: 0; z-index: 20;
background: var(--color-accent-2-500); color: var(--color-accent-2-100);
padding: 14px 20px; box-shadow: var(--shadow-md);
}
.kal-header-inner {
max-width: 1040px; margin: 0 auto;
display: flex; align-items: center; gap: clamp(10px, 2.4vw, 18px); flex-wrap: wrap;
}
/* Logo ist zugleich der Zurueck-Link ins Admin-Panel. */
.kal-brand {
font-family: var(--font-heading); font-size: clamp(17px, 4vw, 22px);
color: #fff; text-decoration: none;
}
.kal-brand:hover { color: #fff; opacity: 0.85; }
.kal-spacer { flex: 1; min-width: 0; }
.kal-stat { display: flex; align-items: baseline; gap: 8px; }
.kal-stat-lbl {
font-size: 11.5px; font-weight: 700; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--color-accent-2-200);
}
.kal-stat-val { font-family: var(--font-heading); line-height: 1; color: #fff; font-size: clamp(16px, 3.6vw, 22px); }
.kal-stat-val.big { font-size: clamp(19px, 4.6vw, 30px); }
main { max-width: 1040px; margin: 0 auto; padding: clamp(18px, 3.4vw, 28px) clamp(12px, 3.2vw, 20px) 100px; }
/* ------------------------------------------------------------------ Hero --- */
.hero {
border-radius: var(--radius-lg); background: var(--color-accent-2-500);
color: var(--color-accent-2-100);
padding: clamp(20px, 4vw, 34px) clamp(20px, 4vw, 34px) clamp(20px, 3.6vw, 30px);
}
.kicker { font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--color-accent-2-200); }
.hero-price-row { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-top: 10px; }
.hero-price { font-family: var(--font-heading); font-size: clamp(46px, 11vw, 72px); line-height: 0.95; color: #fff; }
.hero-price-sub { font-size: 15px; color: var(--color-accent-2-200); padding-bottom: 12px; }
.hero-tiles {
display: grid; grid-template-columns: repeat(auto-fit, minmax(clamp(120px, 44%, 210px), 1fr));
gap: 10px; margin-top: 24px;
}
.tile { background: rgba(255, 255, 255, 0.16); border-radius: var(--radius-md); padding: 16px 18px; }
.tile-lbl { font-size: 11.5px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-accent-2-200); }
.tile-val { font-family: var(--font-heading); font-size: 30px; color: #fff; margin-top: 4px; }
.tile-sub { font-size: 12.5px; color: var(--color-accent-2-200); margin-top: 2px; }
.tile-pay { grid-column: 1 / -1; background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.tile-pay .tile-lbl { color: var(--color-accent-2-600); }
.tile-pay-title { font-weight: 700; font-size: 16px; margin-top: 4px; }
.tile-pay-text { font-size: 13px; line-height: 1.45; margin-top: 4px; text-wrap: pretty; }
.flag {
margin-top: 12px; padding: 15px 18px; border-radius: var(--radius-md);
font-size: 13.5px; line-height: 1.5; text-wrap: pretty;
background: rgba(255, 255, 255, 0.16); color: var(--color-accent-2-100);
}
.flag.ok { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.flag.talk { background: var(--color-accent-200); color: var(--color-accent-800); }
.flag.kenso { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
.btn-solid {
padding: 14px 26px; border-radius: 999px; border: none; background: #fff;
color: var(--color-accent-2-700); font-weight: 700; font-size: 15px; cursor: pointer;
}
.btn-outline {
padding: 14px 22px; border-radius: 999px; border: 2px solid rgba(255, 255, 255, 0.4);
background: transparent; color: var(--color-accent-2-100); font-weight: 700; font-size: 15px; cursor: pointer;
}
.btn-outline:hover { background: rgba(255, 255, 255, 0.14); }
/* -------------------------------------------------------------- Bausteine --- */
.block { margin-top: 38px; }
.block h2 { font-size: clamp(23px, 5.4vw, 32px); }
.block-lead { font-size: 14.5px; color: var(--color-neutral-600); margin: 4px 0 14px; max-width: 620px; text-wrap: pretty; }
.section-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
.section-head h2 { flex: 1; min-width: 150px; }
.section-tag {
font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
padding: 6px 13px; border-radius: 999px;
background: var(--color-accent-200); color: var(--color-accent-800);
}
.section-tag.kenso { background: var(--color-accent-2-200); color: var(--color-accent-2-800); }
.group-label {
font-size: 11.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--color-neutral-500); margin: 22px 0 10px;
}
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
.chip {
display: inline-flex; align-items: center; gap: 9px; padding: 12px 20px; border-radius: 999px;
border: 2px solid var(--color-neutral-300); background: transparent;
color: var(--color-neutral-700); font-weight: 700; font-size: 15px; cursor: pointer; min-height: 48px;
}
.chip.on { border-color: var(--color-accent); background: var(--color-accent-100); color: var(--color-accent-800); }
.chip .mark {
flex: 0 0 20px; height: 20px; border-radius: 999px; border: 2px solid var(--color-neutral-400);
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 11px;
}
.chip.on .mark { border-color: var(--color-accent); background: var(--color-accent); }
.chip .count {
font-weight: 700; font-size: 13px; padding: 2px 9px; border-radius: 999px;
background: var(--color-accent-200); color: var(--color-accent-800);
}
.item-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(clamp(130px, 45%, 300px), 1fr)); gap: 10px; }
.item {
display: flex; align-items: flex-start; gap: clamp(9px, 2vw, 14px);
padding: clamp(13px, 2.6vw, 18px) clamp(14px, 2.8vw, 20px);
border-radius: var(--radius-lg); border: 2px solid var(--color-neutral-200);
background: var(--color-neutral-100); cursor: pointer; min-height: 72px; text-align: left;
color: inherit; width: 100%;
}
.item.on { border-color: var(--color-accent); background: var(--color-accent-100); }
.item .mark {
flex: 0 0 clamp(20px, 5vw, 26px); width: clamp(20px, 5vw, 26px); height: clamp(20px, 5vw, 26px);
border-radius: 999px; border: 2px solid var(--color-neutral-400);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 13px; font-weight: 700; margin-top: 2px;
}
.item.on .mark { border-color: var(--color-accent); background: var(--color-accent); }
.item-body { flex: 1; min-width: 0; }
.item-name { display: block; font-weight: 600; font-size: clamp(14px, 2.9vw, 16px); line-height: 1.3; }
.item-desc {
display: block; font-size: clamp(11.5px, 2.4vw, 13px); line-height: 1.45;
color: var(--color-neutral-600); margin-top: 4px; text-wrap: pretty;
}
.item-hrs {
display: inline-block; margin-top: 9px; padding: 4px 11px; border-radius: 999px;
background: var(--color-neutral-200); color: var(--color-neutral-600);
font-size: 12.5px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap;
}
.item.on .item-hrs { background: var(--color-accent-200); color: var(--color-accent-800); }
.qty { display: flex; align-items: center; gap: 6px; margin-top: 10px; max-width: 100%; }
.qty button {
flex: 0 0 auto; width: clamp(34px, 9vw, 44px); height: 44px; border-radius: 999px;
border: 2px solid var(--color-neutral-300); background: #fff; color: var(--color-accent-700);
font-size: 20px; font-weight: 700; cursor: pointer; line-height: 1; padding: 0;
}
.qty input {
flex: 1 1 auto; min-width: 0; width: clamp(44px, 13vw, 64px); height: 44px; border-radius: 999px;
border: 2px solid var(--color-neutral-300); background: #fff; font: inherit; font-size: 15px;
font-weight: 700; text-align: center; color: var(--color-text); padding: 0;
}
.empty-hint {
margin-top: 34px; padding: 34px; border-radius: var(--radius-lg);
background: var(--color-neutral-100); border: 2px dashed var(--color-neutral-300); text-align: center;
}
.empty-hint h3 { font-size: 24px; margin-bottom: 6px; }
.empty-hint p { font-size: 14.5px; color: var(--color-neutral-600); margin: 0; }
.panel { margin-top: 38px; border-radius: var(--radius-lg); background: var(--color-surface); padding: clamp(20px, 3.6vw, 30px); }
.panel h2 { font-size: clamp(23px, 5.4vw, 30px); margin: 6px 0 4px; }
.panel-lead { font-size: 14.5px; line-height: 1.55; color: var(--color-neutral-700); margin: 0 0 16px; max-width: 560px; text-wrap: pretty; }
.chosen-chip {
display: inline-flex; align-items: center; gap: 10px; padding: 9px 16px; border-radius: 999px;
background: var(--color-neutral-100); font-size: 14px;
}
.chosen-chip b { color: var(--color-accent-700); font-weight: 700; }
/* ---------------------------------------------------------------- Termin --- */
.seg { display: inline-flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--color-neutral-200); margin-bottom: 18px; }
.seg button {
padding: 11px 22px; border-radius: 999px; border: none; background: transparent;
color: var(--color-neutral-700); font-weight: 700; font-size: 14.5px; cursor: pointer; min-height: 44px;
}
.seg button.on { background: var(--color-accent); color: #fff; }
.person {
display: flex; align-items: center; gap: 12px; padding: 12px 20px 12px 12px; border-radius: 999px;
border: 2px solid var(--color-neutral-200); background: var(--color-neutral-100);
color: var(--color-text); cursor: pointer; min-height: 52px; text-align: left;
}
.person.on { border-color: var(--color-accent); background: var(--color-accent-100); }
.person .ava {
flex: 0 0 34px; height: 34px; border-radius: 999px; background: var(--color-neutral-400); color: #fff;
display: flex; align-items: center; justify-content: center; font-family: var(--font-heading); font-size: 15px;
}
.person.on .ava { background: var(--color-accent); }
.person b { display: block; font-weight: 700; font-size: 15px; }
.person span.role { display: block; font-size: 12.5px; color: var(--color-neutral-600); }
.day-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(clamp(74px, 22%, 110px), 1fr)); gap: 8px; }
.day {
padding: 12px 6px; border-radius: var(--radius-md); border: 2px solid var(--color-neutral-200);
background: var(--color-neutral-100); color: var(--color-text); cursor: pointer; min-height: 74px;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
}
.day.on { border-color: var(--color-accent); background: var(--color-accent); color: #fff; }
.day:disabled { background: transparent; color: var(--color-neutral-500); opacity: 0.45; cursor: default; }
.day .wd { font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.day .dm { font-family: var(--font-heading); font-size: 20px; line-height: 1.1; }
.day .free { font-size: 11px; font-weight: 600; }
.slot {
padding: 12px 22px; border-radius: 999px; border: 2px solid var(--color-neutral-200);
background: var(--color-neutral-100); color: var(--color-text);
font-weight: 700; font-size: 15px; cursor: pointer; min-height: 48px;
}
.slot.on { border-color: var(--color-accent); background: var(--color-accent); color: #fff; }
.field-label {
display: block; font-size: 11.5px; font-weight: 700; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--color-neutral-500); margin-bottom: 8px;
}
textarea.kal-input, input.kal-input {
width: 100%; border-radius: var(--radius-md); border: 2px solid var(--color-neutral-300);
background: var(--color-neutral-100); padding: 14px 16px; font: inherit; font-size: 15px;
line-height: 1.5; color: var(--color-text); resize: vertical;
}
textarea.kal-input { min-height: 84px; }
.term-status {
margin-top: 16px; padding: 15px 18px; border-radius: var(--radius-md);
font-size: 14.5px; line-height: 1.5; text-wrap: pretty;
background: var(--color-neutral-100); color: var(--color-neutral-700);
}
.term-status.fixed { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.term-save { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 14px; }
.term-save button:disabled { opacity: 0.45; cursor: not-allowed; }
.term-save-hint { font-size: 13px; color: var(--color-neutral-600); flex: 1; min-width: 200px; text-wrap: pretty; }
.term-save-hint.done { color: var(--color-accent-2-700); font-weight: 700; }
.term-save-hint.error { color: var(--color-accent-800); font-weight: 700; }
.slot:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
.slot.taken-own {
border-color: var(--color-accent-2-500); background: var(--color-accent-2-100);
color: var(--color-accent-2-800); text-decoration: none; opacity: 1;
}
/* ------------------------------------------------------------ Kundenportal --- */
.portal-box {
margin-top: 38px; border-radius: var(--radius-lg); background: var(--color-neutral-100);
border: 2px solid var(--color-neutral-200); padding: clamp(18px, 3.4vw, 28px) clamp(18px, 3.6vw, 30px);
display: flex; gap: clamp(16px, 3vw, 30px); align-items: center; flex-wrap: wrap;
}
.portal-qr { flex: 0 0 auto; background: #fff; border-radius: var(--radius-md); padding: clamp(10px, 2vw, 14px); box-shadow: var(--shadow-sm); }
.portal-qr img { display: block; width: clamp(104px, 26vw, 180px); height: clamp(104px, 26vw, 180px); }
.portal-info { flex: 1; min-width: 190px; }
.portal-info h2 { font-size: 28px; margin: 6px 0 8px; }
.cred-row { display: flex; flex-wrap: wrap; gap: 10px; }
.cred {
background: #fff; border-radius: 999px; padding: 8px 20px;
display: flex; align-items: center; gap: 10px; flex: 1 1 240px;
}
.cred span {
font-size: 11px; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--color-neutral-500); white-space: nowrap;
}
.cred input {
flex: 1; min-width: 0; border: none; background: transparent; font: inherit;
font-size: 15.5px; font-weight: 700; color: var(--color-text); padding: 4px 0;
}
.cred input.mono { color: var(--color-accent-700); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
footer { margin-top: 34px; font-size: 12.5px; color: var(--color-neutral-500); line-height: 1.6; }
/* ---------------------------------------------------------------- Dialog --- */
.backdrop {
position: fixed; inset: 0; z-index: 50; background: rgba(32, 30, 29, 0.55);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.backdrop[hidden] { display: none; }
.dialog {
width: 100%; max-width: 640px; max-height: 84vh; overflow: auto; background: var(--color-bg);
border-radius: var(--radius-lg); padding: 26px 28px; box-shadow: var(--shadow-lg);
}
.dialog h2 { font-size: 26px; margin-bottom: 12px; }
.dialog textarea {
width: 100%; height: 340px; border-radius: var(--radius-md); border: 2px solid var(--color-neutral-300);
background: var(--color-neutral-100); padding: 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.55;
color: var(--color-text); resize: vertical;
}
.dialog-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
.btn-accent {
padding: 13px 24px; border-radius: 999px; border: none; background: var(--color-accent);
color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
}
.btn-quiet {
padding: 13px 22px; border-radius: 999px; border: 2px solid var(--color-neutral-300);
background: transparent; color: var(--color-neutral-700); font-weight: 700; font-size: 15px; cursor: pointer;
}
[hidden] { display: none !important; }
@media print {
.kal-header, .hero-actions, .seg, .qty, .backdrop { display: none !important; }
body { background: #fff; }
.hero { color: #201e1d; background: var(--color-accent-2-100); }
.hero-price, .tile-val, .kal-brand { color: var(--color-accent-2-800); }
}
-176
View File
@@ -1,176 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rechnungsanalyse – Webklar</title>
<link rel="stylesheet" href="/kalkulation.css">
</head>
<body>
<header class="kal-header">
<div class="kal-header-inner">
<!-- Der Weg zurueck ins Admin-Panel steckt im Logo. -->
<a class="kal-brand" href="/admin.html" title="Zurück zum Admin-Panel">webklar</a>
<span class="kal-spacer"></span>
<span class="kal-stat">
<span class="kal-stat-lbl">Projektpreis</span>
<span class="kal-stat-val big" id="hdr-net">0 €</span>
</span>
<span class="kal-stat">
<span class="kal-stat-lbl">Stunden</span>
<span class="kal-stat-val" id="hdr-hrs">0,00</span>
</span>
<span class="kal-stat">
<span class="kal-stat-lbl">Monatlich</span>
<span class="kal-stat-val" id="hdr-monthly">–</span>
</span>
</div>
</header>
<main>
<!-- Preis-Übersicht -->
<section class="hero">
<div class="kicker" id="hero-kicker">Ihr Projekt</div>
<div class="hero-price-row">
<div class="hero-price" id="hero-net">0 €</div>
<div class="hero-price-sub" id="hero-sub">Endpreis · gemäß § 19 UStG ohne Umsatzsteuer</div>
</div>
<div class="hero-tiles">
<div class="tile">
<div class="tile-lbl">Aufwand</div>
<div class="tile-val" id="hero-hrs">0,00 Std</div>
<div class="tile-sub" id="hero-rate">Stundensatz 150 €/Std</div>
</div>
<div class="tile">
<div class="tile-lbl">Monatlich</div>
<div class="tile-val" id="hero-monthly">–</div>
<div class="tile-sub" id="hero-monthly-sub">nichts Laufendes gewählt</div>
</div>
<div class="tile tile-pay">
<div class="tile-lbl">Zahlung</div>
<div class="tile-pay-title" id="pay-title">Noch nichts gewählt</div>
<div class="tile-pay-text" id="pay-text">Kreuze an, was der Kunde braucht.</div>
</div>
</div>
<div class="flag" id="flag"><b>Noch offen.</b> Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.</div>
<div class="hero-actions">
<button type="button" class="btn-solid" data-act="open-summary">Zusammenfassung</button>
<button type="button" class="btn-outline" data-act="reset">Zurücksetzen</button>
</div>
</section>
<!-- Bereiche -->
<section class="block">
<h2>Worum geht es?</h2>
<p class="block-lead">Bereiche antippen — nur die ausgewählten werden unten angezeigt.</p>
<div class="chips" id="type-chips"></div>
</section>
<!-- Positionen (gerendert) -->
<div id="sections"></div>
<div class="empty-hint" id="empty-hint" hidden>
<h3>Noch kein Bereich gewählt</h3>
<p>Tippe oben an, worum es geht — dann erscheinen hier die Positionen.</p>
</div>
<!-- Auswahl -->
<section class="panel" id="chosen-panel" hidden>
<div class="group-label" style="margin:0 0 14px" id="chosen-label">Ausgewählt (0)</div>
<div class="chips" id="chosen-list"></div>
</section>
<!-- Termin -->
<section class="panel">
<div class="kicker" style="color:var(--color-neutral-600)">Termin</div>
<h2>Gespräch festlegen</h2>
<p class="panel-lead">Nur wenn gewünscht: Mit wem, wann — und worum es geht. Der Termin hängt an der Zusammenfassung.</p>
<div class="seg" id="want-seg">
<button type="button" data-act="want" data-want="0" class="on">Kein Termin nötig</button>
<button type="button" data-act="want" data-want="1">Termin gewünscht</button>
</div>
<div id="termin-body" hidden>
<div class="chips" id="person-list"></div>
<div class="group-label" id="days-label">Freie Tage</div>
<div class="day-grid" id="day-list"></div>
<div id="slot-wrap" hidden>
<div class="group-label" id="slots-label">Uhrzeit</div>
<div class="chips" id="slot-list"></div>
</div>
<div style="margin-top:20px">
<label class="field-label" for="term-note">Worum geht es im Gespräch?</label>
<textarea class="kal-input" id="term-note"
placeholder="z. B. Shop-Anbindung an die Warenwirtschaft klären"></textarea>
</div>
<div class="term-status" id="term-status">
<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
</div>
<div class="term-save">
<button type="button" class="btn-accent" id="term-save" data-act="save-termin" disabled>
Termin ins Ticket eintragen
</button>
<span class="term-save-hint" id="term-save-hint">Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.</span>
</div>
</div>
<div style="margin-top:18px">
<label class="field-label" for="notes">Weitere Notizen zum Projekt</label>
<textarea class="kal-input" id="notes" style="min-height:110px"
placeholder="Alles, was sonst noch wichtig ist: Wünsche, Fristen, Ansprechpartner, Besonderheiten…"></textarea>
</div>
</section>
<!-- Kundenportal -->
<section class="portal-box">
<div class="portal-qr">
<img src="/img/portal-qr.png" alt="QR-Code zum Kundenportal project.webklar.com" width="180" height="180">
</div>
<div class="portal-info">
<div class="kicker" style="color:var(--color-neutral-600)">Kundenportal</div>
<h2>Code scannen und einloggen</h2>
<p class="panel-lead">Im Portal sieht der Kunde den Stand des Projekts, alle Dateien und die Rechnungen —
<a href="https://project.webklar.com/" target="_blank" rel="noreferrer">project.webklar.com</a></p>
<div class="cred-row">
<label class="cred">
<span>E-Mail</span>
<input id="portal-email" type="email" autocomplete="off" placeholder="kunde@firma.de">
</label>
<label class="cred">
<span>Passwort</span>
<input id="portal-pass" class="mono" type="text" autocomplete="off" placeholder="wird vergeben">
</label>
</div>
</div>
</section>
<footer>
webklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · 0170 4969375<br>
Alle Preise sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). Stundenwerte sind Richtwerte.
</footer>
</main>
<!-- Zusammenfassung -->
<div class="backdrop" id="summary-dialog" hidden>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="summary-title">
<h2 id="summary-title">Zusammenfassung</h2>
<textarea id="summary-text" readonly></textarea>
<div class="dialog-actions">
<button type="button" class="btn-accent" data-act="copy">Kopieren</button>
<button type="button" class="btn-quiet" data-act="close-summary">Schließen</button>
</div>
</div>
</div>
<script src="/kalkulation.js"></script>
<script>
initKalkulation();
</script>
</body>
</html>
-838
View File
@@ -1,838 +0,0 @@
/* Rechnungsanalyse / Projektkalkulation.
Wird aus dem Admin-Panel bei einem Lead geoeffnet (kalkulation.html?customer=<id>)
und im Gespraech vor Ort oder am Telefon ausgefuellt.
Ergebnis ist bewusst KEIN eigener Datensatz: die Zusammenfassung wird kopiert
und im Akquise-Ticket dokumentiert (dort wird gearbeitet). Damit ein
angefangenes Gespraech nicht verloren geht, liegt der Stand pro Kunde im
localStorage des Browsers. */
/* --------------------------------------------------------------- Katalog --- */
// Kleinunternehmer (§ 19 UStG): alle Preise sind Endpreise, keine Umsatzsteuer
const USt_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung).'
const CATS = [
{
id: 'web', name: 'Website', kenso: false,
hint: 'Alles, was zu einer Website gehört. Das Grundpaket deckt Startseite und drei Unterseiten ab.',
groups: [
{ g: 'Grundlage', items: [
{ id: 'w1', n: 'Grundpaket Website', d: 'Startseite und bis zu 3 Unterseiten, Texte, Design, mobil, Impressum und Datenschutz', h: 2 },
{ id: 'w2', n: 'Weitere Unterseite inkl. Text', d: 'Anzahl eintragen', h: 0.25, q: true },
{ id: 'w3', n: 'Bilder beschaffen und aufbereiten', h: 0.5 },
{ id: 'w4', n: 'Logo neu gestalten', d: 'einfaches Logo, kein Corporate Design', h: 0.5 },
{ id: 'w5', n: 'Domain registrieren und einrichten', h: 0.25 },
{ id: 'w6', n: 'Bestehende Domain umziehen', d: 'Relaunch: Kunde bringt Domain mit', h: 0.5 },
{ id: 'w7', n: 'Inhalte von der alten Seite übernehmen', d: 'Relaunch', h: 1 },
{ id: 'w8', n: 'E-Mail-Postfächer einrichten', h: 0.5 },
] },
{ g: 'Funktionen', items: [
{ id: 'f1', n: 'Kontaktformular', h: 0.25 },
{ id: 'f2', n: 'Anfahrt, Karte, Öffnungszeiten', h: 0.25 },
{ id: 'f3', n: 'Bildergalerie oder Referenzen', h: 0.5 },
{ id: 'f4', n: 'Google-Bewertungen einbinden', h: 0.5 },
{ id: 'f5', n: 'Cookie-Banner und Consent-Tool', h: 0.5 },
{ id: 'f6', n: 'Newsletter-Anmeldung', h: 0.5 },
{ id: 'f7', n: 'Stellenanzeigen mit Bewerbungsformular', h: 0.75 },
{ id: 'f8', n: 'Blog- oder Newsbereich', h: 1 },
{ id: 'f9', n: 'Terminbuchung', h: 1.5 },
{ id: 'f10', n: 'Weitere Sprache', d: 'Anzahl der zusätzlichen Sprachen', h: 1, q: true },
] },
{ g: 'Sichtbarkeit', items: [
{ id: 's1', n: 'Google-Profil einrichten oder aufräumen', h: 0.75 },
{ id: 's2', n: 'SEO-Grundoptimierung', h: 1 },
{ id: 's3', n: 'Statistik und Tracking einrichten', h: 0.5 },
{ id: 's4', n: 'Google Ads einrichten', d: 'Werbebudget zahlt der Kunde selbst', h: 1.5 },
] },
],
},
{
id: 'shop', name: 'Onlineshop', kenso: true,
hint: 'Shop-Positionen stimmt Kenso mit ab — du nimmst hier nur den Bedarf auf.',
groups: [
{ g: 'Shop', items: [
{ id: 'o1', n: 'Shop-Grundeinrichtung', d: 'Warenkorb, Kasse, Bestellabwicklung', h: 6 },
{ id: 'o2', n: 'Produkte anlegen', d: 'Anzahl in 25er-Schritten', h: 1, q: true },
{ id: 'o3', n: 'Zahlungsanbindung', h: 1.5 },
{ id: 'o4', n: 'Versand- und Steuerregeln', h: 1 },
{ id: 'o5', n: 'Gutscheine und Rabatte', h: 1 },
{ id: 'o6', n: 'Kundenkonten im Shop', h: 1.5 },
{ id: 'o7', n: 'Anbindung an die Warenwirtschaft', h: 4 },
] },
],
},
{
id: 'soft', name: 'Softwareprojekt', kenso: true,
hint: 'Eigenentwicklung: Umfang aufnehmen, den Preis bestätigt Kenso.',
groups: [
{ g: 'Fundament', items: [
{ id: 'p1', n: 'Projektfundament', d: 'Architektur, Datenmodell, Grundgerüst, Deployment', h: 8 },
{ id: 'p2', n: 'Datenbank aufsetzen und modellieren', h: 4 },
{ id: 'p3', n: 'Benutzerkonten und Login', h: 3 },
{ id: 'p4', n: 'Rollen und Rechte', h: 2.5 },
] },
{ g: 'Oberfläche und Verwaltung', items: [
{ id: 'a1', n: 'Admin-Panel Grundgerüst', h: 5 },
{ id: 'a2', n: 'Weiterer Verwaltungsbereich', d: 'je Bereich zum Anlegen, Bearbeiten, Löschen', h: 1.5, q: true },
{ id: 'a3', n: 'Produktkatalog mit Kategorien', h: 3 },
{ id: 'a4', n: 'Such- und Filterfunktion', h: 2 },
{ id: 'a5', n: 'Auswertungen und Dashboard', h: 3 },
{ id: 'a6', n: 'Import und Export', d: 'CSV oder Excel', h: 1.5 },
{ id: 'a7', n: 'Datei-Upload und Dokumentenablage', h: 1.5 },
{ id: 'a8', n: 'Benachrichtigungen per E-Mail', h: 1.5 },
] },
{ g: 'App', items: [
{ id: 'm1', n: 'Web-App', d: 'läuft im Browser, auch auf dem Handy', h: 6 },
{ id: 'm2', n: 'Mobile App zusätzlich', d: 'iOS und Android, inkl. Store-Veröffentlichung', h: 12 },
{ id: 'm3', n: 'Offline-Funktion', h: 4 },
{ id: 'm4', n: 'Push-Benachrichtigungen', h: 2 },
] },
{ g: 'Schnittstellen', items: [
{ id: 'i1', n: 'Schnittstelle zu einem Fremdsystem', d: 'Anzahl der Systeme', h: 4, q: true },
{ id: 'i2', n: 'Zahlungsanbieter anbinden', h: 2 },
{ id: 'i3', n: 'Datenübernahme aus einem Altsystem', h: 3 },
] },
],
},
{
id: 'auto', name: 'Automatisierung und KI', kenso: true,
hint: 'Jeder Ablauf wird einzeln gezählt — lieber grob schätzen als weglassen.',
groups: [
{ g: 'Automatisierung', items: [
{ id: 'z1', n: 'Wiederkehrenden Ablauf automatisieren', d: 'je Ablauf, z. B. Terminbestätigung oder Datenweitergabe', h: 3, q: true },
{ id: 'z2', n: 'Automatische Angebots- oder Rechnungserzeugung', h: 3 },
{ id: 'z3', n: 'Dokumente automatisch auslesen', d: 'Lieferscheine, Rechnungen, Formulare', h: 4 },
{ id: 'z4', n: 'KI-Assistent mit eigenem Firmenwissen', d: 'laufende Nutzungskosten trägt der Kunde', h: 6 },
] },
],
},
{
id: 'run', name: 'Laufende Kosten', kenso: false, monthly: true,
hint: 'Was nach dem Start jeden Monat läuft.',
groups: [
{ g: 'Monatlich', items: [
{ id: 'r1', n: 'Wartung und Hosting', d: 'Betrieb, Backups, 1 Stunde Änderungen pro Monat', m: 50 },
{ id: 'r2', n: 'Erhöhter Serverbedarf', d: 'eigene Datenbank oder App-Backend', m: 0, ask: true },
{ id: 'r3', n: 'KI-Nutzung', d: 'ab 50 € je Anliegen pro Monat – je nach Umfang mehr', m: 50, q: true },
] },
],
},
]
// Stundensatz: 100 € für kleine Aufträge, ab 10 Stunden (größeres Projekt) 80 €
const STAFFEL = [{ max: 9.99, r: 100 }, { max: 1e9, r: 80 }]
const PEOPLE = {
justin: {
name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
email: 'justin@webklar.com',
slots: {
1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
2: ['09:00', '10:00', '16:00', '17:00'],
3: ['09:00', '10:00', '11:00', '16:00'],
4: ['09:00', '10:00', '16:00', '17:00'],
5: ['09:00', '10:00', '11:00'],
},
},
kenso: {
name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K',
email: 'kenso@webklar.com',
slots: { 2: ['14:00', '15:00', '18:00'], 4: ['14:00', '15:00', '18:00'], 6: ['10:00', '11:00'] },
},
}
const ITEM_BY_ID = {}
for (const cat of CATS) {
for (const grp of cat.groups) {
for (const item of grp.items) ITEM_BY_ID[item.id] = { cat, item }
}
}
/* ----------------------------------------------------------------- State --- */
const state = {
types: { web: true },
sel: {},
qty: {},
wantTermin: false,
person: 'justin',
day: '',
slot: '',
termNote: '',
notes: '',
portalEmail: '',
portalPass: '',
}
let customer = null
let storageKey = 'wk-kalkulation:neu'
/* Schon vergebene Termine (aus den Akquise-Tickets), damit nichts doppelt
gebucht wird. Wird beim Laden und nach jedem Speichern aktualisiert. */
let booked = []
let savedAppointment = null
/* --------------------------------------------------------------- Helfer --- */
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function fmt(n) {
return Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function eur(n) {
return Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' €'
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || `Fehler ${response.status}`)
return data
}
function el(id) {
return document.getElementById(id)
}
/* ------------------------------------------------------- Zwischenspeicher --- */
function saveState() {
try {
localStorage.setItem(storageKey, JSON.stringify({ ...state, savedAt: new Date().toISOString() }))
} catch {
/* z. B. privater Modus: dann eben ohne Zwischenspeicher */
}
}
function restoreState() {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return
const saved = JSON.parse(raw)
for (const key of Object.keys(state)) {
if (saved[key] !== undefined && saved[key] !== null) state[key] = saved[key]
}
} catch {
/* kaputter Eintrag: ignorieren */
}
}
/* --------------------------------------------------------------- Rechnen --- */
function picked() {
const out = []
for (const cat of CATS) {
for (const grp of cat.groups) {
for (const item of grp.items) {
if (!state.sel[item.id]) continue
const q = item.q ? (state.qty[item.id] || 1) : 1
out.push({
cat, item, q,
h: cat.monthly ? 0 : (item.h || 0) * q,
m: cat.monthly ? (item.m || 0) * q : 0,
})
}
}
}
return out
}
function totals() {
const sel = picked()
const hrsNum = sel.reduce((a, x) => a + x.h, 0)
const rateNum = STAFFEL.find((s) => hrsNum <= s.max).r
const netNum = hrsNum * rateNum
const monthlyItems = sel.filter((x) => x.cat.monthly)
const askServer = monthlyItems.some((x) => x.item.ask)
const mSum = monthlyItems.reduce((a, x) => a + x.m, 0)
const kenso = sel.some((x) => x.cat.kenso)
let payTitle = 'Noch nichts gewählt'
let payText = 'Kreuze an, was der Kunde braucht.'
if (netNum >= 3000) {
payTitle = '40 / 40 / 20'
payText = `${eur(netNum * 0.4)} bei Auftrag, ${eur(netNum * 0.4)} bei Designfreigabe, ${eur(netNum * 0.2)} nach Fertigstellung.`
} else if (netNum >= 1000) {
payTitle = '50 % Anzahlung'
payText = `${eur(netNum * 0.5)} bei Auftragserteilung, der Rest nach der Abnahme.`
} else if (netNum > 0) {
payTitle = 'Keine Anzahlung'
payText = 'Eine Rechnung nach der Abnahme, zahlbar in 14 Tagen.'
}
let flagTitle = 'Noch offen.'
let flagText = 'Sobald etwas gewählt ist, steht hier, ob du selbst abschließen kannst.'
let tone = 'neutral'
if (netNum > 0 || monthlyItems.length) {
if (kenso) {
tone = 'kenso'
flagTitle = 'Kenso gibt frei.'
flagText = 'Ein paar Positionen laufen über ihn. Preis und Termin nennst du erst nach seinem Okay — die Zahl hier ist deine Orientierung.'
} else if (hrsNum <= 5) {
tone = 'ok'
flagTitle = 'Kannst du selbst zusagen.'
flagText = 'Vorschau in unter einer Woche, fertig in maximal einem Monat.'
} else {
tone = 'talk'
flagTitle = 'Termin kurz abstimmen.'
flagText = `Bei ${fmt(hrsNum)} Stunden legt Kenso den Fertigstellungstermin fest. Der Preis steht.`
}
}
return {
sel, hrsNum, rateNum, netNum,
mSum, askServer, monthlyItems, hasMonthly: monthlyItems.length > 0,
payTitle, payText, flagTitle, flagText, tone,
monthly: eur(mSum) + (askServer ? ' +' : ''),
}
}
/* ---------------------------------------------------------------- Termin --- */
/* Feste Zeitfenster der Person, ergaenzt um Zeiten, die in WOMS bereits am
Ticket stehen (jemand kann dort auch von Hand einen Termin setzen). */
function slotsFor(personId, date) {
const base = PEOPLE[personId].slots[date.getDay()] || []
const key = dayKey(date)
const email = PEOPLE[personId].email
const extra = booked
.filter((a) => a.date === key && a.employeeEmail === email && a.time && !base.includes(a.time))
.map((a) => a.time)
return extra.length ? [...new Set([...base, ...extra])].sort() : base
}
function dayKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
/* Ist die Zeit beim aktuell gewaehlten Mitarbeiter schon vergeben?
'own' = der Termin dieses Kunden (der darf natuerlich stehen bleiben). */
function slotTaken(dateKey, time) {
const email = PEOPLE[state.person].email
const hit = booked.find((a) => a.date === dateKey && a.time === time && a.employeeEmail === email)
if (!hit) return null
return hit.customerId && customer && hit.customerId === customer.id ? 'own' : 'other'
}
function terminDays() {
const today = new Date()
today.setHours(0, 0, 0, 0)
const list = []
for (let i = 1; i <= 14; i += 1) {
const d = new Date(today)
d.setDate(today.getDate() + i)
const key = dayKey(d)
const slots = slotsFor(state.person, d)
list.push({
key,
date: d,
slots,
free: slots.filter((s) => slotTaken(key, s) !== 'other'),
})
}
return list
}
async function loadAppointments() {
const days = terminDays()
if (!days.length) return
try {
const { appointments } = await api(
`/api/portal-admin/appointments?from=${days[0].key}&to=${days[days.length - 1].key}`
)
booked = appointments || []
} catch (err) {
console.warn('Belegte Termine konnten nicht geladen werden:', err.message)
booked = []
}
}
function selectedDay() {
return terminDays().find((d) => d.key === state.day && d.free.length) || null
}
function longDay(date) {
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function terminLine() {
const day = selectedDay()
if (!state.wantTermin || !day || !state.slot) return ''
return `${longDay(day.date)} um ${state.slot} Uhr mit ${PEOPLE[state.person].name}`
}
/* ---------------------------------------------------------- Zusammenfassung --- */
function summaryText() {
const t = totals()
const lines = ['PROJEKTKALKULATION webklar', '']
lines.push(`Kunde: ${customer?.name || '______________________'}`)
if (customer?.code) lines.push(`Kundennr.: ${customer.code}`)
if (customer?.email) lines.push(`E-Mail: ${customer.email}`)
if (customer?.phone) lines.push(`Telefon: ${customer.phone}`)
lines.push(`Datum: ${new Date().toLocaleDateString('de-DE')}`)
lines.push('', 'UMFANG')
let cur = ''
for (const x of t.sel.filter((s) => !s.cat.monthly)) {
if (x.cat.name !== cur) {
cur = x.cat.name
lines.push('', `[${cur}]`)
}
lines.push(`- ${x.item.n}${x.q > 1 ? ` (${x.q}×)` : ''} – ${fmt(x.h)} Std`)
}
if (!t.sel.length) lines.push('(noch nichts gewählt)')
lines.push('')
lines.push(`Summe: ${fmt(t.hrsNum)} Stunden`)
lines.push(`Stundensatz: ${eur(t.rateNum)}`)
lines.push(`Projektpreis: ${eur(t.netNum)} (Endpreis, ohne Umsatzsteuer)`)
lines.push('', 'ZAHLUNG', `${t.payTitle} — ${t.payText}`)
if (t.hasMonthly) {
lines.push('', 'LAUFEND')
for (const x of t.monthlyItems) {
lines.push(`- ${x.item.n}${x.q > 1 ? ` (${x.q}×)` : ''}: ${x.item.ask ? 'nach Last – rechnet Kenso aus' : `${eur(x.m)} pro Monat`}`)
}
lines.push(`Summe: ${eur(t.mSum)} pro Monat${t.askServer ? ' zzgl. Serveranteil – rechnet Kenso aus' : ''}`)
}
lines.push('', 'HINWEIS', `${t.flagTitle} ${t.flagText}`)
const term = terminLine()
if (term) {
lines.push('', 'TERMIN', term)
if (state.termNote) lines.push(`Thema: ${state.termNote}`)
if (savedAppointment?.woid) lines.push(`Eingetragen in Ticket #${savedAppointment.woid}`)
}
if (state.notes) lines.push('', 'NOTIZEN', state.notes)
if (state.portalEmail || state.portalPass) {
lines.push('', 'KUNDENPORTAL (project.webklar.com)')
if (state.portalEmail) lines.push(`E-Mail: ${state.portalEmail}`)
if (state.portalPass) lines.push(`Passwort: ${state.portalPass}`)
}
lines.push('', `Alle Preise sind Endpreise. ${USt_HINWEIS}`)
return lines.join('\n')
}
/* --------------------------------------------------------------- Rendern --- */
function renderNumbers(t) {
el('hdr-net').textContent = eur(t.netNum)
el('hdr-hrs').textContent = fmt(t.hrsNum)
el('hdr-monthly').textContent = t.hasMonthly ? t.monthly : '–'
el('hero-net').textContent = eur(t.netNum)
el('hero-sub').textContent = 'Endpreis · gemäß § 19 UStG ohne Umsatzsteuer'
el('hero-hrs').textContent = `${fmt(t.hrsNum)} Std`
el('hero-rate').textContent = `Stundensatz ${eur(t.rateNum)}/Std`
el('hero-monthly').textContent = t.hasMonthly ? t.monthly : '–'
el('hero-monthly-sub').textContent = t.askServer
? 'zzgl. Serveranteil, rechnet Kenso aus'
: (t.hasMonthly ? 'Wartung, Hosting, Backups' : 'nichts Laufendes gewählt')
el('pay-title').textContent = t.payTitle
el('pay-text').textContent = t.payText
const flag = el('flag')
flag.className = `flag ${t.tone === 'neutral' ? '' : t.tone}`.trim()
flag.innerHTML = `<b>${escapeHtml(t.flagTitle)}</b> ${escapeHtml(t.flagText)}`
}
function renderTypeChips(t) {
el('type-chips').innerHTML = CATS.map((cat) => {
const on = Boolean(state.types[cat.id])
const count = t.sel.filter((x) => x.cat.id === cat.id).length
return `<button type="button" class="chip${on ? ' on' : ''}" data-act="toggle-type" data-id="${cat.id}" aria-pressed="${on}">
<span class="mark">${on ? '✓' : ''}</span>${escapeHtml(cat.name)}${count ? `<span class="count">${count}</span>` : ''}
</button>`
}).join('')
}
function itemHours(cat, item) {
const q = item.q ? (state.qty[item.id] || 1) : 1
if (cat.monthly) return item.ask ? 'n. Last' : `${eur((item.m || 0) * q)}/Mon`
return `${fmt((item.h || 0) * q)} Std`
}
function renderItem(cat, item) {
const on = Boolean(state.sel[item.id])
const q = state.qty[item.id] || 1
return `<div class="item${on ? ' on' : ''}" role="button" tabindex="0" aria-pressed="${on}" data-act="toggle-item" data-id="${item.id}">
<span class="mark">${on ? '✓' : ''}</span>
<span class="item-body">
<b class="item-name">${escapeHtml(item.n)}</b>
${item.d ? `<span class="item-desc">${escapeHtml(item.d)}</span>` : ''}
<span class="item-hrs">${escapeHtml(itemHours(cat, item))}</span>
${item.q ? `<span class="qty">
<button type="button" data-act="qty" data-id="${item.id}" data-delta="-1" aria-label="Anzahl verringern">−</button>
<input type="number" min="1" step="1" value="${q}" data-act="qty-set" data-id="${item.id}" aria-label="Anzahl">
<button type="button" data-act="qty" data-id="${item.id}" data-delta="1" aria-label="Anzahl erhöhen">+</button>
</span>` : ''}
</span>
</div>`
}
function renderSections() {
const visible = CATS.filter((cat) => state.types[cat.id])
el('empty-hint').hidden = visible.length > 0
el('sections').innerHTML = visible.map((cat) => `
<section class="block">
<div class="section-head">
<h2>${escapeHtml(cat.name)}</h2>
<span class="section-tag${cat.kenso ? ' kenso' : ''}">${cat.kenso ? 'Kenso gibt frei' : 'darfst du selbst'}</span>
</div>
<p class="block-lead">${escapeHtml(cat.hint)}</p>
${cat.groups.map((grp) => `
<div class="group-label">${escapeHtml(grp.g)}</div>
<div class="item-grid">${grp.items.map((item) => renderItem(cat, item)).join('')}</div>
`).join('')}
</section>`).join('')
}
function renderChosen(t) {
el('chosen-panel').hidden = t.sel.length === 0
el('chosen-label').textContent = `Ausgewählt (${t.sel.length})`
el('chosen-list').innerHTML = t.sel.map((x) => {
const value = x.cat.monthly
? (x.item.ask ? 'n. Last' : `${eur(x.m)}/M`)
: `${fmt(x.h)} Std`
return `<span class="chosen-chip">${escapeHtml(x.item.n)}${x.q > 1 ? ` ×${x.q}` : ''}<b>${escapeHtml(value)}</b></span>`
}).join('')
}
function renderTermin() {
el('termin-body').hidden = !state.wantTermin
document.querySelectorAll('#want-seg button').forEach((btn) => {
btn.classList.toggle('on', (btn.dataset.want === '1') === state.wantTermin)
})
if (!state.wantTermin) return
el('person-list').innerHTML = Object.keys(PEOPLE).map((id) => {
const p = PEOPLE[id]
const on = state.person === id
return `<button type="button" class="person${on ? ' on' : ''}" data-act="person" data-id="${id}" aria-pressed="${on}">
<span class="ava">${p.initial}</span>
<span><b>${escapeHtml(p.name)}</b><span class="role">${escapeHtml(p.role)}</span></span>
</button>`
}).join('')
const days = terminDays()
el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}`
el('day-list').innerHTML = days.map((d) => {
const free = d.free.length
const on = state.day === d.key && free > 0
return `<button type="button" class="day${on ? ' on' : ''}" data-act="day" data-key="${d.key}"${free ? '' : ' disabled'}>
<span class="wd">${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}</span>
<span class="dm">${d.date.getDate()}.${d.date.getMonth() + 1}.</span>
<span class="free">${free ? `${free} frei` : '—'}</span>
</button>`
}).join('')
const day = selectedDay()
el('slot-wrap').hidden = !day
if (day) {
el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}`
el('slot-list').innerHTML = day.slots.map((s) => {
const taken = slotTaken(day.key, s)
const on = state.slot === s
const cls = ['slot', on ? 'on' : '', taken === 'own' && !on ? 'taken-own' : ''].filter(Boolean).join(' ')
const label = taken === 'other' ? `${s} · belegt` : (taken === 'own' && !on ? `${s} · dieser Kunde` : s)
return `<button type="button" class="${cls}" data-act="slot" data-t="${s}"
${taken === 'other' ? 'disabled' : ''} aria-pressed="${on}">${label}</button>`
}).join('')
}
renderTermStatus()
renderSaveButton()
}
function renderSaveButton() {
const btn = el('term-save')
const hint = el('term-save-hint')
if (!btn) return
const ready = Boolean(terminLine()) && Boolean(customer)
btn.disabled = !ready || btn.dataset.busy === '1'
if (hint.dataset.sticky === '1') return
hint.className = 'term-save-hint'
if (!customer) {
hint.textContent = 'Ohne Kunde kann kein Ticket beschrieben werden — Seite über einen Lead öffnen.'
} else if (!ready) {
hint.textContent = 'Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.'
} else {
hint.textContent = `Trägt den Termin am Akquise-Ticket von ${customer.name} ein — damit steht er im Planboard.`
}
}
function renderTermStatus() {
const box = el('term-status')
const line = terminLine()
if (line) {
box.className = 'term-status fixed'
box.innerHTML = `<b>Termin steht:</b> ${escapeHtml(line)}${state.termNote ? ` — Thema: ${escapeHtml(state.termNote)}` : ''}`
} else {
box.className = 'term-status'
box.innerHTML = '<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.'
}
}
function render() {
const t = totals()
renderNumbers(t)
renderTypeChips(t)
renderSections()
renderChosen(t)
renderTermin()
saveState()
}
/* --------------------------------------------------------------- Aktionen --- */
function toggleItem(id) {
if (!ITEM_BY_ID[id]) return
state.sel[id] = !state.sel[id]
render()
}
function changeQty(id, delta) {
state.qty[id] = Math.max(1, (state.qty[id] || 1) + delta)
if (!state.sel[id]) state.sel[id] = true
render()
}
function setQty(id, value) {
const n = Math.max(1, parseInt(value, 10) || 1)
state.qty[id] = n
render()
}
/* Steht am Akquise-Ticket schon ein Termin, ist das die Wahrheit - der
Zwischenspeicher im Browser hat dann das Nachsehen. */
function adoptExistingAppointment() {
const existing = booked.find((a) => a.customerId === customer.id)
if (!existing) return
savedAppointment = existing
const personId = Object.keys(PEOPLE).find((id) => PEOPLE[id].email === existing.employeeEmail)
if (!personId) return
state.wantTermin = true
state.person = personId
state.day = existing.date
state.slot = existing.time
}
function clearSaveHint() {
const hint = el('term-save-hint')
if (hint) delete hint.dataset.sticky
}
/* Termin am Akquise-Ticket eintragen -> erscheint im WOMS-Planboard. */
async function saveTermin() {
const day = selectedDay()
if (!day || !state.slot || !customer) return
const btn = el('term-save')
const hint = el('term-save-hint')
btn.dataset.busy = '1'
btn.disabled = true
hint.dataset.sticky = '1'
hint.className = 'term-save-hint'
hint.textContent = 'Wird eingetragen…'
try {
const { appointment } = await api(`/api/portal-admin/customers/${encodeURIComponent(customer.id)}/appointment`, {
method: 'POST',
body: JSON.stringify({ date: day.key, time: state.slot, employeeEmail: PEOPLE[state.person].email }),
})
savedAppointment = appointment
hint.className = 'term-save-hint done'
const art = appointment.type && appointment.type.toLowerCase() !== 'akquise' ? `${appointment.type}-Ticket` : 'Ticket'
hint.textContent = `Eingetragen in ${art} ${appointment.woid ? `#${appointment.woid}` : ''} – steht jetzt im Planboard.`
await loadAppointments()
} catch (err) {
hint.className = 'term-save-hint error'
hint.textContent = err.message
} finally {
delete btn.dataset.busy
render()
}
}
function openSummary() {
el('summary-text').value = summaryText()
el('summary-dialog').hidden = false
}
function closeSummary() {
el('summary-dialog').hidden = true
}
async function copySummary(btn) {
const text = el('summary-text').value
let ok = false
try {
await navigator.clipboard.writeText(text)
ok = true
} catch {
// Fallback fuer Browser ohne Clipboard-API bzw. ohne HTTPS-Kontext
const area = el('summary-text')
area.removeAttribute('readonly')
area.select()
ok = document.execCommand('copy')
area.setAttribute('readonly', '')
}
btn.textContent = ok ? 'Kopiert ✓' : 'Bitte manuell markieren'
setTimeout(() => { btn.textContent = 'Kopieren' }, 2200)
}
function resetSelection() {
if (!confirm('Alle angekreuzten Positionen zurücksetzen? Notizen und Termin bleiben erhalten.')) return
state.sel = {}
state.qty = {}
render()
}
/* ------------------------------------------------------------------- Init --- */
function bindEvents() {
document.addEventListener('click', (event) => {
const target = event.target.closest('[data-act]')
if (!target) return
const act = target.dataset.act
// Klicks auf die Anzahl-Steuerung treffen zuerst deren eigenes data-act,
// schalten die Karte also nicht mit um.
switch (act) {
case 'toggle-type':
state.types[target.dataset.id] = !state.types[target.dataset.id]
render()
break
case 'toggle-item':
toggleItem(target.dataset.id)
break
case 'qty':
changeQty(target.dataset.id, Number(target.dataset.delta))
break
case 'want':
state.wantTermin = target.dataset.want === '1'
clearSaveHint()
render()
break
case 'person':
state.person = target.dataset.id
state.day = ''
state.slot = ''
clearSaveHint()
render()
break
case 'day':
state.day = target.dataset.key
state.slot = ''
clearSaveHint()
render()
break
case 'slot':
state.slot = target.dataset.t
clearSaveHint()
render()
break
case 'save-termin':
saveTermin()
break
case 'open-summary': openSummary(); break
case 'close-summary': closeSummary(); break
case 'copy': copySummary(target); break
case 'reset': resetSelection(); break
default: break
}
})
document.addEventListener('change', (event) => {
const input = event.target.closest('[data-act="qty-set"]')
if (!input) return
setQty(input.dataset.id, input.value)
})
// Karten sind divs mit role=button -> Tastatur nachruesten.
// Im Anzahl-Feld und auf den +/- Buttons darf das nicht greifen.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
if (event.target.closest('input, textarea, button')) return
const card = event.target.closest('[data-act="toggle-item"]')
if (!card) return
event.preventDefault()
toggleItem(card.dataset.id)
})
el('term-note').addEventListener('input', (event) => {
state.termNote = event.target.value
renderTermStatus()
saveState()
})
el('notes').addEventListener('input', (event) => {
state.notes = event.target.value
saveState()
})
el('portal-email').addEventListener('input', (event) => {
state.portalEmail = event.target.value
saveState()
})
el('portal-pass').addEventListener('input', (event) => {
state.portalPass = event.target.value
saveState()
})
el('summary-dialog').addEventListener('click', (event) => {
if (event.target === el('summary-dialog')) closeSummary()
})
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !el('summary-dialog').hidden) closeSummary()
})
}
async function initKalkulation() {
try {
const me = await api('/api/auth/me')
if (!me.authenticated || me.role !== 'admin') {
window.location.href = '/login.html'
return
}
} catch {
window.location.href = '/login.html'
return
}
const customerId = new URLSearchParams(window.location.search).get('customer') || ''
if (customerId) {
storageKey = `wk-kalkulation:${customerId}`
try {
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`)
customer = data.customer
} catch (err) {
console.warn('Kunde konnte nicht geladen werden:', err.message)
}
}
restoreState()
await loadAppointments()
if (customer) {
el('hero-kicker').textContent = `Angebot für ${customer.name}`
if (!state.portalEmail) state.portalEmail = customer.email || ''
adoptExistingAppointment()
}
el('term-note').value = state.termNote
el('notes').value = state.notes
el('portal-email').value = state.portalEmail
el('portal-pass').value = state.portalPass
bindEvents()
render()
}
window.initKalkulation = initKalkulation
+8 -13
View File
@@ -655,17 +655,12 @@ a.portal-btn-outline {
}
}
/* Mitarbeiter-2FA: Code-Schritt */
#mfa-form.hidden { display: none; }
.mfa-hint { margin: 0 0 4px; text-align: center; }
#mfa-code { letter-spacing: 0.3em; font-variant-numeric: tabular-nums; }
.mfa-actions { margin-top: 12px; }
.mfa-link {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
/* Mitarbeiter-Login gesperrt: Link ins Ticketsystem unter dem Hinweis */
.login-staff-link {
display: block;
margin: 8px 0 0;
text-align: center;
font-size: 0.875rem;
font-weight: 700;
color: #fff;
}
+2 -25
View File
@@ -62,6 +62,8 @@
enterkeyhint="go"
>
<p id="login-error" class="error hidden"></p>
<!-- Mitarbeiter-Login ist gesperrt: Link ins Ticketsystem (Adresse aus der Antwort, ticketUrl) -->
<a id="login-staff-link" class="login-staff-link hidden" href="https://ticket.webklar.com">Zum Ticketsystem</a>
<button type="submit" id="login-btn" class="auth-button login-button">
<span class="button-label">Anmelden</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 11 10" aria-hidden="true">
@@ -72,31 +74,6 @@
</svg>
</button>
</form>
<!-- Mitarbeiter mit 2FA: zweiter Schritt, Code aus der E-Mail -->
<form id="mfa-form" class="login-form hidden" novalidate>
<p id="mfa-hint" class="login-hint mfa-hint"></p>
<label class="auth-label" for="mfa-code">Code aus der E-Mail</label>
<input
class="auth-input"
type="text"
name="otp"
id="mfa-code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
pattern="[0-9]{6}"
enterkeyhint="go"
>
<p id="mfa-error" class="error hidden"></p>
<button type="submit" id="mfa-btn" class="auth-button login-button">
<span class="button-label">Anmelden</span>
</button>
<p class="login-hint mfa-actions">
<button type="button" id="mfa-resend" class="mfa-link">Code erneut senden</button>
·
<a href="/login.html" class="mfa-link">Abbrechen</a>
</p>
</form>
</div>
<p class="login-hint">
Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“).
+4 -6
View File
@@ -13,7 +13,6 @@ import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
import featuresRoutes from './routes/features.js'
import customerDataRoutes from './routes/customerData.js'
import portalAdminRoutes from './routes/portalAdmin.js'
import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
@@ -33,14 +32,13 @@ import adminServicesRoutes from './routes/admin/services.js'
import adminCustomersRoutes from './routes/admin/customers.js'
import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { analyseLegalRouter } from './services/analyseLegal.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import teamRoutes from './routes/team/index.js'
import legacyAdminRedirect from './routes/legacyAdminRedirect.js'
import { corsMiddleware } from './middleware/cors.js'
import { kundenansichtGuard } from './middleware/kundenansichtGuard.js'
@@ -74,7 +72,6 @@ app.use('/api/chat', chatRoutes)
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
@@ -83,8 +80,6 @@ app.use('/api/admin/services', adminServicesRoutes)
app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes)
app.use('/api/admin/finanzen', adminFinanzenRoutes)
app.use('/webhook', giteaWebhookRoutes)
@@ -110,6 +105,9 @@ app.use('/embed/', (_req, res, next) => {
})
// Adressen ohne Fassung: aktuelle geprüfte Fassung der Analyse-Rechtstexte (vor express.static)
app.use('/rechtliches', analyseLegalRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
app.use(express.static(publicDir))
app.get('/dashboard.html', (req, res, next) => {
@@ -7,7 +7,7 @@
Betreff: Neue Portalnachricht von {{kunde_name}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{nachricht}}
Platzhalter: {{kunde_name}}, {{nachricht}}, {{team_link}}
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -35,12 +35,12 @@ a{color:#111111;}
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#efe6fb;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#5a2a91;">Neue Nachricht</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">{{kunde_name}} hat im Kundenportal geschrieben</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-size:16px;line-height:25px;color:#111111;">{{nachricht}}</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/admin.html" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Admin-Bereich antworten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Tab „Chat“. Höchstens eine Benachrichtigung je Kunde alle 15 Minuten.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{team_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem antworten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Kunden → Chat. Höchstens eine Benachrichtigung je Kunde alle 15 Minuten.</td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
+1 -1
View File
@@ -52,7 +52,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -39,7 +39,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -39,7 +39,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -40,7 +40,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -39,7 +39,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
+1 -1
View File
@@ -41,7 +41,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -87,7 +87,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -61,7 +61,7 @@ a{color:#111111;}
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Admin-Bereich abschalten. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
@@ -84,7 +84,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -44,7 +44,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -45,7 +45,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
+1 -1
View File
@@ -60,7 +60,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -38,11 +38,11 @@ a{color:#111111;}
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Postfächer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{postfaecher_hinweis}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Neue Gesamtgröße</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{neue_groesse_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB inklusive + {{pakete}} Pakete Zusatzspeicher zu 5 GB)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Fällig</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{faellig}}</td></tr></table></td></tr>
<!-- WENN einrichten -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#eef6ef;border:1px solid #c9e3cd;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Jetzt einrichten</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen in konsoleH auf {{neue_groesse_gb}} GB anheben, in der Regel innerhalb eines Werktags; bis dahin zeigt das Portal „wird eingerichtet“. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#eef6ef;border:1px solid #c9e3cd;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Jetzt einrichten</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen in konsoleH auf {{neue_groesse_gb}} GB anheben, in der Regel innerhalb eines Werktags; bis dahin zeigt das Portal „wird eingerichtet“. Danach die Aufgabe im Ticketsystem (Kunden → E-Mail-Speicher) als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE einrichten -->
<!-- WENN periodenende -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zum Periodenende</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen zum {{faellig}} auf {{neue_groesse_gb}} GB setzen, nicht früher: Bis dahin ist die bisherige Menge bezahlt, eine Erstattung gibt es nicht. Der Kunde weiß, dass ein volles Postfach danach keine neuen E-Mails annimmt. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zum Periodenende</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen zum {{faellig}} auf {{neue_groesse_gb}} GB setzen, nicht früher: Bis dahin ist die bisherige Menge bezahlt, eine Erstattung gibt es nicht. Der Kunde weiß, dass ein volles Postfach danach keine neuen E-Mails annimmt. Danach die Aufgabe im Ticketsystem (Kunden → E-Mail-Speicher) als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE periodenende -->
<!-- WENN teilweise -->
@@ -50,13 +50,13 @@ a{color:#111111;}
<!-- ENDE teilweise -->
<!-- WENN nachweis_fehlt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „speicher“ (Nachweis der Bestellung und Aufgabe fürs Team) konnte nicht in Appwrite gespeichert werden. Bitte in contractEvents nachtragen; bis dahin ist diese Mail die einzige Aufgabe, im Admin-Bereich erscheint sie nicht.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „speicher“ (Nachweis der Bestellung und Aufgabe fürs Team) konnte nicht in Appwrite gespeichert werden. Bitte in contractEvents nachtragen; bis dahin ist diese Mail die einzige Aufgabe, im Ticketsystem (Kunden → E-Mail-Speicher) erscheint sie nicht.</div></td></tr></table></td></tr>
<!-- ENDE nachweis_fehlt -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
+1 -1
View File
@@ -41,7 +41,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
@@ -46,7 +46,7 @@ a{color:#111111;}
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
+11 -15
View File
@@ -3,11 +3,11 @@ import { VIEW_EXPIRED_MESSAGE, endKundenansicht, isExpired, isKundenansicht, log
/**
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg; ebenso alte Ansichten ohne viewId ("Als Kunde ansehen" aus
* dem frueheren Admin-Panel)
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
* Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden.
*/
export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/news\/?$/],
@@ -19,13 +19,9 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
const LEGACY_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
// Eigene Pruefung (Ticketsystem-JWT, Admin-Sitzung) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/']
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
@@ -41,20 +37,20 @@ export async function kundenansichtGuard(req, res, next) {
if (!session) return next()
const current = isKundenansicht(session)
if (current && isExpired(session)) {
// Abgelaufen oder alte Ansicht ohne Kennung (frueheres Admin-Panel): beenden, nichts mehr lesen oder schreiben
if (session.viewAs && (!current || isExpired(session))) {
endKundenansicht(res)
await logOnce(session, 'abgelaufen')
if (current) await logOnce(session, 'abgelaufen')
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
}
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht, alte
// Admin-Ansicht, Kunden-Login): keine fremden Daten in diesen Tab
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht,
// Kunden-Login): keine fremden Daten in diesen Tab
const header = req.get('X-Kundenansicht')
if (header && header !== session.viewId) return replaced(res, session)
if (!session.viewAs) return next()
if (READ_METHODS.includes(req.method)) return next()
if (current && !header) return replaced(res, session)
if (!header) return replaced(res, session)
const list = current ? KUNDENANSICHT_ALLOWED : LEGACY_ALLOWED
if (list.some(([method, re]) => method === req.method && re.test(pathname))) return next()
if (KUNDENANSICHT_ALLOWED.some(([method, re]) => method === req.method && re.test(pathname))) return next()
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
}
-10
View File
@@ -58,16 +58,6 @@ export async function requireSession(req, res, next) {
next()
}
/** Admin-Session: Ticketsystem-Mitarbeiter (Appwrite-Label "admin") im Portal. */
export function requireAdminSession(req, res, next) {
const session = parsePortalSession(req)
if (!session?.appwriteUserId || session.role !== 'admin') {
return res.status(401).json({ error: 'Admin-Anmeldung erforderlich' })
}
req.session = session
next()
}
export function getSessionCustomerId(req) {
return req.session?.customerId
}
+6 -6
View File
@@ -216,7 +216,7 @@ router.get('/', requireSession, async (req, res) => {
router.post('/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {}
if (!PRODUCT_KEYS.includes(productKey)) {
@@ -280,7 +280,7 @@ router.post('/checkout', requireSession, async (req, res) => {
router.post('/portal', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
const url = await createBillingPortalUrl(getSessionCustomerId(req))
return res.json({ url })
@@ -297,7 +297,7 @@ router.post('/portal', requireSession, async (req, res) => {
router.post('/storage/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
return res.json(await previewStorageChange(getSessionCustomerId(req), req.body?.units))
} catch (err) {
@@ -314,7 +314,7 @@ router.post('/storage/preview', requireSession, async (req, res) => {
router.post('/storage', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann der Zusatzspeicher nicht geändert werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann der Zusatzspeicher nicht geändert werden.' })
}
const customerId = getSessionCustomerId(req)
const { units, acceptTerms, termsVersion } = req.body || {}
@@ -350,7 +350,7 @@ router.post('/storage', requireSession, async (req, res) => {
router.post('/switch/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
return res.json(await previewSwitch(getSessionCustomerId(req)))
} catch (err) {
@@ -368,7 +368,7 @@ router.post('/switch/preview', requireSession, async (req, res) => {
router.post('/switch', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nicht gewechselt werden.' })
}
const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {}
if (acceptTerms !== true) {
+2 -2
View File
@@ -5,8 +5,8 @@ import { withLocks } from '../../services/keyedLock.js'
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
/**
* Funktionen (News-Pinnwand) je Kundenprojekt im Admin-Panel.
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/funktionen, dort hinter requireAdminSession.
* Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
*/
export const funktionenAdminRouter = Router()
-37
View File
@@ -1,37 +0,0 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { appwriteAccountFromJwt } from '../../middleware/staffJwt.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
import { resolveStaffAccount } from '../../services/staffAccount.js'
const router = Router()
/**
* Wechsel Ticketsystem -> Projekt-Admin ohne zweiten Login. Wie requireAppwriteStaff wird das JWT
* ueber /account geprueft (eine Sitzung ohne zweiten Faktor bekommt dort 401), aber ohne Label-Pflicht:
* jeder Mitarbeiter darf rueber, wie beim normalen Portal-Login.
*/
router.post('/', async (req, res) => {
try {
const user = await appwriteAccountFromJwt(req)
if (!user?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' })
const staff = await resolveStaffAccount(user.$id, user.email || '', user)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
const token = createStaffPreviewToken({
purpose: 'switch',
userId: user.$id,
email: staff.email,
name: staff.name,
isAdmin: staff.isAdmin,
})
return res.json({
openUrl: `https://${config.preview.baseHost}/api/auth/staff-switch?token=${encodeURIComponent(token)}`,
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
}
})
export default router
+16 -140
View File
@@ -10,7 +10,7 @@ import {
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js'
import { appwriteSessionFetch, loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
@@ -41,7 +41,6 @@ import {
} from '../services/kundenansicht.js'
import { logViewAction } from '../services/customerViewLog.js'
import { resolveStaffAccount } from '../services/staffAccount.js'
import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.js'
const router = Router()
@@ -59,14 +58,7 @@ function sanitizeCustomer(customer) {
}
}
/**
* Backoffice-Login: Jeder Mitarbeiter aus der employees-Collection darf ins
* Admin-Panel (/admin.html). Das Appwrite-Label "admin" ist ausschliesslich die
* WOMS-Adminrolle (Mitarbeiterverwaltung) und KEINE Voraussetzung fuer den
* Portalzugang - es wird nur als Flag in der Session mitgefuehrt.
* Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der
* normale Kunden-Login).
*/
/** Kunden-Login: Kundenkonto mit freigeschaltetem, aktivem Portalzugang, sonst Fehler mit status 403. */
async function validatePortalAccess(appwriteUserId, email) {
let customer = await getCustomerByAppwriteUserId(appwriteUserId)
if (!customer && email) {
@@ -152,6 +144,8 @@ function safePreviewRedirectUrl(url) {
return null
}
const STAFF_LOGIN_BLOCKED = 'Mitarbeiter arbeiten im Ticketsystem.'
router.get('/login-status', (_req, res) => {
const retryAfterSeconds = getLoginCooldownRemainingSec()
res.json({
@@ -171,30 +165,14 @@ router.post('/login', async (req, res) => {
// Appwrite-Konto: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js)
const account = await getUserById(user.$id).catch(() => null)
// Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht
// Mitarbeiter (employees-Collection oder Label admin, mit oder ohne 2FA) arbeiten im Ticketsystem:
// keine Portal-Sitzung, die eben erzeugte Appwrite-Sitzung wieder beenden (best effort)
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) {
// Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung
// mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite
if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) {
const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => {
if (err.type === 'user_email_not_verified') {
err.status = 403
err.message = '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.'
} else if (err.status === 429) {
err.message = 'Zu viele Versuche. Bitte warte einige Minuten.'
}
throw err
})
res.cookie(MFA_COOKIE, key, { ...mfaCookieOptions(), maxAge: MFA_TTL_MS })
return res.json({ mfaRequired: true, email: maskedEmail })
}
await startStaffPortalSession(res, { userId: user.$id, staff })
return res.json({
success: true,
role: 'admin',
admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin },
})
await appwriteSessionFetch('/account/sessions/current', { method: 'DELETE', sessionCookie: user.sessionCookie }).catch((err) =>
console.warn('[auth] Appwrite-Sitzung eines Mitarbeiters nicht beendet:', err.message)
)
return res.status(403).json({ error: STAFF_LOGIN_BLOCKED, ticketUrl: config.womsBaseUrl })
}
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
@@ -351,19 +329,6 @@ router.get('/staff-preview', async (req, res) => {
return res.redirect(previewUrl)
})
// Wechsel aus dem Ticketsystem (POST /api/admin/switch-link): Einmal-Link -> Admin-Sitzung
router.get('/staff-switch', async (req, res) => {
const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'switch')
if (!tokenData) {
return res.redirect(`/login.html?error=${encodeURIComponent('Link abgelaufen, bitte anmelden')}`)
}
await startStaffPortalSession(res, {
userId: tokenData.userId,
staff: { isAdmin: Boolean(tokenData.isAdmin), name: tokenData.name, email: tokenData.email },
})
return res.redirect('/admin.html')
})
// Kundenansicht aus dem Ticketsystem (POST /api/team/customers/:id/kundenansicht): Einmal-Link -> Portal des Kunden
router.get('/kundenansicht', async (req, res) => {
const gone = (text) => res.status(410).type('text/plain; charset=utf-8').send(text)
@@ -442,36 +407,12 @@ router.get('/me', async (req, res) => {
})
}
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
if (session.role === 'admin' && session.appwriteUserId) {
// Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die
// konnten nur Admins bekommen, also als Admin werten.
const isAdmin = session.isAdmin === undefined ? true : Boolean(session.isAdmin)
let customer = null
if (session.customerId) {
const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null)
if (doc) customer = sanitizeCustomer(doc)
}
await refreshPreviewCookie(req, res, {
userId: session.appwriteUserId,
customerId: '',
email: session.adminEmail || session.email || '',
name: session.adminName || session.name || 'Admin',
role: 'admin',
labels: isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
return res.json({
authenticated: true,
role: 'admin',
viewAs: Boolean(session.viewAs && customer),
admin: {
name: session.adminName || session.name || 'Admin',
email: session.adminEmail || session.email || '',
isAdmin,
},
customer,
})
// Mitarbeiter-Sitzung von frueher (Login im Portal oder "Als Kunde ansehen" im frueheren Admin-Panel):
// beenden, Mitarbeiter arbeiten im Ticketsystem
if (session.role === 'admin') {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false, reason: 'staff', redirect: config.womsBaseUrl })
}
if (!session.customerId || !session.appwriteUserId) {
@@ -524,69 +465,4 @@ router.get('/me', async (req, res) => {
}
})
function mfaCookieOptions() {
return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', signed: true }
}
/** Portal-Sitzung + Preview-Cookie fuer einen Mitarbeiter (Login, Code-Bestaetigung, Wechsel aus dem Ticketsystem). */
async function startStaffPortalSession(res, { userId, staff }) {
setPortalSession(res, {
role: 'admin',
isAdmin: staff.isAdmin,
appwriteUserId: userId,
name: staff.name,
email: staff.email,
})
const staffPreviewToken = await createPreviewSessionToken({
userId,
customerId: '',
email: staff.email,
name: staff.name,
role: 'admin',
labels: staff.isAdmin ? ['admin'] : [],
authSource: 'kundenbereich-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions())
}
const MFA_GONE = 'Bitte melde dich erneut an.'
router.post('/login/mfa', async (req, res) => {
const key = req.signedCookies?.[MFA_COOKIE]
try {
const result = await verifyStaffMfa(key, req.body?.otp)
if (!result.ok) {
if (result.reason === 'invalid') return res.status(401).json({ error: 'Code falsch oder abgelaufen.' })
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
return res.status(410).json({ error: MFA_GONE })
}
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
const account = await getUserById(result.userId).catch(() => null)
const staff = await resolveStaffAccount(result.userId, result.email, account)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
await startStaffPortalSession(res, { userId: result.userId, staff })
return res.json({
success: true,
role: 'admin',
admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin },
})
} catch (err) {
if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' })
return res.status(err.status || 500).json({ error: err.message || 'Anmeldung fehlgeschlagen' })
}
})
router.post('/login/mfa/resend', async (req, res) => {
try {
if (!(await resendStaffMfa(req.signedCookies?.[MFA_COOKIE]))) {
res.clearCookie(MFA_COOKIE, mfaCookieOptions())
return res.status(410).json({ error: MFA_GONE })
}
return res.json({ success: true })
} catch (err) {
if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' })
return res.status(err.status || 500).json({ error: err.message || 'Code konnte nicht gesendet werden' })
}
})
export default router
+2 -2
View File
@@ -16,8 +16,8 @@ import {
import { signFileLink } from '../services/signedLinks.js'
/**
* Chat des Teams mit den Kunden. Eingehaengt im Admin-Panel (/api/portal-admin, Cookie) und im Team-Eingang des
* Ticketsystems (/api/team, JWT); beide liefern req.session mit appwriteUserId und Namen des Mitarbeiters.
* Chat des Teams mit den Kunden. Eingehaengt im Team-Eingang des Ticketsystems (/api/team, JWT); requireStaffJwt
* liefert req.session mit appwriteUserId und Namen des Mitarbeiters.
*/
export const chatAdminRouter = Router()
const router = chatAdminRouter
+3 -3
View File
@@ -134,7 +134,7 @@ async function openOrderSession(doc) {
router.post('/news/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { projectId, consent } = req.body || {}
if (consent !== true) {
@@ -182,7 +182,7 @@ const analyseGuard = [requireSession, requireAnalyseEnabled]
router.post('/analyse/activate', analyseGuard, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
}
const { projectId, accentColor, consent } = req.body || {}
const { doc } = await activateAnalyse({
@@ -202,7 +202,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
}
const { doc } = await deactivateAnalyse({
customerId: getSessionCustomerId(req),
+21
View File
@@ -0,0 +1,21 @@
import { config } from '../config.js'
/**
* Admin-Panel (/admin.html) und Rechnungsanalyse (/kalkulation.html) sind abgeschaltet (Etappe 4). Links aus
* aelteren Team-Mails fuehren ins Ticketsystem; /admin.html?gutschrift=<id> oeffnet den Antrag unter
* Kunden -> Gutschriften.
*/
const ANTRAG_ID = /^[A-Za-z0-9._-]{1,64}$/
const toTicketsystem = (pathFor) => (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next()
return res.redirect(302, `${config.womsBaseUrl}${pathFor(req)}`)
}
export default {
admin: toTicketsystem((req) => {
const antrag = typeof req.query.gutschrift === 'string' ? req.query.gutschrift : ''
return ANTRAG_ID.test(antrag) ? `/customers/gutschriften?antrag=${encodeURIComponent(antrag)}` : '/customers'
}),
kalkulation: toTicketsystem(() => '/customers'),
}
+2 -2
View File
@@ -4,8 +4,8 @@ import { listDocuments, Query } from '../services/appwriteAdmin.js'
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
/**
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/offers, dort hinter requireAdminSession.
* Individuelle Abo-Angebote im Ticketsystem (Kunden -> Abo-Angebote, Kunden-Reiter "Abo & Angebote").
* Eingehaengt in routes/team/index.js unter /api/team/offers, dort hinter requireStaffJwt.
*/
export const offersAdminRouter = Router()
-331
View File
@@ -1,331 +0,0 @@
import { Router } from 'express'
import { config } from '../config.js'
import {
parsePortalSession,
requireAdminSession,
setPortalSession,
} from '../middleware/session.js'
import {
getDocument,
listDocuments,
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { listAppointments, saveAppointment } from '../services/appointments.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { listInvoicesForClient } from '../services/invoiceNinja.js'
import { chatAdminRouter, customerNameMap } from './chatAdmin.js'
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
import { offersAdminRouter } from './offers.js'
import { storageTasksAdminRouter } from './storageTasks.js'
import { takeoverAdminRouter } from './takeoverCredits.js'
const router = Router()
router.use(requireAdminSession)
// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften")
router.use('/takeover-credits', takeoverAdminRouter)
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
router.use('/offers', offersAdminRouter)
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
router.use('/storage-tasks', storageTasksAdminRouter)
// Funktionen (News-Pinnwand) je Kundenprojekt aktivieren/abschalten (Kundenliste, Zeile "Funktionen")
router.use('/funktionen', funktionenAdminRouter)
function norm(value) {
return String(value || '').toLowerCase()
}
function isDeployed(project) {
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
}
// ---------------------------------------------------------------- Übersicht --
router.get('/overview', async (_req, res) => {
try {
const [projects, customers, access] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
listDocuments(config.collections.customers, [Query.limit(500)]),
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
])
const nameById = customerNameMap(customers)
const stageOf = (c) => {
const s = norm(c.customerStatus)
if (s === 'lead') return 'lead'
if (s === 'lost') return 'lost'
return 'customer'
}
const recentLogins = access
.filter((a) => a.lastLoginAt)
.sort((a, b) => String(b.lastLoginAt).localeCompare(String(a.lastLoginAt)))
.slice(0, 5)
.map((a) => ({
customerName: nameById[a.customerId] || a.customerId,
lastLoginAt: a.lastLoginAt,
}))
const recentProjects = [...projects]
.sort((a, b) =>
String(b.updatedAt || b.$updatedAt || '').localeCompare(String(a.updatedAt || a.$updatedAt || ''))
)
.slice(0, 5)
.map((p) => ({
projectName: p.projectName || p.subdomain || p.repoFullName || '',
subdomain: p.subdomain || '',
status: p.status || '',
customerName: p.customerId ? nameById[p.customerId] || '' : '',
updatedAt: p.updatedAt || p.$updatedAt || '',
}))
return res.json({
projects: {
total: projects.length,
deployed: projects.filter(isDeployed).length,
withoutWebsite: projects.filter((p) => !p.subdomain).length,
assigned: projects.filter((p) => p.customerId).length,
},
customers: {
total: customers.length,
leads: customers.filter((c) => stageOf(c) === 'lead').length,
fixed: customers.filter((c) => stageOf(c) === 'customer').length,
portalEnabled: customers.filter((c) => c.portalAccessEnabled).length,
},
recentLogins,
recentProjects,
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Übersicht konnte nicht geladen werden' })
}
})
// ------------------------------------------------------------- Projektsuche --
router.get('/projects', async (req, res) => {
const search = norm(req.query.search)
try {
const [projects, customers] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
listDocuments(config.collections.customers, [Query.limit(500)]),
])
const nameById = customerNameMap(customers)
let items = projects
.filter((p) => !p.archived)
.map((p) => ({
id: p.$id,
projectName: p.projectName || '',
subdomain: p.subdomain || '',
previewUrl: p.previewUrl || '',
liveDomain: p.liveDomain || '',
status: p.status || '',
provisioningStatus: p.provisioningStatus || '',
projectType: p.projectType || (p.subdomain ? 'website' : 'repo'),
giteaRepoUrl: p.giteaRepoUrl || '',
repoFullName: p.repoFullName || '',
customerId: p.customerId || '',
customerName: p.customerId ? nameById[p.customerId] || p.customerId : '',
updatedAt: p.updatedAt || p.$updatedAt || '',
}))
if (search) {
items = items.filter((p) =>
[p.projectName, p.subdomain, p.repoFullName, p.customerName, p.status].some((v) =>
norm(v).includes(search)
)
)
}
items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
return res.json({ projects: items })
} catch (err) {
return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' })
}
})
// -------------------------------------------------------------- Kundenliste --
router.get('/customers', async (req, res) => {
const search = norm(req.query.search)
try {
const [customers, access, projects] = await Promise.all([
listDocuments(config.collections.customers, [Query.orderAsc('name'), Query.limit(500)]),
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
])
const lastLoginByCustomer = {}
for (const a of access) {
if (a.customerId && a.lastLoginAt) lastLoginByCustomer[a.customerId] = a.lastLoginAt
}
const projectCount = {}
for (const p of projects) {
if (p.customerId) projectCount[p.customerId] = (projectCount[p.customerId] || 0) + 1
}
let items = customers
.filter((c) => !c.archived)
.map((c) => ({
id: c.$id,
code: c.code || '',
name: c.name || '',
email: c.email || '',
phone: c.phone || '',
location: c.location || '',
customerStatus: c.customerStatus || '',
portalAccessEnabled: Boolean(c.portalAccessEnabled),
invoiceNinjaLinked: Boolean(c.invoiceNinjaClientId),
lastLoginAt: lastLoginByCustomer[c.$id] || '',
projectCount: projectCount[c.$id] || 0,
}))
if (search) {
items = items.filter((c) =>
[c.code, c.name, c.email, c.location].some((v) => norm(v).includes(search))
)
}
return res.json({ customers: items })
} catch (err) {
return res.status(500).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
}
})
// ----------------------------------------------------------------- Termine --
// Termine haengen am Akquise-Ticket (workorders) und werden vom WOMS-Planboard
// gelesen. Siehe server/services/appointments.js.
router.get('/appointments', async (req, res) => {
try {
const appointments = await listAppointments({
from: String(req.query.from || ''),
to: String(req.query.to || ''),
})
return res.json({ appointments })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Termine konnten nicht geladen werden' })
}
})
router.post('/customers/:customerId/appointment', async (req, res) => {
const { date, time, employeeEmail } = req.body || {}
try {
const appointment = await saveAppointment({
customerId: req.params.customerId,
employeeEmail,
date,
time,
})
return res.json({ success: true, appointment })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Termin konnte nicht gespeichert werden' })
}
})
// --------------------------------------------- Einzelner Kunde (Stammdaten) --
// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
router.get('/customers/:customerId', async (req, res) => {
try {
const customer = await getDocument(config.collections.customers, req.params.customerId)
return res.json({
customer: {
id: customer.$id,
code: customer.code || '',
name: customer.name || '',
email: customer.email || '',
phone: customer.phone || '',
location: customer.location || '',
customerStatus: customer.customerStatus || '',
},
})
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht geladen werden' })
}
})
// ------------------------------------------- Kundennummer/Stammdaten pflegen --
router.patch('/customers/:customerId', async (req, res) => {
const { code, location, phone } = req.body || {}
const data = { updatedAt: new Date().toISOString() }
if (code !== undefined) data.code = String(code).trim()
if (location !== undefined) data.location = String(location).trim()
if (phone !== undefined) data.phone = String(phone).trim()
try {
const updated = await updateDocument(config.collections.customers, req.params.customerId, data)
return res.json({
success: true,
customer: { id: updated.$id, code: updated.code || '', name: updated.name || '' },
})
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' })
}
})
// -------------------------------------- Aktivität + Rechnungen eines Kunden --
router.get('/customers/:customerId/activity', async (req, res) => {
try {
const tickets = await getCustomerActivity(req.params.customerId, { ticketLimit: 50 })
return res.json({ tickets })
} catch (err) {
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
}
})
router.get('/customers/:customerId/invoices', async (req, res) => {
try {
const customer = await getDocument(config.collections.customers, req.params.customerId)
if (!customer.invoiceNinjaClientId) {
return res.json({ linked: false, invoices: [] })
}
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId, { includeDrafts: true })
return res.json({ linked: true, invoices })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
}
})
// Chat (Tab "Chat"): eigener Router, auch im Team-Eingang des Ticketsystems eingehaengt
router.use(chatAdminRouter)
// ------------------------------------------------------- Als Kunde ansehen --
router.post('/view-as/:customerId', async (req, res) => {
try {
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
if (!customer) {
return res.status(404).json({ error: 'Kunde nicht gefunden' })
}
const session = req.session
setPortalSession(res, {
role: 'admin',
viewAs: true,
isAdmin: session.isAdmin,
appwriteUserId: session.appwriteUserId,
adminName: session.adminName || session.name || 'Admin',
adminEmail: session.adminEmail || session.email || '',
customerId: customer.$id,
name: customer.name || '',
email: customer.email || '',
})
return res.json({ success: true, customer: { id: customer.$id, name: customer.name || '' } })
} catch (err) {
return res.status(500).json({ error: err.message || 'Ansicht konnte nicht gewechselt werden' })
}
})
router.post('/exit-view-as', (req, res) => {
const session = parsePortalSession(req) || req.session
setPortalSession(res, {
role: 'admin',
isAdmin: session.isAdmin,
appwriteUserId: session.appwriteUserId,
name: session.adminName || session.name || 'Admin',
email: session.adminEmail || session.email || '',
})
return res.json({ success: true })
})
export default router
+4 -4
View File
@@ -8,10 +8,10 @@ import { PRODUCT_KEYS, productLabel } from '../services/products.js'
import { ACTIVE_STATUSES } from '../services/stripeBilling.js'
/**
* Admin-Reiter "E-Mail-Speicher" (Task 13): offene Zusatzspeicher-Aufgaben fuers Team (Postfachgrenze in
* konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen der letzten
* Wochen. Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/storage-tasks, dort hinter
* requireAdminSession.
* "E-Mail-Speicher" im Ticketsystem (Kunden -> E-Mail-Speicher): offene Zusatzspeicher-Aufgaben fuers Team
* (Postfachgrenze in konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen
* der letzten Wochen. Eingehaengt in routes/team/index.js unter /api/team/storage-tasks, dort hinter
* requireStaffJwt.
*
* Jede Aufgabe traegt ihre Art (details.kind, storageTaskKind): "einrichten" = jetzt auf das Ziel setzen,
* "periodenende" = zum Faelligkeitstag auf das Ziel setzen. Hoechstens eine offene Aufgabe je Art und Abo
+4 -4
View File
@@ -27,7 +27,7 @@ import {
/**
* Uebernahmegutschrift.
* Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten)
* Admin: /api/portal-admin/takeover-credits (Liste, Pruefung, Entscheidung, Nachweise)
* Team: /api/team/takeover-credits (Ticketsystem: Liste, Pruefung, Entscheidung, Nachweise)
* Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js.
*/
@@ -73,7 +73,7 @@ router.get('/', async (req, res) => {
router.post('/', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann kein Antrag gestellt werden.' })
return res.status(403).json({ error: 'In der Kundenansicht kann kein Antrag gestellt werden.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
@@ -92,7 +92,7 @@ router.post('/', async (req, res) => {
router.post('/:id/reply', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
@@ -113,7 +113,7 @@ router.post('/:id/reply', async (req, res) => {
export default router
// ---------------------------------------------------------------------------
// Admin (eingehaengt in routes/portalAdmin.js, dort hinter requireAdminSession)
// Team (eingehaengt in routes/team/index.js, dort hinter requireStaffJwt)
// ---------------------------------------------------------------------------
export const takeoverAdminRouter = Router()
+1 -1
View File
@@ -17,7 +17,7 @@ router.use(requireStaffJwt)
router.use('/customers', customersRoutes)
router.use('/preview-link', previewRoutes)
router.use(chatAdminRouter)
// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen: dieselben Router wie im Admin-Panel
// Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen (frueher im Admin-Panel des Portals)
router.use('/takeover-credits', takeoverAdminRouter)
router.use('/offers', offersAdminRouter)
router.use('/storage-tasks', storageTasksAdminRouter)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'
import { buildStaffPreviewLink } from '../admin/previewOpen.js'
import { buildStaffPreviewLink } from '../../services/staffPreviewLink.js'
// Preview aus dem Ticketsystem oeffnen (alle Mitarbeiter): Einmal-Link statt Mitarbeiter-Login im Portal
const router = Router()
-159
View File
@@ -1,159 +0,0 @@
/* Termine der Rechnungsanalyse.
Ein Termin ist kein eigener Datensatz, sondern haengt am Akquise-Ticket des
Kunden (workorders): startDate + startTime + endTime + assignedTo. Das WOMS-
Planboard liest genau diese Felder, damit Termin und Ticket nie auseinander
laufen. Achtung: WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als
"hhmm" - beides wird hier erzeugt und beim Lesen tolerant geparst. */
import { config } from '../config.js'
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
const TICKET_LIMIT = 500
/* ------------------------------------------------------------- Formate --- */
// "2026-08-12" -> "12.08.2026"
export function isoToGerman(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
if (!m) return ''
return `${m[3]}.${m[2]}.${m[1]}`
}
// "12.08.2026" und "12.8.2026" -> "2026-08-12"
export function germanToIso(value) {
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
if (!m) return ''
return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
}
// "09:00" -> "0900"
export function timeToWoms(value) {
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
if (!m) return ''
return `${m[1].padStart(2, '0')}${m[2]}`
}
// "0900" -> "09:00"
export function womsToTime(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const m = /^(\d{1,2}):?(\d{2})$/.exec(raw)
if (!m) return ''
return `${m[1].padStart(2, '0')}:${m[2]}`
}
function addHour(time) {
const m = /^(\d{2}):(\d{2})$/.exec(womsToTime(time))
if (!m) return ''
const hour = (Number(m[1]) + 1) % 24
return `${String(hour).padStart(2, '0')}${m[2]}`
}
/* ---------------------------------------------------------- Mitarbeiter --- */
export async function findEmployeeByEmail(email) {
const wanted = String(email || '').trim().toLowerCase()
if (!wanted) return null
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
return employees.find((e) => String(e.email || '').toLowerCase() === wanted) || null
}
/* -------------------------------------------------------------- Tickets --- */
function isClosed(ticket) {
return String(ticket.status || '').toLowerCase() === 'closed'
}
/* Das Ticket, an das der Termin gehoert: bevorzugt das offene Akquise-Ticket.
Gibt es keins, kommt nur ein anderes offenes Ticket in Frage, das noch KEIN
Datum traegt - bei einem Projekt- oder Webpage-Ticket bedeutet startDate den
Projektstart, den wuerden wir sonst still ueberschreiben. */
export async function findAcquisitionTicket(customerId) {
const tickets = await listDocuments(config.collections.workorders, [
Query.equal('customerId', customerId),
Query.limit(100),
])
if (!tickets.length) return null
const newestFirst = [...tickets].sort((a, b) => String(b.$createdAt || '').localeCompare(String(a.$createdAt || '')))
const akquise = newestFirst.filter((t) => String(t.type || '').toLowerCase() === 'akquise')
if (akquise.length) return akquise.find((t) => !isClosed(t)) || akquise[0]
return newestFirst.find((t) => !isClosed(t) && !t.startDate) || null
}
function toAppointment(ticket, employeesByUserId) {
const dateIso = germanToIso(ticket.startDate)
const time = womsToTime(ticket.startTime)
if (!dateIso) return null
const employee = employeesByUserId[ticket.assignedTo] || null
return {
ticketId: ticket.$id,
woid: ticket.woid || '',
topic: ticket.topic || ticket.title || '',
type: ticket.type || '',
status: ticket.status || '',
customerId: ticket.customerId || '',
customerName: ticket.customerName || '',
customerLocation: ticket.customerLocation || '',
date: dateIso,
time,
endTime: womsToTime(ticket.endTime),
employeeUserId: ticket.assignedTo || '',
employeeName: employee?.displayName || ticket.assignedName || '',
employeeEmail: employee?.email || '',
}
}
/* Alle terminierten Tickets in einem Zeitraum (ISO-Datum, inklusive). */
export async function listAppointments({ from = '', to = '' } = {}) {
const [tickets, employees] = await Promise.all([
listDocuments(config.collections.workorders, [Query.limit(TICKET_LIMIT)]),
listDocuments(config.collections.employees, [Query.limit(100)]),
])
const employeesByUserId = {}
for (const e of employees) employeesByUserId[e.userId] = e
return tickets
.map((t) => toAppointment(t, employeesByUserId))
.filter((a) => a && (!from || a.date >= from) && (!to || a.date <= to))
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time))
}
/* Termin am Akquise-Ticket eintragen. */
export async function saveAppointment({ customerId, employeeEmail, date, time }) {
const germanDate = isoToGerman(date)
const womsTime = timeToWoms(time)
if (!germanDate) throw badRequest('Ungültiges Datum')
if (!womsTime) throw badRequest('Ungültige Uhrzeit')
const employee = await findEmployeeByEmail(employeeEmail)
if (!employee) throw badRequest(`Kein Mitarbeiter mit der E-Mail ${employeeEmail} in WOMS gefunden`)
const ticket = await findAcquisitionTicket(customerId)
if (!ticket) {
throw badRequest('Kein passendes Ticket gefunden. Bitte in WOMS ein Akquise-Ticket für diesen Kunden anlegen.')
}
const updated = await updateDocument(config.collections.workorders, ticket.$id, {
startDate: germanDate,
startTime: womsTime,
endTime: addHour(womsTime),
assignedTo: employee.userId,
assignedName: employee.displayName || '',
})
return {
ticketId: updated.$id,
woid: updated.woid || '',
topic: updated.topic || '',
type: updated.type || '',
date,
time: womsToTime(womsTime),
employeeName: employee.displayName || '',
}
}
function badRequest(message) {
const error = new Error(message)
error.status = 400
return error
}
+3 -18
View File
@@ -50,8 +50,8 @@ function readSessionCookie(response) {
}
/**
* Appwrite-Aufruf im Namen einer Sitzung aus loginWithAppwrite (z. B. MFA-Challenge). Deutsche Sprache,
* damit Appwrite die deutsche Mailvorlage nimmt.
* Appwrite-Aufruf im Namen einer Sitzung aus loginWithAppwrite (z. B. Sitzung eines abgelehnten
* Mitarbeiter-Logins beenden). Deutsche Sprache, damit Appwrite deutsche Texte liefert.
*/
export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie } = {}) {
return appwriteFetch(path, {
@@ -64,21 +64,6 @@ export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie
})
}
/**
* Braucht die Sitzung noch einen zweiten Faktor (Appwrite-2FA)? Fragt Appwrite selbst statt des
* mfa-Flags aus dem Admin-Abruf, damit ein gestoerter Abruf nicht ohne Code durchlaesst. Andere
* Fehler werden geworfen (fail closed).
*/
export async function sessionNeedsSecondFactor(sessionCookie) {
try {
await appwriteSessionFetch('/account', { sessionCookie })
return false
} catch (err) {
if (err.type === 'user_more_factors_required') return true
throw err
}
}
/**
* Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig.
*/
@@ -131,7 +116,7 @@ export async function loginWithAppwrite(email, password) {
throw error
}
// sessionCookie: fuer den zweiten Faktor (Mitarbeiter-2FA) arbeitet der Server im Namen dieser Sitzung weiter
// sessionCookie: lehnt der Login einen Mitarbeiter ab, beendet der Server damit diese Sitzung wieder
const user = { $id: session.userId, email, name: '', sessionCookie }
clearLoginCooldown()
+2 -1
View File
@@ -11,7 +11,8 @@ import {
/**
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
* endet nach 2 Stunden. Sitzungen ohne viewId ("Als Kunde ansehen" im frueheren Admin-Panel) gelten als beendet
* (kundenansichtGuard, /api/auth/me).
*/
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
const COOKIE_GRACE_MS = 60 * 60 * 1000
+7 -2
View File
@@ -62,7 +62,12 @@ export async function sendChatNotification(customer, text) {
const mail = render({
template: 'T01',
values: { kunde_name: customer.name || customer.email || 'Kunde', nachricht: text },
values: {
kunde_name: customer.name || customer.email || 'Kunde',
nachricht: text,
// Knopf "Im Ticketsystem antworten": Messenger mit dem Gespraech dieses Kunden
team_link: `${config.womsBaseUrl}/customers/chat?kunde=${encodeURIComponent(customer.$id)}`,
},
})
await mailer.sendMail({
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
@@ -83,7 +88,7 @@ const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp
/**
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
* Wirft nie; das Ergebnis zeigt das Admin-Panel unter der Nachricht an.
* Wirft nie; das Ergebnis zeigt das Ticketsystem unter der Nachricht an.
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
*/
export async function sendCustomerChatNotice(customer, { sender, text }) {
+2 -2
View File
@@ -11,7 +11,7 @@ import { shortRef } from './takeoverCredits.js'
* Individuelle Abo-Angebote.
*
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
* je 25 € statt 50 €) und legt ihn vorab im Ticketsystem an. Jedes Angebot bekommt sofort einen
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
@@ -24,7 +24,7 @@ const COLLECTION = () => config.collections.aboOffers
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
// Anzeige im Team-Hinweis (wie im Ticketsystem)
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
function httpError(status, message) {
+2 -2
View File
@@ -45,8 +45,8 @@ async function currentPasswordVersion(userId) {
}
/**
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Sessions der
* Kundenansicht (Mitarbeiter) und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
*/
export async function isSessionPasswordCurrent(session) {
-80
View File
@@ -1,80 +0,0 @@
import { randomBytes } from 'node:crypto'
import { appwriteSessionFetch } from './appwriteClient.js'
/**
* Offene Mitarbeiter-Anmeldungen mit E-Mail-Code (Appwrite-2FA). Das Passwort ist geprueft, die
* Appwrite-Sitzung hat aber erst einen Faktor; erst der richtige Code aus der Mail macht daraus eine
* Portal-Sitzung. Nur im Speicher: nach einem Neustart meldet man sich neu an.
*/
export const MFA_COOKIE = 'webklar_portal_mfa'
export const MFA_TTL_MS = 10 * 60 * 1000
const MAX_ATTEMPTS = 5
const pending = new Map()
function purgeExpired() {
const now = Date.now()
for (const [key, entry] of pending) {
if (entry.expiresAt <= now) pending.delete(key)
}
}
function liveEntry(key) {
purgeExpired()
return (key && pending.get(key)) || null
}
async function createChallenge(sessionCookie) {
const challenge = await appwriteSessionFetch('/account/mfa/challenges', {
method: 'POST',
body: { factor: 'email' },
sessionCookie,
})
return challenge.$id
}
/** kenso@webklar.com -> k…@webklar.com */
export function maskEmail(email) {
const [local, domain] = String(email || '').split('@')
if (!local || !domain) return ''
return `${local[0]}…@${domain}`
}
export async function startStaffMfa({ userId, email, sessionCookie }) {
const challengeId = await createChallenge(sessionCookie)
purgeExpired()
const key = randomBytes(32).toString('hex')
pending.set(key, { userId, email, sessionCookie, challengeId, expiresAt: Date.now() + MFA_TTL_MS, attempts: 0 })
return { key, maskedEmail: maskEmail(email) }
}
/** @returns {Promise<{ok: true, userId: string, email: string} | {ok: false, reason: 'invalid' | 'gone'}>} */
export async function verifyStaffMfa(key, otp) {
const entry = liveEntry(key)
if (!entry || entry.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'gone' }
// Versuch vor dem Appwrite-Aufruf reservieren, sonst zaehlen parallele Anfragen alle als erster Versuch
entry.attempts += 1
try {
await appwriteSessionFetch('/account/mfa/challenges', {
method: 'PUT',
body: { challengeId: entry.challengeId, otp: String(otp || '').trim() },
sessionCookie: entry.sessionCookie,
})
} catch (err) {
if (err.type !== 'user_invalid_token') {
entry.attempts -= 1
throw err
}
if (entry.attempts >= MAX_ATTEMPTS) pending.delete(key)
return { ok: false, reason: 'invalid' }
}
pending.delete(key)
return { ok: true, userId: entry.userId, email: entry.email }
}
export async function resendStaffMfa(key) {
const entry = liveEntry(key)
if (!entry) return false
entry.challengeId = await createChallenge(entry.sessionCookie)
return true
}
@@ -1,15 +1,7 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { isPreviewProjectReady } from '../../services/previewReadiness.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
const router = Router()
function isStaffAdmin(user) {
return (user?.labels || []).includes('admin')
}
import { config } from '../config.js'
import { listDocuments, Query } from './appwriteAdmin.js'
import { isPreviewProjectReady } from './previewReadiness.js'
import { createStaffPreviewToken } from './staffPreviewTokens.js'
function safePreviewUrl(url) {
try {
@@ -36,8 +28,8 @@ function previewUrlForSubdomain(subdomain) {
}
/**
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview). Genutzt von dieser
* Admin-Route und vom Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview), genutzt vom
* Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
*/
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
const subdomain = String(rawSubdomain || '')
@@ -76,22 +68,3 @@ export async function buildStaffPreviewLink({ userId, email, name, subdomain: ra
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
}
}
router.use(requireAppwriteStaff)
router.post('/', async (req, res) => {
const user = req.appwriteUser
if (!isStaffAdmin(user)) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
}
const { status, body } = await buildStaffPreviewLink({
userId: user.$id,
email: user.email,
name: user.name,
subdomain: req.body?.subdomain,
previewUrl: req.body?.previewUrl,
})
return res.status(status).json(body)
})
export default router
+2 -3
View File
@@ -21,9 +21,8 @@ export function createStaffPreviewToken(payload) {
}
/**
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
* Zweck ist trotzdem verbraucht.
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) von der Kundenansicht aus dem
* Ticketsystem ('kundenansicht'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
*/
export function consumeStaffPreviewToken(token, purpose = 'preview') {
purgeExpired()
+6 -2
View File
@@ -383,6 +383,10 @@ export function customerView(doc) {
}
}
/** Antrag im Ticketsystem: Kunden-Reiter "Gutschriften" mit geoeffnetem Antrag (Team-Mails T10, T11). */
const teamCreditLink = (doc) =>
`${config.womsBaseUrl}/customers/${encodeURIComponent(doc.customerId)}?tab=gutschriften&antrag=${encodeURIComponent(doc.$id)}`
// ---------------------------------------------------------------------------
// Kunde: Antrag stellen, Rueckfrage beantworten
// ---------------------------------------------------------------------------
@@ -457,7 +461,7 @@ export async function createTakeoverRequest({ customer, input, uploads, consentI
email: input.email,
...summary,
nachricht: input.message,
admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
admin_link: teamCreditLink(doc),
},
blocks: { nachricht: Boolean(input.message) },
})
@@ -486,7 +490,7 @@ export async function answerQuestion({ customer, doc, message, uploads }) {
antrag_nr: shortRef(doc.$id),
antwort: text || '(ohne Text)',
neue_nachweise: added.map((f) => f.name).join(', '),
admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
admin_link: teamCreditLink(doc),
},
blocks: { neue_nachweise: added.length > 0 },
})
+4 -4
View File
@@ -20,7 +20,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, viewAsCookie, seedFixtures, subscriptionDoc, stripeSubscription } =
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, stripeSubscription } =
await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js')
@@ -123,7 +123,7 @@ test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async ()
assert.equal(r.subscription.canSwitch, false)
})
test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
test('Ohne Session 401, alte Mitarbeiter-Sitzung ist keine Kunden-Session', async () => {
assert.equal((await rawGet('/api/abo')).status, 401)
assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401)
await assert.rejects(get('/api/abo'), (e) => e.status === 401)
@@ -225,8 +225,8 @@ test('Checkout mit laufendem Abo → 409', async () => {
assert.equal(call, null)
})
test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') })
test('Checkout in der Kundenansicht → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: kundenansichtCookie({ customerId: 'c3' }) })
assert.equal(res.status, 403)
assert.equal(call, null)
})
+96
View File
@@ -0,0 +1,96 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import path from 'node:path'
import { Router } from 'express'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Etappe 4: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen sind weg; was das Ticketsystem nutzt, bleibt
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { viewAsCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: legacyAdminRedirect } = await import('../server/routes/legacyAdminRedirect.js')
// Hinter dem Waechter: eine Lese- und eine Schreib-Route, die selbst immer 200 liefern
const probe = Router()
probe.get('/', (_req, res) => res.json({ ok: true }))
probe.post('/', (_req, res) => res.json({ ok: true }))
const app = await startTestApp({ '/': kundenansichtGuard, '/api/projects': probe, '/admin.html': legacyAdminRedirect.admin, '/kalkulation.html': legacyAdminRedirect.kalkulation })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
const root = new URL('..', import.meta.url).pathname
const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c))
function filesUnder(dir) {
const out = []
for (const name of readdirSync(dir)) {
const full = path.join(dir, name)
if (statSync(full).isDirectory()) out.push(...filesUnder(full))
else if (/\.(js|mjs|html|css)$/.test(name)) out.push(full)
}
return out
}
test('Admin-Panel und alte Admin-Schnittstellen sind weg', () => {
for (const f of [
'public/admin.html', 'public/admin.js', 'public/kalkulation.html', 'public/kalkulation.js', 'public/kalkulation.css',
'server/routes/portalAdmin.js', 'server/routes/admin/switchLink.js', 'server/routes/admin/previewOpen.js', 'server/services/appointments.js',
]) {
assert.equal(existsSync(path.join(root, f)), false, f)
}
const index = readFileSync(path.join(root, 'server/index.js'), 'utf8')
for (const mount of ['/api/portal-admin', '/api/admin/switch-link', '/api/admin/preview-open']) assert.ok(!index.includes(`'${mount}'`), mount)
for (const f of ['public/chat-composer.js', 'public/chat-composer.css', 'public/kundenansicht.js', 'server/services/staffPreviewLink.js']) {
assert.ok(existsSync(path.join(root, f)), `${f} muss bleiben`)
}
})
test('keine Verweise mehr auf admin.html, kalkulation.html oder /api/portal-admin in server/ und public/', () => {
// einzige Ausnahme: die Weiterleitung alter Mail-Links ins Ticketsystem (Modul und seine zwei Mounts)
const allowed = [path.join(root, 'server/routes/legacyAdminRedirect.js')]
const allowedLines = ["app.use('/admin.html', legacyAdminRedirect.admin)", "app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)"]
const hits = []
for (const file of [...filesUnder(path.join(root, 'server')), ...filesUnder(path.join(root, 'public'))]) {
if (allowed.includes(file)) continue
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
if (allowedLines.includes(line.trim())) return
if (/admin\.html|kalkulation\.html|\/api\/portal-admin/.test(line)) hits.push(`${path.relative(root, file)}:${i + 1}`)
})
}
assert.deepEqual(hits, [])
})
test('Alt-Ansicht ohne Kennung: Lesen und Schreiben 401 view_expired, Cookies geloescht', async () => {
for (const method of ['GET', 'POST']) {
const res = await fetch(`${app.url}/api/projects`, {
method,
headers: { cookie: viewAsCookie('c1'), ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
body: method === 'POST' ? '{}' : undefined,
})
assert.equal(res.status, 401, method)
assert.equal((await res.json()).code, 'view_expired', method)
assert.ok(clears(res, config.cookieName), `${method}: Portal-Cookie geloescht`)
assert.ok(clears(res, 'preview_session'), `${method}: Preview-Cookie geloescht`)
}
})
test('alte Mail-Links (/admin.html, /kalkulation.html) leiten ins Ticketsystem, Antrag direkt geoeffnet', async () => {
const location = async (p) => {
const res = await fetch(`${app.url}${p}`, { redirect: 'manual' })
assert.equal(res.status, 302, p)
return res.headers.get('location')
}
assert.equal(await location('/admin.html?gutschrift=6f1c2d3e-aaaa-4bbb-8ccc-123456789abc'), 'https://ticket.webklar.com/customers/gutschriften?antrag=6f1c2d3e-aaaa-4bbb-8ccc-123456789abc')
assert.equal(await location('/admin.html'), 'https://ticket.webklar.com/customers')
assert.equal(await location('/admin.html?gutschrift=%3Cscript%3E'), 'https://ticket.webklar.com/customers')
assert.equal(await location('/kalkulation.html'), 'https://ticket.webklar.com/customers')
const index = readFileSync(path.join(root, 'server/index.js'), 'utf8')
assert.ok(index.includes("app.use('/admin.html', legacyAdminRedirect.admin)"), 'in server/index.js eingehaengt')
assert.ok(index.indexOf("app.use('/admin.html'") < index.indexOf('express.static(publicDir)'), 'vor den statischen Dateien')
})
+1 -1
View File
@@ -49,7 +49,7 @@ test('Sessions: nach Passwortaenderung ungueltig, alte ohne Stand und Admins ble
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1', pwv: '2026-09-01T08:00:00.000+00:00' }), false)
// Session von vor dieser Pruefung (ohne pwv) laeuft normal aus
assert.equal(await isSessionPasswordCurrent({ appwriteUserId: 'u1', customerId: 'c1' }), true)
// "Als Kunde ansehen": appwriteUserId ist die des Admins
// Kundenansicht: appwriteUserId ist die des Mitarbeiters
assert.equal(await isSessionPasswordCurrent({ role: 'admin', viewAs: true, appwriteUserId: 'u1', customerId: 'c1', pwv: 'x' }), true)
// Konto ohne Passwort (geleert): keine Session gilt mehr
rememberPasswordVersion('u2', '')
+15 -13
View File
@@ -14,10 +14,10 @@ const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
const { adminCookie, customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
@@ -27,7 +27,7 @@ const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/api/portal-admin': portalAdminRoutes,
'/api/team': teamRoutes,
'/api/public/news': publicNewsRoutes,
})
const mails = mailSink()
@@ -235,9 +235,11 @@ test('parallele_aktivierung_mailt_einmal', async () => {
assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1)
})
// ---------------------------------------------------------------- Admin ---
const adminC = adminCookie()
const adminPost = (customerId, body, cookie = adminC) => post(`/api/portal-admin/funktionen/${customerId}/news`, body, { cookie })
// ------------------------------------------------- Ticketsystem (Team) ---
// Mitarbeiter ohne Admin-Label ueber den Team-Eingang (JWT)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const adminPost = (customerId, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/news`, body, opts)
test('admin_aktivieren_ohne_zahlung', async () => {
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Admin-Test' })
@@ -247,12 +249,12 @@ test('admin_aktivieren_ohne_zahlung', async () => {
const view = res.body.projects.find((p) => p.projectId === 'pa1').features.news
assert.equal(view.state, 'active')
assert.equal(view.ticketWoid, null)
assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Kenso: activate – Kulanz$/)
assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Kulanz$/)
const doc = await sf.getFeatureDoc('c1', 'pa1', 'news')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
assert.equal(workorders().length, wosBefore)
const list = await get('/api/portal-admin/funktionen/c1', { cookie: adminC })
const list = await get('/api/team/funktionen/c1', STAFF)
assert.equal(list.projects.find((p) => p.projectId === 'pa1').features.news.state, 'active')
})
@@ -264,7 +266,7 @@ test('admin_abschalten', async () => {
assert.equal(res.status, 200)
const view = res.body.projects[0].features.news
assert.equal(view.state, 'disabled')
assert.match(view.adminNote, /Kenso: disable \| .*Kenso: activate – Kulanz$/)
assert.match(view.adminNote, /Andrej: disable \| .*Andrej: activate – Kulanz$/)
assert.ok(view.adminNote.length <= 300)
assert.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false)
const { status, body } = await rawGet(publicUrl)
@@ -286,15 +288,15 @@ test('admin_notiz_bleibt_im_limit', async () => {
}
const note = (await sf.getFeatureDoc('c1', 'pa2', 'news')).adminNote
assert.ok(note.length <= 300)
assert.match(note, /^\S+ Kenso: disable – x+/)
assert.match(note, /^\S+ Andrej: disable – x+/)
})
test('kunde_darf_admin_route_nicht', async () => {
aw.seed('websiteProjects', { $id: 'pa5', customerId: 'c1', projectName: 'Kundenversuch' })
const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, customerCookie('c1'))
const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, { cookie: customerCookie('c1') })
assert.ok(res.status === 401 || res.status === 403)
assert.equal(await sf.getFeatureDoc('c1', 'pa5', 'news'), null)
const list = await rawGet('/api/portal-admin/funktionen/c1', { cookie: customerCookie('c1') })
const list = await rawGet('/api/team/funktionen/c1', { cookie: customerCookie('c1') })
assert.ok(list.status === 401 || list.status === 403)
})
@@ -305,7 +307,7 @@ test('ticket_fehlt_markiert', async () => {
const doc = await sf.getFeatureDoc('c1', 'pa3', 'news')
assert.equal(doc.state, 'setup')
const newsOf = async () =>
(await get('/api/portal-admin/funktionen/c1', { cookie: adminC })).projects.find((p) => p.projectId === 'pa3').features.news
(await get('/api/team/funktionen/c1', STAFF)).projects.find((p) => p.projectId === 'pa3').features.news
assert.equal((await newsOf()).ticketMissing, false)
await deleteDocument(config.collections.workorders, doc.ticketId)
const missing = await newsOf()
+5 -6
View File
@@ -10,7 +10,7 @@ const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('cust
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post } = await import('./support/testApp.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { seedFixtures, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
@@ -81,9 +81,8 @@ test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abge
assert.equal(logOf('abgelaufen').length, before + 1)
})
test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => {
const body = await get('/api/auth/me', { cookie: viewAsCookie('c1') })
assert.equal(body.viewAs, true)
assert.equal(body.viewId, undefined)
assert.equal(body.admin.name, 'Kenso')
test('/me alte Ansicht (viewAsCookie, ohne viewId): beendet, reason staff, Weiterleitung ins Ticketsystem', async () => {
const res = await me(viewAsCookie('c1'))
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.equal(res.headers.getSetCookie().some((c) => c.startsWith(`${config.cookieName}=`) && /Expires=Thu, 01 Jan 1970/.test(c)), true)
})
+16 -13
View File
@@ -97,7 +97,7 @@ test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', asy
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
checked += 1
}
assert.ok(checked >= 20, `nur ${checked} gesperrte Routen geprueft`)
assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`)
})
test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => {
@@ -156,13 +156,16 @@ test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abge
assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej'])
})
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {
test('alte Ansicht ohne viewId (frueheres Admin-Panel): Lesen, Schreiben, Logout 401 view_expired, Cookies weg, kein Protokoll', async () => {
const cookie = viewAsCookie('c1')
assert.equal((await call('GET', '/api/news?projectId=p1', { cookie })).status, 200)
const write = await call('POST', '/api/news', { cookie, body: note })
assert.equal(write.status, 403)
assert.equal(write.body.code, 'view_blocked')
assert.equal((await call('POST', '/api/auth/logout', { cookie })).status, 200)
const before = logEntries().length
for (const [method, p, body] of [['GET', '/api/news?projectId=p1'], ['POST', '/api/news', note], ['POST', '/api/auth/logout']]) {
const res = await call(method, p, { cookie, body })
assert.equal(res.status, 401, `${method} ${p}`)
assert.equal(res.body.code, 'view_expired', `${method} ${p}`)
assert.ok(clears(res, config.cookieName), `${method} ${p}: Portal-Cookie geloescht`)
}
assert.equal(logEntries().length, before)
})
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {
@@ -192,18 +195,18 @@ test('Grossschreibung umgeht den Waechter nicht: /API/... wie /api/...', async (
assert.equal(res.status, 403, `${method} ${upper}`)
}
const legacy = await call('POST', '/API/news', { cookie: viewAsCookie('c1'), body: note })
assert.equal(legacy.status, 403)
assert.equal(legacy.status, 401)
const expired = await call('GET', '/Api/Projects', kv({ viewExpiresAt: Date.now() - 1000 }))
assert.equal(expired.status, 401)
})
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
const header = { 'x-kundenansicht': VIEW }
for (const cookie of [customerCookie('c2'), viewAsCookie('c2')]) {
const res = await call('GET', '/api/projects', { cookie, headers: header })
assert.equal(res.status, 409)
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
}
const res = await call('GET', '/api/projects', { cookie: customerCookie('c2'), headers: header })
assert.equal(res.status, 409)
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
// alte Ansicht ohne Kennung: beendet, ebenfalls keine fremden Daten
assert.equal((await call('GET', '/api/projects', { cookie: viewAsCookie('c2'), headers: header })).status, 401)
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
})
+4 -4
View File
@@ -18,7 +18,7 @@ const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { hostingDoc, consent, customerCookie, kundenansichtCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } =
await import('../server/services/mailStorage.js')
@@ -1024,13 +1024,13 @@ test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde
// Routen
// ---------------------------------------------------------------------------
test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
test('Routen: ohne Session 401, in der Kundenansicht 403, ohne Stripe-Aufruf', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
assert.equal((await post('/api/abo/storage/preview', { units: 2 })).status, 401)
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion })).status, 401)
assert.equal((await post('/api/abo/storage/preview', { units: 2 }, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/storage/preview', { units: 2 }, { cookie: kundenansichtCookie() })).status, 403)
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: kundenansichtCookie() })).status, 403)
assert.equal(st.calls.length, before)
assert.equal((await rawGet('/api/abo/storage')).status, 404)
})
+25
View File
@@ -498,3 +498,28 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
assert.match(h.text, /Gebuchter Zusatzspeicher endet mit dem Abo\./)
assert.doesNotMatch(h.text, /Arbeitszeit|Stundenkontingent|Restguthaben/)
})
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16']
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const id of EXPECTED) {
const source = readFileSync(new URL(loadTemplate(id).file, DIR), 'utf8')
assert.ok(!source.includes('admin.html'), `${id}: admin.html`)
assert.ok(!source.includes('Admin-Bereich'), `${id}: Admin-Bereich`)
}
})
test('Fusszeile der Team-Mails: Ticketsystem', () => {
for (const id of TEAM_FOOTER) {
const tpl = loadTemplate(id)
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
assert.match(mail.html, /Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https:\/\/ticket\.webklar\.com\/customers"[^>]*>Ticketsystem<\/a>/, id)
}
})
test('T01: Knopf antwortet im Ticketsystem beim Kunden (team_link)', () => {
const mail = renderMail('T01', { kunde_name: 'Berta', nachricht: 'Hallo', team_link: 'https://ticket.webklar.com/customers/chat?kunde=c1' })
assert.deepEqual(mail.problems, [])
assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/chat\?kunde=c1"[^>]*>Im Ticketsystem antworten &rarr;<\/a>/)
})
+6 -4
View File
@@ -20,7 +20,7 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: newsRoutes } = await import('../server/routes/news.js')
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
// Waechter wie in server/index.js: er sperrt Schreibzugriffe der alten Ansicht "Als Kunde ansehen"
// Waechter wie in server/index.js: alte Ansichten ohne Kennung (frueheres Admin-Panel) gelten als beendet
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -55,10 +55,12 @@ test('nicht_aktiv_409', async () => {
assert.equal(res.body.error, 'News ist für diese Website nicht aktiv.')
})
test('fremdes_projekt_403_und_viewAs_403', async () => {
test('fremdes_projekt_403_und_alte_Ansicht_401', async () => {
assert.equal((await post('/api/news', { ...note, projectId: 'p3' }, { cookie })).status, 403)
const admin = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
assert.equal((await post('/api/news', note, { cookie: admin })).status, 403)
const alt = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
const res = await post('/api/news', note, { cookie: alt })
assert.equal(res.status, 401)
assert.equal(res.body.code, 'view_expired')
})
test('kunde_crud', async () => {
+4 -4
View File
@@ -20,7 +20,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
const { hostingDoc, hosting2Doc, consent, customerCookie, kundenansichtCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, reconcilePendingSwitch, SWITCH_FAILED_REASONS } =
@@ -590,7 +590,7 @@ test('Route: ohne earlyStart 400', async () => {
assert.equal(res.status, 400)
})
test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, Kundenansicht 403, ohne Stripe-Aufruf', async () => {
await resetHosting()
const cookie = customerCookie()
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }
@@ -607,8 +607,8 @@ test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, „Als K
assert.match(stale.body.error, /Bedingungen/)
assert.equal((await post('/api/abo/switch', body)).status, 401)
assert.equal((await post('/api/abo/switch/preview', {})).status, 401)
assert.equal((await post('/api/abo/switch', body, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/switch/preview', {}, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/switch', body, { cookie: kundenansichtCookie() })).status, 403)
assert.equal((await post('/api/abo/switch/preview', {}, { cookie: kundenansichtCookie() })).status, 403)
assert.equal(st.calls.length, before)
})
+61 -73
View File
@@ -4,12 +4,13 @@ import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Portal-Login mit E-Mail-Code fuer Mitarbeiter mit Appwrite-2FA; Kunden und Mitarbeiter ohne 2FA wie bisher
// Portal-Login: Kunden wie bisher; Mitarbeiter (mit oder ohne 2FA) arbeiten im Ticketsystem (Etappe 4)
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { startTestApp, post, rawGet, sessionCookie } = await import('./support/testApp.js')
const { viewAsCookie } = await import('./support/fixtures.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
const app = await startTestApp({ '/api/auth': authRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -17,98 +18,85 @@ after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.addUser({ $id: 'u_kunde', email: 'kunde@example.invalid', password: 'kunde-kunde' })
aw.addUser({ $id: 'u_ohne', email: 'justin@webklar.com', password: 'ohne-ohne-ohne' })
aw.addUser({ $id: 'u_mfa', email: 'kenso@webklar.com', password: 'mit-mit-mit', mfa: true, emailVerification: true, labels: ['admin'] })
aw.addUser({ $id: 'u_label', email: 'label@webklar.com', password: 'label-label', labels: ['admin'] })
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u_kunde', email: 'kunde@example.invalid', name: 'Kunde', customerStatus: 'active', portalAccessEnabled: true })
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
aw.seed('employees', { $id: 'e1', userId: 'u_ohne', displayName: 'Justin', email: 'justin@webklar.com' })
aw.seed('employees', { $id: 'e2', userId: 'u_mfa', displayName: 'Kenso', email: 'kenso@webklar.com' })
const SESSION = config.cookieName
const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' }
const setCookies = (res) => res.headers.getSetCookie()
const cookieNamed = (res, name) => setCookies(res).find((c) => c.startsWith(`${name}=`)) || null
const jar = (res) => setCookies(res).map((c) => c.split(';')[0]).join('; ')
const cleared = (c) => /Expires=Thu, 01 Jan 1970/.test(c)
const liveCookies = (res, name) => setCookies(res).filter((c) => c.startsWith(`${name}=`) && !cleared(c))
const clears = (res, name) => setCookies(res).some((c) => c.startsWith(`${name}=`) && cleared(c))
const login = (email, password) => post('/api/auth/login', { email, password })
test('Kunde ohne 2FA: Anmeldung wie bisher', async () => {
test('Kunde meldet sich weiter normal an', async () => {
const before = aw.sessionDeletes()
const res = await login('kunde@example.invalid', 'kunde-kunde')
assert.equal(res.status, 200)
assert.equal(res.body.mfaRequired, undefined)
assert.ok(cookieNamed(res, SESSION))
assert.equal(res.body.role, 'customer')
assert.equal(liveCookies(res, SESSION).length, 1)
assert.equal(aw.sessionDeletes(), before)
})
test('Mitarbeiter ohne 2FA: Admin-Anmeldung wie bisher', async () => {
test('Mitarbeiter ohne 2FA: 403 mit Link ins Ticketsystem, keine Sitzung, Appwrite-Sitzung beendet', async () => {
const before = aw.sessionDeletes()
const res = await login('justin@webklar.com', 'ohne-ohne-ohne')
assert.equal(res.status, 200)
assert.equal(res.body.role, 'admin')
assert.ok(cookieNamed(res, SESSION))
})
test('Mitarbeiter mit 2FA: vor dem Code keine Portal-Sitzung', async () => {
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(res.status, 200)
assert.deepEqual(res.body, { mfaRequired: true, email: 'k…@webklar.com' })
assert.ok(cookieNamed(res, 'webklar_portal_mfa'))
assert.equal(cookieNamed(res, SESSION), null)
assert.equal(setCookies(res).length, 1)
const me = await rawGet('/api/auth/me', { cookie: jar(res) })
assert.equal(me.body.authenticated, false)
})
test('Richtiger Code: Admin-Sitzung, Code-Cookie geloescht', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) })
assert.equal(res.status, 200)
assert.equal(res.body.role, 'admin')
assert.equal(res.body.admin.isAdmin, true)
assert.ok(cookieNamed(res, SESSION))
assert.match(cookieNamed(res, 'webklar_portal_mfa'), /Expires=Thu, 01 Jan 1970/)
const me = await rawGet('/api/auth/me', { cookie: jar(res) })
assert.equal(me.body.role, 'admin')
})
test('Falscher Code: Hinweis, nach 5 Fehlversuchen neu anmelden', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
for (let i = 0; i < 5; i++) {
const res = await post('/api/auth/login/mfa', { otp: '000000' }, { cookie: jar(first) })
assert.equal(res.status, 401)
assert.equal(res.body.error, 'Code falsch oder abgelaufen.')
assert.equal(cookieNamed(res, SESSION), null)
}
const res = await post('/api/auth/login/mfa', { otp: aw.lastOtp('u_mfa') }, { cookie: jar(first) })
assert.equal(res.status, 410)
assert.equal(res.body.error, 'Bitte melde dich erneut an.')
})
test('Ohne Code-Cookie: neu anmelden', async () => {
const res = await post('/api/auth/login/mfa', { otp: '123456' })
assert.equal(res.status, 410)
const resend = await post('/api/auth/login/mfa/resend', {})
assert.equal(resend.status, 410)
})
test('Code erneut senden: zweite Mail', async () => {
const first = await login('kenso@webklar.com', 'mit-mit-mit')
const before = aw.mailsSent('u_mfa')
const res = await post('/api/auth/login/mfa/resend', {}, { cookie: jar(first) })
assert.equal(res.status, 200)
assert.equal(aw.mailsSent('u_mfa'), before + 1)
})
test('2FA an, E-Mail nicht bestaetigt: Hinweis statt Sitzung', async () => {
aw.addUser({ $id: 'u_unbest', email: 'andrej@webklar.com', password: 'unbest-unbest', mfa: true, emailVerification: false })
aw.seed('employees', { $id: 'e3', userId: 'u_unbest', displayName: 'Andrej', email: 'andrej@webklar.com' })
const res = await login('andrej@webklar.com', 'unbest-unbest')
assert.equal(res.status, 403)
assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.')
assert.equal(cookieNamed(res, SESSION), null)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
assert.equal(liveCookies(res, 'preview_session').length, 0)
assert.equal(aw.sessionDeletes(), before + 1)
})
test('2FA-Pruefung schlaegt nicht offen fehl, wenn der Konto-Abruf scheitert', async () => {
test('Mitarbeiter mit 2FA: dieselbe Antwort, keine Code-Mail', async () => {
const before = aw.sessionDeletes()
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(res.status, 403)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(setCookies(res).filter((c) => !cleared(c)).length, 0)
assert.equal(aw.mailsSent('u_mfa'), 0)
assert.equal(aw.sessionDeletes(), before + 1)
})
test('Nur Label admin (ohne employees-Eintrag): ebenfalls gesperrt', async () => {
const res = await login('label@webklar.com', 'label-label')
assert.equal(res.status, 403)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
})
test('Konto-Abruf gestoert: Mitarbeiter bleibt gesperrt (employees-Treffer)', async () => {
aw.failUserLookups(true)
try {
const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(cookieNamed(res, SESSION), null)
assert.equal(res.body.mfaRequired, true)
assert.equal(res.status, 403)
assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
} finally {
aw.failUserLookups(false)
}
})
test('/me mit alter Mitarbeiter-Sitzung: reason staff, Weiterleitung, Cookies geloescht', async () => {
const res = await rawGet('/api/auth/me', { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_mfa', name: 'Kenso', email: 'kenso@webklar.com' }) })
assert.equal(res.status, 200)
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.ok(clears(res, SESSION))
assert.ok(clears(res, 'preview_session'))
assert.equal(liveCookies(res, 'preview_session').length, 0)
})
test('/me mit Alt-Ansicht (viewAs ohne viewId): ebenso reason staff', async () => {
const res = await rawGet('/api/auth/me', { cookie: viewAsCookie('c1') })
assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.ok(clears(res, SESSION))
})
test('/login/mfa, /login/mfa/resend und /staff-switch gibt es nicht mehr (404)', async () => {
assert.equal((await post('/api/auth/login/mfa', { otp: '123456' })).status, 404)
assert.equal((await post('/api/auth/login/mfa/resend', {})).status, 404)
assert.equal((await rawGet('/api/auth/staff-switch?token=abc')).status, 404)
})
-89
View File
@@ -1,89 +0,0 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Mitarbeiter-2FA per E-Mail-Code: Appwrite-Sitzung aus dem Login und offene Code-Anmeldungen
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { loginWithAppwrite, appwriteSessionFetch } = await import('../server/services/appwriteClient.js')
after(() => Promise.all([aw.close(), st.close()]))
aw.addUser({ $id: 'u_staff', email: 'kenso@webklar.com', password: 'geheim-geheim', mfa: true, emailVerification: true })
test('loginWithAppwrite liefert die Appwrite-Sitzung', async () => {
const user = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim')
assert.equal(user.$id, 'u_staff')
assert.equal(user.sessionCookie, 'sess-u_staff')
})
test('appwriteSessionFetch schickt Sitzung und Sprache', async () => {
const before = aw.mailsSent('u_staff')
const challenge = await appwriteSessionFetch('/account/mfa/challenges', {
method: 'POST',
body: { factor: 'email' },
sessionCookie: 'sess-u_staff',
})
assert.match(challenge.$id, /^ch/)
assert.equal(aw.mailsSent('u_staff'), before + 1)
assert.equal(aw.lastLocale(), 'de')
await assert.rejects(
appwriteSessionFetch('/account/mfa/challenges', { method: 'PUT', body: { challengeId: challenge.$id, otp: '000000' }, sessionCookie: 'sess-u_staff' }),
(err) => err.status === 401 && err.type === 'user_invalid_token'
)
})
const { maskEmail, startStaffMfa, verifyStaffMfa, resendStaffMfa } = await import('../server/services/staffMfa.js')
async function start() {
const { sessionCookie } = await loginWithAppwrite('kenso@webklar.com', 'geheim-geheim')
return startStaffMfa({ userId: 'u_staff', email: 'kenso@webklar.com', sessionCookie })
}
test('maskEmail: erstes Zeichen, Rest der Adresse ausgeblendet', () => {
assert.equal(maskEmail('kenso@webklar.com'), 'k…@webklar.com')
assert.equal(maskEmail('a@b.de'), 'a…@b.de')
assert.equal(maskEmail(''), '')
})
test('Richtiger Code meldet an, danach ist die Anmeldung verbraucht', async () => {
const { key, maskedEmail } = await start()
assert.equal(maskedEmail, 'k…@webklar.com')
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: true, userId: 'u_staff', email: 'kenso@webklar.com' })
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
test('Falscher Code: nach 5 Fehlversuchen ist die Anmeldung weg', async () => {
const { key } = await start()
for (let i = 0; i < 5; i++) assert.deepEqual(await verifyStaffMfa(key, '000000'), { ok: false, reason: 'invalid' })
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
test('Nach 10 Minuten ist die Anmeldung abgelaufen', async (t) => {
const { key } = await start()
const now = Date.now()
t.mock.method(Date, 'now', () => now + 11 * 60 * 1000)
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
test('Code erneut senden: neue Mail, nur der neue Code gilt', async () => {
const { key } = await start()
const oldOtp = aw.lastOtp('u_staff')
const before = aw.mailsSent('u_staff')
assert.equal(await resendStaffMfa(key), true)
assert.equal(aw.mailsSent('u_staff'), before + 1)
const newOtp = aw.lastOtp('u_staff')
if (oldOtp !== newOtp) assert.deepEqual(await verifyStaffMfa(key, oldOtp), { ok: false, reason: 'invalid' })
assert.equal((await verifyStaffMfa(key, newOtp)).ok, true)
assert.equal(await resendStaffMfa(key), false)
})
test('Parallele Fehlversuche: hoechstens 5 Versuche zaehlen', async () => {
const { key } = await start()
const results = await Promise.all(Array.from({ length: 10 }, () => verifyStaffMfa(key, '000000')))
assert.equal(results.filter((r) => r.reason === 'invalid').length, 5)
assert.equal(results.filter((r) => r.reason === 'gone').length, 5)
assert.deepEqual(await verifyStaffMfa(key, aw.lastOtp('u_staff')), { ok: false, reason: 'gone' })
})
-85
View File
@@ -1,85 +0,0 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Wechsel Ticketsystem -> Projekt-Admin per Einmal-Link (purpose 'switch'), Rueckweg-Button im Admin
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
const { default: authRoutes } = await import('../server/routes/auth.js')
const { default: switchLinkRoutes } = await import('../server/routes/admin/switchLink.js')
const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js')
const app = await startTestApp({ '/api/auth': authRoutes, '/api/admin/switch-link': switchLinkRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } })
const switchLink = (jwt) => post('/api/admin/switch-link', {}, jwt ? bearer(jwt) : {})
const tokenOf = (openUrl) => new URL(openUrl).searchParams.get('token')
const portalSession = (res) => {
const cookie = res.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`))
if (!cookie) return null
const raw = decodeURIComponent(cookie.split(';')[0].slice(config.cookieName.length + 1))
return JSON.parse(raw.slice(2, raw.lastIndexOf('.')))
}
test('switch-link: ohne, ungueltiges und halbes JWT -> 401', async () => {
assert.equal((await switchLink(null)).status, 401)
assert.equal((await switchLink('jwt-falsch')).status, 401)
assert.equal((await switchLink('jwt-halb')).status, 401)
})
test('switch-link: kein Mitarbeiter -> 403', async () => {
assert.equal((await switchLink('jwt-kunde')).status, 403)
})
test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () => {
const res = await switchLink('jwt-staff')
assert.equal(res.status, 200)
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/)
})
test('staff-switch: gueltig -> Admin-Sitzung und /admin.html, zweites Mal -> Login', async () => {
const { body } = await switchLink('jwt-staff')
const path = `/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`
const res = await rawGet(path)
assert.equal(res.status, 302)
assert.equal(res.headers.get('location'), '/admin.html')
const session = portalSession(res)
assert.equal(session.role, 'admin')
assert.equal(session.isAdmin, false)
assert.equal(session.appwriteUserId, 'u_staff2')
const again = await rawGet(path)
assert.equal(again.status, 302)
assert.equal(again.headers.get('location'), '/login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden')
assert.equal(portalSession(again), null)
})
test('staff-switch: nach 61 Sekunden abgelaufen', async (t) => {
const { body } = await switchLink('jwt-staff')
const now = Date.now()
t.mock.method(Date, 'now', () => now + 61 * 1000)
const res = await rawGet(`/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`)
assert.match(res.headers.get('location'), /^\/login\.html\?error=/)
})
test('Tokens nicht vertauschbar: Switch ueber staff-preview, Preview ueber staff-switch', async () => {
const { body } = await switchLink('jwt-staff')
assert.equal((await rawGet(`/api/auth/staff-preview?token=${tokenOf(body.openUrl)}`)).status, 410)
const previewToken = createStaffPreviewToken({ userId: 'u_admin', email: 'kenso@webklar.com', name: 'Kenso', previewUrl: 'https://x.project.webklar.com/' })
const res = await rawGet(`/api/auth/staff-switch?token=${previewToken}`)
assert.match(res.headers.get('location'), /^\/login\.html\?error=/)
assert.equal(portalSession(res), null)
})
test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => {
const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8')
const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"'))
assert.match(actions, /<a class="portal-btn-outline" href="https:\/\/ticket\.webklar\.com\/tickets">← Ticketsystem<\/a>/)
})
+34 -39
View File
@@ -7,8 +7,8 @@ import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
/**
* Task 13: Admin-Reiter "E-Mail-Speicher" (GET/POST /api/portal-admin/storage-tasks), Schnittstelle fuers
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden des Reiters.
* "E-Mail-Speicher" im Ticketsystem (GET/POST /api/team/storage-tasks, alle Mitarbeiter), Schnittstelle fuers
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden der Liste.
*/
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
@@ -21,16 +21,16 @@ const aw = await startAppwriteMock({
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet, sessionCookie } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } =
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, seedFixtures, stripeSubscription } =
await import('./support/fixtures.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const { default: adminCustomersRoutes } = await import('../server/routes/admin/customers.js')
const { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/portal-admin': portalAdminRoutes, '/api/admin/customers': adminCustomersRoutes })
const app = await startTestApp({ '/api/team': teamRoutes, '/api/admin/customers': adminCustomersRoutes })
// F25: Mails (K14 beim Wechsel-Abgleich) landen in der Senke statt bei SMTP
const mails = mailSink()
setMailTransportForTests(mails)
@@ -46,7 +46,8 @@ st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage
const HOUR = 3600000
const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString()
const ADMIN = { cookie: adminCookie() }
// Mitarbeiter ohne Admin-Label (Team-Eingang: alle Mitarbeiter gleich)
const ADMIN = { headers: { Authorization: 'Bearer jwt-staff' } }
const STAFF_JWT = { headers: { Authorization: 'Bearer jwt-admin' } }
async function clearEvents() {
@@ -77,6 +78,7 @@ beforeEach(async () => {
await clearEvents()
mails.sent.length = 0
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
// sub_h wurde von 3 auf 1 Paket verringert; konsoleH haelt bis zum Periodenende noch 3 (offene Aufgabe unten)
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd })
@@ -88,7 +90,7 @@ beforeEach(async () => {
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
// sub_h: 1 Paket, aber 3→1 erst zum Periodenende → konsoleH haelt 5 + 15 = 20 GB; sub_h2: 5 + 10 = 15 GB
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const r = await get('/api/team/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 35)
assert.equal(r.packageGb, 50)
assert.equal(r.warnGb, 40)
@@ -116,7 +118,7 @@ test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...hostingDoc, customerId: 'c3', status: 'canceled', storageUnits: 10 }))
aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
try {
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const r = await get('/api/team/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 45)
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_alt')
@@ -125,31 +127,24 @@ test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)
})
test('Admin: belegter Speicher zaehlt die Verringerung erst nach Erledigt', async () => {
const before = await get('/api/portal-admin/storage-tasks', ADMIN)
const before = await get('/api/team/storage-tasks', ADMIN)
assert.equal(before.allocatedGb, 35, 'offene Aufgabe 3→1: konsoleH haelt noch 20 GB fuer sub_h')
const task = before.tasks.find((t) => t.kind === 'periodenende')
await post(`/api/portal-admin/storage-tasks/${task.id}/done`, {}, ADMIN)
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/team/storage-tasks/${task.id}/done`, {}, ADMIN)
const after = await get('/api/team/storage-tasks', ADMIN)
assert.equal(after.allocatedGb, 25, 'nach Erledigt: 5 + 5 = 10 GB fuer sub_h, dazu 15 GB fuer sub_h2')
})
test('Admin: Aufgabe nennt Gesamtspeicher des Kunden und Kontakt-Domain', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
assert.equal(tasks[0].targetGb, 15)
assert.equal(tasks[0].contactDomain, 'example.invalid')
assert.equal(tasks[1].targetGb, 10)
})
test('Admin: Erledigt ohne E-Mail des Mitarbeiters wird abgelehnt (400)', async () => {
const noEmail = { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso' }) }
const r = await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, noEmail)
assert.equal(r.status, 400)
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
})
test('Admin: Erledigt auf ein gescheitertes Ereignis → 409', async () => {
await storageEvent({ id: 'speicher-gescheitert-x', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
const r = await post('/api/portal-admin/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN)
const r = await post('/api/team/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN)
assert.equal(r.status, 409)
assert.equal((await getContractEvent('speicher-gescheitert-x')).status, 'gescheitert')
})
@@ -159,41 +154,41 @@ test('Admin: gescheiterte Aenderungen nur der letzten 30 Tage', async () => {
await storageEvent({ id: 'speicher-gescheitert-alt', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 5, dueAt: iso(-2000), status: 'gescheitert' })
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-alt')), $createdAt: iso(-31 * 24 * HOUR) })
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-neu')), $createdAt: iso(-29 * 24 * HOUR) })
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const r = await get('/api/team/storage-tasks', ADMIN)
assert.deepEqual(r.failed.map((f) => f.id), ['speicher-gescheitert-neu'])
})
test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(status, 200)
assert.equal(task.status, 'erledigt')
assert.equal(task.doneBy, 'kenso@webklar.com')
assert.equal(task.doneBy, 'andrej@webklar.com')
assert.ok(task.doneAt)
assert.equal(task.id, tasks[0].id)
const stored = await getContractEvent(tasks[0].id)
assert.equal(stored.status, 'erledigt')
assert.equal(stored.doneBy, 'kenso@webklar.com')
assert.equal(stored.doneBy, 'andrej@webklar.com')
// Die Liste zeigt sie nicht mehr
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
const after = await get('/api/team/storage-tasks', ADMIN)
assert.deepEqual(after.tasks.map((t) => t.id), [tasks[1].id])
})
test('Admin: Erledigt nur fuer offene Speicher-Aufgaben (404 unbekannt, 409 schon erledigt)', async () => {
assert.equal((await post('/api/portal-admin/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const again = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal((await post('/api/team/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
const { tasks } = await get('/api/team/storage-tasks', ADMIN)
await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const again = await post(`/api/team/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(again.status, 409)
// Ein Wechsel-Ereignis ist keine Speicher-Aufgabe
await recordContractEvent({ id: 'wechsel-offen-x', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: {}, consent, status: 'offen' })
assert.equal((await post('/api/portal-admin/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404)
assert.equal((await post('/api/team/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404)
})
test('Admin: teilweise (Stripe-Schritt 2 gescheitert) und gescheiterte Aenderungen sind sichtbar', async () => {
await storageEvent({ id: 'speicher-teilweise-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 2, toUnits: 5, dueAt: iso(-1000), extra: { partial: true, error: 'Stripe: card_declined' } })
await storageEvent({ id: 'speicher-gescheitert-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert', extra: { error: 'Stripe: resource_missing' } })
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const r = await get('/api/team/storage-tasks', ADMIN)
const partial = r.tasks.find((t) => t.id === 'speicher-teilweise-c2')
assert.equal(partial.partial, true)
assert.equal(partial.error, 'Stripe: card_declined')
@@ -218,7 +213,7 @@ test('Admin: Wechsel-Abgleich beim Laden des Reiters (verlorener Stripe-Webhook)
consent,
status: 'offen',
})
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const r = await get('/api/team/storage-tasks', ADMIN)
assert.equal(r.tasks.length, 2)
const ev = await getContractEvent('wechsel-abgelaufen-c1')
assert.equal(ev.status, 'gescheitert')
@@ -237,15 +232,15 @@ test('Admin: Wechsel-Abgleich laesst einen noch laufenden Wechsel in Ruhe', asyn
status: 'offen',
})
const before = st.calls.length
await get('/api/portal-admin/storage-tasks', ADMIN)
await get('/api/team/storage-tasks', ADMIN)
assert.equal((await getContractEvent('wechsel-laufend-c1')).status, 'offen')
assert.equal(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel')
})
test('Ohne Admin-Session 401', async () => {
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/portal-admin/storage-tasks')).status, 401)
assert.equal((await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
test('Ohne Mitarbeiter-JWT 401 (auch mit Kunden-Cookie)', async () => {
assert.equal((await rawGet('/api/team/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/team/storage-tasks')).status, 401)
assert.equal((await post('/api/team/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
})
+10
View File
@@ -21,6 +21,7 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const userCalls = []
let lastLocale = null
let failUserLookups = false
let sessionDeletes = 0
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
@@ -117,6 +118,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const project = req.headers['x-appwrite-project'] || 'x'
return [201, { $id: `s-${user.$id}`, userId: user.$id }, { 'Set-Cookie': `a_session_${project}=${secret}; path=/; httponly` }]
}
// Abmelden der eben erzeugten Sitzung (Portal-Login lehnt Mitarbeiter ab)
if (url.pathname === '/v1/account/sessions/current' && req.method === 'DELETE') {
const match = String(req.headers.cookie || '').match(/a_session_[^=]+=([^;]+)/)
if (!match || !sessions.delete(decodeURIComponent(match[1]))) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }]
sessionDeletes += 1
return [204, null]
}
if (url.pathname === '/v1/account/mfa/challenges') {
const session = sessionOf(req)
if (!session) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }]
@@ -256,6 +264,8 @@ export async function startAppwriteMock({ schema = {} } = {}) {
lastOtp: (userId) => [...challenges.values()].filter((c) => c.userId === userId).at(-1)?.otp || null,
expireChallenges: () => challenges.forEach((c) => (c.expired = true)),
mailsSent: (userId) => mails.get(userId) || 0,
/** Zahl der beendeten Appwrite-Sitzungen (DELETE /account/sessions/current) */
sessionDeletes: () => sessionDeletes,
lastLocale: () => lastLocale,
/** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */
failUserLookups: (on) => (failUserLookups = Boolean(on)),
+2 -2
View File
@@ -92,11 +92,11 @@ export const customerCookie = (customerId = 'c1') =>
email: customers[customerId]?.email || '',
})
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
/** Alte Mitarbeiter-Sitzung im Portal (Login bis Etappe 4): /api/auth/me beendet sie, Kunden-Routen 401. */
export const adminCookie = () =>
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
/** Admin im Modus "Als Kunde ansehen" (portalAdmin.js /view-as): Bestellen und Aendern sind gesperrt. */
/** Alte Ansicht "Als Kunde ansehen" aus dem frueheren Admin-Panel (ohne viewId): gilt als beendet (401 view_expired). */
export const viewAsCookie = (customerId = 'c1') =>
sessionCookie({
role: 'admin',
+1 -1
View File
@@ -9,7 +9,7 @@ import { sessionMiddleware } from '../../server/middleware/session.js'
* Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit
* SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der
* Test übergibt:
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes })
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/team': teamRoutes })
*
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
+3 -9
View File
@@ -8,17 +8,16 @@ import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
// Chat ueber den Team-Eingang (/api/team, JWT) mit signierten Anhang-Links; Admin-Panel (/api/portal-admin) unveraendert
// Chat ueber den Team-Eingang (/api/team, JWT) mit signierten Anhang-Links
process.env.CHAT_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-chat-'))
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { seedFixtures, adminCookie } = await import('./support/fixtures.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const app = await startTestApp({ '/api/team': teamRoutes, '/api/portal-admin': portalAdminRoutes })
const app = await startTestApp({ '/api/team': teamRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -93,11 +92,6 @@ test('Lesen markiert Kundennachrichten', async () => {
assert.equal((await rawGet('/api/team/chats/unread-count', staff)).body.unread, 0)
})
test('Admin-Panel unveraendert: /api/portal-admin/chats mit Admin-Cookie 200, ohne 401', async () => {
assert.equal((await rawGet('/api/portal-admin/chats', { cookie: adminCookie() })).status, 200)
assert.equal((await rawGet('/api/portal-admin/chats')).status, 401)
})
test('Team-Chat ohne JWT -> 401', async () => {
assert.equal((await rawGet('/api/team/chats')).status, 401)
assert.equal((await post('/api/team/chats/c1/messages', { text: 'x' })).status, 401)
+73
View File
@@ -0,0 +1,73 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Etappe 4: Team-Mails oeffnen das Ticketsystem (Chat beim Kunden, Antrag im Kunden-Reiter Gutschriften)
process.env.TAKEOVER_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'team-mail-links-'))
process.env.CHAT_NOTIFY_EMAILS = 'team@webklar.invalid'
const aw = await startAppwriteMock({
schema: { subscriptions: schemaKeys('subscriptions'), takeoverCredits: schemaKeys('takeoverCredits') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { seedFixtures, hostingDoc, customers } = await import('./support/fixtures.js')
const { setMailTransportForTests, sendChatNotification } = await import('../server/services/mailer.js')
const { createTakeoverRequest, answerQuestion } = await import('../server/services/takeoverCredits.js')
after(() => {
setMailTransportForTests(null)
return Promise.all([aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('subscriptions', hostingDoc)
let sink
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
})
const teamMail = (subject) => sink.sent.find((m) => m.to.some((t) => t.address === 'team@webklar.invalid') && subject.test(m.subject))
const input = {
email: 'k@example.invalid',
services: ['hosting'],
provider: 'Altanbieter',
domain: '',
invoiceAmountCents: 12000,
billingMonths: 12,
paidUntil: '2027-01-31',
message: '',
}
test('T01 antwortet im Ticketsystem beim Kunden', async () => {
const res = await sendChatNotification({ $id: 'c1', name: 'Berta' }, 'Hallo')
assert.deepEqual(res, { sent: true })
const mail = teamMail(/Neue Portalnachricht von Berta/)
assert.ok(mail)
assert.match(mail.html, /href="https:\/\/ticket\.webklar\.com\/customers\/chat\?kunde=c1"[^>]*>Im Ticketsystem antworten &rarr;<\/a>/)
assert.match(mail.html, /href="https:\/\/ticket\.webklar\.com\/customers"[^>]*>Ticketsystem<\/a>/)
assert.doesNotMatch(mail.html, /admin\.html|Admin-Bereich/)
})
test('T10/T11 oeffnen den Antrag im Kunden-Reiter Gutschriften', async () => {
const doc = await createTakeoverRequest({ customer: customers.c1, input, uploads: [], consentIp: '127.0.0.1' })
const link = `https://ticket.webklar.com/customers/c1?tab=gutschriften&amp;antrag=${doc.$id}`
const t10 = teamMail(/Übernahmegutschrift/)
assert.ok(t10, sink.sent.map((m) => m.subject).join(' | '))
assert.ok(t10.html.includes(`href="${link}"`), 'T10-Link')
assert.doesNotMatch(t10.html, /admin\.html|Admin-Bereich/)
aw.seed('takeoverCredits', { ...aw.docs('takeoverCredits').get(doc.$id), status: 'rueckfrage' })
sink.sent.length = 0
await answerQuestion({ customer: customers.c1, doc: aw.docs('takeoverCredits').get(doc.$id), message: 'Hier die Antwort', uploads: [] })
const t11 = sink.sent.find((m) => m.to.some((t) => t.address === 'team@webklar.invalid'))
assert.ok(t11)
assert.ok(t11.html.includes(`href="${link}"`), 'T11-Link')
assert.doesNotMatch(t11.html, /admin\.html|Admin-Bereich/)
})
-95
View File
@@ -1,95 +0,0 @@
// Playwright-Check admin_funktionen: statischer Server + JSON-Fixtures, nicht Teil von npm test.
// Aufruf: node test/ui/admin-funktionen.ui.mjs
import http from 'node:http'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' }
const news = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: null, adminNote: '', ticketMissing: false, ...extra })
const projects = [
{ projectId: 'p1', projectName: 'Bäckerei', features: { news: news('off') } },
{ projectId: 'p2', projectName: 'Café', features: { news: news('setup', { ticketMissing: true }) } },
{ projectId: 'p3', projectName: 'Kiosk', features: { news: news('active', { ticketWoid: '1234' }) } },
]
const posts = []
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
if (url.pathname === '/api/auth/me') return json(res, 200, { authenticated: true, role: 'admin', admin: { name: 'Kenso', email: 'k@w.de' } })
if (url.pathname === '/api/portal-admin/customers') {
return json(res, 200, { customers: [{ id: 'c1', code: '1001', name: 'Test GmbH', email: 't@t.de', customerStatus: 'customer', portalAccessEnabled: true, projectCount: 3 }] })
}
if (url.pathname === '/api/portal-admin/funktionen/c1') return json(res, 200, { projects })
if (url.pathname === '/api/portal-admin/funktionen/c1/news' && req.method === 'POST') {
let raw = ''
for await (const chunk of req) raw += chunk
const body = JSON.parse(raw)
posts.push(body)
const p = projects.find((x) => x.projectId === body.projectId)
p.features.news = news(body.action === 'activate' ? 'active' : 'disabled', { adminNote: `2026-09-30T10:00:00.000Z Kenso: ${body.action}${body.note ? ` – ${body.note}` : ''}` })
return json(res, 200, { projects: [p] })
}
if (url.pathname.startsWith('/api/')) return json(res, 200, {})
try {
const file = path.join(pub, url.pathname === '/' ? 'admin.html' : url.pathname)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(await readFile(file))
} catch {
res.writeHead(404)
res.end()
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const browser = await chromium.launch()
try {
const page = await browser.newPage()
const dialogs = []
page.on('dialog', (d) => { dialogs.push(d.type()); d.dismiss() })
page.on('pageerror', (e) => check('keine Seitenfehler', false, e.message))
await page.goto(`http://127.0.0.1:${server.address().port}/admin.html`)
await page.click('.admin-tab[data-tab="customers"]')
await page.click('[data-features="c1"]')
await page.waitForSelector('[data-feature-project="p1"]')
const row = (id) => page.locator(`[data-feature-project="${id}"]`)
check('3 Zeilen', (await page.locator('[data-feature-project]').count()) === 3)
check('Ticket fehlt sichtbar', (await row('p2').innerText()).includes('Ticket fehlt'))
// Aktivieren: erst Rückfrage in der Zeile, nichts gesendet
await row('p1').locator('[data-feature-ask="activate"]').click()
check('Rückfrage statt Sofort-Aktion', posts.length === 0 && (await row('p1').innerText()).includes('ohne Zahlung aktivieren?'))
await row('p1').locator('[data-feature-no]').click()
check('Abbrechen stellt Knöpfe wieder her', (await row('p1').locator('[data-feature-ask="activate"]').count()) === 1 && posts.length === 0)
await row('p1').locator('[data-feature-ask="activate"]').click()
await row('p1').locator('[data-feature-note]').fill('Kulanz')
await row('p1').locator('[data-feature-yes]').click()
await page.waitForFunction(() => document.querySelector('[data-feature-project="p1"]')?.innerText.includes('Aktiv'))
check('POST activate mit Notiz', JSON.stringify(posts[0]) === JSON.stringify({ projectId: 'p1', action: 'activate', note: 'Kulanz' }), JSON.stringify(posts[0]))
check('Zeile zeigt Aktiv und Notiz', (await row('p1').innerText()).includes('Kenso: activate – Kulanz'))
// Abschalten
await row('p3').locator('[data-feature-ask="disable"]').click()
await row('p3').locator('[data-feature-yes]').click()
await page.waitForFunction(() => document.querySelector('[data-feature-project="p3"]')?.innerText.includes('Abgeschaltet'))
check('Abschalten aktualisiert Zeile', posts[1]?.action === 'disable')
check('keine Browser-Dialoge', dialogs.length === 0)
await page.screenshot({ path: '/tmp/claude-0/-root/a97f1ff7-2f71-45a7-bb9f-eaeacf7e64f1/scratchpad/admin-funktionen.png' })
} finally {
await browser.close()
server.close()
}
console.log(failed ? `admin_funktionen: ${failed} FEHLER` : 'admin_funktionen: alle Prüfungen bestanden')
process.exit(failed ? 1 : 0)
+25 -6
View File
@@ -31,8 +31,11 @@ const VIEW = 'a'.repeat(32)
const customer = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const ME = {
neu: { authenticated: true, role: 'admin', viewAs: true, viewId: VIEW, viewExpiresAt: Date.now() + 3600000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer },
alt: { authenticated: true, role: 'admin', viewAs: true, admin: { name: 'Kenso', email: 'kenso@webklar.com', isAdmin: true }, customer },
// alte Mitarbeiter-Sitzung (Login im Portal oder Alt-Ansicht ohne Kennung): /me beendet sie, weiter ins Ticketsystem
staff: { authenticated: false, reason: 'staff', redirect: `${base}/zurueck-im-ticketsystem.html?von=staff` },
gast: { authenticated: false },
}
const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' }
let scenario = 'neu'
let replaceAfterMe = false
@@ -50,6 +53,7 @@ async function mockApi(page) {
seen.ende += 1
return json({ redirect: `${base}/zurueck-im-ticketsystem.html` })
}
if (p === '/api/auth/login') return json(STAFF_BLOCKED, 403)
if (p === '/api/chat/read') seen.chatRead += 1
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
@@ -104,11 +108,26 @@ try {
await page.screenshot({ path: `${SHOTS}/${label}-ersetzt.png` })
replaceAfterMe = false
// 3) alte Ansicht aus dem Admin-Panel: altes Banner, keine Kennung
await open('alt')
const old = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim()
check(`${label} altes_banner`, old.includes('Admin-Ansicht') && old.includes('Zurück zur Admin-Übersicht'), old)
check(`${label} alt_ohne_kennung`, seen.headers.every((h) => h === null), JSON.stringify(seen.headers))
// 3) alte Mitarbeiter-Sitzung: Dashboard leitet ins Ticketsystem weiter
scenario = 'staff'
await page.goto('about:blank')
await Promise.all([page.waitForURL(/zurueck-im-ticketsystem\.html\?von=staff/), page.goto(`${base}/dashboard.html`)])
check(`${label} staff_weiterleitung`, page.url().endsWith('/zurueck-im-ticketsystem.html?von=staff'), page.url())
// 4) Mitarbeiter meldet sich im Portal an: Hinweis und Link ins Ticketsystem
scenario = 'gast'
await page.goto(`${base}/login.html`)
await page.fill('#email', 'andrej@webklar.com')
await page.fill('#password', 'geheim-geheim')
await page.click('#login-btn')
await page.waitForSelector('#login-staff-link:not(.hidden)', { timeout: 15000 }).catch(() => null)
const hint = (await page.textContent('#login-error')).trim()
check(`${label} login_hinweis`, hint === 'Mitarbeiter arbeiten im Ticketsystem.', hint)
const link = await page.$eval('#login-staff-link', (a) => ({ text: a.textContent.trim(), href: a.getAttribute('href'), visible: a.offsetParent !== null }))
check(`${label} login_link_ticketsystem`, link.visible && link.text === 'Zum Ticketsystem' && link.href === 'https://ticket.webklar.com', JSON.stringify(link))
check(`${label} kein_mfa_formular`, (await page.$('#mfa-form')) === null)
await page.waitForTimeout(1200)
await page.screenshot({ path: `${SHOTS}/${label}-login-mitarbeiter.png` })
check(`${label} keine_js_fehler`, errors.length === 0, errors.join(' | '))
await ctx.close()