From 0f11e4a83694249742bda75c00a10051319fbf0c Mon Sep 17 00:00:00 2001 From: CRM Bot Date: Wed, 3 Jun 2026 22:02:56 +0000 Subject: [PATCH] feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests --- app/webui/404.html | 29 ++ app/webui/accounts-detail.html | 350 +++++++++++++++++++++++ app/webui/accounts.html | 189 ++++++++++++ app/webui/activities.html | 220 ++++++++++++++ app/webui/app.html | 145 ++++++++++ app/webui/components/account-list.js | 149 ++++++++++ app/webui/components/activity-list.js | 206 +++++++++++++ app/webui/components/app-shell.js | 47 +++ app/webui/components/dashboard-kpis.js | 85 ++++++ app/webui/components/deal-kanban.js | 207 ++++++++++++++ app/webui/contacts-detail.html | 268 +++++++++++++++++ app/webui/contacts.html | 231 +++++++++++++++ app/webui/css/app.css | 255 +++++++++++++++++ app/webui/dashboard.html | 145 ++++++++++ app/webui/index.html | 221 ++++++++++++++ app/webui/js/api.js | 158 ++++++++++ app/webui/js/auth.js | 81 ++++++ app/webui/js/components/notifications.js | 44 +++ app/webui/js/store.js | 155 ++++++++++ app/webui/pipeline.html | 167 +++++++++++ app/webui/settings-org.html | 142 +++++++++ app/webui/settings-profile.html | 148 ++++++++++ app/webui/settings-users.html | 231 +++++++++++++++ tests/test_frontend_assets.py | 177 ++++++++++++ tests/test_frontend_pages.py | 111 +++++++ tests/test_frontend_security.py | 177 ++++++++++++ 26 files changed, 4338 insertions(+) create mode 100644 app/webui/404.html create mode 100644 app/webui/accounts-detail.html create mode 100644 app/webui/accounts.html create mode 100644 app/webui/activities.html create mode 100644 app/webui/app.html create mode 100644 app/webui/components/account-list.js create mode 100644 app/webui/components/activity-list.js create mode 100644 app/webui/components/app-shell.js create mode 100644 app/webui/components/dashboard-kpis.js create mode 100644 app/webui/components/deal-kanban.js create mode 100644 app/webui/contacts-detail.html create mode 100644 app/webui/contacts.html create mode 100644 app/webui/css/app.css create mode 100644 app/webui/dashboard.html create mode 100644 app/webui/index.html create mode 100644 app/webui/js/api.js create mode 100644 app/webui/js/auth.js create mode 100644 app/webui/js/components/notifications.js create mode 100644 app/webui/js/store.js create mode 100644 app/webui/pipeline.html create mode 100644 app/webui/settings-org.html create mode 100644 app/webui/settings-profile.html create mode 100644 app/webui/settings-users.html create mode 100644 tests/test_frontend_assets.py create mode 100644 tests/test_frontend_pages.py create mode 100644 tests/test_frontend_security.py diff --git a/app/webui/404.html b/app/webui/404.html new file mode 100644 index 0000000..77e9539 --- /dev/null +++ b/app/webui/404.html @@ -0,0 +1,29 @@ + + + + + + 404 – Seite nicht gefunden + + + + + + + +
+

404

+

Seite nicht gefunden

+

+ Die angeforderte URL existiert nicht oder du bist nicht berechtigt. +

+ Zum Dashboard + Oder zur App-Startseite + +

+ Falls du eine bestimmte Page suchst: Accounts, Contacts, Pipeline oder Activities. +

+
+ + + diff --git a/app/webui/accounts-detail.html b/app/webui/accounts-detail.html new file mode 100644 index 0000000..46993c7 --- /dev/null +++ b/app/webui/accounts-detail.html @@ -0,0 +1,350 @@ + + + + + + CRM – Account Detail + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Account-Detail

+
+ + + + +
+
+ +
+ + +
+
+

Lade Account …

+
+ +
+ +
+
+
+

+

+ + + Owner # +

+

+ +

+
+
+ + +
+
+
+ + +
+ + + + + +
+ + +
+
+
Name
+
Branche
+
Größe
+
Website
+
Org-ID
+
Owner
+
Erstellt
+
Aktualisiert
+
+
+ + +
+
+
+
+ + + + + + + + +
NameEmailPhone
Keine Contacts.
+
+ + +
+
+ + + + + + + + +
TitleStageValueClose
Keine Deals.
+
+ + +
+
+
    + +
  • Keine Activities.
  • +
+
+ + +
+
+
    + +
  • Keine Notes.
  • +
+
+
+ + +
+
+

Account bearbeiten

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ + + + diff --git a/app/webui/accounts.html b/app/webui/accounts.html new file mode 100644 index 0000000..eb52e43 --- /dev/null +++ b/app/webui/accounts.html @@ -0,0 +1,189 @@ + + + + + + CRM – Accounts + + + + + + + + + + + + +
+ +
+ + +
+ + + +
+
+

Accounts

+
+ + + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+

Lade Accounts …

+
+ + +
+ + + + + + + + + + + + + + + + +
NameBrancheGrößeWebsiteOwner
+ Keine Accounts gefunden. +
+
+ + +
+ +
+ + +
+
+ + +
+
+

Neuer Account

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + diff --git a/app/webui/activities.html b/app/webui/activities.html new file mode 100644 index 0000000..1418da6 --- /dev/null +++ b/app/webui/activities.html @@ -0,0 +1,220 @@ + + + + + + CRM – Activities + + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Activities

+
+ + + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+

Lade Activities …

+
+ + +
+ + + + + + + + + + + + + + + + +
TypSubjectFälligOwnerStatus
+ Keine Activities gefunden. +
+
+ + +
+ +
+ + +
+
+ + +
+
+

Neue Activity

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ Polymorph-Parent: mindestens eine ID angeben. +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+

+

+ + Fällig: +

+

+
+ + +
+
+
+
+
+
+ + + diff --git a/app/webui/app.html b/app/webui/app.html new file mode 100644 index 0000000..7307778 --- /dev/null +++ b/app/webui/app.html @@ -0,0 +1,145 @@ + + + + + + CRM – Dashboard + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+
+ + + + + +
+ + +
+

+
+ + Angemeldet als + + + +
+
+ + +
+ + +
+
+

Lade Dashboard …

+
+ + +
+ +
+ +
+ + +

Kennzahlen

+
+
+

Open Deals

+

+
+
+

Pipeline Value

+

+
+
+

Won this Month

+

+
+
+

Conversion Rate

+

+
+
+ + +

Schnellaktionen

+ + + +

Letzte Aktivitäten

+
+ +

+ Keine Aktivitäten. +

+
+
+
+
+ +
+
+ + + diff --git a/app/webui/components/account-list.js b/app/webui/components/account-list.js new file mode 100644 index 0000000..e9358a3 --- /dev/null +++ b/app/webui/components/account-list.js @@ -0,0 +1,149 @@ +/** + * Account-List Component (Alpine.js) + * + * Used in: accounts.html + * + * Features: + * - Paginated table of accounts + * - Search by name (?q=) + * - Filter by industry (?industry=) and size (?size=) + * - "+ New Account" modal that POSTs to /api/v1/accounts/ + * - Row click → /accounts-detail.html?id=… + * + * Backend: GET /api/v1/accounts/?skip=&limit=&q=&industry=&size=&owner_id= + * POST /api/v1/accounts/ → AccountOut + * + * R-5: x-text only. All user-controlled strings go through x-text or + * textContent (never x-html / innerHTML). + */ + +import { api } from '/js/api.js'; + +export function accountList() { + return { + // State + accounts: [], + total: 0, + skip: 0, + limit: 20, + q: '', + industry: '', + size: '', + ownerId: '', + loading: false, + error: null, + + // Create-modal state + showCreate: false, + createForm: this._initCreateForm(), + createError: '', + creating: false, + + // Lookup values (could be hard-coded; kept here for clarity) + industries: ['technology', 'finance', 'healthcare', 'retail', 'manufacturing', 'other'], + sizes: ['startup', 'smb', 'midmarket', 'enterprise'], + + async init() { + await this.load(); + }, + + async load() { + this.loading = true; + this.error = null; + try { + const params = new URLSearchParams(); + params.set('skip', String(this.skip)); + params.set('limit', String(this.limit)); + if (this.q) params.set('q', this.q); + if (this.industry) params.set('industry', this.industry); + if (this.size) params.set('size', this.size); + if (this.ownerId) params.set('owner_id', this.ownerId); + + const res = await api.get(`/accounts/?${params.toString()}`); + this.accounts = Array.isArray(res) ? res : (res && res.items) || []; + this.total = (res && res.total) || this.accounts.length; + } catch (err) { + this.error = err.message || 'Fehler beim Laden der Accounts.'; + this._notify('error', this.error); + } finally { + this.loading = false; + } + }, + + async search() { + this.skip = 0; + await this.load(); + }, + + async nextPage() { + if (this.skip + this.limit < this.total) { + this.skip += this.limit; + await this.load(); + } + }, + + async prevPage() { + if (this.skip - this.limit >= 0) { + this.skip -= this.limit; + await this.load(); + } + }, + + get pageInfo() { + const from = this.total === 0 ? 0 : this.skip + 1; + const to = Math.min(this.skip + this.limit, this.total); + return `${from}–${to} von ${this.total}`; + }, + + // === Create modal === + openCreate() { + this.createForm = this._initCreateForm(); + this.createError = ''; + this.showCreate = true; + }, + closeCreate() { + this.showCreate = false; + }, + async submitCreate() { + this.creating = true; + this.createError = ''; + try { + const payload = { + name: this.createForm.name, + }; + if (this.createForm.website) payload.website = this.createForm.website; + if (this.createForm.industry) payload.industry = this.createForm.industry; + if (this.createForm.size) payload.size = this.createForm.size; + + await api.post('/accounts/', payload); + this._notify('success', 'Account angelegt.'); + this.showCreate = false; + await this.load(); + } catch (err) { + this.createError = err.message || 'Fehler beim Anlegen.'; + } finally { + this.creating = false; + } + }, + + // === Internals === + _initCreateForm() { + return { name: '', website: '', industry: '', size: '' }; + }, + _notify(type, message) { + if (window.Alpine) { + Alpine.store('notifications').push(message, type); + } + }, + }; +} + +if (typeof window !== 'undefined') { + document.addEventListener('alpine:init', () => { + if (window.Alpine) { + window.Alpine.data('accountList', accountList); + } + }); +} + +export default accountList; diff --git a/app/webui/components/activity-list.js b/app/webui/components/activity-list.js new file mode 100644 index 0000000..c333a93 --- /dev/null +++ b/app/webui/components/activity-list.js @@ -0,0 +1,206 @@ +/** + * Activity-List Component (Alpine.js) + * + * Used in: activities.html + * + * Features: + * - Paginated table of activities + * - Filter by type, owner, overdue, completed + * - "+ New Activity" modal that POSTs to /api/v1/activities/ + * - Row click opens a detail modal with a "Complete" button + * + * Backend: GET /api/v1/activities/?skip=&limit=&type=&owner_id=&overdue=&completed= + * POST /api/v1/activities/ + * PATCH /api/v1/activities/{id}/complete + * + * R-5: x-text only. Even activity bodies go through x-text, never x-html. + */ + +import { api } from '/js/api.js'; + +export const ACTIVITY_TYPES = ['call', 'email', 'meeting', 'task', 'note']; + +export const TYPE_BADGE = { + call: 'badge-blue', + email: 'badge-yellow', + meeting: 'badge-green', + task: 'badge-gray', + note: 'badge-gray', +}; + +export function activityList() { + return { + // State + activities: [], + total: 0, + skip: 0, + limit: 20, + type: '', + ownerId: '', + overdue: '', // '', 'true', 'false' + completed: '', // '', 'true', 'false' + loading: false, + error: null, + + // Create-modal state + showCreate: false, + createForm: this._initForm(), + createError: '', + creating: false, + + // Detail-modal state + showDetail: false, + selected: null, + completing: false, + + // Lookups + types: ACTIVITY_TYPES.slice(), + + async init() { + await this.load(); + }, + + async load() { + this.loading = true; + this.error = null; + try { + const params = new URLSearchParams(); + params.set('skip', String(this.skip)); + params.set('limit', String(this.limit)); + if (this.type) params.set('type', this.type); + if (this.ownerId) params.set('owner_id', this.ownerId); + if (this.overdue) params.set('overdue', this.overdue); + if (this.completed) params.set('completed', this.completed); + + const res = await api.get(`/activities/?${params.toString()}`); + this.activities = Array.isArray(res) ? res : (res && res.items) || []; + this.total = (res && res.total) || this.activities.length; + } catch (err) { + this.error = err.message || 'Fehler beim Laden der Activities.'; + this._notify('error', this.error); + } finally { + this.loading = false; + } + }, + + async applyFilters() { + this.skip = 0; + await this.load(); + }, + + async nextPage() { + if (this.skip + this.limit < this.total) { + this.skip += this.limit; + await this.load(); + } + }, + async prevPage() { + if (this.skip - this.limit >= 0) { + this.skip -= this.limit; + await this.load(); + } + }, + get pageInfo() { + const from = this.total === 0 ? 0 : this.skip + 1; + const to = Math.min(this.skip + this.limit, this.total); + return `${from}–${to} von ${this.total}`; + }, + + // === Row helpers === + isOverdue(a) { + if (a.completed_at) return false; + if (!a.due_date) return false; + return new Date(a.due_date) < new Date(); + }, + isCompleted(a) { return !!a.completed_at; }, + typeBadge(t) { return TYPE_BADGE[t] || 'badge-gray'; }, + + formatDateTime(iso) { + if (!iso) return ''; + try { return new Date(iso).toLocaleString('de-DE'); } + catch { return iso; } + }, + + // === Create modal === + openCreate() { + this.createForm = this._initForm(); + this.createError = ''; + this.showCreate = true; + }, + closeCreate() { this.showCreate = false; }, + async submitCreate() { + this.creating = true; + this.createError = ''; + try { + const payload = { + type: this.createForm.type, + subject: this.createForm.subject, + }; + if (this.createForm.body) payload.body = this.createForm.body; + if (this.createForm.due_date) payload.due_date = this.createForm.due_date; + if (this.createForm.account_id) payload.account_id = Number(this.createForm.account_id); + if (this.createForm.contact_id) payload.contact_id = Number(this.createForm.contact_id); + if (this.createForm.deal_id) payload.deal_id = Number(this.createForm.deal_id); + + await api.post('/activities/', payload); + this._notify('success', 'Activity angelegt.'); + this.showCreate = false; + await this.load(); + } catch (err) { + this.createError = err.message || 'Fehler beim Anlegen.'; + } finally { + this.creating = false; + } + }, + + // === Detail / Complete === + openDetail(a) { + this.selected = a; + this.showDetail = true; + }, + closeDetail() { + this.showDetail = false; + this.selected = null; + }, + async completeSelected() { + if (!this.selected) return; + this.completing = true; + try { + await api.patch(`/activities/${this.selected.id}/complete`, { outcome: '' }); + this._notify('success', 'Activity abgeschlossen.'); + this.closeDetail(); + await this.load(); + } catch (err) { + this._notify('error', err.message || 'Fehler beim Abschließen.'); + } finally { + this.completing = false; + } + }, + + // === Internals === + _initForm() { + return { + type: 'task', + subject: '', + body: '', + due_date: '', + account_id: '', + contact_id: '', + deal_id: '', + }; + }, + _notify(type, message) { + if (window.Alpine) Alpine.store('notifications').push(message, type); + }, + }; +} + +if (typeof window !== 'undefined') { + document.addEventListener('alpine:init', () => { + if (window.Alpine) { + window.Alpine.data('activityList', activityList); + } + }); +} + +export default activityList; diff --git a/app/webui/components/app-shell.js b/app/webui/components/app-shell.js new file mode 100644 index 0000000..307eff8 --- /dev/null +++ b/app/webui/components/app-shell.js @@ -0,0 +1,47 @@ +/** + * app-shell.js — Layout-Shell for the protected area. + * + * Each protected page (dashboard, accounts, etc.) renders the sidebar + + * topbar via x-data="appShell('')". The shell owns: + * - the active nav state + * - the title text (derived from the page key) + * - the Logout button handler (delegates to the auth store) + * - the admin-only nav items (uses $store.auth.isAdmin) + * + * The auth-gate itself (redirect to /index.html when no JWT) is enforced + * by store.js on the `Alpine.store('auth').init()` call, which every + * protected page invokes from its outermost x-data. + */ + +const TITLES = { + 'dashboard': 'Dashboard', + 'accounts': 'Accounts', + 'accounts-detail': 'Account-Detail', + 'contacts': 'Contacts', + 'contacts-detail': 'Contact-Detail', + 'pipeline': 'Pipeline', + 'activities': 'Activities', + 'settings-profile': 'Mein Profil', + 'settings-users': 'User-Verwaltung', + 'settings-org': 'Org-Einstellungen', +}; + + +export function appShell(active) { + return { + active: active || 'dashboard', + + get title() { + return TITLES[this.active] || 'CRM'; + }, + + async logout() { + await Alpine.store('auth').logout(); + }, + }; +} + +// Global registration for the inline `x-data="appShell('…')"` usage. +document.addEventListener('alpine:init', () => { + Alpine.data('appShell', (active) => appShell(active)); +}); diff --git a/app/webui/components/dashboard-kpis.js b/app/webui/components/dashboard-kpis.js new file mode 100644 index 0000000..3a7d293 --- /dev/null +++ b/app/webui/components/dashboard-kpis.js @@ -0,0 +1,85 @@ +/** + * Dashboard-KPIs + Activity-Feed Component (Alpine.js) + * + * Used in: dashboard.html and app.html (default landing page) + * + * Loads: + * GET /api/v1/dashboard/kpis → { open_deals_count, pipeline_value, won_this_month, conversion_rate } + * GET /api/v1/dashboard/feed?limit=20 → ActivityFeedItem[] + * + * Renders: 4 KPI cards + an activity feed list. + * + * R-5: x-text only. User-controlled strings are not interpolated as HTML. + */ + +import { api } from '/js/api.js'; + +export function dashboardKpis() { + return { + loading: true, + kpis: null, + feed: [], + error: null, + + async load() { + this.loading = true; + this.error = null; + try { + // Fetch KPIs and feed in parallel + const [kpis, feed] = await Promise.all([ + api.get('/dashboard/kpis'), + api.get('/dashboard/feed?limit=20'), + ]); + this.kpis = kpis; + this.feed = Array.isArray(feed) ? feed : (feed && feed.items) || []; + } catch (err) { + this.error = err.message || 'Fehler beim Laden der Dashboard-Daten.'; + if (window.Alpine) { + Alpine.store('notifications').error(this.error); + } + } finally { + this.loading = false; + } + }, + + formatCurrency(value) { + if (value === null || value === undefined) return '–'; + const num = Number(value); + if (Number.isNaN(num)) return '–'; + return num.toLocaleString('de-DE', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + }); + }, + + formatPercent(value) { + if (value === null || value === undefined) return '–'; + const num = Number(value); + if (Number.isNaN(num)) return '–'; + // Backend may return 0-1 or 0-100. Heuristic: >1 → already in %. + const pct = num > 1 ? num : num * 100; + return pct.toFixed(1) + ' %'; + }, + + formatDate(iso) { + if (!iso) return ''; + try { + return new Date(iso).toLocaleDateString('de-DE'); + } catch { + return iso; + } + }, + }; +} + +// Register globally for `x-data="dashboardKpis()"` +if (typeof window !== 'undefined') { + document.addEventListener('alpine:init', () => { + if (window.Alpine) { + window.Alpine.data('dashboardKpis', dashboardKpis); + } + }); +} + +export default dashboardKpis; diff --git a/app/webui/components/deal-kanban.js b/app/webui/components/deal-kanban.js new file mode 100644 index 0000000..a8c7ba0 --- /dev/null +++ b/app/webui/components/deal-kanban.js @@ -0,0 +1,207 @@ +/** + * Deal-Kanban Component (Alpine.js) + * + * Used in: pipeline.html + * + * Loads all deals and groups them client-side by stage into 6 Kanban columns: + * lead → qualified → proposal → negotiation → won → lost + * + * Drag-and-drop: uses HTML5 Drag API. On drop, PATCH /api/v1/deals/{id}/stage + * with the new stage. Backend: { stage, reason? } per DealStageUpdate schema. + * + * The pipeline-summary endpoint (GET /deals/pipeline) is also fetched for + * aggregate counts/values shown in each column header. + * + * R-5: x-text only. No x-html anywhere — including dynamic deal titles. + */ + +import { api } from '/js/api.js'; + +export const STAGES = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']; + +export const STAGE_LABELS = { + lead: 'Lead', + qualified: 'Qualified', + proposal: 'Proposal', + negotiation: 'Negotiation', + won: 'Won', + lost: 'Lost', +}; + +export const STAGE_BADGE = { + lead: 'badge-gray', + qualified: 'badge-blue', + proposal: 'badge-yellow', + negotiation: 'badge-yellow', + won: 'badge-green', + lost: 'badge-red', +}; + +export function dealKanban() { + return { + // State + stages: STAGES.slice(), + dealsByStage: {}, // { stage: DealOut[] } + pipelineSummary: [], // [{stage, count, total_value}] from /deals/pipeline + ownerId: '', + dragOverStage: null, + loading: false, + error: null, + + async init() { + await this.load(); + }, + + async load() { + this.loading = true; + this.error = null; + try { + // Build URL with optional owner_id filter + let url = '/deals/?skip=0&limit=200'; + if (this.ownerId) url += `&owner_id=${encodeURIComponent(this.ownerId)}`; + + const [deals, summary] = await Promise.all([ + api.get(url), + api.get('/deals/pipeline').catch(() => []), // optional + ]); + + const list = Array.isArray(deals) ? deals : (deals && deals.items) || []; + this.dealsByStage = {}; + this.stages.forEach(s => { this.dealsByStage[s] = []; }); + for (const d of list) { + if (this.dealsByStage[d.stage]) { + this.dealsByStage[d.stage].push(d); + } + } + this.pipelineSummary = Array.isArray(summary) ? summary : []; + } catch (err) { + this.error = err.message || 'Fehler beim Laden der Pipeline.'; + this._notify('error', this.error); + } finally { + this.loading = false; + } + }, + + async filterByOwner() { + await this.load(); + }, + + // === Drag-and-Drop === + onDragStart(event, dealId) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(dealId)); + }, + onDragOver(event, stage) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + this.dragOverStage = stage; + }, + onDragLeave(stage) { + if (this.dragOverStage === stage) this.dragOverStage = null; + }, + async onDrop(event, newStage) { + event.preventDefault(); + this.dragOverStage = null; + const dealId = event.dataTransfer.getData('text/plain'); + if (!dealId) return; + + // Find the deal in its current column + let currentStage = null; + let deal = null; + for (const s of this.stages) { + const idx = this.dealsByStage[s].findIndex(d => String(d.id) === String(dealId)); + if (idx >= 0) { currentStage = s; deal = this.dealsByStage[s][idx]; break; } + } + if (!deal || currentStage === newStage) return; + + // Optimistic UI move + this.dealsByStage[currentStage] = this.dealsByStage[currentStage].filter(d => d.id !== deal.id); + const oldDeal = { ...deal }; + deal.stage = newStage; + this.dealsByStage[newStage].push(deal); + + // Persist to backend + try { + await api.patch(`/deals/${dealId}/stage`, { stage: newStage }); + this._notify('success', `Deal "${oldDeal.title}" → ${STAGE_LABELS[newStage]}`); + } catch (err) { + // Revert on failure + this.dealsByStage[newStage] = this.dealsByStage[newStage].filter(d => d.id !== deal.id); + oldDeal.stage = currentStage; + this.dealsByStage[currentStage].push(oldDeal); + this._notify('error', err.message || 'Stage-Update fehlgeschlagen.'); + } + }, + + // === Create modal === + showCreate: false, + createForm: this._initForm(), + creating: false, + createError: '', + + openCreate() { + this.createForm = this._initForm(); + this.createError = ''; + this.showCreate = true; + }, + closeCreate() { this.showCreate = false; }, + async submitCreate() { + this.creating = true; + this.createError = ''; + try { + const payload = { + title: this.createForm.title, + value: Number(this.createForm.value) || 0, + currency: this.createForm.currency || 'EUR', + stage: this.createForm.stage || 'lead', + account_id: Number(this.createForm.account_id), + }; + if (this.createForm.close_date) payload.close_date = this.createForm.close_date; + await api.post('/deals/', payload); + this._notify('success', 'Deal angelegt.'); + this.showCreate = false; + await this.load(); + } catch (err) { + this.createError = err.message || 'Fehler beim Anlegen.'; + } finally { + this.creating = false; + } + }, + + // === Helpers === + stageLabel(s) { return STAGE_LABELS[s] || s; }, + stageBadge(s) { return STAGE_BADGE[s] || 'badge-gray'; }, + columnCount(s) { return (this.dealsByStage[s] || []).length; }, + columnValue(s) { + const sum = (this.dealsByStage[s] || []).reduce( + (acc, d) => acc + Number(d.value || 0), 0); + return sum.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); + }, + formatCurrency(v) { + return Number(v || 0).toLocaleString('de-DE', { + style: 'currency', currency: 'EUR', maximumFractionDigits: 0, + }); + }, + formatDate(iso) { + if (!iso) return ''; + try { return new Date(iso).toLocaleDateString('de-DE'); } + catch { return iso; } + }, + _initForm() { + return { title: '', value: 0, currency: 'EUR', stage: 'lead', account_id: '', close_date: '' }; + }, + _notify(type, message) { + if (window.Alpine) Alpine.store('notifications').push(message, type); + }, + }; +} + +if (typeof window !== 'undefined') { + document.addEventListener('alpine:init', () => { + if (window.Alpine) { + window.Alpine.data('dealKanban', dealKanban); + } + }); +} + +export default dealKanban; diff --git a/app/webui/contacts-detail.html b/app/webui/contacts-detail.html new file mode 100644 index 0000000..472ceb1 --- /dev/null +++ b/app/webui/contacts-detail.html @@ -0,0 +1,268 @@ + + + + + + CRM – Contact Detail + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Contact-Detail

+
+ + + + +
+
+ +
+ +
+
+

Lade Contact …

+
+ +
+
+
+
+

+

+ + · + + · + Account # +

+
+
+ + +
+
+
+ +
+ + + +
+ +
+
+
Vorname
+
Nachname
+
E-Mail
+
Telefon
+
Account
+
Owner
+
+
+ +
+
+
    + +
  • Keine Activities.
  • +
+
+ +
+
+
    + +
  • Keine Notes.
  • +
+
+
+ + +
+
+

Contact bearbeiten

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+ + + + diff --git a/app/webui/contacts.html b/app/webui/contacts.html new file mode 100644 index 0000000..1b2db71 --- /dev/null +++ b/app/webui/contacts.html @@ -0,0 +1,231 @@ + + + + + + CRM – Contacts + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Contacts

+
+ + + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+

Lade Contacts …

+
+ + +
+ + + + + + + + + + + + + + + + +
NameE-MailTelefonAccount-IDOwner
+ Keine Contacts gefunden. +
+
+ + +
+ +
+ + +
+
+ + +
+
+

Neuer Contact

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+ + + + diff --git a/app/webui/css/app.css b/app/webui/css/app.css new file mode 100644 index 0000000..8dc00ff --- /dev/null +++ b/app/webui/css/app.css @@ -0,0 +1,255 @@ +/* === CRM Frontend Custom Styles === */ +/* Tailwind-CDN provides utility classes; this file adds app-specific overrides. */ + +:root { + --crm-primary: #2563eb; /* blue-600 */ + --crm-primary-dark: #1d4ed8; + --crm-success: #16a34a; /* green-600 */ + --crm-danger: #dc2626; /* red-600 */ + --crm-warning: #d97706; /* amber-600 */ + --crm-bg: #f8fafc; /* slate-50 */ + --crm-card: #ffffff; + --crm-border: #e2e8f0; /* slate-200 */ + --crm-text: #0f172a; /* slate-900 */ + --crm-muted: #64748b; /* slate-500 */ +} + +/* === Body === */ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + background: var(--crm-bg); + color: var(--crm-text); +} + +/* === Buttons (primary-color override) === */ +.btn-primary { + background-color: var(--crm-primary); + color: #fff; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 500; + transition: background-color 0.15s ease; +} +.btn-primary:hover { background-color: var(--crm-primary-dark); } +.btn-primary:disabled { background-color: #94a3b8; cursor: not-allowed; } + +.btn-secondary { + background-color: #fff; + color: var(--crm-text); + padding: 0.5rem 1rem; + border: 1px solid var(--crm-border); + border-radius: 0.375rem; + font-weight: 500; +} +.btn-secondary:hover { background-color: #f1f5f9; } + +.btn-danger { + background-color: var(--crm-danger); + color: #fff; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 500; +} +.btn-danger:hover { background-color: #b91c1c; } + +/* === Cards === */ +.crm-card { + background: var(--crm-card); + border: 1px solid var(--crm-border); + border-radius: 0.5rem; + padding: 1.25rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +/* === Form fields === */ +.crm-input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--crm-border); + border-radius: 0.375rem; + background: #fff; + font-size: 0.95rem; +} +.crm-input:focus { + outline: none; + border-color: var(--crm-primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.crm-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--crm-text); + margin-bottom: 0.25rem; +} + +/* === Loading spinner (Alpine x-show) === */ +.crm-spinner { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + border: 2px solid #cbd5e1; + border-top-color: var(--crm-primary); + border-radius: 50%; + animation: crm-spin 0.8s linear infinite; +} + +@keyframes crm-spin { + to { transform: rotate(360deg); } +} + +.crm-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +/* === Toast Notifications (top-right) === */ +.crm-toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 22rem; +} + +.crm-toast { + padding: 0.75rem 1rem; + border-radius: 0.375rem; + color: #fff; + font-size: 0.9rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: crm-toast-in 0.25s ease-out; +} + +.crm-toast.toast-success { background-color: var(--crm-success); } +.crm-toast.toast-error { background-color: var(--crm-danger); } +.crm-toast.toast-info { background-color: var(--crm-primary); } +.crm-toast.toast-warning { background-color: var(--crm-warning); } + +@keyframes crm-toast-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* === Tables === */ +.crm-table { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--crm-border); + border-radius: 0.5rem; + overflow: hidden; +} +.crm-table th { + text-align: left; + padding: 0.75rem 1rem; + background: #f1f5f9; + font-size: 0.8rem; + font-weight: 600; + color: var(--crm-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--crm-border); +} +.crm-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--crm-border); + font-size: 0.9rem; +} +.crm-table tr:last-child td { border-bottom: none; } +.crm-table tr:hover td { background: #f8fafc; } +.crm-table tr.row-overdue td { background: #fef2f2; } +.crm-table tr.row-overdue:hover td { background: #fee2e2; } + +/* === Badges === */ +.crm-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} +.badge-blue { background: #dbeafe; color: #1e40af; } +.badge-green { background: #dcfce7; color: #166534; } +.badge-red { background: #fee2e2; color: #991b1b; } +.badge-yellow { background: #fef3c7; color: #92400e; } +.badge-gray { background: #f1f5f9; color: #475569; } + +/* === Sidebar (app.html) === */ +.crm-sidebar { + width: 16rem; + background: #0f172a; + color: #cbd5e1; + min-height: 100vh; + padding: 1rem 0; +} +.crm-sidebar a { + display: block; + padding: 0.5rem 1.25rem; + color: #cbd5e1; + text-decoration: none; + font-size: 0.9rem; + border-left: 3px solid transparent; +} +.crm-sidebar a:hover { background: #1e293b; color: #fff; } +.crm-sidebar a.active { + background: #1e293b; + color: #fff; + border-left-color: var(--crm-primary); +} + +/* === Kanban (pipeline.html) === */ +.crm-kanban { + display: flex; + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 1rem; +} +.crm-kanban-col { + flex: 0 0 16rem; + background: #f1f5f9; + border-radius: 0.5rem; + padding: 0.75rem; + min-height: 12rem; +} +.crm-kanban-col.drag-over { background: #e0e7ff; outline: 2px dashed var(--crm-primary); } +.crm-kanban-card { + background: #fff; + border: 1px solid var(--crm-border); + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + cursor: grab; + font-size: 0.85rem; +} +.crm-kanban-card:active { cursor: grabbing; } + +/* === Modal (Alpine x-show) === */ +.crm-modal-backdrop { + position: fixed; inset: 0; + background: rgba(15, 23, 42, 0.5); + z-index: 100; + display: flex; align-items: center; justify-content: center; +} +.crm-modal { + background: #fff; + border-radius: 0.5rem; + max-width: 32rem; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); +} + +/* === Utility === */ +[x-cloak] { display: none !important; } diff --git a/app/webui/dashboard.html b/app/webui/dashboard.html new file mode 100644 index 0000000..7307778 --- /dev/null +++ b/app/webui/dashboard.html @@ -0,0 +1,145 @@ + + + + + + CRM – Dashboard + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+
+ + + + + +
+ + +
+

+
+ + Angemeldet als + + + +
+
+ + +
+ + +
+
+

Lade Dashboard …

+
+ + +
+ +
+ +
+ + +

Kennzahlen

+
+
+

Open Deals

+

+
+
+

Pipeline Value

+

+
+
+

Won this Month

+

+
+
+

Conversion Rate

+

+
+
+ + +

Schnellaktionen

+ + + +

Letzte Aktivitäten

+
+ +

+ Keine Aktivitäten. +

+
+
+
+
+ +
+
+ + + diff --git a/app/webui/index.html b/app/webui/index.html new file mode 100644 index 0000000..12a523a --- /dev/null +++ b/app/webui/index.html @@ -0,0 +1,221 @@ + + + + + + CRM – Anmeldung + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + +
+

CRM System

+

+
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +

Mindestens 8 Zeichen.

+
+
+ + +

+ Hinweis: Bei der ersten Registrierung (Bootstrap) wird meistens ein + Admin-Konto angelegt. +

+
+ +
+ +

+ API-Endpoint: /api/v1/auth/* +

+
+
+ + + + + diff --git a/app/webui/js/api.js b/app/webui/js/api.js new file mode 100644 index 0000000..410f962 --- /dev/null +++ b/app/webui/js/api.js @@ -0,0 +1,158 @@ +/** + * CRM Frontend API-Wrapper + * + * Lightweight fetch() wrapper with: + * - Automatic JWT injection (Authorization: Bearer from localStorage) + * - 401 handling: clears token + redirects to /index.html + * - JSON-only requests (FormData→x-www-form-urlencoded via postForm) + * - ApiError class with status and parsed body for granular error handling + * + * All pages import this via + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Pipeline

+
+ + + + +
+
+ +
+ + +
+
+ Ziehe Karten zwischen Spalten, um die Stage zu ändern. +
+
+
+ + + +
+
+ +
+ + +
+ +
+ + +
+
+

Neuer Deal

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+ + + diff --git a/app/webui/settings-org.html b/app/webui/settings-org.html new file mode 100644 index 0000000..d8e1af2 --- /dev/null +++ b/app/webui/settings-org.html @@ -0,0 +1,142 @@ + + + + + + CRM – Org-Einstellungen + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Org-Einstellungen (Admin)

+
+ + + + +
+
+ +
+ + +
+

Zugriff verweigert.

+

Diese Seite ist nur für Admins.

+ Zum Dashboard +
+ +
+
+
+ +
+ + +
+ Hinweis: Das Bearbeiten von Organisationseinstellungen ist + für v1.1 geplant. In v1 ist der PATCH /api/v1/orgs/{id}-Endpoint + noch nicht verfügbar. Die Form ist deshalb deaktiviert (nur Read-View). +
+ +

Aktuelle Organisation

+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ Quelle: GET /api/v1/orgs/{id} (read-only in v1). +

+
+
+
+
+ + + + diff --git a/app/webui/settings-profile.html b/app/webui/settings-profile.html new file mode 100644 index 0000000..980fb45 --- /dev/null +++ b/app/webui/settings-profile.html @@ -0,0 +1,148 @@ + + + + + + CRM – Mein Profil + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

Mein Profil

+
+ + + + +
+
+ +
+ +
+
+
+ +
+

Profil bearbeiten

+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+
+
+
+
+ + + + diff --git a/app/webui/settings-users.html b/app/webui/settings-users.html new file mode 100644 index 0000000..a9550dc --- /dev/null +++ b/app/webui/settings-users.html @@ -0,0 +1,231 @@ + + + + + + CRM – User-Verwaltung + + + + + + + + + + +
+ +
+ +
+ + + +
+
+

User-Verwaltung (Admin)

+
+ + + + +
+
+ +
+ + +
+

Zugriff verweigert.

+

Diese Seite ist nur für Admins.

+ Zum Dashboard +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+ + + + + + + + + + +
IDNameE-MailRolleAktionen
Keine User.
+
+ + +
+
+

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+ + + + diff --git a/tests/test_frontend_assets.py b/tests/test_frontend_assets.py new file mode 100644 index 0000000..292d819 --- /dev/null +++ b/tests/test_frontend_assets.py @@ -0,0 +1,177 @@ +"""Frontend-Asset Tests: verify JS modules and CSS are syntactically valid +and export / register the expected symbols. + +These tests perform static text-level checks on the JS / CSS files — no +runtime execution, no nodejs dependency. They are sufficient to catch: + - Missing exports (e.g. `export { api, ApiError }` removed from api.js) + - Missing global Alpine.data() registrations for the components + - CSS variables removed from the custom-overrides file +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +WEBUI = Path(__file__).resolve().parent.parent / "app" / "webui" + + +def _read(rel: str) -> str: + p = WEBUI / rel + assert p.exists(), f"Missing asset: {p}" + return p.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# api.js +# --------------------------------------------------------------------------- + +def test_api_js_exports_api_and_ApiError(): + content = _read("js/api.js") + assert "export class ApiError" in content + assert "export const api" in content or "export default api" in content + + +def test_api_js_uses_localStorage_for_jwt(): + """R-1 / architecture: JWT must live in localStorage.""" + content = _read("js/api.js") + assert "localStorage" in content + assert "jwt" in content + + +def test_api_js_handles_401_redirect(): + """401 must clear the token and redirect to /index.html.""" + content = _read("js/api.js") + assert "401" in content + assert "/index.html" in content + + +def test_api_js_handles_204_no_content(): + content = _read("js/api.js") + assert "204" in content + + +# --------------------------------------------------------------------------- +# store.js +# --------------------------------------------------------------------------- + +def test_store_js_defines_auth_and_notifications(): + content = _read("js/store.js") + assert "Alpine.store('auth'" in content + assert "Alpine.store('notifications'" in content + + +def test_store_js_auth_has_login_and_logout(): + content = _read("js/store.js") + assert "login" in content + assert "logout" in content + assert "fetchUser" in content + + +def test_store_js_notifications_has_push_and_dismiss(): + content = _read("js/store.js") + assert "push" in content + assert "dismiss" in content + assert "success" in content + assert "error" in content + + +# --------------------------------------------------------------------------- +# app.css +# --------------------------------------------------------------------------- + +def test_app_css_contains_tailwind_overrides(): + content = _read("css/app.css") + # The file defines custom CSS variables (--crm-primary, etc.) + assert "--crm-primary" in content + assert "--crm-success" in content + assert "--crm-danger" in content + + +def test_app_css_has_loading_spinner(): + content = _read("css/app.css") + assert "crm-spinner" in content + assert "@keyframes crm-spin" in content + + +def test_app_css_has_toast_styles(): + content = _read("css/app.css") + assert "crm-toast" in content + assert "toast-success" in content + assert "toast-error" in content + + +# --------------------------------------------------------------------------- +# Alpine components +# --------------------------------------------------------------------------- + +COMPONENTS = [ + ("account-list.js", "accountList"), + ("deal-kanban.js", "dealKanban"), + ("activity-list.js", "activityList"), + ("dashboard-kpis.js", "dashboardKpis"), +] + + +@pytest.mark.parametrize("filename,factory", COMPONENTS) +def test_alpine_component_defined(filename, factory): + """Each component file must define a named factory and register it + globally with Alpine.data(name, factory).""" + content = _read(f"components/{filename}") + # factory function definition + assert f"export function {factory}" in content, \ + f"{filename} missing `export function {factory}()`" + # Alpine.data() registration + assert f"Alpine.data('{factory}'" in content, \ + f"{filename} missing `Alpine.data('{factory}', …)` registration" + + +def test_deal_kanban_handles_drag_and_drop(): + content = _read("components/deal-kanban.js") + assert "draggable" in content or "dataTransfer" in content + assert "onDrop" in content + assert "/deals/" in content and "/stage" in content + + +def test_activity_list_supports_overdue_filter(): + content = _read("components/activity-list.js") + assert "overdue" in content + assert "/activities/" in content + assert "/complete" in content + + +def test_dashboard_kpis_loads_both_kpis_and_feed(): + content = _read("components/dashboard-kpis.js") + assert "/dashboard/kpis" in content + assert "/dashboard/feed" in content + + +def test_account_list_uses_pagination(): + content = _read("components/account-list.js") + assert "skip" in content + assert "limit" in content + assert "q" in content + assert "/accounts/" in content + + +# --------------------------------------------------------------------------- +# notifications.js (toast component) +# --------------------------------------------------------------------------- + +def test_notifications_js_registers_toast_container(): + content = _read("js/components/notifications.js") + assert "toastContainer" in content + assert "Alpine.data" in content + + +# --------------------------------------------------------------------------- +# auth.js +# --------------------------------------------------------------------------- + +def test_auth_js_exports_login_logout_register(): + content = _read("js/auth.js") + assert "export async function login" in content + assert "export async function logout" in content + assert "export async function register" in content + assert "export async function refreshToken" in content diff --git a/tests/test_frontend_pages.py b/tests/test_frontend_pages.py new file mode 100644 index 0000000..04a8716 --- /dev/null +++ b/tests/test_frontend_pages.py @@ -0,0 +1,111 @@ +"""Frontend-Smoke-Tests: verify all 13 HTML pages exist and contain the +expected keywords/structure. + +These tests read the HTML files directly from `app/webui/` (rather than +hitting the FastAPI server) because the static-file mount is added in a +later phase by the orchestrator. Once that mount is in place, the same +checks could be re-implemented as `httpx.AsyncClient.get("/…")`. + +Each test loads exactly one page and asserts: + - file exists and is non-empty + - contains the page's title text (so a typo / removal is caught) + - has Tailwind-CDN, Alpine-CDN, and the app.css link + - has no x-html attribute (R-5) +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +WEBUI_DIR = Path(__file__).resolve().parent.parent / "app" / "webui" + + +def _read(name: str) -> str: + """Read a file from the webui directory.""" + p = WEBUI_DIR / name + assert p.exists(), f"Expected frontend file {p} to exist" + return p.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Each page: (filename, [required substrings]) +# --------------------------------------------------------------------------- +PAGES = [ + ("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]), + ("app.html", ["Dashboard", "Logout", "Pipeline"]), + ("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]), + ("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]), + ("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]), + ("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]), + ("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]), + ("activities.html", ["Activities", "+ New Activity", "Fällig"]), + ("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]), + ("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]), + ("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]), + ("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]), + # Dashboard: ships inside app.html. We test the marker text directly there. +] + + +@pytest.mark.parametrize("filename,required", PAGES) +def test_frontend_page_loads(filename, required): + """Each HTML page must exist and contain its identifying substrings.""" + content = _read(filename) + assert len(content) > 200, f"{filename} seems empty or too small" + for needle in required: + assert needle in content, f"{filename} missing required string {needle!r}" + + +def test_index_html_contains_login_and_register(): + """Index page (auth screen) must show both Login and Register tabs.""" + content = _read("index.html") + assert "Login" in content + assert "Registrieren" in content + # Alpine-CDN required + assert "alpinejs" in content + + +def test_app_html_contains_dashboard_and_logout(): + """app.html is the post-login shell — must show Dashboard + Logout.""" + content = _read("app.html") + assert "Dashboard" in content + assert "Logout" in content + + +def test_13_pages_exist(): + """Exactly 13 HTML pages must be present (index + app + 10 + 404).""" + pages = sorted(p.name for p in WEBUI_DIR.glob("*.html")) + assert len(pages) == 13, f"Expected 13 pages, found {len(pages)}: {pages}" + + +def test_dashboard_section_inside_app_html(): + """The dashboard content is rendered inside app.html (default landing).""" + content = _read("app.html") + # The 4 KPI labels from the dashboard section + assert "Open Deals" in content + assert "Pipeline Value" in content + assert "Won this Month" in content + assert "Conversion Rate" in content + + +def test_each_page_links_app_css(): + """Every page must reference /css/app.css.""" + for filename, _ in PAGES: + content = _read(filename) + assert "/css/app.css" in content, f"{filename} missing /css/app.css link" + + +def test_each_page_includes_alpine_cdn(): + """Every page must include the Alpine.js CDN script.""" + for filename, _ in PAGES: + content = _read(filename) + assert "alpinejs" in content, f"{filename} missing Alpine.js CDN" + + +def test_each_page_includes_tailwind_cdn(): + """Every page must include the Tailwind-CDN script (dev-mode).""" + for filename, _ in PAGES: + content = _read(filename) + assert "cdn.tailwindcss.com" in content, f"{filename} missing Tailwind CDN" diff --git a/tests/test_frontend_security.py b/tests/test_frontend_security.py new file mode 100644 index 0000000..2532a7b --- /dev/null +++ b/tests/test_frontend_security.py @@ -0,0 +1,177 @@ +"""Frontend-Security Tests: XSS-mitigation, JWT-handling, CSP-middleware. + +These tests enforce the rules from architecture Section 13 + task graph R-5: + + - R-5: No `x-html` attribute anywhere in any HTML template (only x-text). + - R-1: JWT must be read from / written to localStorage (not cookies, not sessionStorage). + - 13.3: CSP-Header is configured in app/main.py (security_headers_middleware), + not in a reverse-proxy config file. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +WEBUI = ROOT / "app" / "webui" +MAIN_PY = ROOT / "app" / "main.py" + + +# --------------------------------------------------------------------------- +# R-5: no x-html in any HTML template +# --------------------------------------------------------------------------- + +# Match `x-html` as a whole word (attribute, not part of `x-htmlSomething`). +# We deliberately allow `x-html="..."` to fail loudly, and we also catch +# any camel-cased variant like `xHtml` for paranoia. +XHTML_PATTERN = re.compile(r"\bx[-_]?html\b", re.IGNORECASE) + + +def _all_html_files() -> list[Path]: + return sorted(WEBUI.glob("*.html")) + + +def test_no_x_html_in_alpine_templates(): + """R-5: no `x-html` attribute anywhere in any HTML file.""" + offenders: list[tuple[str, int, str]] = [] + for path in _all_html_files(): + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + # Strip HTML comments to avoid false positives in documentation + stripped = re.sub(r"", "", line) + if XHTML_PATTERN.search(stripped): + offenders.append((path.name, lineno, line.strip()[:120])) + assert not offenders, ( + "x-html usage is forbidden (R-5); offenders:\n" + + "\n".join(f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders) + ) + + +def test_no_innerHTML_in_alpine_pages(): + """Defence in depth: also flag direct `innerHTML` assignments.""" + for path in _all_html_files(): + content = path.read_text(encoding="utf-8") + # allow within