neoagent 3.0.1-beta.2 → 3.0.1-beta.21
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/.env.example +19 -0
- package/README.md +20 -3
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +224 -0
- package/docs/configuration.md +22 -0
- package/docs/getting-started.md +10 -8
- package/docs/index.md +1 -0
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +4 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +316 -0
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1594 -224
- package/flutter_app/lib/main_controller.dart +263 -26
- package/flutter_app/lib/main_devices.dart +1 -1
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +12 -0
- package/flutter_app/lib/main_operations.dart +134 -9
- package/flutter_app/lib/main_settings.dart +148 -52
- package/flutter_app/lib/main_shared.dart +1 -0
- package/flutter_app/lib/src/backend_client.dart +86 -1
- package/landing/index.html +2 -2
- package/lib/manager.js +184 -66
- package/lib/schema_migrations.js +84 -0
- package/package.json +10 -4
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +511 -23
- package/server/admin/billing.js +415 -0
- package/server/admin/index.html +120 -13
- package/server/admin/users.js +15 -15
- package/server/db/database.js +13 -21
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/http/routes.js +9 -0
- package/server/http/static.js +4 -2
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +89347 -85449
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +515 -63
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +127 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/routes/triggers.js +2 -2
- package/server/services/account/erasure.js +263 -0
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/completion_judge.js +42 -0
- package/server/services/ai/loop/conversation_loop.js +248 -34
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +37 -44
- package/server/services/ai/messagingFallback.js +25 -1
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/systemPrompt.js +23 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/tools.js +231 -20
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +121 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/google/calendar.js +30 -2
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/manager.js +11 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/telnyx.js +12 -11
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/tasks/runtime.js +103 -15
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +8 -5
- package/server/services/websocket.js +26 -8
- package/server/services/workspace/manager.js +37 -3
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let _billingPlans = [];
|
|
4
|
+
let _billingSubsOffset = 0;
|
|
5
|
+
const BILLING_SUBS_LIMIT = 50;
|
|
6
|
+
|
|
7
|
+
async function loadBilling() {
|
|
8
|
+
// Check if billing is enabled by probing the plans endpoint.
|
|
9
|
+
try {
|
|
10
|
+
const r = await fetch('/admin/api/billing/plans');
|
|
11
|
+
if (!r.ok) {
|
|
12
|
+
document.getElementById('billing-plans-content').innerHTML =
|
|
13
|
+
'<div class="empty">Billing is not enabled on this server. Set <code>NEOAGENT_BILLING_ENABLED=1</code> to enable it.</div>';
|
|
14
|
+
document.getElementById('billing-subs-content').innerHTML = '';
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const data = await r.json();
|
|
18
|
+
_billingPlans = data.plans || [];
|
|
19
|
+
renderPlansTable(_billingPlans);
|
|
20
|
+
} catch {
|
|
21
|
+
document.getElementById('billing-plans-content').innerHTML = '<div class="empty">Failed to load billing data.</div>';
|
|
22
|
+
}
|
|
23
|
+
document.getElementById('nav-billing').style.display = '';
|
|
24
|
+
_billingSubsOffset = 0;
|
|
25
|
+
await loadBillingSubscriptions();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Show the billing nav item on load if billing is enabled.
|
|
29
|
+
(async function checkBillingEnabled() {
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch('/admin/api/billing/plans');
|
|
32
|
+
if (r.ok) document.getElementById('nav-billing').style.display = '';
|
|
33
|
+
} catch {}
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
function planRowHtml(plan) {
|
|
37
|
+
const id = escAttr(plan.id);
|
|
38
|
+
const intervalLabel = plan.interval ? `/ ${plan.interval}` : '';
|
|
39
|
+
const price = plan.price_cents === 0 ? 'Free' : `${(plan.price_cents / 100).toFixed(2)} ${(plan.currency || 'usd').toUpperCase()} ${intervalLabel}`;
|
|
40
|
+
const status = plan.is_active ? '<span style="color:var(--success)">Active</span>' : '<span style="color:var(--text-muted)">Inactive</span>';
|
|
41
|
+
return `
|
|
42
|
+
<tr data-plan-id="${id}">
|
|
43
|
+
<td><strong>${esc(plan.name)}</strong><br><small style="color:var(--text-muted)">${esc(plan.id)}</small></td>
|
|
44
|
+
<td>${esc(price)}</td>
|
|
45
|
+
<td>${plan.token_limit_4h != null ? fmtTokens(plan.token_limit_4h) : '<span style="color:var(--text-muted)">Default</span>'}</td>
|
|
46
|
+
<td>${plan.token_limit_weekly != null ? fmtTokens(plan.token_limit_weekly) : '<span style="color:var(--text-muted)">Default</span>'}</td>
|
|
47
|
+
<td><code style="font-size:11px">${esc(plan.stripe_price_id || '—')}</code></td>
|
|
48
|
+
<td>${status}</td>
|
|
49
|
+
<td>
|
|
50
|
+
<button class="btn btn-sm" data-action="edit" data-plan-id="${id}">Edit</button>
|
|
51
|
+
<button class="btn btn-sm btn-danger" data-action="delete" data-plan-id="${id}">Delete</button>
|
|
52
|
+
</td>
|
|
53
|
+
</tr>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderPlansTable(plans) {
|
|
57
|
+
const el = document.getElementById('billing-plans-content');
|
|
58
|
+
if (!plans.length) {
|
|
59
|
+
el.innerHTML = '<div class="empty">No plans yet. Create one above.</div>';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
el.innerHTML = `
|
|
63
|
+
<table class="data-table">
|
|
64
|
+
<thead><tr>
|
|
65
|
+
<th>Plan</th><th>Price</th><th>4h Tokens</th><th>Weekly Tokens</th><th>Stripe Price ID</th><th>Status</th><th>Actions</th>
|
|
66
|
+
</tr></thead>
|
|
67
|
+
<tbody>${plans.map(planRowHtml).join('')}</tbody>
|
|
68
|
+
</table>`;
|
|
69
|
+
el.querySelector('tbody').addEventListener('click', (e) => {
|
|
70
|
+
const btn = e.target.closest('[data-action]');
|
|
71
|
+
if (!btn) return;
|
|
72
|
+
const planId = btn.dataset.planId;
|
|
73
|
+
if (btn.dataset.action === 'edit') billingEditPlan(planId);
|
|
74
|
+
else if (btn.dataset.action === 'delete') billingDeletePlan(planId);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function billingShowNewPlanForm() {
|
|
79
|
+
billingOpenPlanModal(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function billingEditPlan(planId) {
|
|
83
|
+
const plan = _billingPlans.find((p) => p.id === planId);
|
|
84
|
+
billingOpenPlanModal(plan);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function billingOpenPlanModal(plan) {
|
|
88
|
+
const isNew = !plan;
|
|
89
|
+
const title = isNew ? 'New Plan' : `Edit Plan: ${plan.name}`;
|
|
90
|
+
const allowedSet = new Set(plan?.allowed_models || []);
|
|
91
|
+
|
|
92
|
+
// Fetch available models to render the picker
|
|
93
|
+
let modelPickerHtml = '<div style="color:var(--text-muted);font-size:12px;padding:8px 0;">Loading models…</div>';
|
|
94
|
+
let fetchedModels = [];
|
|
95
|
+
try {
|
|
96
|
+
const r = await fetch('/admin/api/models');
|
|
97
|
+
if (r.ok) {
|
|
98
|
+
const data = await r.json();
|
|
99
|
+
fetchedModels = (data.models || []).sort((a, b) => {
|
|
100
|
+
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
|
101
|
+
return (a.label || a.id).localeCompare(b.label || b.id);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
|
|
106
|
+
if (fetchedModels.length) {
|
|
107
|
+
const rows = fetchedModels.map((m) => {
|
|
108
|
+
const checked = allowedSet.has(m.id) ? 'checked' : '';
|
|
109
|
+
return `
|
|
110
|
+
<tr class="bp-model-row" style="opacity:${checked ? '1' : '0.55'}" onclick="billingModelRowClick(this)">
|
|
111
|
+
<td style="width:36px;text-align:center;pointer-events:none;">
|
|
112
|
+
<input type="checkbox" class="bp-model-cb" value="${escAttr(m.id)}" ${checked}
|
|
113
|
+
onchange="this.closest('tr').style.opacity=this.checked?'1':'0.55'" onclick="event.stopPropagation()">
|
|
114
|
+
</td>
|
|
115
|
+
<td style="pointer-events:none;">
|
|
116
|
+
<div style="font-weight:600;color:var(--text);font-size:13px;">${esc(m.label || m.id)}</div>
|
|
117
|
+
<div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);margin-top:1px;">${esc(m.id)}</div>
|
|
118
|
+
</td>
|
|
119
|
+
<td style="font-size:12px;text-transform:capitalize;color:var(--text-secondary);pointer-events:none;">${esc(m.provider)}</td>
|
|
120
|
+
</tr>`;
|
|
121
|
+
}).join('');
|
|
122
|
+
|
|
123
|
+
modelPickerHtml = `
|
|
124
|
+
<div style="border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden;max-height:200px;overflow-y:auto;">
|
|
125
|
+
<table class="data-table" style="margin:0;">
|
|
126
|
+
<thead><tr>
|
|
127
|
+
<th style="width:36px;position:sticky;top:0;background:var(--bg-secondary);z-index:1;"></th>
|
|
128
|
+
<th style="position:sticky;top:0;background:var(--bg-secondary);z-index:1;">Model</th>
|
|
129
|
+
<th style="position:sticky;top:0;background:var(--bg-secondary);z-index:1;">Provider</th>
|
|
130
|
+
</tr></thead>
|
|
131
|
+
<tbody>${rows}</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
<div style="font-size:11px;color:var(--text-muted);margin-top:5px;">Check models to restrict this plan. Leave all unchecked to allow every model.</div>`;
|
|
135
|
+
} else {
|
|
136
|
+
modelPickerHtml = `
|
|
137
|
+
<input class="form-input" id="bp-models-fallback" value="${escAttr((plan?.allowed_models || []).join(', '))}" placeholder="claude-opus-4-8, gpt-4o (comma-separated, or blank for all)">
|
|
138
|
+
<div style="font-size:11px;color:var(--text-muted);margin-top:5px;">Configure providers first to use the model picker.</div>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const html = `
|
|
142
|
+
<div id="billing-plan-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000" onclick="if(event.target===this)billingClosePlanModal()">
|
|
143
|
+
<div style="background:var(--bg-card);border-radius:12px;padding:28px;width:560px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.3)">
|
|
144
|
+
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700;color:var(--text);">${esc(title)}</h2>
|
|
145
|
+
<form id="billing-plan-form" onsubmit="billingPlanFormSubmit(event)">
|
|
146
|
+
<input type="hidden" id="bp-id" value="${escAttr(plan?.id || '')}">
|
|
147
|
+
|
|
148
|
+
<label class="form-label">Plan ID <small>(set once, used as identifier)</small></label>
|
|
149
|
+
<input class="form-input" id="bp-slug" value="${escAttr(plan?.id || '')}" ${isNew ? '' : 'disabled'} placeholder="plan_pro" style="margin-bottom:14px">
|
|
150
|
+
|
|
151
|
+
<label class="form-label">Name</label>
|
|
152
|
+
<input class="form-input" id="bp-name" value="${escAttr(plan?.name || '')}" required placeholder="Pro" style="margin-bottom:14px">
|
|
153
|
+
|
|
154
|
+
<label class="form-label">Description</label>
|
|
155
|
+
<input class="form-input" id="bp-desc" value="${escAttr(plan?.description || '')}" placeholder="Optional description" style="margin-bottom:14px">
|
|
156
|
+
|
|
157
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
|
158
|
+
<div>
|
|
159
|
+
<label class="form-label">Price (cents)</label>
|
|
160
|
+
<input class="form-input" id="bp-price" type="number" min="0" value="${plan?.price_cents ?? 0}" required>
|
|
161
|
+
</div>
|
|
162
|
+
<div>
|
|
163
|
+
<label class="form-label">Currency</label>
|
|
164
|
+
<input class="form-input" id="bp-currency" value="${escAttr(plan?.currency || 'usd')}" placeholder="usd">
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
|
169
|
+
<div>
|
|
170
|
+
<label class="form-label">Billing interval</label>
|
|
171
|
+
<select class="form-input" id="bp-interval">
|
|
172
|
+
<option value="month" ${plan?.interval === 'month' ? 'selected' : ''}>Monthly</option>
|
|
173
|
+
<option value="year" ${plan?.interval === 'year' ? 'selected' : ''}>Yearly</option>
|
|
174
|
+
<option value="" ${!plan?.interval ? 'selected' : ''}>One-time / Free</option>
|
|
175
|
+
</select>
|
|
176
|
+
</div>
|
|
177
|
+
<div>
|
|
178
|
+
<label class="form-label">Sort order</label>
|
|
179
|
+
<input class="form-input" id="bp-sort" type="number" value="${plan?.sort_order ?? 0}">
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<label class="form-label">Stripe Price ID</label>
|
|
184
|
+
<input class="form-input" id="bp-stripe-price" value="${escAttr(plan?.stripe_price_id || '')}" placeholder="price_..." style="margin-bottom:14px">
|
|
185
|
+
|
|
186
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
|
187
|
+
<div>
|
|
188
|
+
<label class="form-label">4h token limit <small>(blank = default)</small></label>
|
|
189
|
+
<input class="form-input" id="bp-tok-4h" type="number" min="0" value="${plan?.token_limit_4h ?? ''}" placeholder="2500000">
|
|
190
|
+
</div>
|
|
191
|
+
<div>
|
|
192
|
+
<label class="form-label">Weekly token limit <small>(blank = default)</small></label>
|
|
193
|
+
<input class="form-input" id="bp-tok-weekly" type="number" min="0" value="${plan?.token_limit_weekly ?? ''}" placeholder="10000000">
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<label class="form-label" style="margin-bottom:8px;">Allowed models <small>(unchecked = not allowed on this plan)</small></label>
|
|
198
|
+
<div id="bp-models-wrap" style="margin-bottom:14px">${modelPickerHtml}</div>
|
|
199
|
+
|
|
200
|
+
<label class="form-label">Features <small>(comma-separated, shown on pricing page)</small></label>
|
|
201
|
+
<input class="form-input" id="bp-features" value="${escAttr((plan?.features || []).join(', '))}" placeholder="Unlimited agents, Priority support" style="margin-bottom:14px">
|
|
202
|
+
|
|
203
|
+
<label style="display:flex;align-items:center;gap:8px;margin-bottom:20px;cursor:pointer;font-size:13px;color:var(--text-secondary);text-transform:none;letter-spacing:0;font-weight:500;">
|
|
204
|
+
<input type="checkbox" id="bp-active" ${!plan || plan.is_active ? 'checked' : ''}> Active
|
|
205
|
+
</label>
|
|
206
|
+
|
|
207
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
208
|
+
<button type="button" class="btn btn-ghost" onclick="billingClosePlanModal()">Cancel</button>
|
|
209
|
+
<button type="submit" class="btn btn-primary">${isNew ? 'Create plan' : 'Save changes'}</button>
|
|
210
|
+
</div>
|
|
211
|
+
</form>
|
|
212
|
+
</div>
|
|
213
|
+
</div>`;
|
|
214
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function billingModelRowClick(row) {
|
|
218
|
+
const cb = row.querySelector('.bp-model-cb');
|
|
219
|
+
cb.checked = !cb.checked;
|
|
220
|
+
row.style.opacity = cb.checked ? '1' : '0.55';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function billingClosePlanModal() {
|
|
224
|
+
document.getElementById('billing-plan-modal')?.remove();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function billingPlanFormSubmit(e) {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
const id = document.getElementById('bp-id').value;
|
|
230
|
+
const slug = document.getElementById('bp-slug').value.trim();
|
|
231
|
+
const isNew = !id;
|
|
232
|
+
|
|
233
|
+
const parseTokenLimit = (val) => {
|
|
234
|
+
const n = parseInt(val, 10);
|
|
235
|
+
return isNaN(n) || val === '' ? null : n;
|
|
236
|
+
};
|
|
237
|
+
const parseModels = () => {
|
|
238
|
+
const cbs = document.querySelectorAll('.bp-model-cb');
|
|
239
|
+
if (cbs.length) return Array.from(cbs).filter(cb => cb.checked).map(cb => cb.value);
|
|
240
|
+
const fallback = document.getElementById('bp-models-fallback');
|
|
241
|
+
return fallback ? fallback.value.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
242
|
+
};
|
|
243
|
+
const parseFeatures = (val) => val.split(',').map((s) => s.trim()).filter(Boolean);
|
|
244
|
+
|
|
245
|
+
const body = {
|
|
246
|
+
id: isNew ? slug : undefined,
|
|
247
|
+
name: document.getElementById('bp-name').value.trim(),
|
|
248
|
+
description: document.getElementById('bp-desc').value.trim(),
|
|
249
|
+
price_cents: parseInt(document.getElementById('bp-price').value, 10),
|
|
250
|
+
currency: document.getElementById('bp-currency').value.trim() || 'usd',
|
|
251
|
+
interval: document.getElementById('bp-interval').value || null,
|
|
252
|
+
stripe_price_id: document.getElementById('bp-stripe-price').value.trim() || null,
|
|
253
|
+
token_limit_4h: parseTokenLimit(document.getElementById('bp-tok-4h').value),
|
|
254
|
+
token_limit_weekly: parseTokenLimit(document.getElementById('bp-tok-weekly').value),
|
|
255
|
+
allowed_models: parseModels(),
|
|
256
|
+
features: parseFeatures(document.getElementById('bp-features').value),
|
|
257
|
+
sort_order: parseInt(document.getElementById('bp-sort').value, 10) || 0,
|
|
258
|
+
is_active: document.getElementById('bp-active').checked,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const url = isNew ? '/admin/api/billing/plans' : `/admin/api/billing/plans/${encodeURIComponent(id)}`;
|
|
263
|
+
const method = isNew ? 'POST' : 'PUT';
|
|
264
|
+
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
265
|
+
const data = await r.json();
|
|
266
|
+
if (!r.ok) { showToast(data.error || 'Failed to save plan.', 'error'); return; }
|
|
267
|
+
billingClosePlanModal();
|
|
268
|
+
await loadBilling();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
showToast('Error: ' + err.message, 'error');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function billingDeletePlan(planId) {
|
|
275
|
+
if (!await showConfirmModal({
|
|
276
|
+
title: `Delete plan "${esc(planId)}"?`,
|
|
277
|
+
body: 'The plan will be deactivated (not hard-deleted). Existing subscribers will keep access until their period ends.',
|
|
278
|
+
confirmLabel: 'Delete Plan',
|
|
279
|
+
confirmClass: 'btn-danger',
|
|
280
|
+
})) return;
|
|
281
|
+
try {
|
|
282
|
+
const r = await fetch(`/admin/api/billing/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
|
|
283
|
+
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed.', 'error'); return; }
|
|
284
|
+
await loadBilling();
|
|
285
|
+
} catch (err) {
|
|
286
|
+
showToast('Error: ' + err.message, 'error');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function loadBillingSubscriptions() {
|
|
291
|
+
const statusFilter = document.getElementById('billing-sub-status-filter')?.value || '';
|
|
292
|
+
const el = document.getElementById('billing-subs-content');
|
|
293
|
+
el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
|
|
294
|
+
try {
|
|
295
|
+
const params = new URLSearchParams({ limit: BILLING_SUBS_LIMIT, offset: _billingSubsOffset });
|
|
296
|
+
if (statusFilter) params.set('status', statusFilter);
|
|
297
|
+
const r = await fetch('/admin/api/billing/subscriptions?' + params);
|
|
298
|
+
if (!r.ok) { el.innerHTML = '<div class="empty">Failed to load subscriptions.</div>'; return; }
|
|
299
|
+
const data = await r.json();
|
|
300
|
+
renderSubsTable(data.subscriptions, data.total);
|
|
301
|
+
} catch {
|
|
302
|
+
el.innerHTML = '<div class="empty">Failed to load subscriptions.</div>';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderSubsTable(rows, total) {
|
|
307
|
+
const el = document.getElementById('billing-subs-content');
|
|
308
|
+
if (!rows.length) { el.innerHTML = '<div class="empty">No subscriptions found.</div>'; return; }
|
|
309
|
+
el.innerHTML = `
|
|
310
|
+
<table class="data-table">
|
|
311
|
+
<thead><tr>
|
|
312
|
+
<th>User</th><th>Plan</th><th>Status</th><th>Period end</th><th>Actions</th>
|
|
313
|
+
</tr></thead>
|
|
314
|
+
<tbody>${rows.map(subRowHtml).join('')}</tbody>
|
|
315
|
+
</table>`;
|
|
316
|
+
|
|
317
|
+
const pag = document.getElementById('billing-subs-pagination');
|
|
318
|
+
const hasPrev = _billingSubsOffset > 0;
|
|
319
|
+
const hasNext = _billingSubsOffset + BILLING_SUBS_LIMIT < total;
|
|
320
|
+
pag.innerHTML = `
|
|
321
|
+
<span style="color:var(--text-muted);font-size:13px">${total} total</span>
|
|
322
|
+
${hasPrev ? `<button class="btn btn-sm" onclick="_billingSubsOffset-=${BILLING_SUBS_LIMIT};loadBillingSubscriptions()">← Prev</button>` : ''}
|
|
323
|
+
${hasNext ? `<button class="btn btn-sm" onclick="_billingSubsOffset+=${BILLING_SUBS_LIMIT};loadBillingSubscriptions()">Next →</button>` : ''}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function subRowHtml(sub) {
|
|
327
|
+
const userLabel = esc(sub.display_name || sub.username || String(sub.user_id));
|
|
328
|
+
const email = sub.email ? `<br><small style="color:var(--text-muted)">${esc(sub.email)}</small>` : '';
|
|
329
|
+
const statusColor = { active: 'var(--success)', trialing: 'var(--warning)', past_due: 'var(--danger)', canceled: 'var(--text-muted)' }[sub.status] || 'var(--text-muted)';
|
|
330
|
+
const periodEnd = sub.current_period_end ? sub.current_period_end.slice(0, 10) : '—';
|
|
331
|
+
return `
|
|
332
|
+
<tr>
|
|
333
|
+
<td>${userLabel}${email}</td>
|
|
334
|
+
<td>${esc(sub.plan_name)}</td>
|
|
335
|
+
<td><span style="color:${statusColor}">${esc(sub.status)}</span></td>
|
|
336
|
+
<td>${periodEnd}</td>
|
|
337
|
+
<td><button class="btn btn-sm" onclick="billingOverrideSub(${sub.user_id})">Override</button></td>
|
|
338
|
+
</tr>`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function billingOverrideSub(userId) {
|
|
342
|
+
if (!_billingPlans.length) { showToast('No plans available.', 'error'); return; }
|
|
343
|
+
const activePlans = _billingPlans.filter((p) => p.is_active);
|
|
344
|
+
const planId = await billingPromptPlan(activePlans);
|
|
345
|
+
if (!planId) return;
|
|
346
|
+
try {
|
|
347
|
+
const r = await fetch(`/admin/api/billing/users/${userId}/subscription`, {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'Content-Type': 'application/json' },
|
|
350
|
+
body: JSON.stringify({ planId: planId.trim() }),
|
|
351
|
+
});
|
|
352
|
+
const data = await r.json();
|
|
353
|
+
if (!r.ok) { showToast(data.error || 'Failed.', 'error'); return; }
|
|
354
|
+
await loadBillingSubscriptions();
|
|
355
|
+
} catch (err) {
|
|
356
|
+
showToast('Error: ' + err.message, 'error');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function billingPromptPlan(plans) {
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
const overlay = document.createElement('div');
|
|
363
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:99990;backdrop-filter:blur(2px);';
|
|
364
|
+
const optionsHtml = plans.map((p) => `<option value="${escAttr(p.id)}">${esc(p.id)} — ${esc(p.name)}</option>`).join('');
|
|
365
|
+
const modal = document.createElement('div');
|
|
366
|
+
modal.style.cssText = 'width:400px;max-width:calc(100vw - 32px);background:var(--bg-primary,#1a1a1a);border:1px solid var(--border,#2a2a2a);border-radius:12px;padding:28px;box-shadow:0 16px 48px rgba(0,0,0,0.6);';
|
|
367
|
+
modal.innerHTML = `
|
|
368
|
+
<div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:12px;">Assign plan</div>
|
|
369
|
+
<div style="margin-bottom:16px;">
|
|
370
|
+
<select id="billing-plan-select" style="width:100%;padding:8px 10px;background:var(--bg-input,#111);border:1px solid var(--border,#2a2a2a);border-radius:6px;color:var(--text,#fff);font-size:13px;">
|
|
371
|
+
${optionsHtml}
|
|
372
|
+
</select>
|
|
373
|
+
</div>
|
|
374
|
+
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
375
|
+
<button class="btn btn-ghost" id="billing-plan-cancel" style="padding:8px 16px;">Cancel</button>
|
|
376
|
+
<button class="btn btn-primary" id="billing-plan-confirm" style="padding:8px 16px;">Assign</button>
|
|
377
|
+
</div>
|
|
378
|
+
`;
|
|
379
|
+
overlay.appendChild(modal);
|
|
380
|
+
document.body.appendChild(overlay);
|
|
381
|
+
const cancel = () => { overlay.remove(); resolve(null); };
|
|
382
|
+
const confirm = () => {
|
|
383
|
+
const val = modal.querySelector('#billing-plan-select').value;
|
|
384
|
+
overlay.remove();
|
|
385
|
+
resolve(val || null);
|
|
386
|
+
};
|
|
387
|
+
modal.querySelector('#billing-plan-cancel').onclick = cancel;
|
|
388
|
+
modal.querySelector('#billing-plan-confirm').onclick = confirm;
|
|
389
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); });
|
|
390
|
+
document.addEventListener('keydown', function handler(e) {
|
|
391
|
+
if (e.key === 'Escape') { cancel(); document.removeEventListener('keydown', handler); }
|
|
392
|
+
});
|
|
393
|
+
modal.querySelector('#billing-plan-confirm').focus();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
function esc(str) {
|
|
400
|
+
return String(str ?? '')
|
|
401
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
402
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function escAttr(str) {
|
|
406
|
+
// JSON.stringify gives us a properly quoted JS string; strip the outer quotes
|
|
407
|
+
// so it can be embedded in an HTML attribute value (already inside quotes).
|
|
408
|
+
return JSON.stringify(String(str ?? '')).slice(1, -1).replace(/"/g, '"');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function fmtTokens(n) {
|
|
412
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
413
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
414
|
+
return String(n);
|
|
415
|
+
}
|
package/server/admin/index.html
CHANGED
|
@@ -7,10 +7,7 @@
|
|
|
7
7
|
<link rel="icon" href="/admin/logo.svg" type="image/svg+xml">
|
|
8
8
|
<link rel="stylesheet" href="/admin/admin.css">
|
|
9
9
|
<script>
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
document.documentElement.setAttribute('data-theme', localStorage.getItem('admin-theme') || 'dark');
|
|
13
|
-
} catch (_) { document.documentElement.setAttribute('data-theme', 'dark'); }
|
|
10
|
+
document.documentElement.setAttribute('data-theme', window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
14
11
|
</script>
|
|
15
12
|
<style>
|
|
16
13
|
/* ── Layout ────────────────────────────────────────────────────── */
|
|
@@ -66,6 +63,7 @@
|
|
|
66
63
|
width: 100%;
|
|
67
64
|
text-align: left;
|
|
68
65
|
text-decoration: none;
|
|
66
|
+
position: relative;
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
.nav-item:hover { background: var(--row-hover); color: var(--text); }
|
|
@@ -85,7 +83,10 @@
|
|
|
85
83
|
.nav-item.active svg { opacity: 1; }
|
|
86
84
|
|
|
87
85
|
.nav-badge {
|
|
88
|
-
|
|
86
|
+
position: absolute;
|
|
87
|
+
right: 10px;
|
|
88
|
+
top: 50%;
|
|
89
|
+
transform: translateY(-50%);
|
|
89
90
|
min-width: 18px;
|
|
90
91
|
padding: 1px 6px;
|
|
91
92
|
border-radius: 999px;
|
|
@@ -838,6 +839,27 @@
|
|
|
838
839
|
font-size: 12px;
|
|
839
840
|
flex-shrink: 0;
|
|
840
841
|
}
|
|
842
|
+
|
|
843
|
+
/* ── Integration sections ──────────────────────────────────────── */
|
|
844
|
+
.integration-section {
|
|
845
|
+
border-bottom: 1px solid var(--border);
|
|
846
|
+
padding: 16px 0;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.integration-section:last-child { border-bottom: none; }
|
|
850
|
+
|
|
851
|
+
.integration-header {
|
|
852
|
+
display: flex;
|
|
853
|
+
align-items: center;
|
|
854
|
+
gap: 10px;
|
|
855
|
+
margin-bottom: 14px;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.integration-name {
|
|
859
|
+
font-size: 13px;
|
|
860
|
+
font-weight: 700;
|
|
861
|
+
color: var(--text);
|
|
862
|
+
}
|
|
841
863
|
</style>
|
|
842
864
|
</head>
|
|
843
865
|
<body>
|
|
@@ -874,7 +896,7 @@
|
|
|
874
896
|
|
|
875
897
|
<button class="nav-item" data-page="issues" onclick="showPage('issues',this)">
|
|
876
898
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
877
|
-
<path fill-rule="evenodd" d="
|
|
899
|
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
878
900
|
</svg>
|
|
879
901
|
Issues
|
|
880
902
|
<span class="nav-badge" id="issues-badge" hidden>0</span>
|
|
@@ -903,6 +925,14 @@
|
|
|
903
925
|
Users
|
|
904
926
|
</button>
|
|
905
927
|
|
|
928
|
+
<button class="nav-item" id="nav-billing" data-page="billing" onclick="showPage('billing',this)" style="display:none">
|
|
929
|
+
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
930
|
+
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z"/>
|
|
931
|
+
<path fill-rule="evenodd" d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z" clip-rule="evenodd"/>
|
|
932
|
+
</svg>
|
|
933
|
+
Billing
|
|
934
|
+
</button>
|
|
935
|
+
|
|
906
936
|
<button class="nav-item" data-page="sql" onclick="showPage('sql',this)">
|
|
907
937
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
908
938
|
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5zm11 1H6v8l4-2 4 2V6z" clip-rule="evenodd"/>
|
|
@@ -933,6 +963,13 @@
|
|
|
933
963
|
Models
|
|
934
964
|
</button>
|
|
935
965
|
|
|
966
|
+
<button class="nav-item" data-page="integrations" onclick="showPage('integrations',this)">
|
|
967
|
+
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
968
|
+
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/>
|
|
969
|
+
</svg>
|
|
970
|
+
Integrations
|
|
971
|
+
</button>
|
|
972
|
+
|
|
936
973
|
<button class="nav-item" data-page="config" onclick="showPage('config',this)">
|
|
937
974
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
938
975
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
|
|
@@ -942,12 +979,6 @@
|
|
|
942
979
|
</nav>
|
|
943
980
|
|
|
944
981
|
<div class="sidebar-footer">
|
|
945
|
-
<button class="nav-item" id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light and dark theme">
|
|
946
|
-
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
947
|
-
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/>
|
|
948
|
-
</svg>
|
|
949
|
-
<span id="theme-toggle-label">Light mode</span>
|
|
950
|
-
</button>
|
|
951
982
|
<button class="nav-item btn-danger" onclick="signOut()">
|
|
952
983
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
953
984
|
<path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clip-rule="evenodd"/>
|
|
@@ -1178,15 +1209,57 @@
|
|
|
1178
1209
|
</div>
|
|
1179
1210
|
</section>
|
|
1180
1211
|
|
|
1212
|
+
<!-- Integrations -->
|
|
1213
|
+
<section id="page-integrations" class="page" aria-label="Integrations">
|
|
1214
|
+
<div class="page-header">
|
|
1215
|
+
<div>
|
|
1216
|
+
<h1 class="page-title">Integrations</h1>
|
|
1217
|
+
<p class="page-subtitle">OAuth client credentials for official integrations — saved to .env, applied immediately</p>
|
|
1218
|
+
</div>
|
|
1219
|
+
</div>
|
|
1220
|
+
<div class="content">
|
|
1221
|
+
<div class="card" style="margin-bottom:20px;">
|
|
1222
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
1223
|
+
<div class="card-title" style="margin-bottom:0;">OAuth Integrations</div>
|
|
1224
|
+
<button class="btn btn-primary" onclick="saveIntegrationsConfig(this)">Save All</button>
|
|
1225
|
+
</div>
|
|
1226
|
+
<div style="font-size:12px;color:var(--text-muted);margin-bottom:18px;line-height:1.5;">
|
|
1227
|
+
Redirect URIs should match <code style="font-family:var(--font-mono);font-size:11px;background:var(--bg-secondary);padding:1px 5px;border-radius:4px;color:var(--accent);"><PUBLIC_URL>/api/integrations/oauth/callback</code>
|
|
1228
|
+
</div>
|
|
1229
|
+
<div id="integrations-config-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1230
|
+
</div>
|
|
1231
|
+
<div class="card">
|
|
1232
|
+
<div class="card-title">Deepgram Configuration</div>
|
|
1233
|
+
<div id="deepgram-config-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1234
|
+
</div>
|
|
1235
|
+
</div>
|
|
1236
|
+
</section>
|
|
1237
|
+
|
|
1181
1238
|
<!-- Configuration -->
|
|
1182
1239
|
<section id="page-config" class="page" aria-label="Configuration">
|
|
1183
1240
|
<div class="page-header">
|
|
1184
1241
|
<div>
|
|
1185
1242
|
<h1 class="page-title">Configuration</h1>
|
|
1186
|
-
<p class="page-subtitle">
|
|
1243
|
+
<p class="page-subtitle">Server and runtime settings — saved to .env, applied immediately</p>
|
|
1187
1244
|
</div>
|
|
1188
1245
|
</div>
|
|
1189
1246
|
<div class="content">
|
|
1247
|
+
<div class="card" style="margin-bottom:20px;">
|
|
1248
|
+
<div class="card-title">General Settings</div>
|
|
1249
|
+
<div id="general-config-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1250
|
+
</div>
|
|
1251
|
+
<div class="card" style="margin-bottom:20px;">
|
|
1252
|
+
<div class="card-title">VM Runtime</div>
|
|
1253
|
+
<div id="vm-config-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div class="card" style="margin-bottom:20px;">
|
|
1256
|
+
<div class="card-title">Screen Recorder</div>
|
|
1257
|
+
<div id="screen-recorder-config-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1258
|
+
</div>
|
|
1259
|
+
<div class="card" style="margin-bottom:20px;">
|
|
1260
|
+
<div class="card-title">Stripe / Billing</div>
|
|
1261
|
+
<div id="billing-setup-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1262
|
+
</div>
|
|
1190
1263
|
<div class="card" style="margin-bottom:20px;">
|
|
1191
1264
|
<div class="card-title">Service Email</div>
|
|
1192
1265
|
<div id="email-config-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
@@ -1198,6 +1271,39 @@
|
|
|
1198
1271
|
</div>
|
|
1199
1272
|
</section>
|
|
1200
1273
|
|
|
1274
|
+
<section id="page-billing" class="page" aria-label="Billing">
|
|
1275
|
+
<div class="page-header">
|
|
1276
|
+
<div>
|
|
1277
|
+
<h1 class="page-title">Billing</h1>
|
|
1278
|
+
<p class="page-subtitle">Subscription plans and user billing management</p>
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
<div class="content">
|
|
1282
|
+
<div class="card">
|
|
1283
|
+
<div class="card-title">Subscription Plans</div>
|
|
1284
|
+
<div style="margin-bottom:12px">
|
|
1285
|
+
<button class="btn btn-primary" onclick="billingShowNewPlanForm()">+ New Plan</button>
|
|
1286
|
+
</div>
|
|
1287
|
+
<div id="billing-plans-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1288
|
+
</div>
|
|
1289
|
+
<div class="card">
|
|
1290
|
+
<div class="card-title">User Subscriptions</div>
|
|
1291
|
+
<div style="margin-bottom:12px;display:flex;gap:8px;align-items:center">
|
|
1292
|
+
<select id="billing-sub-status-filter" onchange="loadBillingSubscriptions()" style="padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--input-bg);color:var(--text)">
|
|
1293
|
+
<option value="">All statuses</option>
|
|
1294
|
+
<option value="active">Active</option>
|
|
1295
|
+
<option value="trialing">Trialing</option>
|
|
1296
|
+
<option value="past_due">Past due</option>
|
|
1297
|
+
<option value="canceled">Canceled</option>
|
|
1298
|
+
</select>
|
|
1299
|
+
<button class="btn" onclick="loadBillingSubscriptions()">Refresh</button>
|
|
1300
|
+
</div>
|
|
1301
|
+
<div id="billing-subs-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1302
|
+
<div id="billing-subs-pagination" style="margin-top:12px;display:flex;gap:8px;align-items:center"></div>
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
</section>
|
|
1306
|
+
|
|
1201
1307
|
</main>
|
|
1202
1308
|
|
|
1203
1309
|
<script src="/admin/admin.js"></script>
|
|
@@ -1205,5 +1311,6 @@
|
|
|
1205
1311
|
<script src="/admin/users.js"></script>
|
|
1206
1312
|
<script src="/admin/sql.js"></script>
|
|
1207
1313
|
<script src="/admin/access.js"></script>
|
|
1314
|
+
<script src="/admin/billing.js"></script>
|
|
1208
1315
|
</body>
|
|
1209
1316
|
</html>
|