diff --git a/.env.example b/.env.example index 39178f3..1ab02fb 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ APPWRITE_ENDPOINT=https://ticket.webklar.com/v1 APPWRITE_PROJECT_ID=6a1058610003c5a13a05 # Fest: nur woms-database (Ticketsystem). Wird bei abweichendem Wert ignoriert. # APPWRITE_DATABASE_ID=woms-database +# API-Key Scopes (Appwrite 1.8): databases.read/write, collections.read/write, documents.read/write APPWRITE_API_KEY= APPWRITE_COLLECTION_CUSTOMERS=customers diff --git a/package.json b/package.json index 034e068..1013668 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch server/index.js" + "dev": "node --watch server/index.js", + "setup:check": "node scripts/portal-setup.mjs --check", + "setup:link": "node scripts/portal-setup.mjs --link" }, "engines": { "node": ">=18" diff --git a/public/app.js b/public/app.js index e60565f..b76e83d 100644 --- a/public/app.js +++ b/public/app.js @@ -6,7 +6,10 @@ async function api(path, options = {}) { }) const data = await response.json().catch(() => ({})) if (!response.ok) { - throw new Error(data.error || `Fehler ${response.status}`) + const err = new Error(data.error || `Fehler ${response.status}`) + err.status = response.status + if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds + throw err } return data } @@ -64,9 +67,41 @@ async function initLoginPage() { const errorEl = document.getElementById('login-error') const btn = document.getElementById('login-btn') + async function applyCooldown(seconds) { + if (seconds <= 0) return + showError( + errorEl, + `Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).` + ) + btn.disabled = true + const tick = setInterval(async () => { + const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({})) + const left = status.retryAfterSeconds || 0 + if (left <= 0) { + clearInterval(tick) + hideError(errorEl) + btn.disabled = false + return + } + showError( + errorEl, + `Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).` + ) + }, 1000) + } + + try { + const status = await fetch('/api/auth/login-status').then((r) => r.json()) + if (status.blocked) { + await applyCooldown(status.retryAfterSeconds) + } + } catch { + /* ignore */ + } + try { const me = await api('/api/auth/me') - if (me.customer) { + if (me.authenticated && me.customer) { window.location.href = '/dashboard.html' return } @@ -76,8 +111,10 @@ async function initLoginPage() { form?.addEventListener('submit', async (e) => { e.preventDefault() + if (btn.disabled) return errorEl.classList.add('hidden') btn.disabled = true + let cooldownActive = false try { await api('/api/auth/login', { method: 'POST', @@ -87,10 +124,15 @@ async function initLoginPage() { }), }) window.location.href = '/dashboard.html' + return } catch (err) { showError(errorEl, err.message) + if (err.status === 429) { + cooldownActive = true + await applyCooldown(err.retryAfterSeconds || 900) + } } finally { - btn.disabled = false + if (!cooldownActive) btn.disabled = false } }) } @@ -109,12 +151,17 @@ async function initDashboardPage() { }) try { - const [{ customer }, { projects }, { features }] = await Promise.all([ + const [{ authenticated, customer }, { projects }, { features }] = await Promise.all([ api('/api/auth/me'), api('/api/projects'), api('/api/features'), ]) + if (!authenticated || !customer) { + window.location.href = '/login.html' + return + } + meta.textContent = customer.name ? `${customer.name} (${customer.email})` : customer.email loading.classList.add('hidden') diff --git a/scripts/portal-setup.mjs b/scripts/portal-setup.mjs new file mode 100644 index 0000000..ac26e5c --- /dev/null +++ b/scripts/portal-setup.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Portal-Setup: API-Key prüfen, Kunde verknüpfen, Portal-Zugang aktivieren. + * + * Usage: + * node scripts/portal-setup.mjs --check + * node scripts/portal-setup.mjs --link --email kenso@webklar.com --appwrite-user-id 6a10d87f0003f576f126 + */ +import 'dotenv/config' +import { randomUUID } from 'node:crypto' +import { config, WOMS_DATABASE_ID } from '../server/config.js' +import { + getCustomerByEmail, + getPortalAccessByCustomerId, + listDocuments, + updateDocument, + Query, + verifyDatabaseAccess, +} from '../server/services/appwriteAdmin.js' + +function parseArgs(argv) { + const args = { check: false, link: false, email: '', appwriteUserId: '' } + for (let i = 2; i < argv.length; i++) { + const a = argv[i] + if (a === '--check') args.check = true + else if (a === '--link') args.link = true + else if (a === '--email') args.email = argv[++i] || '' + else if (a === '--appwrite-user-id') args.appwriteUserId = argv[++i] || '' + } + return args +} + +async function adminPost(collectionId, data) { + const path = `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents` + const url = `${config.appwrite.endpoint}${path}` + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': config.appwrite.projectId, + 'X-Appwrite-Key': config.appwrite.apiKey, + }, + body: JSON.stringify({ documentId: randomUUID(), data }), + }) + const text = await response.text() + const body = text ? JSON.parse(text) : {} + if (!response.ok) throw new Error(body.message || `HTTP ${response.status}`) + return body +} + +async function checkCollections() { + const names = [ + config.collections.customers, + config.collections.customerPortalAccess, + config.collections.websiteProjects, + config.collections.portalFeatures, + ] + const missing = [] + for (const id of names) { + const url = `${config.appwrite.endpoint}/databases/${config.appwrite.databaseId}/collections/${id}` + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': config.appwrite.projectId, + 'X-Appwrite-Key': config.appwrite.apiKey, + }, + }) + if (!response.ok) missing.push(id) + } + return missing +} + +async function linkCustomer(email, appwriteUserId) { + const customer = await getCustomerByEmail(email) + if (!customer) { + throw new Error(`Kein Kunde mit E-Mail "${email}" in customers gefunden.`) + } + + await updateDocument(config.collections.customers, customer.$id, { + appwriteUserId, + portalAccessEnabled: true, + customerStatus: customer.customerStatus || 'active', + updatedAt: new Date().toISOString(), + }) + + let portalAccess = await getPortalAccessByCustomerId(customer.$id) + if (portalAccess) { + await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, { + enabled: true, + appwriteUserId, + passwordSet: true, + }) + } else { + portalAccess = await adminPost(config.collections.customerPortalAccess, { + customerId: customer.$id, + enabled: true, + appwriteUserId, + passwordSet: true, + }) + } + + return { customerId: customer.$id, portalAccessId: portalAccess.$id } +} + +async function main() { + const args = parseArgs(process.argv) + + console.log(`Endpoint: ${config.appwrite.endpoint}`) + console.log(`Database: ${WOMS_DATABASE_ID}`) + console.log(`Project: ${config.appwrite.projectId}`) + console.log('') + + const access = await verifyDatabaseAccess() + if (!access.ok) { + console.error('❌ APPWRITE_API_KEY: Kein Zugriff auf woms-database.') + console.error(` Appwrite: ${access.reason}`) + console.error(' Benötigt: Scopes databases.read + databases.write') + process.exit(1) + } + console.log('✅ API-Key: databases.read OK') + + const sample = await listDocuments(config.collections.customers, [Query.limit(1)]) + console.log(`✅ Collection "${config.collections.customers}": ${sample.length >= 0 ? 'erreichbar' : '?'}`) + + const missing = await checkCollections() + if (missing.length) { + console.warn(`⚠️ Fehlende Collections: ${missing.join(', ')}`) + console.warn(' Siehe APPWRITE_SCHEMA.md – im Ticketsystem/Appwrite Console anlegen.') + } else { + console.log('✅ Alle Portal-Collections vorhanden') + } + + if (args.link) { + if (!args.email || !args.appwriteUserId) { + console.error('❌ --link erfordert --email und --appwrite-user-id') + process.exit(1) + } + const result = await linkCustomer(args.email.trim(), args.appwriteUserId.trim()) + console.log('') + console.log('✅ Kunde verknüpft:') + console.log(` customerId: ${result.customerId}`) + console.log(` appwriteUserId: ${args.appwriteUserId}`) + console.log(` portalAccessId: ${result.portalAccessId}`) + console.log(' portalAccessEnabled: true') + console.log(' customerPortalAccess.enabled: true') + } + + if (!args.check && !args.link) { + console.log('') + console.log('Nur Check ausgeführt. Für Verknüpfung:') + console.log(' node scripts/portal-setup.mjs --link --email USER@example.com --appwrite-user-id USER_ID') + } +} + +main().catch((err) => { + console.error('❌', err.message) + process.exit(1) +}) diff --git a/server/index.js b/server/index.js index a382f1f..ddd61ad 100644 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,7 @@ import express from 'express' import path from 'node:path' import { fileURLToPath } from 'node:url' import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' +import { verifyDatabaseAccess } from './services/appwriteAdmin.js' import { sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' @@ -22,12 +23,14 @@ app.use('/api/projects', projectsRoutes) app.use('/api/features', featuresRoutes) app.use('/webhook', giteaWebhookRoutes) -app.get('/api/health', (_req, res) => { +app.get('/api/health', async (_req, res) => { + const dbAccess = await verifyDatabaseAccess() res.json({ - ok: true, + ok: dbAccess.ok, service: 'webklar-kundenbereich', databaseId: WOMS_DATABASE_ID, endpoint: config.appwrite.endpoint, + appwriteDbAccess: dbAccess, }) }) @@ -47,6 +50,13 @@ app.get('/', (_req, res) => { const server = app.listen(config.port, () => { console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`) + verifyDatabaseAccess().then((result) => { + if (result.ok) return + console.error( + '[startup] APPWRITE_API_KEY: Kein Lesezugriff auf woms-database. In Appwrite Console neuen API-Key mit Scopes databases.read + databases.write anlegen und in .env eintragen.' + ) + if (result.reason) console.error(`[startup] Appwrite: ${result.reason}`) + }) }) server.on('error', (err) => { diff --git a/server/routes/auth.js b/server/routes/auth.js index 31a5f0a..beef1c6 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -6,10 +6,9 @@ import { getPortalAccessByCustomerId, updateDocument, } from '../services/appwriteAdmin.js' -import { loginWithAppwrite } from '../services/appwriteClient.js' +import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js' import { clearPortalSession, - requireSession, setPortalSession, } from '../middleware/session.js' @@ -65,6 +64,14 @@ async function validatePortalAccess(appwriteUserId, email) { return { customer, portalAccess } } +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) { @@ -93,6 +100,14 @@ router.post('/login', async (req, res) => { return res.json({ success: true, 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: @@ -108,14 +123,28 @@ router.post('/logout', (_req, res) => { res.json({ success: true }) }) -router.get('/me', requireSession, async (req, res) => { +router.get('/me', async (req, res) => { + const raw = req.signedCookies?.[config.cookieName] + if (!raw) { + return res.json({ authenticated: false }) + } + try { - const customer = await getCustomerByAppwriteUserId(req.session.appwriteUserId) + const session = JSON.parse(raw) + if (!session.customerId || !session.appwriteUserId) { + return res.json({ authenticated: false }) + } + + const customer = await getCustomerByAppwriteUserId(session.appwriteUserId) if (!customer) { clearPortalSession(res) - return res.status(403).json({ error: 'Kundenkonto nicht gefunden' }) + return res.json({ authenticated: false }) } - return res.json({ customer: sanitizeCustomer(customer) }) + + return res.json({ + authenticated: true, + customer: sanitizeCustomer(customer), + }) } catch (err) { return res.status(500).json({ error: err.message || 'Fehler beim Laden' }) } diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index 1088a6b..f09005e 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -16,6 +16,15 @@ function adminHeaders() { } } +function formatRequestBody(body, method) { + if (!body || method === 'GET' || method === 'DELETE') return body + if (body.data !== undefined) return body + const { documentId, ...fields } = body + const payload = { data: fields } + if (documentId) payload.documentId = documentId + return payload +} + async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) { if (!config.appwrite.apiKey) { const error = new Error('APPWRITE_API_KEY fehlt in .env') @@ -28,10 +37,12 @@ async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) { url.searchParams.append('queries[]', q) } + const requestBody = formatRequestBody(body, method) + const response = await fetch(url.toString(), { method, headers: adminHeaders(), - body: body ? JSON.stringify(body) : undefined, + body: requestBody ? JSON.stringify(requestBody) : undefined, }) const text = await response.text() @@ -48,6 +59,23 @@ async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) { const error = new Error(data?.message || `Appwrite ${response.status}`) error.status = response.status >= 500 ? 500 : response.status error.code = data?.code + error.type = data?.type + if (response.status === 401 && data?.type === 'user_unauthorized') { + // #region agent log + fetch('http://127.0.0.1:7281/ingest/30e8e71c-b377-4e72-84f9-593826c6d234', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '80bbfc' }, + body: JSON.stringify({ + sessionId: '80bbfc', + location: 'appwriteAdmin.js:adminFetch', + message: 'API key unauthorized', + data: { path, status: response.status, type: data?.type, code: data?.code }, + hypothesisId: 'H9', + timestamp: Date.now(), + }), + }).catch(() => {}) + // #endregion + } throw error } @@ -142,6 +170,24 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { }) } +export async function verifyDatabaseAccess() { + if (!config.appwrite.apiKey) { + return { ok: false, reason: 'APPWRITE_API_KEY fehlt' } + } + try { + await listDocuments(config.collections.customers, [Query.limit(1)]) + return { ok: true } + } catch (err) { + return { + ok: false, + reason: err.message, + code: err.code, + type: err.type, + status: err.status, + } + } +} + export function createAdminClient() { return { usesNativeFetch: true, databaseId: WOMS_DATABASE_ID } } diff --git a/server/services/appwriteClient.js b/server/services/appwriteClient.js index 93fe9fc..00ad50e 100644 --- a/server/services/appwriteClient.js +++ b/server/services/appwriteClient.js @@ -1,5 +1,4 @@ import { config } from '../config.js' -import { deleteUserSession, getUserById } from './appwriteAdmin.js' const DEBUG_LOG = (location, message, data, hypothesisId) => { // #region agent log @@ -45,9 +44,14 @@ async function appwriteFetch(path, { method = 'GET', body } = {}) { if (!response.ok) { const error = new Error(data?.message || `Appwrite ${response.status}`) - error.status = response.status === 401 ? 401 : response.status >= 500 ? 500 : 401 + error.status = response.status error.code = data?.code error.type = data?.type + if (response.status === 429 || data?.type === 'general_rate_limit_exceeded') { + error.message = + 'Zu viele Anmeldeversuche. Bitte warte einige Minuten, bevor du es erneut versuchst.' + error.status = 429 + } throw error } @@ -55,10 +59,33 @@ async function appwriteFetch(path, { method = 'GET', body } = {}) { } /** - * Appwrite Auth per native fetch (Node 26 + node-appwrite-Agent ist inkompatibel). - * session.secret fehlt serverseitig oft – userId aus Session + Users-API. + * Login via Appwrite Auth REST. userId kommt aus der Session – kein users.read nötig. */ +let appwriteLoginBlockedUntil = 0 +const APPWRITE_LOGIN_COOLDOWN_MS = 5 * 60 * 1000 +const APPWRITE_RATE_LIMIT_COOLDOWN_MS = 15 * 60 * 1000 + +export function getLoginCooldownRemainingSec() { + const left = appwriteLoginBlockedUntil - Date.now() + return left > 0 ? Math.ceil(left / 1000) : 0 +} + +export function clearLoginCooldown() { + appwriteLoginBlockedUntil = 0 +} + export async function loginWithAppwrite(email, password) { + const now = Date.now() + if (now < appwriteLoginBlockedUntil) { + const waitSec = Math.ceil((appwriteLoginBlockedUntil - now) / 1000) + DEBUG_LOG('appwriteClient.js:cooldown', 'login blocked locally', { waitSec }, 'H8') + const error = new Error( + `Zu viele Anmeldeversuche. Bitte warte noch ${waitSec} Sekunden.` + ) + error.status = 429 + throw error + } + let session try { session = await appwriteFetch('/account/sessions/email', { @@ -74,6 +101,9 @@ export async function loginWithAppwrite(email, password) { message: err?.message?.slice(0, 120), code: err?.code, }, 'H1') + if (err.status === 429) { + appwriteLoginBlockedUntil = Date.now() + APPWRITE_RATE_LIMIT_COOLDOWN_MS + } const error = new Error(err.message || 'Anmeldung fehlgeschlagen') error.status = err.status || 401 throw error @@ -85,32 +115,9 @@ export async function loginWithAppwrite(email, password) { throw error } - let user - try { - user = await getUserById(session.userId) - DEBUG_LOG('appwriteClient.js:getUser', 'users.get ok', { userId: user?.$id || null }, 'H6') - } catch (err) { - DEBUG_LOG('appwriteClient.js:getUser', 'users.get fail', { - message: err?.message?.slice(0, 120), - code: err?.code, - }, 'H7') - if (err?.message?.includes('not authorized')) { - const error = new Error( - 'Server-API-Key: Scope users.read erforderlich (Appwrite Console).' - ) - error.status = 500 - throw error - } - user = { $id: session.userId, email, name: '' } - } - - if (session.$id) { - try { - await deleteUserSession(session.userId, session.$id) - } catch { - // Portal nutzt eigene Cookie-Session - } - } + const user = { $id: session.userId, email, name: '' } + DEBUG_LOG('appwriteClient.js:user', 'using session userId', { userId: user.$id }, 'H7') + clearLoginCooldown() return user }