Im Konto-Menü (Profilbild) gibt es unter "Konto" jetzt "Passwort ändern". Die Seite fragt aktuelles Passwort, neues Passwort und Wiederholung ab (mindestens 8 Zeichen, "Passwörter anzeigen"). Am Handy ist sie eine Unterseite des Tabs, aus dem man kam; Direktlink /dashboard.html#passwort. Im Ansichtsmodus ist sie gesperrt. Server (POST /api/auth/password): - Das aktuelle Passwort wird über eine kurze Appwrite-Session geprüft, die gleich wieder gelöscht wird. Tippfehler lösen nicht die globale Login-Sperre aus. Es gibt höchstens 5 Prüfungen je Kunde und Stunde, das bleibt unter Appwrites Grenze (10 Anmeldungen je E-Mail und Stunde). - Das neue Passwort wird über die Users-API gesetzt. Fehler von Appwrite (zuletzt benutzt, zu häufig, persönliche Daten) kommen als verständliche Meldung. - Die Klartext-Kopie in customers.portalPassword und ein Alt-Eintrag in den Notizen werden geleert. Ein selbst gewähltes Passwort wird nie im Klartext gespeichert; das Ticketsystem zeigt dann "–". - Der Kunde bekommt eine Bestätigungsmail (Kopie an die Admins wie bei allen Kundenmails). Andere Geräte werden abgemeldet: Kunden-Sessions merken sich beim Login den Passwortstand (Appwrite passwordUpdate, pwv). requireSession und /api/auth/me prüfen ihn, 60 s zwischengespeichert. Das gilt auch, wenn wir im Ticketsystem ein neues Passwort setzen. Ist Appwrite nicht erreichbar, bleibt die Session gültig. Sessions von vor diesem Update haben keinen Stand und laufen normal aus (höchstens 7 Tage). Login-Seite: Hinweis auf "Passwort ändern" statt "über das Ticketsystem", dazu "Passwort vergessen?" mit support@webklar.com. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
import { Router } from 'express'
|
|
import { config } from '../config.js'
|
|
import {
|
|
getCustomerByAppwriteUserId,
|
|
getCustomerByEmail,
|
|
getDocument,
|
|
getEmployeeByEmail,
|
|
getEmployeeByUserId,
|
|
getPortalAccessByCustomerId,
|
|
getUserById,
|
|
listDocuments,
|
|
updateDocument,
|
|
Query,
|
|
} from '../services/appwriteAdmin.js'
|
|
import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
|
|
import {
|
|
SESSION_REVOKED_MESSAGE,
|
|
clearPortalSession,
|
|
requireSession,
|
|
setPortalSession,
|
|
} from '../middleware/session.js'
|
|
import { changeCustomerPassword } from '../services/customerPassword.js'
|
|
import { isSessionPasswordCurrent, rememberPasswordVersion } from '../services/passwordVersion.js'
|
|
import {
|
|
PREVIEW_COOKIE_NAME,
|
|
clearPreviewSession,
|
|
createPreviewSessionToken,
|
|
previewSessionCookieOptions,
|
|
verifyPreviewSessionToken,
|
|
} from '../services/previewSession.js'
|
|
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
|
|
|
|
const router = Router()
|
|
|
|
function sanitizeCustomer(customer) {
|
|
return {
|
|
id: customer.$id,
|
|
code: customer.code || '',
|
|
name: customer.name || '',
|
|
companyName: customer.companyName || '',
|
|
email: customer.email || '',
|
|
phone: customer.phone || '',
|
|
location: customer.location || '',
|
|
customerStatus: customer.customerStatus || '',
|
|
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 resolveStaffAccount(appwriteUserId, email, account) {
|
|
const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin')
|
|
|
|
let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null)
|
|
if (!employee) {
|
|
employee = await getEmployeeByEmail(account?.email || email).catch(() => null)
|
|
}
|
|
|
|
if (!employee && !isAdmin) return null
|
|
|
|
return {
|
|
isAdmin,
|
|
name: employee?.displayName || account?.name || 'Mitarbeiter',
|
|
email: account?.email || employee?.email || email,
|
|
}
|
|
}
|
|
|
|
async function validatePortalAccess(appwriteUserId, email) {
|
|
let customer = await getCustomerByAppwriteUserId(appwriteUserId)
|
|
if (!customer && email) {
|
|
customer = await getCustomerByEmail(email)
|
|
}
|
|
if (!customer) {
|
|
const error = new Error(
|
|
`Kein Kundenkonto für diesen Login gefunden. Im Ticketsystem customers.appwriteUserId auf "${appwriteUserId}" setzen (E-Mail: ${email}).`
|
|
)
|
|
error.status = 403
|
|
throw error
|
|
}
|
|
|
|
if (!customer.portalAccessEnabled) {
|
|
const error = new Error('Portalzugang ist nicht freigeschaltet.')
|
|
error.status = 403
|
|
throw error
|
|
}
|
|
|
|
const portalAccess = await getPortalAccessByCustomerId(customer.$id)
|
|
if (!portalAccess || !portalAccess.enabled) {
|
|
const error = new Error('Portalzugang ist deaktiviert.')
|
|
error.status = 403
|
|
throw error
|
|
}
|
|
|
|
const status = (customer.customerStatus || '').toLowerCase()
|
|
if (!config.allowedCustomerStatuses.includes(status)) {
|
|
const error = new Error('Kundenkonto ist nicht aktiv.')
|
|
error.status = 403
|
|
throw error
|
|
}
|
|
|
|
return { customer, portalAccess }
|
|
}
|
|
|
|
async function syncCustomerAppwriteUserId(customer, appwriteUserId) {
|
|
if (!customer?.$id || !appwriteUserId) return customer
|
|
if (customer.appwriteUserId === appwriteUserId) return customer
|
|
|
|
const now = new Date().toISOString()
|
|
return updateDocument(config.collections.customers, customer.$id, {
|
|
appwriteUserId,
|
|
portalAccessEnabled: true,
|
|
updatedAt: now,
|
|
})
|
|
}
|
|
|
|
async function resolveSessionCustomer(session) {
|
|
if (!session?.customerId) return null
|
|
|
|
let customer = await getDocument(config.collections.customers, session.customerId).catch(() => null)
|
|
if (customer) return customer
|
|
|
|
if (session.appwriteUserId) {
|
|
customer = await getCustomerByAppwriteUserId(session.appwriteUserId)
|
|
if (customer) return customer
|
|
}
|
|
|
|
if (session.email) {
|
|
customer = await getCustomerByEmail(session.email)
|
|
}
|
|
|
|
return customer
|
|
}
|
|
|
|
function isPreviewProjectReady(project) {
|
|
if (!project) return false
|
|
if (project.hasPreview === false) return false
|
|
const status = (project.provisioningStatus || project.status || '').toLowerCase()
|
|
return ['ready', 'deployed'].includes(status)
|
|
}
|
|
|
|
function isAdminSession(session) {
|
|
return session?.role === 'admin' || (session?.labels || []).includes('admin')
|
|
}
|
|
|
|
function safePreviewRedirectUrl(url) {
|
|
try {
|
|
const parsed = new URL(String(url || ''))
|
|
if (
|
|
parsed.hostname.endsWith('.project.webklar.com') ||
|
|
parsed.hostname === 'project.webklar.com'
|
|
) {
|
|
return parsed.toString()
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return null
|
|
}
|
|
|
|
router.get('/login-status', (_req, res) => {
|
|
const retryAfterSeconds = getLoginCooldownRemainingSec()
|
|
res.json({
|
|
blocked: retryAfterSeconds > 0,
|
|
retryAfterSeconds,
|
|
})
|
|
})
|
|
|
|
router.post('/login', async (req, res) => {
|
|
const { email, password } = req.body || {}
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' })
|
|
}
|
|
|
|
try {
|
|
const user = await loginWithAppwrite(email.trim(), password)
|
|
// 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
|
|
const staff = await resolveStaffAccount(user.$id, email.trim(), account)
|
|
if (staff) {
|
|
setPortalSession(res, {
|
|
role: 'admin',
|
|
isAdmin: staff.isAdmin,
|
|
appwriteUserId: user.$id,
|
|
name: staff.name,
|
|
email: staff.email,
|
|
})
|
|
const staffPreviewToken = await createPreviewSessionToken({
|
|
userId: user.$id,
|
|
customerId: '',
|
|
email: staff.email,
|
|
name: staff.name,
|
|
role: 'admin',
|
|
labels: staff.isAdmin ? ['admin'] : [],
|
|
authSource: 'kundenbereich-admin',
|
|
})
|
|
res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions())
|
|
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())
|
|
customer = await syncCustomerAppwriteUserId(customer, user.$id)
|
|
|
|
setPortalSession(res, {
|
|
customerId: customer.$id,
|
|
appwriteUserId: user.$id,
|
|
name: customer.name || user.name || '',
|
|
email: customer.email || user.email || email,
|
|
pwv: account?.passwordUpdate || undefined,
|
|
})
|
|
if (account) rememberPasswordVersion(user.$id, account.passwordUpdate)
|
|
|
|
const previewToken = await createPreviewSessionToken({
|
|
userId: user.$id,
|
|
customerId: customer.$id,
|
|
email: customer.email || user.email || email,
|
|
name: customer.name || user.name || '',
|
|
role: 'customer',
|
|
labels: [],
|
|
authSource: 'kundenbereich',
|
|
})
|
|
res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions())
|
|
|
|
try {
|
|
await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, {
|
|
lastLoginAt: new Date().toISOString(),
|
|
})
|
|
} catch (err) {
|
|
console.warn('[auth] lastLoginAt update failed:', err.message)
|
|
}
|
|
|
|
return res.json({ success: true, role: 'customer', customer: sanitizeCustomer(customer) })
|
|
} catch (err) {
|
|
const status = err.status || 500
|
|
if (status === 429) {
|
|
return res.status(429).json({
|
|
error:
|
|
err.message ||
|
|
'Zu viele Anmeldeversuche. Bitte warte einige Minuten, bevor du es erneut versuchst.',
|
|
retryAfterSeconds: getLoginCooldownRemainingSec(),
|
|
})
|
|
}
|
|
if (err?.message?.includes('not authorized')) {
|
|
return res.status(500).json({
|
|
error:
|
|
'Server-Konfiguration: APPWRITE_API_KEY benötigt databases.read für woms-database (customers, customerPortalAccess).',
|
|
})
|
|
}
|
|
return res.status(status).json({ error: err.message || 'Anmeldung fehlgeschlagen' })
|
|
}
|
|
})
|
|
|
|
// Passwort aendern (Konto-Menue "Passwort ändern"). Die eigene Session bekommt den neuen
|
|
// Passwortstand; alle anderen Sessions des Kunden gelten danach nicht mehr.
|
|
router.post('/password', requireSession, async (req, res) => {
|
|
if (req.session.role === 'admin') {
|
|
return res.status(403).json({ error: 'Im Ansichtsmodus kann das Passwort nicht geändert werden.' })
|
|
}
|
|
try {
|
|
const { passwordVersion } = await changeCustomerPassword(req.session, req.body)
|
|
setPortalSession(res, { ...req.session, pwv: passwordVersion || undefined })
|
|
return res.json({ success: true })
|
|
} catch (err) {
|
|
return res.status(err.status || 500).json({
|
|
error: err.message || 'Das Passwort konnte nicht geändert werden.',
|
|
...(err.code ? { code: err.code } : {}),
|
|
...(err.retryAfterSeconds ? { retryAfterSeconds: err.retryAfterSeconds } : {}),
|
|
})
|
|
}
|
|
})
|
|
|
|
router.post('/logout', (_req, res) => {
|
|
clearPortalSession(res)
|
|
clearPreviewSession(res)
|
|
res.json({ success: true })
|
|
})
|
|
|
|
router.get('/preview-access', async (req, res) => {
|
|
const token = req.cookies?.[PREVIEW_COOKIE_NAME]
|
|
if (!token) {
|
|
return res.status(401).json({ allowed: false })
|
|
}
|
|
|
|
const session = await verifyPreviewSessionToken(token)
|
|
const admin = isAdminSession(session)
|
|
if (!session || (!session.customerId && !admin)) {
|
|
return res.status(401).json({ allowed: false })
|
|
}
|
|
|
|
const subdomain = String(req.query.subdomain || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
if (!subdomain) {
|
|
return res.status(400).json({ allowed: false, error: 'subdomain required' })
|
|
}
|
|
|
|
try {
|
|
const queries = [Query.equal('subdomain', subdomain), Query.limit(1)]
|
|
if (!admin) {
|
|
queries.splice(1, 0, Query.equal('customerId', session.customerId))
|
|
}
|
|
|
|
const projects = await listDocuments(config.collections.websiteProjects, queries)
|
|
const project = projects[0]
|
|
if (!project) {
|
|
return res.json({ allowed: false, reason: 'no_project' })
|
|
}
|
|
if (!isPreviewProjectReady(project)) {
|
|
return res.json({ allowed: false, reason: 'not_ready', projectId: project.$id })
|
|
}
|
|
return res.json({ allowed: true, projectId: project.$id })
|
|
} catch (err) {
|
|
return res.status(500).json({ allowed: false, error: err.message || 'Fehler' })
|
|
}
|
|
})
|
|
|
|
router.get('/staff-preview', async (req, res) => {
|
|
const tokenValue = String(req.query.token || '').trim()
|
|
if (!tokenValue) {
|
|
return res.status(400).send('Token fehlt')
|
|
}
|
|
|
|
const tokenData = consumeStaffPreviewToken(tokenValue)
|
|
if (!tokenData) {
|
|
return res.status(410).send('Der Preview-Link ist abgelaufen. Bitte erneut aus dem Ticketsystem oeffnen.')
|
|
}
|
|
|
|
const previewUrl = safePreviewRedirectUrl(tokenData.previewUrl)
|
|
if (!previewUrl) {
|
|
return res.status(400).send('Ungueltige Preview-URL')
|
|
}
|
|
|
|
const previewToken = await createPreviewSessionToken({
|
|
userId: tokenData.userId,
|
|
customerId: tokenData.customerId || '',
|
|
email: tokenData.email || '',
|
|
name: tokenData.name || 'Admin',
|
|
role: 'admin',
|
|
labels: ['admin'],
|
|
authSource: 'ticket-admin',
|
|
})
|
|
res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions())
|
|
return res.redirect(previewUrl)
|
|
})
|
|
|
|
// Selbstheilung: Portal-Session lebt oft laenger als das preview_session-Cookie
|
|
// (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges
|
|
// Preview-Cookie laufen die *.project.webklar.com-Hosts in eine 302-Schleife.
|
|
// Deshalb stellt /me das Preview-Cookie automatisch neu aus, solange die
|
|
// Portal-Session gueltig ist.
|
|
async function refreshPreviewCookie(req, res, payload) {
|
|
try {
|
|
const existing = req.cookies?.[PREVIEW_COOKIE_NAME]
|
|
if (existing && (await verifyPreviewSessionToken(existing))) return
|
|
const token = await createPreviewSessionToken(payload)
|
|
res.cookie(PREVIEW_COOKIE_NAME, token, previewSessionCookieOptions())
|
|
} catch (err) {
|
|
console.warn('[auth] preview-cookie refresh failed:', err.message)
|
|
}
|
|
}
|
|
|
|
router.get('/me', async (req, res) => {
|
|
const raw = req.signedCookies?.[config.cookieName]
|
|
if (!raw) {
|
|
return res.json({ authenticated: false })
|
|
}
|
|
|
|
try {
|
|
const session = JSON.parse(raw)
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
if (!session.customerId || !session.appwriteUserId) {
|
|
return res.json({ authenticated: false })
|
|
}
|
|
|
|
// Passwort inzwischen geaendert (anderes Geraet oder durch uns): neu anmelden
|
|
if (!(await isSessionPasswordCurrent(session))) {
|
|
clearPortalSession(res)
|
|
clearPreviewSession(res)
|
|
return res.json({ authenticated: false, reason: 'password_changed', message: SESSION_REVOKED_MESSAGE })
|
|
}
|
|
|
|
let customer = await resolveSessionCustomer(session)
|
|
if (!customer) {
|
|
clearPortalSession(res)
|
|
clearPreviewSession(res)
|
|
return res.json({ authenticated: false })
|
|
}
|
|
|
|
try {
|
|
await validatePortalAccess(session.appwriteUserId, session.email || customer.email)
|
|
} catch {
|
|
clearPortalSession(res)
|
|
clearPreviewSession(res)
|
|
return res.json({ authenticated: false })
|
|
}
|
|
|
|
if (customer.appwriteUserId !== session.appwriteUserId) {
|
|
customer = await syncCustomerAppwriteUserId(customer, session.appwriteUserId)
|
|
}
|
|
|
|
await refreshPreviewCookie(req, res, {
|
|
userId: session.appwriteUserId,
|
|
customerId: customer.$id,
|
|
email: customer.email || session.email || '',
|
|
name: customer.name || session.name || '',
|
|
role: 'customer',
|
|
labels: [],
|
|
authSource: 'kundenbereich',
|
|
})
|
|
|
|
return res.json({
|
|
authenticated: true,
|
|
role: 'customer',
|
|
customer: sanitizeCustomer(customer),
|
|
})
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message || 'Fehler beim Laden' })
|
|
}
|
|
})
|
|
|
|
export default router
|