thatcher 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { TOAST_SCRIPT, TABLE_SCRIPT, statusBadge as _statusBadge } from '@/ui/render-helpers.js';
|
|
3
|
+
import { SPACING, renderCard } from '@/ui/spacing-system.js';
|
|
4
|
+
|
|
5
|
+
export { TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
6
|
+
|
|
7
|
+
export function settingsPage(user, title, bc, content, scripts = []) {
|
|
8
|
+
return page(user, title, bc, content, scripts);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function settingsBack() {
|
|
12
|
+
return `<a href="/admin/settings" class="btn btn-ghost btn-sm gap-1 mb-4">← Back to Settings</a>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function inlineTable(headers, rows, emptyMsg) {
|
|
16
|
+
const ths = headers.map(h => `<th>${h}</th>`).join('');
|
|
17
|
+
const empty = `<tr><td colspan="${headers.length}" class="text-center py-8 text-base-content/40 text-sm">${emptyMsg}</td></tr>`;
|
|
18
|
+
return `<div class="table-wrap"><table class="data-table"><thead><tr>${ths}</tr></thead><tbody>${rows || empty}</tbody></table></div>`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KNOWN_ROLE_LABELS = { admin:'Admin', partner:'Partner', manager:'Manager', clerk:'Clerk', user:'User', auditor:'Auditor', client_admin:'Client Admin', client_user:'Client User' };
|
|
22
|
+
|
|
23
|
+
export function roleBadge(role) {
|
|
24
|
+
const r = (role || '').toLowerCase();
|
|
25
|
+
const pillMap = { admin:'pill pill-danger', partner:'pill pill-info', manager:'pill pill-success', clerk:'pill pill-warning', user:'pill pill-neutral', auditor:'pill pill-neutral', client_admin:'pill pill-info', client_user:'pill pill-neutral' };
|
|
26
|
+
const cls = pillMap[r] || 'pill pill-neutral';
|
|
27
|
+
const label = KNOWN_ROLE_LABELS[r] || (r.length > 20 ? 'Staff' : (r.charAt(0).toUpperCase() + r.slice(1))) || 'Staff';
|
|
28
|
+
return `<span class="${cls}">${label}</span>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function statusBadge(status) { return _statusBadge(status); }
|
|
32
|
+
|
|
33
|
+
const SETTINGS_CARDS = [
|
|
34
|
+
{ key: 'system', icon: '⚙', title: 'System Info', desc: 'Database, server, cache configuration', href: '/admin/settings/system' },
|
|
35
|
+
{ key: 'users', icon: '👤', title: 'Users', desc: 'Manage user accounts and roles', href: '/admin/settings/users', countKey: 'users' },
|
|
36
|
+
{ key: 'teams', icon: '👥', title: 'Teams', desc: 'Manage audit teams', href: '/admin/settings/teams', countKey: 'teams' },
|
|
37
|
+
{ key: 'rfi-sections', icon: '📁', title: 'RFI Sections', desc: 'Manage RFI section categories and colors', href: '/admin/settings/rfi-sections', countKey: 'rfiSections' },
|
|
38
|
+
{ key: 'templates', icon: '📄', title: 'Templates', desc: 'Review templates and sections', href: '/admin/settings/templates', countKey: 'templates' },
|
|
39
|
+
{ key: 'notifications', icon: '🔔', title: 'Notifications', desc: 'Configure alerts, reminders, and reports', href: '/admin/settings/notifications' },
|
|
40
|
+
{ key: 'integrations', icon: '🔗', title: 'Integrations', desc: 'Google Drive, Gmail, and external services', href: '/admin/settings/integrations' },
|
|
41
|
+
{ key: 'checklists', icon: '☑', title: 'Checklists', desc: 'Manage checklist definitions', href: '/admin/settings/checklists', countKey: 'checklists' },
|
|
42
|
+
{ key: 'rfi-templates', icon: '📋', title: 'RFI Templates', desc: 'Manage RFI template definitions', href: '/admin/settings/rfi-templates', countKey: 'rfiTemplates' },
|
|
43
|
+
{ key: 'recreation', icon: '📋', title: 'Audit Logs', desc: 'Audit trail, recreation history and event log', href: '/admin/settings/recreation' },
|
|
44
|
+
{ key: 'entity-types', icon: '🏢', title: 'Entity Types', desc: 'Manage client entity type definitions', href: '/admin/settings/entity-types', countKey: 'entityTypes' },
|
|
45
|
+
{ key: 'engagement-types', icon: '📄', title: 'Engagement Types', desc: 'Manage engagement type definitions', href: '/admin/settings/engagement-types', countKey: 'engagementTypes' },
|
|
46
|
+
{ key: 'permissions', icon: '🔒', title: 'Permissions', desc: 'MWR permissions and access control', href: '/admin/settings/permissions' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
50
|
+
const cards = SETTINGS_CARDS.map(c => {
|
|
51
|
+
const cnt = c.countKey && counts[c.countKey] !== undefined ? counts[c.countKey] : null;
|
|
52
|
+
const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString()} items</span>` : '';
|
|
53
|
+
return `<a href="${c.href}" class="card-clean" style="text-decoration:none;border:1px solid var(--color-border);transition:box-shadow 0.15s">
|
|
54
|
+
<div class="card-clean-body" style="padding:${SPACING.md}">
|
|
55
|
+
<div style="display:flex;align-items:flex-start;gap:${SPACING.md}">
|
|
56
|
+
<div style="font-size:1.25rem;width:2.25rem;height:2.25rem;display:flex;align-items:center;justify-content:center;background:var(--color-base-200);border-radius:8px;flex-shrink:0">${c.icon}</div>
|
|
57
|
+
<div style="flex:1;min-width:0">
|
|
58
|
+
<div style="font-weight:600;font-size:0.875rem;color:var(--color-base-content)">${c.title}</div>
|
|
59
|
+
<div style="font-size:0.75rem;color:var(--color-text-muted);margin-top:${SPACING.xs};line-height:1.4">${c.desc}</div>
|
|
60
|
+
${badge ? `<div style="margin-top:${SPACING.sm}">${badge}</div>` : ''}
|
|
61
|
+
</div>
|
|
62
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</a>`;
|
|
66
|
+
}).join('');
|
|
67
|
+
const content = `
|
|
68
|
+
<div class="flex items-center justify-between mb-6">
|
|
69
|
+
<div><h1 class="text-2xl font-bold text-base-content">Settings</h1><p class="text-sm text-base-content/50 mt-1">System configuration and administration</p></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="settings-home-grid">${cards}</div>`;
|
|
72
|
+
return settingsPage(user, 'Settings', [{ href: '/', label: 'Dashboard' }, { label: 'Settings' }], content);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderSettingsSystem(user, config = {}) {
|
|
76
|
+
const t = config.thresholds || {};
|
|
77
|
+
const sections = [
|
|
78
|
+
{ icon: '🗄️', title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port || 3004], ['Session TTL', (t.cache?.session_ttl_seconds || 3600) + 's'], ['Page Size (Default)', t.system?.default_page_size || 50], ['Page Size (Max)', t.system?.max_page_size || 500]] },
|
|
79
|
+
{ icon: '📋', title: 'RFI Configuration', items: [['Max Days Outstanding', (t.rfi?.max_days_outstanding || 90) + ' days'], ['Escalation Delay', (t.rfi?.escalation_delay_hours || 24) + ' hours'], ['Notification Days', (t.rfi?.notification_days || [7, 3, 1, 0]).join(', ')]] },
|
|
80
|
+
{ icon: '✉️', title: 'Email Configuration', items: [['Batch Size', t.email?.send_batch_size || 10], ['Max Retries', t.email?.send_max_retries || 3], ['Rate Limit Delay', (t.email?.rate_limit_delay_ms || 6000) + 'ms']] },
|
|
81
|
+
{ icon: '⚙️', title: 'Workflow Configuration', items: [['Stage Transition Lockout', (t.workflow?.stage_transition_lockout_minutes || 5) + ' min'], ['Collaborator Default Expiry', (t.collaborator?.default_expiry_days || 7) + ' days'], ['Collaborator Max Expiry', (t.collaborator?.max_expiry_days || 30) + ' days']] },
|
|
82
|
+
];
|
|
83
|
+
const cards = sections.map(s => `<div class="card-clean">
|
|
84
|
+
<div class="card-clean-body" style="padding:1rem">
|
|
85
|
+
<h2 class="font-semibold text-sm flex items-center gap-2 mb-3"><span>${s.icon}</span> ${s.title}</h2>
|
|
86
|
+
${s.items.map(([l, v]) => `<div class="flex justify-between items-center py-2 border-b border-base-200 last:border-0">
|
|
87
|
+
<span class="text-sm text-base-content/60">${l}</span>
|
|
88
|
+
<span class="text-sm font-semibold font-mono bg-base-200 px-2 py-0.5 rounded">${v}</span>
|
|
89
|
+
</div>`).join('')}
|
|
90
|
+
</div>
|
|
91
|
+
</div>`).join('');
|
|
92
|
+
const content = `${settingsBack()}
|
|
93
|
+
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem">
|
|
94
|
+
<h1 class="text-2xl font-bold">System Info</h1>
|
|
95
|
+
<span class="badge badge-flat-success text-xs">Read-only</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="settings-info-grid">${cards}</div>`;
|
|
98
|
+
return settingsPage(user, 'System Info - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'System Info' }], content);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderSettingsUsers(user, users = []) {
|
|
102
|
+
const active = users.filter(u => (u.status||'active') === 'active').length;
|
|
103
|
+
const rows = users.map(u => `<tr class="hover cursor-pointer" data-row data-navigate="/user/${u.id}">
|
|
104
|
+
<td data-col="name">
|
|
105
|
+
<div class="flex items-center gap-2">
|
|
106
|
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary flex-shrink-0">${(u.name||'?').charAt(0).toUpperCase()}</div>
|
|
107
|
+
<div>
|
|
108
|
+
<div class="text-sm font-medium">${u.name || '-'}</div>
|
|
109
|
+
<div class="text-xs text-base-content/50">${u.email || ''}</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</td>
|
|
113
|
+
<td data-col="role">${roleBadge(u.role || '-')}</td>
|
|
114
|
+
<td data-col="status">${statusBadge(u.status)}</td>
|
|
115
|
+
<td><a href="/user/${u.id}/edit" data-stop-propagation="true" class="btn btn-ghost btn-xs">Edit</a></td>
|
|
116
|
+
</tr>`).join('');
|
|
117
|
+
|
|
118
|
+
const content = `${settingsBack()}
|
|
119
|
+
<div class="flex justify-between items-center mb-2">
|
|
120
|
+
<div>
|
|
121
|
+
<h1 class="text-2xl font-bold">Users</h1>
|
|
122
|
+
<p class="text-sm text-base-content/50 mt-0.5">${users.length} total · ${active} active</p>
|
|
123
|
+
</div>
|
|
124
|
+
<a href="/user/new" class="btn btn-primary btn-sm gap-1">
|
|
125
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
|
|
126
|
+
</a>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="table-toolbar mb-3">
|
|
129
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search users..."></div>
|
|
130
|
+
<div class="table-filter">
|
|
131
|
+
<select data-filter="role">
|
|
132
|
+
<option value="">All roles</option>
|
|
133
|
+
<option value="admin">Admin</option>
|
|
134
|
+
<option value="partner">Partner</option>
|
|
135
|
+
<option value="manager">Manager</option>
|
|
136
|
+
<option value="clerk">Clerk</option>
|
|
137
|
+
<option value="auditor">Auditor</option>
|
|
138
|
+
<option value="user">User</option>
|
|
139
|
+
</select>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="table-filter">
|
|
142
|
+
<select data-filter="status">
|
|
143
|
+
<option value="">All statuses</option>
|
|
144
|
+
<option value="active">Active</option>
|
|
145
|
+
<option value="inactive">Inactive</option>
|
|
146
|
+
</select>
|
|
147
|
+
</div>
|
|
148
|
+
<span class="table-count" id="row-count">${users.length} items</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="card-clean">
|
|
151
|
+
<div class="card-clean-body" style="padding:0rem">
|
|
152
|
+
<div class="table-wrap"><table class="data-table">
|
|
153
|
+
<thead><tr>
|
|
154
|
+
<th data-sort="name">User</th>
|
|
155
|
+
<th data-sort="role">Role</th>
|
|
156
|
+
<th data-sort="status">Status</th>
|
|
157
|
+
<th>Actions</th>
|
|
158
|
+
</tr></thead>
|
|
159
|
+
<tbody>${rows || '<tr><td colspan="4" class="text-center py-10 text-base-content/40">No users found</td></tr>'}</tbody>
|
|
160
|
+
</table></div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>`;
|
|
163
|
+
return settingsPage(user, 'Users - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Users' }], content, [TABLE_SCRIPT]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { renderSettingsTeams, renderSettingsRfiSections } from '@/ui/settings-renderer-teams.js';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export const SPACING = {
|
|
2
|
+
xs: '4px',
|
|
3
|
+
sm: '8px',
|
|
4
|
+
md: '16px',
|
|
5
|
+
lg: '24px',
|
|
6
|
+
xl: '32px',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function renderPageWrapper(content, opts = {}) {
|
|
10
|
+
const padding = opts.padding || 'var(--page-gutter, 1rem)';
|
|
11
|
+
return `<div style="padding:${padding};max-width:100%">${content}</div>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderSection(content, opts = {}) {
|
|
15
|
+
const marginBottom = opts.marginBottom || SPACING.lg;
|
|
16
|
+
return `<div style="margin-bottom:${marginBottom}">${content}</div>`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderCard(content, opts = {}) {
|
|
20
|
+
const padding = opts.padding !== undefined ? opts.padding : SPACING.md;
|
|
21
|
+
const marginBottom = opts.marginBottom || SPACING.md;
|
|
22
|
+
const className = opts.className ? ` ${opts.className}` : '';
|
|
23
|
+
const id = opts.id ? ` id="${opts.id}"` : '';
|
|
24
|
+
return `<div class="card-clean${className}"${id} style="margin-bottom:${marginBottom}"><div class="card-clean-body" style="padding:${padding}">${content}</div></div>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderCardHeader(content, marginBottom = SPACING.md) {
|
|
28
|
+
return `<h2 style="font-size:0.875rem;font-weight:600;margin-bottom:${marginBottom}">${content}</h2>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderCardGrid(cols, gap = SPACING.md, marginBottom = SPACING.lg) {
|
|
32
|
+
const colsStr = cols === 2 ? '1fr 1fr' : cols === 3 ? '1fr 1fr 1fr' : '1fr';
|
|
33
|
+
return {
|
|
34
|
+
wrap: (content) => `<div style="display:grid;grid-template-columns:${colsStr};gap:${gap};margin-bottom:${marginBottom}">${content}</div>`,
|
|
35
|
+
col: (content) => `<div>${content}</div>`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderTable(cols, rows, marginTop = SPACING.md) {
|
|
40
|
+
return `<div class="table-wrap" style="margin-top:${marginTop}"><table class="data-table">
|
|
41
|
+
<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>
|
|
42
|
+
<tbody>${rows || `<tr><td colspan="${cols.length}" style="text-align:center;padding:${SPACING.lg} 0;color:var(--color-text-muted)">No data</td></tr>`}</tbody>
|
|
43
|
+
</table></div>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderFlex(content, opts = {}) {
|
|
47
|
+
const { gap = SPACING.md, direction = 'row', justify = 'flex-start', align = 'center', marginBottom = SPACING.md, className = '', wrap = 'nowrap' } = opts;
|
|
48
|
+
const flexDirection = direction === 'col' ? 'column' : 'row';
|
|
49
|
+
return `<div class="${className}" style="display:flex;flex-direction:${flexDirection};gap:${gap};justify-content:${justify};align-items:${align};margin-bottom:${marginBottom};flex-wrap:${wrap}">${content}</div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderTabBar(tabs, activeDef = null) {
|
|
53
|
+
return `<nav class="tab-bar" style="margin-bottom:${SPACING.lg};overflow-x:auto;scrollbar-width:none">
|
|
54
|
+
${tabs.map(t => `<a href="${t.href}" class="tab-btn${t.key === activeDef ? ' active' : ''}">${t.label}</a>`).join('')}
|
|
55
|
+
</nav>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderInfoGrid(items) {
|
|
59
|
+
return `<div style="display:grid;grid-template-columns:1fr 1fr;gap:${SPACING.md}">
|
|
60
|
+
${items.map(([label, value]) => `<div class="info-card">
|
|
61
|
+
<div class="info-label">${label}</div>
|
|
62
|
+
<div class="info-value">${value}</div>
|
|
63
|
+
</div>`).join('')}
|
|
64
|
+
</div>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderButton(label, opts = {}) {
|
|
68
|
+
const { onclick, action, args, href, variant = 'ghost', size = 'sm', style = '', disabled = false } = opts;
|
|
69
|
+
const variantMap = {
|
|
70
|
+
primary: 'btn-primary-clean',
|
|
71
|
+
ghost: 'btn-ghost-clean',
|
|
72
|
+
danger: 'btn-danger-clean',
|
|
73
|
+
outline: 'btn-ghost-clean',
|
|
74
|
+
success: 'btn-primary-clean',
|
|
75
|
+
};
|
|
76
|
+
const btnClass = variantMap[variant] || 'btn-ghost-clean';
|
|
77
|
+
const sizeStyle = size === 'sm' ? 'font-size:13px;padding:6px 14px' : '';
|
|
78
|
+
const extraStyle = [sizeStyle, style].filter(Boolean).join(';');
|
|
79
|
+
|
|
80
|
+
if (href) {
|
|
81
|
+
return `<a href="${href}" class="${btnClass}" style="${extraStyle}">${label}</a>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (action) {
|
|
85
|
+
const argsAttr = args ? ` data-args='${JSON.stringify(args)}'` : '';
|
|
86
|
+
return `<button data-action="${action}"${argsAttr} class="${btnClass}" ${disabled ? 'disabled' : ''} style="${extraStyle}">${label}</button>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `<button onclick="${onclick}" class="${btnClass}" ${disabled ? 'disabled' : ''} style="${extraStyle}">${label}</button>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderProgress(value, max = 100, variant = 'success', marginTop = SPACING.md) {
|
|
93
|
+
const pct = max > 0 ? Math.round((value / max) * 100) : 0;
|
|
94
|
+
return `<div style="margin-top:${typeof marginTop === 'string' ? marginTop : SPACING.md}">
|
|
95
|
+
<div style="display:flex;justify-content:space-between;margin-bottom:${SPACING.sm};align-items:center">
|
|
96
|
+
<span style="font-size:0.8rem;font-weight:600;color:var(--color-text-muted)">Progress</span>
|
|
97
|
+
<span style="font-size:0.8rem;color:var(--color-text-light)">${pct}%</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="resolution-bar">
|
|
100
|
+
<div class="resolution-bar-segment resolution-bar-resolved" style="width:${pct}%"></div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderBadge(text, variant = 'success') {
|
|
106
|
+
const map = { success: 'pill pill-success', danger: 'pill pill-danger', warning: 'pill pill-warning', info: 'pill pill-info', neutral: 'pill pill-neutral' };
|
|
107
|
+
const cls = map[variant] || 'pill pill-neutral';
|
|
108
|
+
return `<span class="${cls}">${text}</span>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderEmptyState(message, marginTop = SPACING.lg) {
|
|
112
|
+
return `<div style="text-align:center;padding:${SPACING.xl} ${SPACING.md};color:var(--color-text-muted);margin-top:${marginTop}">${message}</div>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function renderStatsRow(stats) {
|
|
116
|
+
return `<div class="stats-row">${stats.map(s => `<div class="stat-card">
|
|
117
|
+
<div class="stat-card-value" style="${s.color ? `color:${s.color}` : ''}">${s.value}</div>
|
|
118
|
+
<div class="stat-card-label">${s.label}</div>
|
|
119
|
+
${s.sub ? `<div class="stat-card-sub">${s.sub}</div>` : ''}
|
|
120
|
+
</div>`).join('')}</div>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderPageHeader(title, subtitle, actionsHtml = '') {
|
|
124
|
+
return `<div class="page-header">
|
|
125
|
+
<div>
|
|
126
|
+
<h1 class="page-title">${title}</h1>
|
|
127
|
+
${subtitle ? `<p class="page-subtitle">${subtitle}</p>` : ''}
|
|
128
|
+
</div>
|
|
129
|
+
${actionsHtml ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">${actionsHtml}</div>` : ''}
|
|
130
|
+
</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderResolutionBar(resolved, partial, total) {
|
|
134
|
+
if (total === 0) return `<div style="font-size:13px;color:var(--color-text-light);font-style:italic">No highlights</div>`;
|
|
135
|
+
const rPct = Math.round((resolved / total) * 100);
|
|
136
|
+
const pPct = Math.round((partial / total) * 100);
|
|
137
|
+
const uPct = Math.max(0, 100 - rPct - pPct);
|
|
138
|
+
return `<div class="resolution-bar">
|
|
139
|
+
<div class="resolution-bar-segment resolution-bar-resolved" style="width:${rPct}%"></div>
|
|
140
|
+
<div class="resolution-bar-segment resolution-bar-partial" style="width:${pPct}%"></div>
|
|
141
|
+
<div class="resolution-bar-segment resolution-bar-open" style="width:${uPct}%"></div>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="resolution-legend">
|
|
144
|
+
<span class="resolution-legend-resolved">${resolved} resolved</span>
|
|
145
|
+
<span class="resolution-legend-partial">${partial} partial</span>
|
|
146
|
+
<span class="resolution-legend-open">${total - resolved - partial} open</span>
|
|
147
|
+
</div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function renderFilterBar(filters, activeKey = 'all') {
|
|
151
|
+
return `<div class="review-filter-bar">${filters.map(f => {
|
|
152
|
+
const actionAttr = f.action ? `data-action="${f.action}"${f.args ? ` data-args='${JSON.stringify(f.args)}'` : ''}` : (f.onclick ? `onclick="${f.onclick}"` : '');
|
|
153
|
+
return `<button class="pill${f.key === activeKey ? ' pill-primary' : ' pill-neutral'}" data-thread-filter="${f.key}" ${actionAttr}>${f.label}${f.count !== undefined ? ` (${f.count})` : ''}</button>`;
|
|
154
|
+
}).join('')}</div>`;
|
|
155
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export function renderStandaloneLogin(showGoogleAuth = false) {
|
|
2
|
+
return `<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>Sign In | Moonlanding</title>
|
|
8
|
+
<style>
|
|
9
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
10
|
+
:root{--bg:#f1f5f9;--surface:#fff;--border:#e2e8f0;--text:#0f172a;--text-muted:#64748b;--primary:#04141f;--primary-hover:#0a2535;--accent:#2563eb;--danger:#dc2626;--danger-bg:#fee2e2;--danger-border:#fca5a5;--radius:10px;--shadow:0 4px 24px rgba(0,0,0,0.09)}
|
|
11
|
+
@media(prefers-color-scheme:dark){:root{--bg:#0f172a;--surface:#1a1a1a;--border:#334155;--text:#f1f5f9;--text-muted:#94a3b8;--primary:#1e3a5f;--primary-hover:#1e293b;--shadow:0 4px 24px rgba(0,0,0,0.4)}}
|
|
12
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;color:var(--text);transition:background 0.2s}
|
|
13
|
+
.wrap{width:100%;max-width:400px}
|
|
14
|
+
.logo{text-align:center;margin-bottom:32px}
|
|
15
|
+
.logo-mark{display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;background:var(--primary);border-radius:14px;margin-bottom:14px;box-shadow:0 4px 12px rgba(4,20,31,0.25)}
|
|
16
|
+
.logo-text{font-size:1.35rem;font-weight:800;color:var(--text);letter-spacing:0.5px;display:block;margin-bottom:4px}
|
|
17
|
+
.logo-sub{color:var(--text-muted);font-size:0.875rem}
|
|
18
|
+
.card{background:var(--surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:32px;border:1px solid var(--border)}
|
|
19
|
+
.google-btn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;padding:11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:0.875rem;font-weight:500;text-decoration:none;margin-bottom:20px;transition:all 0.15s;cursor:pointer;min-height:44px}
|
|
20
|
+
.google-btn:hover{background:var(--bg);border-color:#94a3b8;box-shadow:0 1px 4px rgba(0,0,0,0.08)}
|
|
21
|
+
.google-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
22
|
+
.divider{display:flex;align-items:center;gap:12px;margin-bottom:20px}
|
|
23
|
+
.divider-line{flex:1;height:1px;background:var(--border)}
|
|
24
|
+
.divider-text{font-size:0.75rem;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.5px}
|
|
25
|
+
.field{margin-bottom:18px}
|
|
26
|
+
label{display:block;font-size:0.8125rem;font-weight:600;color:var(--text);margin-bottom:6px}
|
|
27
|
+
input[type=email],input[type=password]{width:100%;padding:10px 13px;border:1.5px solid var(--border);border-radius:8px;font-size:0.9rem;outline:none;transition:border-color 0.15s,box-shadow 0.15s;background:var(--surface);color:var(--text);min-height:44px}
|
|
28
|
+
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,0.12)}
|
|
29
|
+
input::placeholder{color:var(--text-muted)}
|
|
30
|
+
.forgot{text-align:right;margin-bottom:22px}
|
|
31
|
+
.forgot a{font-size:0.8125rem;color:var(--accent);text-decoration:none;font-weight:500}
|
|
32
|
+
.forgot a:hover{text-decoration:underline}
|
|
33
|
+
.submit-btn{width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:8px;font-size:0.9375rem;font-weight:600;cursor:pointer;transition:all 0.15s;min-height:46px;position:relative}
|
|
34
|
+
.submit-btn:hover:not(:disabled){background:var(--primary-hover);box-shadow:0 4px 12px rgba(4,20,31,0.2)}
|
|
35
|
+
.submit-btn:active:not(:disabled){transform:translateY(1px)}
|
|
36
|
+
.submit-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
37
|
+
.submit-btn:disabled{opacity:0.65;cursor:not-allowed}
|
|
38
|
+
.err{background:var(--danger-bg);border:1px solid var(--danger-border);color:var(--danger);padding:11px 14px;border-radius:8px;margin-bottom:18px;font-size:0.875rem;display:flex;align-items:flex-start;gap:8px;line-height:1.4}
|
|
39
|
+
@keyframes fade-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
|
|
40
|
+
.err{animation:fade-in 0.2s ease}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<main id="main-content" aria-label="Sign in">
|
|
45
|
+
<div class="wrap">
|
|
46
|
+
<div class="logo">
|
|
47
|
+
<div class="logo-mark"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.75" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
|
48
|
+
<span class="logo-text">Moonlanding</span>
|
|
49
|
+
<div class="logo-sub">Sign in to your account</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="card">
|
|
52
|
+
<div id="err-area" role="alert" aria-live="assertive"></div>
|
|
53
|
+
${showGoogleAuth ? `<div id="google-area">
|
|
54
|
+
<a href="/api/auth/google" class="google-btn" aria-label="Sign in with Google">
|
|
55
|
+
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.706c-.18-.54-.282-1.117-.282-1.706s.102-1.166.282-1.706V4.962H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.038l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58z"/></svg>
|
|
56
|
+
Continue with Google
|
|
57
|
+
</a>
|
|
58
|
+
<div class="divider"><div class="divider-line"></div><span class="divider-text">or</span><div class="divider-line"></div></div>
|
|
59
|
+
</div>` : ''}
|
|
60
|
+
<form id="loginForm" novalidate>
|
|
61
|
+
<div class="field">
|
|
62
|
+
<label for="email">Email address</label>
|
|
63
|
+
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" autofocus>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="field">
|
|
66
|
+
<label for="password">Password</label>
|
|
67
|
+
<input type="password" id="password" name="password" placeholder="Your password" required autocomplete="current-password">
|
|
68
|
+
</div>
|
|
69
|
+
<div class="forgot"><a href="/password-reset">Forgot password?</a></div>
|
|
70
|
+
<button type="submit" id="submitBtn" class="submit-btn">Sign In</button>
|
|
71
|
+
</form>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</main>
|
|
75
|
+
<script src="/ui/event-delegation.js"></script>
|
|
76
|
+
<script>
|
|
77
|
+
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
var btn = document.getElementById('submitBtn');
|
|
80
|
+
var errArea = document.getElementById('err-area');
|
|
81
|
+
btn.textContent = 'Signing in\u2026';
|
|
82
|
+
btn.disabled = true;
|
|
83
|
+
errArea.innerHTML = '';
|
|
84
|
+
try {
|
|
85
|
+
var res = await fetch('/api/auth/login', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ email: e.target.email.value, password: e.target.password.value })
|
|
89
|
+
});
|
|
90
|
+
var data = await res.json().catch(function() { return {}; });
|
|
91
|
+
if (res.ok) {
|
|
92
|
+
btn.textContent = 'Redirecting\u2026';
|
|
93
|
+
window.location.href = data.redirect || '/';
|
|
94
|
+
} else {
|
|
95
|
+
errArea.innerHTML = '<div class="err"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span></span></div>';
|
|
96
|
+
errArea.querySelector('span').textContent = data.error || 'Login failed. Please check your credentials.';
|
|
97
|
+
btn.textContent = 'Sign In';
|
|
98
|
+
btn.disabled = false;
|
|
99
|
+
}
|
|
100
|
+
} catch(err) {
|
|
101
|
+
errArea.innerHTML = '<div class="err">Network error. Please try again.</div>';
|
|
102
|
+
btn.textContent = 'Sign In';
|
|
103
|
+
btn.disabled = false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
107
|
+
</body>
|
|
108
|
+
</html>`;
|
|
109
|
+
}
|