Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/abforge_admin_user.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/abforge_admin_user.html

added on local at 2026-07-01 16:01:46

Added
+523
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 007a2c7bdfcc
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!-- =====================================================================
2 Admin: per-user inspection + control. All buttons that mutate
3 state POST to /admin_action.cgi -- never GET, never bare links.
4 The "View as user" submit starts impersonation; the wrapper-level
5 banner takes over from the next request.
6===================================================================== -->
7<style>
8 .au-grid {
9 display: grid;
10 grid-template-columns: 320px 1fr;
11 gap: 16px;
12 margin-top: 14px;
13 }
14 @media (max-width: 1000px) { .au-grid { grid-template-columns: 1fr; } }
15
16 .au-card {
17 background: var(--col-surface-1);
18 border: 1px solid var(--col-border);
19 border-radius: 12px;
20 padding: 18px;
21 }
22 .au-card-title {
23 font-size: 10px; font-weight: 700;
24 letter-spacing: 1.5px; text-transform: uppercase;
25 color: var(--col-text-3);
26 margin-bottom: 12px;
27 }
28 .au-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: 13px; border-bottom: 1px solid var(--col-border); }
29 .au-row:last-child { border-bottom: none; }
30 .au-row .lbl { color: var(--col-text-3); }
31 .au-row .val { color: var(--col-text); font-weight: 600; text-align: right; word-break: break-word; }
32 .au-row .val.mono { font-family: var(--font-mono, monospace); font-size: 12px; }
33
34 .au-pill {
35 font-size: 9px; font-weight: 700;
36 padding: 3px 8px; border-radius: 6px;
37 letter-spacing: 1px; text-transform: uppercase;
38 }
39 .au-pill.ok { background: rgba(34,197,94,0.18); color: #4ade80; }
40 .au-pill.warn { background: rgba(245,158,11,0.20); color: #fbbf24; }
41 .au-pill.danger { background: rgba(239,68,68,0.20); color: #f87171; }
42 .au-pill.is-admin { background: rgba(124,58,237,0.18); color: #a78bfa; }
43
44 .au-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
45 .au-actions form { margin: 0; }
46 .au-actions button, .au-actions input[type=submit] {
47 width: 100%;
48 background: var(--col-surface-2);
49 border: 1px solid var(--col-border);
50 color: var(--col-text);
51 padding: 9px 12px;
52 border-radius: 8px;
53 cursor: pointer;
54 font-size: 13px;
55 font-weight: 600;
56 text-align: left;
57 transition: all 0.12s;
58 }
59 .au-actions button:hover { border-color: var(--col-accent); }
60 .au-actions .danger button { color: #fca5a5; border-color: rgba(239,68,68,0.30); }
61 .au-actions .danger button:hover { background: rgba(239,68,68,0.10); border-color: rgba(239,68,68,0.65); }
62 .au-actions .primary button { background: var(--grad-brand); border-color: transparent; color: #fff; }
63
64 .au-edit-grid {
65 display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
66 }
67 .au-edit-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--col-text-3); letter-spacing: 1px; text-transform: uppercase; }
68 .au-edit-grid input, .au-edit-grid select {
69 background: var(--col-surface-2);
70 border: 1px solid var(--col-border);
71 color: var(--col-text);
72 padding: 8px 10px; border-radius: 8px;
73 font-size: 13px; font-family: inherit;
74 }
75 .au-edit-grid input:focus, .au-edit-grid select:focus { outline: none; border-color: var(--col-accent); }
76 .au-edit-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
77
78 .au-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
79 .au-table th { text-align: left; font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); padding: 8px 10px; font-weight: 700; border-bottom: 1px solid var(--col-border); }
80 .au-table td { padding: 9px 10px; border-bottom: 1px solid var(--col-border); color: var(--col-text-2); }
81 .au-table tr:last-child td { border-bottom: none; }
82 .au-table .mono { font-family: var(--font-mono, monospace); color: var(--col-text); }
83
84 .au-mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
85 .au-mp-row { padding: 10px 12px; border: 1px solid var(--col-border); border-radius: 9px; background: var(--col-surface-2); }
86 .au-mp-row .nm { color: var(--col-text); font-weight: 700; font-size: 13px; }
87 .au-mp-row .meta { color: var(--col-text-3); font-size: 11px; margin-top: 3px; }
88
89 /* Credits card */
90 .au-credit-balance {
91 display: flex; align-items: baseline; gap: 10px;
92 padding: 14px 16px; margin-bottom: 12px;
93 background: var(--col-surface-2);
94 border: 1px solid var(--col-border);
95 border-radius: 10px;
96 }
97 .au-credit-balance .amt { font-size: 24px; font-weight: 800; color: var(--col-text); font-family: var(--font-mono, monospace); }
98 .au-credit-balance .amt.pos { color: #4ade80; }
99 .au-credit-balance .amt.neg { color: #f87171; }
100 .au-credit-balance .lbl { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); }
101
102 /* Full-width edit fields card -- 4 cols on wide, 2 on narrow */
103 .au-edit-wide { margin-top: 16px; }
104 .au-edit-wide .au-edit-grid { grid-template-columns: repeat(4, 1fr); }
105 @media (max-width: 900px) { .au-edit-wide .au-edit-grid { grid-template-columns: 1fr 1fr; } }
106 @media (max-width: 520px) { .au-edit-wide .au-edit-grid { grid-template-columns: 1fr; } }
107</style>
108
109<div class="page-head">
110 <div>
111 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; User detail</span>
112 <h1 class="page-title">$target_display_name</h1>
113 <p class="page-subtitle">$target_email &middot; user #$target_id</p>
114 </div>
115 <div class="page-actions">
116 <a href="/admin.cgi" class="btn btn-secondary">
117 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
118 Back to search
119 </a>
120 </div>
121</div>
122
123<div class="au-grid">
124
125 <!-- LEFT COLUMN: account info + admin actions -->
126 <div>
127 <div class="au-card" data-kpi-detail='{
128 "eyebrow":"Account . user record",
129 "description":"Identity, plan tier, status, and security posture for this user. Every field here maps 1-to-1 to a column on the <code>users</code> row -- the Edit account fields panel below writes back to those same columns.",
130 "methodology":"<code>SELECT * FROM users WHERE id=this LIMIT 1</code>. account_status is the gate: only <code>active</code> can sign in (login_verify in MODS::Login enforces this). Email_verified_at being NULL blocks certain workflows like withdrawing payouts.",
131 "kpis":[
132 {"label":"Status","value":"$target_account_status","sub":"can sign in if active"},
133 {"label":"Plan","value":"$target_plan_tier","sub":"current tier"},
134 {"label":"2FA","value":"$target_2fa","sub":"two-factor auth"}
135 ],
136 "interpretation":"Three combinations to watch: <strong>active + 2FA off</strong> on a high-revenue account is a security risk (ask them to enable 2FA). <strong>active + email_unverified</strong> means signup half-completed -- they cannot get payouts. <strong>pending_verification</strong> for &gt; 7 days means the verification email never arrived; trigger a resend."
137 }'>
138 <div class="au-card-title">Account</div>
139 <div class="au-row"><span class="lbl">Email</span><span class="val">$target_email</span></div>
140 <div class="au-row"><span class="lbl">Name</span><span class="val">$target_display_name</span></div>
141 <div class="au-row"><span class="lbl">Plan</span><span class="val">$target_plan_tier</span></div>
142 <div class="au-row"><span class="lbl">Status</span><span class="val">
143 [if:$is_active]<span class="au-pill ok">Active</span>[/if]
144 [if:$is_suspended]<span class="au-pill warn">Suspended</span>[/if]
145 [if:$is_closed]<span class="au-pill danger">Closed</span>[/if]
146 </span></div>
147 <div class="au-row"><span class="lbl">Role</span><span class="val">
148 [if:$target_is_admin]<span class="au-pill is-admin">Company admin</span>[/if]
149 [if:!$target_is_admin]User[/if]
150 </span></div>
151 <div class="au-row"><span class="lbl">Email verified</span><span class="val">$target_email_verified</span></div>
152 <div class="au-row"><span class="lbl">2FA</span><span class="val">$target_2fa</span></div>
153 <div class="au-row"><span class="lbl">Trust level</span><span class="val">$target_trust_level</span></div>
154 <div class="au-row"><span class="lbl">Currency</span><span class="val">$target_currency</span></div>
155 <div class="au-row"><span class="lbl">Timezone</span><span class="val">$target_timezone</span></div>
156 <div class="au-row"><span class="lbl">Joined</span><span class="val mono">$target_created_at</span></div>
157 <div class="au-row"><span class="lbl">Last login</span><span class="val mono">$target_last_login_at</span></div>
158 </div>
159
160 <div class="au-card" style="margin-top:14px">
161 <div class="au-card-title">Admin actions</div>
162 <div class="au-actions">
163
164 [if:!$is_self]
165 <form method="POST" action="/admin_action.cgi" class="primary">
166 <input type="hidden" name="act" value="impersonate">
167 <input type="hidden" name="u" value="$target_id">
168 <button type="submit">View as user (start impersonation)</button>
169 </form>
170 [/if]
171 [if:$is_self]
172 <div style="font-size:11px;color:var(--col-text-3);padding:8px 10px">This is your own account &mdash; nothing to impersonate.</div>
173 [/if]
174
175 [if:$is_active]
176 <form method="POST" action="/admin_action.cgi"
177 data-confirm-title="Suspend this account?"
178 data-confirm-message="They will be logged out immediately and unable to sign in until you reactivate the account."
179 data-confirm-label="Suspend account"
180 data-confirm-style="warning">
181 <input type="hidden" name="act" value="suspend">
182 <input type="hidden" name="u" value="$target_id">
183 <button type="submit">Suspend account</button>
184 </form>
185 [/if]
186
187 [if:$is_suspended]
188 <form method="POST" action="/admin_action.cgi">
189 <input type="hidden" name="act" value="reactivate">
190 <input type="hidden" name="u" value="$target_id">
191 <button type="submit">Reactivate account</button>
192 </form>
193 [/if]
194
195 [if:!$is_self]
196 <form method="POST" action="/admin_action.cgi" class="danger"
197 data-confirm-title="Close this account permanently?"
198 data-confirm-message="Their data stays in the database, but they can never sign in again. This cannot be undone from the UI."
199 data-confirm-label="Close account"
200 data-confirm-style="danger">
201 <input type="hidden" name="act" value="close">
202 <input type="hidden" name="u" value="$target_id">
203 <button type="submit">Close account</button>
204 </form>
205
206 <form method="POST" action="/admin_action.cgi"
207 data-confirm-title="Generate a password reset?"
208 data-confirm-message="A one-time reset link will be shown on the next screen. Share it directly with the user."
209 data-confirm-label="Generate reset link"
210 data-confirm-style="primary">
211 <input type="hidden" name="act" value="reset_password">
212 <input type="hidden" name="u" value="$target_id">
213 <button type="submit">Reset password (generate link)</button>
214 </form>
215
216 [if:!$target_is_admin]
217 <form method="POST" action="/admin_action.cgi"
218 data-confirm-title="Promote to company admin?"
219 data-confirm-message="They will see the Admin Console and can act on every account on the platform."
220 data-confirm-label="Promote to admin"
221 data-confirm-style="warning">
222 <input type="hidden" name="act" value="promote_admin">
223 <input type="hidden" name="u" value="$target_id">
224 <button type="submit">Promote to admin</button>
225 </form>
226 [/if]
227 [if:$target_is_admin]
228 <form method="POST" action="/admin_action.cgi"
229 data-confirm-title="Remove admin role?"
230 data-confirm-message="They will lose access to the Admin Console immediately."
231 data-confirm-label="Remove admin"
232 data-confirm-style="warning">
233 <input type="hidden" name="act" value="demote_admin">
234 <input type="hidden" name="u" value="$target_id">
235 <button type="submit">Remove admin role</button>
236 </form>
237 [/if]
238
239 [if:$viewer_is_super_admin]
240 [if:$target_not_super_admin]
241 <form method="POST" action="/admin_action.cgi"
242 data-confirm-title="Promote to SUPER admin?"
243 data-confirm-message="They get the same powers as the platform owner: revenue dashboards, plan pricing, sandbox test cards, software configuration, and the ability to promote / demote other super admins. Use sparingly."
244 data-confirm-label="Promote to super admin"
245 data-confirm-style="danger">
246 <input type="hidden" name="act" value="promote_super_admin">
247 <input type="hidden" name="u" value="$target_id">
248 <button type="submit" style="background:linear-gradient(135deg,#f59e0b,#b45309);color:#fff;border:0">Promote to super admin</button>
249 </form>
250 [/if]
251 [if:$target_is_super_admin]
252 [if:!$is_self]
253 <form method="POST" action="/admin_action.cgi"
254 data-confirm-title="Remove SUPER admin role?"
255 data-confirm-message="They lose access to revenue dashboards, plan pricing, sandbox test cards, and software configuration. Their regular admin role stays in place -- demote that separately to fully revoke."
256 data-confirm-label="Remove super admin"
257 data-confirm-style="warning">
258 <input type="hidden" name="act" value="demote_super_admin">
259 <input type="hidden" name="u" value="$target_id">
260 <button type="submit">Remove super admin</button>
261 </form>
262 [/if]
263 [if:$is_self]
264 <div class="text-xs text-dim" style="padding:8px 0">You are a super admin (self-demote disabled; have another super admin demote you).</div>
265 [/if]
266 [/if]
267 [/if]
268 [/if]
269 </div>
270 </div>
271
272 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
273 "eyebrow":"Account credits . goodwill + refunds",
274 "description":"Running balance of this user-s credit_ledger. Positive balance is credit they have not yet consumed; the next invoice will apply it before charging the card. Negative is unusual -- it means more was consumed than granted, which usually points at a manual ledger entry that needs review.",
275 "methodology":"<code>SUM(amount_cents) FROM credit_ledger WHERE user_id = this</code>. Append-only ledger; balance is always recomputed from the source rows. Grant Credit posts to /admin_billing_action.cgi with act=grant_credit and reason=manual_grant.",
276 "kpis":[
277 {"label":"Balance","value":"$target_credit_dollars","sub":"applied to next invoice"}
278 ],
279 "interpretation":"Use Grant credit for goodwill (apology for downtime, comp for a missed feature). For invoice-specific refunds prefer the per-invoice Refund-as-credit button on the billing page so the refund links back to the invoice id."
280 }'>
281 <div class="au-card-title">Account credits</div>
282 <div class="au-credit-balance">
283 <span class="amt[if:$target_has_credit] pos[/if][if:$target_has_debit] neg[/if]">$target_credit_dollars</span>
284 <span class="lbl">balance</span>
285 </div>
286 <div class="au-actions">
287 <form class="abil-grant-form" method="POST" action="/admin_billing_action.cgi"
288 data-grant-user-label="$target_display_name">
289 <input type="hidden" name="act" value="grant_credit">
290 <input type="hidden" name="user_id" value="$target_id">
291 <input type="hidden" name="amount_cents" value="">
292 <input type="hidden" name="note" value="">
293 <input type="hidden" name="return_to" value="/admin_user.cgi?u=$target_id">
294 <button type="button" class="js-grant-credit">Grant credit</button>
295 </form>
296 <a href="/admin_billing.cgi" class="btn btn-secondary btn-sm" style="text-align:center;display:block;text-decoration:none;padding:9px 12px;font-size:13px;font-weight:600">View full billing &amp; credit history &rarr;</a>
297 </div>
298 </div>
299 </div>
300
301 <!-- RIGHT COLUMN: sites, models, orders, channels -->
302 <div>
303 <div class="au-card" data-kpi-detail='{
304 "eyebrow":"Sites . owned by this user",
305 "description":"Every site row in the DB owned by this user. Includes drafts and unpublished ones. The View store link opens the public view -- useful for spot-checking what visitors see right now.",
306 "methodology":"<code>SELECT id, name, subdomain, created_at FROM sites WHERE user_id=this_user ORDER BY id ASC</code>. Multi-site-per-user is permitted by the schema but the user UI does not expose it yet, so most users have exactly one row here.",
307 "kpis":[
308 {"label":"Sites","value":"$site_count","sub":"owned by this user"}
309 ],
310 "interpretation":"More than one site on a non-staff user is unusual today -- worth a check. A user with zero sites cannot run any experiments or collect data; they likely got stuck during onboarding."
311 }'>
312 <div class="au-card-title">Sites &middot; $site_count</div>
313 [if:$has_site]
314 <table class="au-table">
315 <thead><tr><th>Name</th><th>Subdomain</th><th>Created</th><th>Actions</th></tr></thead>
316 <tbody>
317 [loop:@sites]
318 <tr>
319 <td><strong>$loop1.name</strong></td>
320 <td class="mono">$loop1.subdomain</td>
321 <td>$loop1.created_at</td>
322 <td><a href="/store.cgi?id=$loop1.id" target="_blank">View store &rarr;</a></td>
323 </tr>
324 [/loop]
325 </tbody>
326 </table>
327 [/if]
328 [if:!$has_site]
329 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No sites created yet.</div>
330 [/if]
331 </div>
332
333 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
334 "eyebrow":"Models . catalog snapshot",
335 "description":"Counts and the 10 most-recently-updated models for this user. Use this to spot inactive accounts (no models OR no recent updates) and to verify what a user has published before taking admin action.",
336 "methodology":"Total: <code>COUNT(*) FROM models WHERE user_id=this AND purged_at IS NULL</code>. Published: <code>+ AND status=&#39;published&#39;</code>. Trashed: <code>WHERE purged_at IS NOT NULL</code>. Recent: <code>ORDER BY updated_at DESC LIMIT 10</code>.",
337 "kpis":[
338 {"label":"In catalog","value":"$m_total","sub":"non-purged total"},
339 {"label":"Published","value":"$m_published","sub":"visitor-visible","color":"#4ade80"},
340 {"label":"In trash","value":"$m_purged","sub":"soft-deleted, recoverable"}
341 ],
342 "interpretation":"A high trash count can indicate a user who experiments a lot (healthy) or one who keeps abandoning the platform mid-upload (less healthy). Cross-reference with last_login_at on the account card -- if their last login is recent and they have many trash items, they may be wrestling with the upload UX."
343 }'>
344 <div class="au-card-title">Models &middot; $m_total ($m_published published &middot; $m_purged in trash)</div>
345 [if:$has_models]
346 <table class="au-table">
347 <thead><tr><th>Title</th><th>Status</th><th>Visibility</th><th>Price</th><th>Updated</th></tr></thead>
348 <tbody>
349 [loop:@models]
350 <tr>
351 <td><strong>$loop1.title</strong></td>
352 <td>$loop1.status</td>
353 <td>$loop1.visibility</td>
354 <td class="mono">$loop1.price</td>
355 <td>$loop1.updated_at</td>
356 </tr>
357 [/loop]
358 </tbody>
359 </table>
360 [/if]
361 [if:!$has_models]
362 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No models in this user-s catalog.</div>
363 [/if]
364 </div>
365
366 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
367 "eyebrow":"Orders . revenue history",
368 "description":"Paid order metrics for this user. 30-day window for the headline numbers; lifetime gross is the cumulative total ever earned on their sites. The list below shows the 10 most recent orders regardless of status.",
369 "methodology":"30-day revenue: <code>SUM(orders.total_amount_cents) WHERE site_id IN (their sites) AND status=&#39;paid&#39; AND created_at &gt;= NOW() - INTERVAL 30 DAY</code>. Lifetime: same query without the date filter. Recent list: <code>ORDER BY created_at DESC LIMIT 10</code> (status not filtered).",
370 "kpis":[
371 {"label":"Last 30 days","value":"$orders_30d_count orders","sub":"$orders_30d_revenue"},
372 {"label":"Lifetime","value":"$orders_all_revenue","sub":"gross since signup"}
373 ],
374 "interpretation":"A user with zero lifetime revenue and an old account is dormant; consider reaching out before suspending. A user with strong 30-day revenue is a power-user worth keeping happy. The list below also shows non-paid orders (pending, refunded, failed) -- a cluster of failed orders for one visitor email often means a payment-method problem worth flagging in support."
375 }'>
376 <div class="au-card-title">Orders &middot; $orders_30d_count in last 30d &middot; $orders_30d_revenue this month &middot; $orders_all_revenue lifetime</div>
377 [if:$has_orders]
378 <table class="au-table">
379 <thead><tr><th>Order</th><th>Visitor</th><th>Total</th><th>Status</th><th>Placed</th></tr></thead>
380 <tbody>
381 [loop:@recent_orders]
382 <tr>
383 <td class="mono">#$loop1.id</td>
384 <td>$loop1.visitor_email</td>
385 <td class="mono">$loop1.total</td>
386 <td>$loop1.status</td>
387 <td>$loop1.created_at</td>
388 </tr>
389 [/loop]
390 </tbody>
391 </table>
392 [/if]
393 [if:!$has_orders]
394 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No orders yet on any of this user-s sites.</div>
395 [/if]
396 </div>
397
398 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
399 "eyebrow":"Channel connections . OAuth + API keys",
400 "description":"Every external integration this user has linked (Slack, webhooks, Google Sheets, etc). Status reflects the last health check; an expired token is functionally the same as disconnected until the user re-authenticates.",
401 "methodology":"<code>SELECT platform, status, account_handle, connected_at, last_used_at FROM channel_accounts WHERE user_id=this</code>. status enum: <code>connected</code> | <code>expired</code> | <code>error</code> | <code>disconnected</code>.",
402 "kpis":[
403 {"label":"Connections","value":"see list","sub":"per-platform status below"}
404 ],
405 "interpretation":"If a user complains that webhook deliveries or exports are failing, check here first -- an expired or error status almost always explains it. Re-authenticate from their account (after they consent) by signing in as them and going to /profile.cgi."
406 }'>
407 <div class="au-card-title">Channel connections</div>
408 [if:$has_channels]
409 <div class="au-mp-grid">
410 [loop:@channels]
411 <div class="au-mp-row">
412 <div class="nm">$loop1.name</div>
413 <div class="meta">Status: $loop1.status &middot; Handle: $loop1.account_handle</div>
414 <div class="meta">Connected: $loop1.connected_at</div>
415 <div class="meta">Last used: $loop1.last_used_at</div>
416 </div>
417 [/loop]
418 </div>
419 [/if]
420 [if:!$has_channels]
421 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No channels connected for this user yet.</div>
422 [/if]
423 </div>
424 </div>
425
426</div>
427
428<!-- Full-width edit fields, moved out of the sidebar so the form has room to breathe -->
429<div class="au-card au-edit-wide">
430 <div class="au-card-title">Edit account fields</div>
431 <form method="POST" action="/admin_action.cgi">
432 <input type="hidden" name="act" value="edit_account">
433 <input type="hidden" name="u" value="$target_id">
434 <div class="au-edit-grid">
435 <label>Email
436 <input type="email" name="email" value="$target_email" required>
437 </label>
438 <label>Display name
439 <input type="text" name="display_name" value="$target_display_name" required>
440 </label>
441 <label>Plan
442 <select name="plan_tier">
443 <option value="free">Free</option>
444 <option value="starter">Starter</option>
445 <option value="pro">Pro</option>
446 <option value="studio">Studio</option>
447 </select>
448 </label>
449 <label>Status
450 <select name="account_status">
451 <option value="active">Active</option>
452 <option value="suspended">Suspended</option>
453 <option value="closed">Closed</option>
454 <option value="pending_verification">Pending verification</option>
455 </select>
456 </label>
457 </div>
458 <div class="au-edit-actions">
459 <button type="submit" class="btn btn-primary btn-sm">Save changes</button>
460 </div>
461 </form>
462 <script>
463 // Pre-select the dropdowns from the server values.
464 (function () {
465 var plan = "$target_plan_raw";
466 var status = "$target_account_status";
467 var planSel = document.querySelector('select[name=plan_tier]');
468 var statusSel = document.querySelector('select[name=account_status]');
469 if (planSel) planSel.value = plan;
470 if (statusSel) statusSel.value = status;
471 })();
472 </script>
473</div>
474
475<script>
476// Grant-credit flow: amount prompt -> note prompt -> confirm -> submit.
477// Mirrors the handler on admin_billing.cgi; pointed at the same action endpoint.
478document.addEventListener('click', function (e) {
479 var btn = e.target.closest('.js-grant-credit');
480 if (!btn) return;
481 e.preventDefault();
482 var form = btn.closest('.abil-grant-form');
483 if (!form) return;
484 var label = form.getAttribute('data-grant-user-label') || 'this user';
485 appPrompt({
486 title: 'Grant credit to ' + label,
487 message: 'Enter the amount in cents. Example: 500 for \$5.00.',
488 placeholder: '500',
489 input_type: 'number',
490 label: 'Next',
491 required: true,
492 validate: function (v) {
493 if (!/^[0-9]+\$/.test(v)) return 'Whole-cent integer required (no decimals, no dollar sign).';
494 if (parseInt(v, 10) <= 0) return 'Must be greater than zero.';
495 return '';
496 }
497 }).then(function (amt) {
498 if (amt === null) return;
499 appPrompt({
500 title: 'Note for ' + label,
501 message: 'Shown to the user on their credit history. Keep it short and clear.',
502 placeholder: 'Goodwill credit',
503 default_value: 'Goodwill credit',
504 label: 'Next',
505 required: true
506 }).then(function (note) {
507 if (note === null) return;
508 var dollars = (parseInt(amt, 10) / 100).toFixed(2);
509 appConfirm({
510 title: 'Grant \$' + dollars + ' credit to ' + label + '?',
511 message: 'Note: "' + note + '". Appends to the credit ledger immediately and applies on the next invoice.',
512 confirm_label: 'Grant credit',
513 confirm_style: 'success'
514 }).then(function (ok) {
515 if (!ok) return;
516 form.querySelector('input[name="amount_cents"]').value = amt;
517 form.querySelector('input[name="note"]').value = note;
518 form.submit();
519 });
520 });
521 });
522});
523</script>