Diff -- /var/www/vhosts/webstls.com/httpdocs/TEMPLATES/webstls_admin_user.html

O Operator
Diff

/var/www/vhosts/webstls.com/httpdocs/TEMPLATES/webstls_admin_user.html

added on WebSTLs (webstls.com) at 2026-07-11 18:31:17

Added
+590
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to f680ea2791b5
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]Seller[/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"><span class="ts" data-ts="$target_created_epoch" data-fmt="datetime">$target_created_at</span></span></div>
157 <div class="au-row"><span class="lbl">Last login</span><span class="val mono"><span class="ts" data-ts="$target_last_login_epoch" data-fmt="relative">$target_last_login_at</span></span></div>
158 </div>
159
160 <!-- Storage card: live usage + per-user override fields + recompute -->
161 <div class="au-card" style="margin-top:14px">
162 <div class="au-card-title">Storage</div>
163 <div style="display:flex;flex-direction:column;gap:10px;font-size:13px">
164 <div class="au-row"><span class="lbl">Used</span><span class="val">
165 <strong>$storage_used_human</strong> of $storage_cap_human ($storage_pct_of_cap%)
166 </span></div>
167 <div style="height:10px;border-radius:6px;background:#1f2937;overflow:hidden">
168 <div style="height:100%;width:${storage_pct_of_cap}%;background:$storage_bar_color"></div>
169 </div>
170 <div class="au-row"><span class="lbl">Tier</span><span class="val">$storage_tier</span></div>
171 <div class="au-row"><span class="lbl">Per-file cap</span><span class="val">$storage_upload_mb MB <span style="color:var(--col-text-3);font-size:11px">($storage_upload_src)</span></span></div>
172 <div class="au-row"><span class="lbl">Total cap</span><span class="val">$storage_hard_gb GB <span style="color:var(--col-text-3);font-size:11px">($storage_hard_src)</span></span></div>
173 <div class="au-row"><span class="lbl">Extensions</span><span class="val" style="font-size:11px">$storage_allowed_csv</span></div>
174 <div class="au-row"><span class="lbl">Compression</span><span class="val">$storage_compression_algo</span></div>
175 <div class="au-row"><span class="lbl">Last recompute</span><span class="val mono"><span class="ts" data-ts="$storage_last_recompute_epoch" data-fmt="relative">$storage_last_recompute</span></span></div>
176 <div class="au-row"><span class="lbl">Last alert sent</span><span class="val mono"><span class="ts" data-ts="$storage_last_alert_epoch" data-fmt="relative">$storage_last_alert</span></span></div>
177 </div>
178
179 <form method="POST" action="/admin_action.cgi" style="margin-top:12px;display:flex;flex-direction:column;gap:8px">
180 <input type="hidden" name="act" value="save_storage_overrides">
181 <input type="hidden" name="u" value="$target_id">
182 <div style="font-size:11px;color:var(--col-text-3);letter-spacing:.5px;text-transform:uppercase;font-weight:700">Per-user overrides (blank = tier default)</div>
183 <label style="font-size:12px;color:var(--col-text-2)">Per-file cap (MB)
184 <input type="number" name="upload_max_mb_override" value="$storage_upload_override_value" min="1" max="65535" class="input" style="margin-top:3px">
185 </label>
186 <label style="font-size:12px;color:var(--col-text-2)">Total cap (GB)
187 <input type="number" name="storage_hard_cap_gb_override" value="$storage_hard_override_value" min="1" max="65535" class="input" style="margin-top:3px">
188 </label>
189 <label style="font-size:12px;color:var(--col-text-2)">Allowed extensions (csv, no dots)
190 <input type="text" name="allowed_extensions_override" value="$storage_ext_override_value" class="input" placeholder="e.g. stl,obj,3mf" style="margin-top:3px">
191 </label>
192 <button type="submit" class="btn btn-primary btn-sm">Save overrides</button>
193 </form>
194
195 <form method="POST" action="/admin_action.cgi" style="margin-top:8px">
196 <input type="hidden" name="act" value="recompute_storage">
197 <input type="hidden" name="u" value="$target_id">
198 <button type="submit" class="btn btn-secondary btn-sm" style="width:100%">Recompute usage from disk</button>
199 </form>
200 </div>
201
202 <div class="au-card" style="margin-top:14px">
203 <div class="au-card-title">Admin actions</div>
204 <div class="au-actions">
205
206 [if:!$is_self]
207 <form method="POST" action="/admin_action.cgi" class="primary">
208 <input type="hidden" name="act" value="impersonate">
209 <input type="hidden" name="u" value="$target_id">
210 <button type="submit">View as user (start impersonation)</button>
211 </form>
212 [/if]
213 [if:$is_self]
214 <div style="font-size:11px;color:var(--col-text-3);padding:8px 10px">This is your own account &mdash; nothing to impersonate.</div>
215 [/if]
216
217 [if:$is_active]
218 <form method="POST" action="/admin_action.cgi"
219 data-confirm-title="Suspend this account?"
220 data-confirm-message="They will be logged out immediately and unable to sign in until you reactivate the account."
221 data-confirm-label="Suspend account"
222 data-confirm-style="warning">
223 <input type="hidden" name="act" value="suspend">
224 <input type="hidden" name="u" value="$target_id">
225 <button type="submit">Suspend account</button>
226 </form>
227 [/if]
228
229 [if:$is_suspended]
230 <form method="POST" action="/admin_action.cgi">
231 <input type="hidden" name="act" value="reactivate">
232 <input type="hidden" name="u" value="$target_id">
233 <button type="submit">Reactivate account</button>
234 </form>
235 [/if]
236
237 [if:!$is_self]
238 <form method="POST" action="/admin_action.cgi" class="danger"
239 data-confirm-title="Close this account permanently?"
240 data-confirm-message="Their data stays in the database, but they can never sign in again. This cannot be undone from the UI."
241 data-confirm-label="Close account"
242 data-confirm-style="danger">
243 <input type="hidden" name="act" value="close">
244 <input type="hidden" name="u" value="$target_id">
245 <button type="submit">Close account</button>
246 </form>
247
248 <form method="POST" action="/admin_action.cgi"
249 data-confirm-title="Generate a password reset?"
250 data-confirm-message="A one-time reset link will be shown on the next screen. Share it directly with the user."
251 data-confirm-label="Generate reset link"
252 data-confirm-style="primary">
253 <input type="hidden" name="act" value="reset_password">
254 <input type="hidden" name="u" value="$target_id">
255 <button type="submit">Reset password (generate link)</button>
256 </form>
257
258 [if:!$target_is_admin]
259 <form method="POST" action="/admin_action.cgi"
260 data-confirm-title="Promote to company admin?"
261 data-confirm-message="They will see the Admin Console and can act on every account on the platform."
262 data-confirm-label="Promote to admin"
263 data-confirm-style="warning">
264 <input type="hidden" name="act" value="promote_admin">
265 <input type="hidden" name="u" value="$target_id">
266 <button type="submit">Promote to admin</button>
267 </form>
268 [/if]
269 [if:$target_is_admin]
270 <form method="POST" action="/admin_action.cgi"
271 data-confirm-title="Remove admin role?"
272 data-confirm-message="They will lose access to the Admin Console immediately."
273 data-confirm-label="Remove admin"
274 data-confirm-style="warning">
275 <input type="hidden" name="act" value="demote_admin">
276 <input type="hidden" name="u" value="$target_id">
277 <button type="submit">Remove admin role</button>
278 </form>
279 [/if]
280
281 [if:$viewer_is_super_admin]
282 [if:$target_not_super_admin]
283 <form method="POST" action="/admin_action.cgi"
284 data-confirm-title="Promote to SUPER admin?"
285 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."
286 data-confirm-label="Promote to super admin"
287 data-confirm-style="danger">
288 <input type="hidden" name="act" value="promote_super_admin">
289 <input type="hidden" name="u" value="$target_id">
290 <button type="submit" style="background:linear-gradient(135deg,#f59e0b,#b45309);color:#fff;border:0">Promote to super admin</button>
291 </form>
292 [/if]
293 [if:$target_is_super_admin]
294 [if:!$is_self]
295 <form method="POST" action="/admin_action.cgi"
296 data-confirm-title="Remove SUPER admin role?"
297 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."
298 data-confirm-label="Remove super admin"
299 data-confirm-style="warning">
300 <input type="hidden" name="act" value="demote_super_admin">
301 <input type="hidden" name="u" value="$target_id">
302 <button type="submit">Remove super admin</button>
303 </form>
304 [/if]
305 [if:$is_self]
306 <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>
307 [/if]
308 [/if]
309 [/if]
310 [/if]
311 </div>
312 </div>
313<div class="au-card" style="margin-top:14px">
314 <div class="au-card-title">Billing overrides</div>
315 <form method="POST" action="/admin_action.cgi" style="margin-bottom:14px">
316 <input type="hidden" name="act" value="grant_credit">
317 <input type="hidden" name="u" value="$target_id">
318 <div style="font-size:11px;color:var(--col-text-3);margin-bottom:6px">Grant credit (positive) or adjust (negative)</div>
319 <div style="display:flex;gap:6px">
320 <input type="text" name="amount_dollars" placeholder="25.00" style="width:90px;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px" required>
321 <input type="text" name="reason" placeholder="Reason (internal)" style="flex:1;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px">
322 </div>
323 <button type="submit" class="btn btn-primary btn-sm" style="margin-top:8px;width:100%">Apply credit</button>
324 </form>
325 <form method="POST" action="/admin_action.cgi">
326 <input type="hidden" name="act" value="queue_refund">
327 <input type="hidden" name="u" value="$target_id">
328 <div style="font-size:11px;color:var(--col-text-3);margin-bottom:6px">Queue refund (Stripe pending - soft credit applied immediately)</div>
329 <div style="display:flex;gap:6px">
330 <input type="text" name="invoice_id" placeholder="Invoice #" style="width:90px;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px">
331 <input type="text" name="amount_dollars" placeholder="25.00" style="width:90px;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px" required>
332 <input type="text" name="reason" placeholder="Reason" style="flex:1;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px">
333 </div>
334 <button type="submit" class="btn btn-warn btn-sm" style="margin-top:8px;width:100%;background:rgba(245,158,11,0.20);color:#fbbf24;border:1px solid rgba(245,158,11,0.40)">Queue refund</button>
335 </form>
336</div>
337
338
339 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
340 "eyebrow":"Account credits . goodwill + refunds",
341 "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.",
342 "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.",
343 "kpis":[
344 {"label":"Balance","value":"$target_credit_dollars","sub":"applied to next invoice"}
345 ],
346 "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."
347 }'>
348 <div class="au-card-title">Account credits</div>
349 <div class="au-credit-balance">
350 <span class="amt[if:$target_has_credit] pos[/if][if:$target_has_debit] neg[/if]">$target_credit_dollars</span>
351 <span class="lbl">balance</span>
352 </div>
353 <div class="au-actions">
354 <form class="abil-grant-form" method="POST" action="/admin_billing_action.cgi"
355 data-grant-user-label="$target_display_name">
356 <input type="hidden" name="act" value="grant_credit">
357 <input type="hidden" name="user_id" value="$target_id">
358 <input type="hidden" name="amount_cents" value="">
359 <input type="hidden" name="note" value="">
360 <input type="hidden" name="return_to" value="/admin_user.cgi?u=$target_id">
361 <button type="button" class="js-grant-credit">Grant credit</button>
362 </form>
363 <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>
364 </div>
365 </div>
366 </div>
367
368 <!-- RIGHT COLUMN: storefronts, models, orders, marketplaces -->
369 <div>
370 <div class="au-card" data-kpi-detail='{
371 "eyebrow":"Storefronts . owned by this user",
372 "description":"Every storefront 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 buyers see right now.",
373 "methodology":"<code>SELECT id, name, subdomain, created_at FROM storefronts WHERE user_id=this_user ORDER BY id ASC</code>. Multi-storefront-per-user is permitted by the schema but the seller UI does not expose it yet, so most users have exactly one row here.",
374 "kpis":[
375 {"label":"Storefronts","value":"$storefront_count","sub":"owned by this user"}
376 ],
377 "interpretation":"More than one storefront on a non-staff user is unusual today -- worth a check. A user with zero storefronts cannot publish to a buyer-facing URL even if they have models; they likely got stuck during onboarding."
378 }'>
379 <div class="au-card-title">Storefronts &middot; $storefront_count</div>
380 [if:$has_storefront]
381 <table class="au-table">
382 <thead><tr><th>Name</th><th>Subdomain</th><th>Created</th><th>Actions</th></tr></thead>
383 <tbody>
384 [loop:@storefronts]
385 <tr>
386 <td><strong>$loop1.name</strong></td>
387 <td class="mono">$loop1.subdomain</td>
388 <td><span class="ts" data-ts="$loop1.created_epoch" data-fmt="date-short">$loop1.created_at</span></td>
389 <td><a href="/store.cgi?id=$loop1.id" target="_blank">View store &rarr;</a></td>
390 </tr>
391 [/loop]
392 </tbody>
393 </table>
394 [/if]
395 [if:!$has_storefront]
396 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No storefronts created yet.</div>
397 [/if]
398 </div>
399
400 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
401 "eyebrow":"Models . catalog snapshot",
402 "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.",
403 "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>.",
404 "kpis":[
405 {"label":"In catalog","value":"$m_total","sub":"non-purged total"},
406 {"label":"Published","value":"$m_published","sub":"buyer-visible","color":"#4ade80"},
407 {"label":"In trash","value":"$m_purged","sub":"soft-deleted, recoverable"}
408 ],
409 "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."
410 }'>
411 <div class="au-card-title">Models &middot; $m_total ($m_published published &middot; $m_purged in trash)</div>
412 [if:$has_models]
413 <table class="au-table">
414 <thead><tr><th>Title</th><th>Status</th><th>Visibility</th><th>Price</th><th>Updated</th></tr></thead>
415 <tbody>
416 [loop:@models]
417 <tr>
418 <td><strong>$loop1.title</strong></td>
419 <td>$loop1.status</td>
420 <td>$loop1.visibility</td>
421 <td class="mono">$loop1.price</td>
422 <td><span class="ts" data-ts="$loop1.updated_epoch" data-fmt="relative">$loop1.updated_at</span></td>
423 </tr>
424 [/loop]
425 </tbody>
426 </table>
427 [/if]
428 [if:!$has_models]
429 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No models in this user-s catalog.</div>
430 [/if]
431 </div>
432
433 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
434 "eyebrow":"Orders . revenue history",
435 "description":"Paid order metrics for this user. 30-day window for the headline numbers; lifetime gross is the cumulative total ever earned on their storefronts. The list below shows the 10 most recent orders regardless of status.",
436 "methodology":"30-day revenue: <code>SUM(orders.total_amount_cents) WHERE storefront_id IN (their storefronts) 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).",
437 "kpis":[
438 {"label":"Last 30 days","value":"$orders_30d_count orders","sub":"$orders_30d_revenue"},
439 {"label":"Lifetime","value":"$orders_all_revenue","sub":"gross since signup"}
440 ],
441 "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-seller worth keeping happy. The list below also shows non-paid orders (pending, refunded, failed) -- a cluster of failed orders for one buyer email often means a payment-method problem worth flagging in support."
442 }'>
443 <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>
444 [if:$has_orders]
445 <table class="au-table">
446 <thead><tr><th>Order</th><th>Buyer</th><th>Total</th><th>Status</th><th>Placed</th></tr></thead>
447 <tbody>
448 [loop:@recent_orders]
449 <tr>
450 <td class="mono">#$loop1.id</td>
451 <td>$loop1.buyer_email</td>
452 <td class="mono">$loop1.total</td>
453 <td>$loop1.status</td>
454 <td><span class="ts" data-ts="$loop1.created_epoch" data-fmt="datetime">$loop1.created_at</span></td>
455 </tr>
456 [/loop]
457 </tbody>
458 </table>
459 [/if]
460 [if:!$has_orders]
461 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No orders yet on any of this user-s storefronts.</div>
462 [/if]
463 </div>
464
465 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
466 "eyebrow":"Marketplace connections . OAuth + API keys",
467 "description":"Every external marketplace this user has linked. Status reflects the last health check; an expired token is functionally the same as disconnected (no publishes go out) until the user re-authenticates.",
468 "methodology":"<code>SELECT platform, status, account_handle, connected_at, last_used_at FROM marketplace_accounts WHERE user_id=this</code>. status enum: <code>connected</code> | <code>expired</code> | <code>error</code> | <code>disconnected</code>.",
469 "kpis":[
470 {"label":"Connections","value":"see list","sub":"per-platform status below"}
471 ],
472 "interpretation":"Users with multiple healthy connections drive more revenue on average. If a user complains about publishing failing, check here first -- an expired or error status almost always explains it. Re-authenticate from their account (after they consent) by impersonating and going to /marketplaces.cgi."
473 }'>
474 <div class="au-card-title">Marketplace connections</div>
475 [if:$has_marketplaces]
476 <div class="au-mp-grid">
477 [loop:@marketplaces]
478 <div class="au-mp-row">
479 <div class="nm">$loop1.name</div>
480 <div class="meta">Status: $loop1.status &middot; Handle: $loop1.account_handle</div>
481 <div class="meta">Connected: <span class="ts" data-ts="$loop1.connected_epoch" data-fmt="date-short">$loop1.connected_at</span></div>
482 <div class="meta">Last used: <span class="ts" data-ts="$loop1.last_used_epoch" data-fmt="relative">$loop1.last_used_at</span></div>
483 </div>
484 [/loop]
485 </div>
486 [/if]
487 [if:!$has_marketplaces]
488 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No marketplaces connected for this user yet.</div>
489 [/if]
490 </div>
491 </div>
492
493</div>
494
495<!-- Full-width edit fields, moved out of the sidebar so the form has room to breathe -->
496<div class="au-card au-edit-wide">
497 <div class="au-card-title">Edit account fields</div>
498 <form method="POST" action="/admin_action.cgi">
499 <input type="hidden" name="act" value="edit_account">
500 <input type="hidden" name="u" value="$target_id">
501 <div class="au-edit-grid">
502 <label>Email
503 <input type="email" name="email" value="$target_email" required>
504 </label>
505 <label>Display name
506 <input type="text" name="display_name" value="$target_display_name" required>
507 </label>
508 <label>Plan
509 <select name="plan_tier">
510 <option value="free">Free</option>
511 <option value="starter">Starter</option>
512 <option value="pro">Pro</option>
513 <option value="studio">Studio</option>
514 </select>
515 </label>
516 <label>Status
517 <select name="account_status">
518 <option value="active">Active</option>
519 <option value="suspended">Suspended</option>
520 <option value="closed">Closed</option>
521 <option value="pending_verification">Pending verification</option>
522 </select>
523 </label>
524 </div>
525 <div class="au-edit-actions">
526 <button type="submit" class="btn btn-primary btn-sm">Save changes</button>
527 </div>
528 </form>
529 <script>
530 // Pre-select the dropdowns from the server values.
531 (function () {
532 var plan = "$target_plan_raw";
533 var status = "$target_account_status";
534 var planSel = document.querySelector('select[name=plan_tier]');
535 var statusSel = document.querySelector('select[name=account_status]');
536 if (planSel) planSel.value = plan;
537 if (statusSel) statusSel.value = status;
538 })();
539 </script>
540</div>
541
542<script>
543// Grant-credit flow: amount prompt -> note prompt -> confirm -> submit.
544// Mirrors the handler on admin_billing.cgi; pointed at the same action endpoint.
545document.addEventListener('click', function (e) {
546 var btn = e.target.closest('.js-grant-credit');
547 if (!btn) return;
548 e.preventDefault();
549 var form = btn.closest('.abil-grant-form');
550 if (!form) return;
551 var label = form.getAttribute('data-grant-user-label') || 'this user';
552 appPrompt({
553 title: 'Grant credit to ' + label,
554 message: 'Enter the amount in cents. Example: 500 for \$5.00.',
555 placeholder: '500',
556 input_type: 'number',
557 label: 'Next',
558 required: true,
559 validate: function (v) {
560 if (!/^[0-9]+\$/.test(v)) return 'Whole-cent integer required (no decimals, no dollar sign).';
561 if (parseInt(v, 10) <= 0) return 'Must be greater than zero.';
562 return '';
563 }
564 }).then(function (amt) {
565 if (amt === null) return;
566 appPrompt({
567 title: 'Note for ' + label,
568 message: 'Shown to the user on their credit history. Keep it short and clear.',
569 placeholder: 'Goodwill credit',
570 default_value: 'Goodwill credit',
571 label: 'Next',
572 required: true
573 }).then(function (note) {
574 if (note === null) return;
575 var dollars = (parseInt(amt, 10) / 100).toFixed(2);
576 appConfirm({
577 title: 'Grant \$' + dollars + ' credit to ' + label + '?',
578 message: 'Note: "' + note + '". Appends to the credit ledger immediately and applies on the next invoice.',
579 confirm_label: 'Grant credit',
580 confirm_style: 'success'
581 }).then(function (ok) {
582 if (!ok) return;
583 form.querySelector('input[name="amount_cents"]').value = amt;
584 form.querySelector('input[name="note"]').value = note;
585 form.submit();
586 });
587 });
588 });
589});
590</script>