Portal: Mitarbeiter-Login gesperrt (Hinweis aufs Ticketsystem), alte Mitarbeiter-Sitzungen enden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:44:10 +00:00
1 parent d0ca3c41cd
commit 3a4fa581c0
13 files changed
+145 -559

No files matched your search

+14 -75
View File
@@ -17,6 +17,8 @@ async function api(path, options = {}) {
err.status = response.status err.status = response.status
if (data.code) err.code = data.code if (data.code) err.code = data.code
if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds 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 // 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) { if (data.code === 'session_revoked' && document.body.classList.contains('dashboard-page') && !sessionEndRedirected) {
sessionEndRedirected = true sessionEndRedirected = true
@@ -473,17 +475,12 @@ async function postLoginRedirect() {
window.location.href = await resolvePostLoginTarget() 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 --- */ /* -------------------------------------------------------------- Login-Page --- */
async function initLoginPage() { async function initLoginPage() {
const form = document.getElementById('login-form') const form = document.getElementById('login-form')
const errorEl = document.getElementById('login-error') const errorEl = document.getElementById('login-error')
const btn = document.getElementById('login-btn') const btn = document.getElementById('login-btn')
const staffLink = document.getElementById('login-staff-link')
const errorFromQuery = getQueryParam('error') const errorFromQuery = getQueryParam('error')
if (errorFromQuery) { if (errorFromQuery) {
@@ -523,11 +520,8 @@ async function initLoginPage() {
} }
try { try {
// Alte Mitarbeiter-Sitzung beendet /me selbst (reason staff): dann bleibt die Anmeldeseite stehen
const me = await api('/api/auth/me') 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) { if (me.authenticated && me.customer) {
await postLoginRedirect() await postLoginRedirect()
return return
@@ -540,6 +534,7 @@ async function initLoginPage() {
e.preventDefault() e.preventDefault()
if (btn.disabled) return if (btn.disabled) return
errorEl.classList.add('hidden') errorEl.classList.add('hidden')
staffLink?.classList.add('hidden')
btn.disabled = true btn.disabled = true
btn.classList.add('is-loading') btn.classList.add('is-loading')
btn.setAttribute('aria-busy', 'true') btn.setAttribute('aria-busy', 'true')
@@ -552,18 +547,14 @@ async function initLoginPage() {
password: document.getElementById('password').value, password: document.getElementById('password').value,
}), }),
}) })
if (result.mfaRequired) {
showMfaStep(result.email)
return
}
if (result.role === 'admin') {
window.location.href = adminLoginTarget()
return
}
await postLoginRedirect() await postLoginRedirect()
return return
} catch (err) { } catch (err) {
showError(errorEl, err.message) showError(errorEl, err.message)
if (err.ticketUrl && staffLink) {
staffLink.href = err.ticketUrl
staffLink.classList.remove('hidden')
}
if (err.status === 429) { if (err.status === 429) {
cooldownActive = true cooldownActive = true
await applyCooldown(err.retryAfterSeconds || 900) await applyCooldown(err.retryAfterSeconds || 900)
@@ -576,59 +567,6 @@ 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 --- */ /* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) { function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner') const banner = document.getElementById('viewas-banner')
@@ -2088,16 +2026,17 @@ async function initDashboardPage() {
const me = await api('/api/auth/me') const me = await api('/api/auth/me')
if (!me.authenticated) { 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) const target = new URL(loginUrl, window.location.origin)
// Passwort auf einem anderen Gerät geändert oder Kundenansicht abgelaufen: Hinweis auf der Anmeldeseite // 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) if ((me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) target.searchParams.set('error', me.message)
window.location.href = target.pathname + target.search window.location.href = target.pathname + target.search
return return
} }
if (me.role === 'admin' && !me.customer) {
window.location.href = '/admin.html'
return
}
if (!me.customer) { if (!me.customer) {
window.location.href = loginUrl window.location.href = loginUrl
return return
+8 -13
View File
@@ -655,17 +655,12 @@ a.portal-btn-outline {
} }
} }
/* Mitarbeiter-2FA: Code-Schritt */ /* Mitarbeiter-Login gesperrt: Link ins Ticketsystem unter dem Hinweis */
#mfa-form.hidden { display: none; } .login-staff-link {
.mfa-hint { margin: 0 0 4px; text-align: center; } display: block;
#mfa-code { letter-spacing: 0.3em; font-variant-numeric: tabular-nums; } margin: 8px 0 0;
.mfa-actions { margin-top: 12px; } text-align: center;
.mfa-link { font-size: 0.875rem;
background: none; font-weight: 700;
border: 0; color: #fff;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
} }
+2 -25
View File
@@ -62,6 +62,8 @@
enterkeyhint="go" enterkeyhint="go"
> >
<p id="login-error" class="error hidden"></p> <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"> <button type="submit" id="login-btn" class="auth-button login-button">
<span class="button-label">Anmelden</span> <span class="button-label">Anmelden</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 11 10" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 11 10" aria-hidden="true">
@@ -72,31 +74,6 @@
</svg> </svg>
</button> </button>
</form> </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> </div>
<p class="login-hint"> <p class="login-hint">
Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“). Zugangsdaten erhältst du von Webklar. Dein Passwort kannst du nach der Anmeldung selbst ändern (Profilbild oben rechts, „Passwort ändern“).
+16 -140
View File
@@ -10,7 +10,7 @@ import {
updateDocument, updateDocument,
Query, Query,
} from '../services/appwriteAdmin.js' } from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js' import { appwriteSessionFetch, loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
import { import {
SESSION_REVOKED_MESSAGE, SESSION_REVOKED_MESSAGE,
clearPortalSession, clearPortalSession,
@@ -41,7 +41,6 @@ import {
} from '../services/kundenansicht.js' } from '../services/kundenansicht.js'
import { logViewAction } from '../services/customerViewLog.js' import { logViewAction } from '../services/customerViewLog.js'
import { resolveStaffAccount } from '../services/staffAccount.js' import { resolveStaffAccount } from '../services/staffAccount.js'
import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.js'
const router = Router() const router = Router()
@@ -59,14 +58,7 @@ function sanitizeCustomer(customer) {
} }
} }
/** /** Kunden-Login: Kundenkonto mit freigeschaltetem, aktivem Portalzugang, sonst Fehler mit status 403. */
* 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).
*/
async function validatePortalAccess(appwriteUserId, email) { async function validatePortalAccess(appwriteUserId, email) {
let customer = await getCustomerByAppwriteUserId(appwriteUserId) let customer = await getCustomerByAppwriteUserId(appwriteUserId)
if (!customer && email) { if (!customer && email) {
@@ -152,6 +144,8 @@ function safePreviewRedirectUrl(url) {
return null return null
} }
const STAFF_LOGIN_BLOCKED = 'Mitarbeiter arbeiten im Ticketsystem.'
router.get('/login-status', (_req, res) => { router.get('/login-status', (_req, res) => {
const retryAfterSeconds = getLoginCooldownRemainingSec() const retryAfterSeconds = getLoginCooldownRemainingSec()
res.json({ res.json({
@@ -171,30 +165,14 @@ router.post('/login', async (req, res) => {
// Appwrite-Konto: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js) // Appwrite-Konto: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js)
const account = await getUserById(user.$id).catch(() => null) 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) const staff = await resolveStaffAccount(user.$id, email.trim(), account)
if (staff) { if (staff) {
// Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung await appwriteSessionFetch('/account/sessions/current', { method: 'DELETE', sessionCookie: user.sessionCookie }).catch((err) =>
// mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite console.warn('[auth] Appwrite-Sitzung eines Mitarbeiters nicht beendet:', err.message)
if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) { )
const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => { return res.status(403).json({ error: STAFF_LOGIN_BLOCKED, ticketUrl: config.womsBaseUrl })
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 },
})
} }
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim()) let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
@@ -351,19 +329,6 @@ router.get('/staff-preview', async (req, res) => {
return res.redirect(previewUrl) 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 // Kundenansicht aus dem Ticketsystem (POST /api/team/customers/:id/kundenansicht): Einmal-Link -> Portal des Kunden
router.get('/kundenansicht', async (req, res) => { router.get('/kundenansicht', async (req, res) => {
const gone = (text) => res.status(410).type('text/plain; charset=utf-8').send(text) 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 // Mitarbeiter-Sitzung von frueher (Login im Portal oder "Als Kunde ansehen" im frueheren Admin-Panel):
if (session.role === 'admin' && session.appwriteUserId) { // beenden, Mitarbeiter arbeiten im Ticketsystem
// Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die if (session.role === 'admin') {
// konnten nur Admins bekommen, also als Admin werten. clearPortalSession(res)
const isAdmin = session.isAdmin === undefined ? true : Boolean(session.isAdmin) clearPreviewSession(res)
let customer = null return res.json({ authenticated: false, reason: 'staff', redirect: config.womsBaseUrl })
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,
})
} }
if (!session.customerId || !session.appwriteUserId) { 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 export default router
+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, * Appwrite-Aufruf im Namen einer Sitzung aus loginWithAppwrite (z. B. Sitzung eines abgelehnten
* damit Appwrite die deutsche Mailvorlage nimmt. * Mitarbeiter-Logins beenden). Deutsche Sprache, damit Appwrite deutsche Texte liefert.
*/ */
export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie } = {}) { export function appwriteSessionFetch(path, { method = 'GET', body, sessionCookie } = {}) {
return appwriteFetch(path, { 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. * 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 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 } const user = { $id: session.userId, email, name: '', sessionCookie }
clearLoginCooldown() clearLoginCooldown()
-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
}
+5 -6
View File
@@ -10,7 +10,7 @@ const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('cust
const st = await startFakeStripe() const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js') 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 { seedFixtures, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: authRoutes } = await import('../server/routes/auth.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) assert.equal(logOf('abgelaufen').length, before + 1)
}) })
test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => { test('/me alte Ansicht (viewAsCookie, ohne viewId): beendet, reason staff, Weiterleitung ins Ticketsystem', async () => {
const body = await get('/api/auth/me', { cookie: viewAsCookie('c1') }) const res = await me(viewAsCookie('c1'))
assert.equal(body.viewAs, true) assert.deepEqual(res.body, { authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' })
assert.equal(body.viewId, undefined) assert.equal(res.headers.getSetCookie().some((c) => c.startsWith(`${config.cookieName}=`) && /Expires=Thu, 01 Jan 1970/.test(c)), true)
assert.equal(body.admin.name, 'Kenso')
}) })
+1 -1
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}`) assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
checked += 1 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', () => { test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => {
+61 -73
View File
@@ -4,12 +4,13 @@ import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js' import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.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 aw = await startAppwriteMock()
const st = await startFakeStripe() const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js') 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 { default: authRoutes } = await import('../server/routes/auth.js')
const app = await startTestApp({ '/api/auth': authRoutes }) const app = await startTestApp({ '/api/auth': authRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()])) 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_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_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_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('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('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: 'e1', userId: 'u_ohne', displayName: 'Justin', email: 'justin@webklar.com' })
aw.seed('employees', { $id: 'e2', userId: 'u_mfa', displayName: 'Kenso', email: 'kenso@webklar.com' }) aw.seed('employees', { $id: 'e2', userId: 'u_mfa', displayName: 'Kenso', email: 'kenso@webklar.com' })
const SESSION = config.cookieName const SESSION = config.cookieName
const STAFF_BLOCKED = { error: 'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl: 'https://ticket.webklar.com' }
const setCookies = (res) => res.headers.getSetCookie() const setCookies = (res) => res.headers.getSetCookie()
const cookieNamed = (res, name) => setCookies(res).find((c) => c.startsWith(`${name}=`)) || null const cleared = (c) => /Expires=Thu, 01 Jan 1970/.test(c)
const jar = (res) => setCookies(res).map((c) => c.split(';')[0]).join('; ') 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 }) 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') const res = await login('kunde@example.invalid', 'kunde-kunde')
assert.equal(res.status, 200) assert.equal(res.status, 200)
assert.equal(res.body.mfaRequired, undefined) assert.equal(res.body.role, 'customer')
assert.ok(cookieNamed(res, SESSION)) 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') 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.status, 403)
assert.equal(res.body.error, '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.') assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(cookieNamed(res, SESSION), null) 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) aw.failUserLookups(true)
try { try {
const res = await login('kenso@webklar.com', 'mit-mit-mit') const res = await login('kenso@webklar.com', 'mit-mit-mit')
assert.equal(cookieNamed(res, SESSION), null) assert.equal(res.status, 403)
assert.equal(res.body.mfaRequired, true) assert.deepEqual(res.body, STAFF_BLOCKED)
assert.equal(liveCookies(res, SESSION).length, 0)
} finally { } finally {
aw.failUserLookups(false) 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' })
})
-33
View File
@@ -45,39 +45,6 @@ test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () =>
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/) 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', () => { test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => {
const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8') 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"')) const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"'))
+10
View File
@@ -21,6 +21,7 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const userCalls = [] const userCalls = []
let lastLocale = null let lastLocale = null
let failUserLookups = false let failUserLookups = false
let sessionDeletes = 0
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35} // 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)) 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' 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` }] 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') { if (url.pathname === '/v1/account/mfa/challenges') {
const session = sessionOf(req) const session = sessionOf(req)
if (!session) return [401, { message: 'Unauthorized', code: 401, type: 'general_unauthorized_scope' }] 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, lastOtp: (userId) => [...challenges.values()].filter((c) => c.userId === userId).at(-1)?.otp || null,
expireChallenges: () => challenges.forEach((c) => (c.expired = true)), expireChallenges: () => challenges.forEach((c) => (c.expired = true)),
mailsSent: (userId) => mails.get(userId) || 0, mailsSent: (userId) => mails.get(userId) || 0,
/** Zahl der beendeten Appwrite-Sitzungen (DELETE /account/sessions/current) */
sessionDeletes: () => sessionDeletes,
lastLocale: () => lastLocale, lastLocale: () => lastLocale,
/** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */ /** GET /v1/users/:id scheitert (503), bis wieder false gesetzt wird */
failUserLookups: (on) => (failUserLookups = Boolean(on)), failUserLookups: (on) => (failUserLookups = Boolean(on)),
+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 customer = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const ME = { 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 }, 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 scenario = 'neu'
let replaceAfterMe = false let replaceAfterMe = false
@@ -50,6 +53,7 @@ async function mockApi(page) {
seen.ende += 1 seen.ende += 1
return json({ redirect: `${base}/zurueck-im-ticketsystem.html` }) 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/chat/read') seen.chatRead += 1
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] }) 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' }) 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` }) await page.screenshot({ path: `${SHOTS}/${label}-ersetzt.png` })
replaceAfterMe = false replaceAfterMe = false
// 3) alte Ansicht aus dem Admin-Panel: altes Banner, keine Kennung // 3) alte Mitarbeiter-Sitzung: Dashboard leitet ins Ticketsystem weiter
await open('alt') scenario = 'staff'
const old = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim() await page.goto('about:blank')
check(`${label} altes_banner`, old.includes('Admin-Ansicht') && old.includes('Zurück zur Admin-Übersicht'), old) await Promise.all([page.waitForURL(/zurueck-im-ticketsystem\.html\?von=staff/), page.goto(`${base}/dashboard.html`)])
check(`${label} alt_ohne_kennung`, seen.headers.every((h) => h === null), JSON.stringify(seen.headers)) 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(' | ')) check(`${label} keine_js_fehler`, errors.length === 0, errors.join(' | '))
await ctx.close() await ctx.close()