Commit7293c67unknown_key
Verify GDPR deletion cascade + add admin Stripe sync page
Verify GDPR deletion cascade + add admin Stripe sync page - account-deletion.ts: add disk repo cleanup (rm GIT_REPOS_PATH/<username>/) and Stripe subscription cancellation to purgeScheduledAccounts; update top-of-file cascade summary comment documenting all covered steps - admin-deletions.tsx: new GET /admin/deletions listing overdue purges and grace-period users; POST /admin/deletions/:id/purge-now force-runs cascade - admin-stripe.tsx: new GET /admin/stripe showing non-free users with live Stripe status + mismatch detection; POST /admin/stripe/:userId/sync fixes local plan to match Stripe; gracefully degrades when STRIPE_SECRET_KEY unset - app.tsx: register both new admin routes - admin.tsx: add nav links for GDPR Deletions and Stripe Sync https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
5 files changed+794−17293c6717072b2557115659e102a68a89fc6f99f
5 changed files+794−1
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -88,6 +88,8 @@ import orgRoutes from "./routes/orgs";
8888import notificationRoutes from "./routes/notifications";
8989import onboardingRoutes from "./routes/onboarding";
9090import adminRoutes from "./routes/admin";
91import adminDeletionsRoutes from "./routes/admin-deletions";
92import adminStripeRoutes from "./routes/admin-stripe";
9193import adminDeploysRoutes from "./routes/admin-deploys";
9294import adminDeploysPageRoutes from "./routes/admin-deploys-page";
9395import adminServerTargetsRoutes from "./routes/admin-server-targets";
@@ -597,6 +599,8 @@ app.route("/", onboardingRoutes);
597599
598600// Admin + feature routes
599601app.route("/", adminRoutes);
602app.route("/", adminDeletionsRoutes);
603app.route("/", adminStripeRoutes);
600604app.route("/", adminIntegrationsRoutes);
601605app.route("/", adminAdvancementRoutes);
602606app.route("/", adminDeploysRoutes);
Modifiedsrc/lib/account-deletion.ts+72−1View fileUnifiedSplit
@@ -19,12 +19,23 @@
1919 * tick. Per-user errors are logged + skipped — never thrown — so a
2020 * single FK violation can't stall the queue.
2121 *
22 * Cascade executed by `purgeScheduledAccounts` for each user:
23 * - Cancel any active Stripe subscription (if STRIPE_SECRET_KEY is set).
24 * - Delete bare git repo directories from disk (GIT_REPOS_PATH/<username>/).
25 * - Hard-delete the `users` row; FK ON DELETE CASCADE handles:
26 * sessions, ssh_keys, api_tokens, notifications, stars, reactions,
27 * ai_chats, push_subscriptions, oauth_access_tokens, gists, etc.
28 * - audit_log.user_id is already set to ON DELETE SET NULL at the DB level,
29 * so audit rows are automatically anonymised when the user row is removed.
30 *
2231 * Nothing here throws. All DB / email failures are logged and swallowed.
2332 */
2433
2534import { eq, lt } from "drizzle-orm";
35import { rm } from "fs/promises";
36import { join } from "path";
2637import { db } from "../db";
27import { sessions, users } from "../db/schema";
38import { repositories, sessions, userQuotas, users } from "../db/schema";
2839import { sendEmail, absoluteUrl } from "./email";
2940import { audit } from "./notify";
3041
@@ -124,6 +135,26 @@ export async function cancelAccountDeletion(
124135 return { ok: true };
125136}
126137
138/**
139 * Cancel a Stripe subscription at period end for GDPR purge.
140 * Silently swallows all errors — a Stripe outage must never block deletion.
141 */
142async function cancelStripeSubscription(subscriptionId: string): Promise<void> {
143 const key = process.env.STRIPE_SECRET_KEY;
144 if (!key || key.length < 10) return;
145 try {
146 await fetch(`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, {
147 method: "DELETE",
148 headers: {
149 Authorization: `Bearer ${key}`,
150 "Content-Type": "application/x-www-form-urlencoded",
151 },
152 });
153 } catch (err) {
154 console.error(`[account-deletion] stripe cancel failed for sub=${subscriptionId}:`, err);
155 }
156}
157
127158export async function purgeScheduledAccounts(
128159 opts: { now?: Date; cap?: number } = {}
129160): Promise<{ purged: number; errors: number }> {
@@ -150,6 +181,46 @@ export async function purgeScheduledAccounts(
150181 let errors = 0;
151182 for (const c of candidates) {
152183 try {
184 // 1. Cancel any active Stripe subscription before removing the user row.
185 try {
186 const quotaRows = await db
187 .select({ stripeSubscriptionId: userQuotas.stripeSubscriptionId })
188 .from(userQuotas)
189 .where(eq(userQuotas.userId, c.id))
190 .limit(1);
191 const subId = quotaRows[0]?.stripeSubscriptionId;
192 if (subId) {
193 await cancelStripeSubscription(subId);
194 }
195 } catch (err) {
196 console.error(`[account-deletion] quota lookup failed for user=${c.id}:`, err);
197 }
198
199 // 2. Delete bare git repo directories from disk (GIT_REPOS_PATH/<username>/).
200 // We collect all repo diskPaths owned by this user and remove each one.
201 try {
202 const repoRows = await db
203 .select({ diskPath: repositories.diskPath })
204 .from(repositories)
205 .where(eq(repositories.ownerId, c.id));
206 for (const r of repoRows) {
207 const absPath = r.diskPath.startsWith("/")
208 ? r.diskPath
209 : join(process.env.GIT_REPOS_PATH || "./repos", r.diskPath);
210 await rm(absPath, { recursive: true, force: true });
211 }
212 // Also remove the per-user directory if it exists (may be empty or already gone).
213 const userDir = join(process.env.GIT_REPOS_PATH || "./repos", c.username);
214 await rm(userDir, { recursive: true, force: true });
215 } catch (err) {
216 console.error(`[account-deletion] disk cleanup failed for user=${c.id}:`, err);
217 }
218
219 // 3. Hard-delete the users row.
220 // FK ON DELETE CASCADE handles: sessions, ssh_keys, api_tokens,
221 // notifications, stars, reactions, ai_chats, push_subscriptions,
222 // oauth_access_tokens, gists, user_quotas, user_totp, user_passkeys, etc.
223 // FK ON DELETE SET NULL handles: audit_log.user_id (anonymises entries).
153224 const deleted = await db
154225 .delete(users)
155226 .where(eq(users.id, c.id))
Addedsrc/routes/admin-deletions.tsx+300−0View fileUnifiedSplit
@@ -0,0 +1,300 @@
1/**
2 * GET /admin/deletions — list users pending purge + completed purges
3 * POST /admin/deletions/:id/purge-now — force-run deletion cascade immediately
4 *
5 * Gated by isSiteAdmin (same pattern as all other admin sub-pages).
6 */
7
8import { Hono } from "hono";
9import { eq, isNotNull, lt } from "drizzle-orm";
10import { db } from "../db";
11import { users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { softAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import { isSiteAdmin } from "../lib/admin";
16import { purgeScheduledAccounts } from "../lib/account-deletion";
17import { audit } from "../lib/notify";
18
19const adminDeletions = new Hono<AuthEnv>();
20adminDeletions.use("*", softAuth);
21
22async function gate(c: any): Promise<{ user: any } | Response> {
23 const user = c.get("user");
24 if (!user) return c.redirect("/login?next=/admin/deletions");
25 if (!(await isSiteAdmin(user.id))) {
26 return c.html(
27 <Layout title="Forbidden" user={user}>
28 <div style="max-width:540px;margin:80px auto;padding:32px;text-align:center;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px;">
29 <h2 style="font-family:var(--font-display);font-size:22px;margin:0 0 8px;color:var(--text-strong);">403 — Not a site admin</h2>
30 <p style="color:var(--text-muted);margin:0;font-size:14px;">You don't have permission to view this page.</p>
31 </div>
32 </Layout>,
33 403
34 );
35 }
36 return { user };
37}
38
39const styles = `
40 .adm-del-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
41
42 .adm-del-hero {
43 position: relative;
44 margin-bottom: var(--space-5);
45 padding: var(--space-5) var(--space-6);
46 background: var(--bg-elevated);
47 border: 1px solid var(--border);
48 border-radius: 16px;
49 overflow: hidden;
50 }
51 .adm-del-hero::before {
52 content: '';
53 position: absolute;
54 top: 0; left: 0; right: 0;
55 height: 2px;
56 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fb923c 70%, transparent 100%);
57 opacity: 0.7;
58 pointer-events: none;
59 }
60 .adm-del-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
61 .adm-del-eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: #fb923c; margin-bottom: 6px; }
62 .adm-del-title { font-family: var(--font-display); font-size: clamp(24px,3.5vw,36px); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 4px; color: var(--text-strong); }
63 .adm-del-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
64 .adm-del-back { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 12.5px; color: var(--text-muted); background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px; text-decoration: none; font-weight: 500; transition: border-color 120ms,color 120ms,background 120ms; }
65 .adm-del-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
66
67 .adm-del-section { margin-bottom: var(--space-6); }
68 .adm-del-section-title { font-family: var(--font-display); font-size: 16px; font-weight: 700; letter-spacing: -0.012em; color: var(--text-strong); margin: 0 0 var(--space-3); display: flex; align-items: center; gap: 10px; }
69 .adm-del-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 700; }
70 .adm-del-badge-warn { background: rgba(251,146,60,0.15); color: #fdba74; box-shadow: inset 0 0 0 1px rgba(251,146,60,0.35); }
71 .adm-del-badge-ok { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
72
73 .adm-del-table { width: 100%; border-collapse: collapse; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
74 .adm-del-table thead th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); padding: 10px 14px; background: rgba(255,255,255,0.015); border-bottom: 1px solid var(--border); }
75 .adm-del-table tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); font-size: 13px; color: var(--text); vertical-align: middle; }
76 .adm-del-table tbody tr:last-child td { border-bottom: none; }
77 .adm-del-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
78 .adm-del-table code { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-strong); }
79
80 .adm-del-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 7px; font-size: 12.5px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; transition: background 120ms, border-color 120ms, color 120ms; border: 1px solid rgba(248,113,113,0.45); background: rgba(248,113,113,0.08); color: #fca5a5; }
81 .adm-del-btn:hover { background: rgba(248,113,113,0.18); border-color: rgba(248,113,113,0.70); color: #fecaca; }
82
83 .adm-del-empty { padding: var(--space-6); text-align: center; color: var(--text-muted); font-size: 13.5px; background: var(--bg-elevated); border: 1px dashed var(--border); border-radius: 14px; }
84
85 .adm-del-banner { margin-bottom: var(--space-4); padding: 10px 14px; border-radius: 10px; font-size: 13.5px; border: 1px solid var(--border); background: rgba(255,255,255,0.025); color: var(--text); }
86 .adm-del-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
87 .adm-del-banner.is-err { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
88
89 @media (max-width: 720px) {
90 .adm-del-wrap { padding: var(--space-4) var(--space-3); }
91 .adm-del-hero { padding: var(--space-4); }
92 .adm-del-table { display: block; overflow-x: auto; }
93 }
94`;
95
96adminDeletions.get("/admin/deletions", async (c) => {
97 const g = await gate(c);
98 if (g instanceof Response) return g;
99 const { user } = g;
100
101 const now = new Date();
102
103 // Users pending purge: deletion_scheduled_for is set and in the past (overdue)
104 const pendingRows = await db
105 .select({
106 id: users.id,
107 username: users.username,
108 email: users.email,
109 deletedAt: users.deletedAt,
110 deletionScheduledFor: users.deletionScheduledFor,
111 })
112 .from(users)
113 .where(lt(users.deletionScheduledFor, now))
114 .orderBy(users.deletionScheduledFor)
115 .limit(200);
116
117 // Users in grace period (deletion_scheduled_for is set but not yet overdue).
118 // We fetch all with a non-null scheduled_for and filter in JS.
119 const scheduledRows = await db
120 .select({
121 id: users.id,
122 username: users.username,
123 email: users.email,
124 deletedAt: users.deletedAt,
125 deletionScheduledFor: users.deletionScheduledFor,
126 })
127 .from(users)
128 .where(isNotNull(users.deletionScheduledFor))
129 .orderBy(users.deletionScheduledFor)
130 .limit(400);
131
132 // Filter: grace period = scheduled but not yet overdue
133 const graceRows = scheduledRows.filter(
134 (r) => r.deletionScheduledFor && r.deletionScheduledFor > now
135 );
136
137 const msg = c.req.query("msg") || "";
138 const errMsg = c.req.query("err") || "";
139
140 const fmt = (d: Date | null | undefined) =>
141 d ? new Date(d).toLocaleString() : "—";
142
143 return c.html(
144 <Layout title="Admin — Account Deletions" user={user}>
145 <style dangerouslySetInnerHTML={{ __html: styles }} />
146 <div class="adm-del-wrap">
147 <div class="adm-del-hero">
148 <div class="adm-del-hero-inner">
149 <div>
150 <div class="adm-del-eyebrow">Admin / GDPR</div>
151 <h1 class="adm-del-title">Account Deletions</h1>
152 <p class="adm-del-sub">
153 Users scheduled for deletion, their grace-period status, and completed purges.
154 </p>
155 </div>
156 <a href="/admin" class="adm-del-back">← Admin</a>
157 </div>
158 </div>
159
160 {msg && <div class="adm-del-banner is-ok">{msg}</div>}
161 {errMsg && <div class="adm-del-banner is-err">{errMsg}</div>}
162
163 {/* Overdue — pending execution */}
164 <div class="adm-del-section">
165 <div class="adm-del-section-title">
166 Overdue (pending execution)
167 <span class="adm-del-badge adm-del-badge-warn">{pendingRows.length}</span>
168 </div>
169 {pendingRows.length === 0 ? (
170 <div class="adm-del-empty">No overdue deletions. Autopilot is keeping up.</div>
171 ) : (
172 <table class="adm-del-table">
173 <thead>
174 <tr>
175 <th>Username</th>
176 <th>Email</th>
177 <th>Deleted at</th>
178 <th>Scheduled for</th>
179 <th>Action</th>
180 </tr>
181 </thead>
182 <tbody>
183 {pendingRows.map((r) => (
184 <tr>
185 <td><code>{r.username}</code></td>
186 <td>{r.email}</td>
187 <td>{fmt(r.deletedAt)}</td>
188 <td style="color:#fb923c">{fmt(r.deletionScheduledFor)}</td>
189 <td>
190 <form method="post" action={`/admin/deletions/${r.id}/purge-now`}>
191 <button type="submit" class="adm-del-btn"
192 onclick="return confirm('Hard-delete this user immediately? This cannot be undone.')">
193 Purge now
194 </button>
195 </form>
196 </td>
197 </tr>
198 ))}
199 </tbody>
200 </table>
201 )}
202 </div>
203
204 {/* Grace period */}
205 <div class="adm-del-section">
206 <div class="adm-del-section-title">
207 In grace period
208 <span class="adm-del-badge adm-del-badge-ok">{graceRows.length}</span>
209 </div>
210 {graceRows.length === 0 ? (
211 <div class="adm-del-empty">No accounts in the grace period.</div>
212 ) : (
213 <table class="adm-del-table">
214 <thead>
215 <tr>
216 <th>Username</th>
217 <th>Email</th>
218 <th>Deletion initiated</th>
219 <th>Purge scheduled for</th>
220 <th>Action</th>
221 </tr>
222 </thead>
223 <tbody>
224 {graceRows.map((r) => (
225 <tr>
226 <td><code>{r.username}</code></td>
227 <td>{r.email}</td>
228 <td>{fmt(r.deletedAt)}</td>
229 <td>{fmt(r.deletionScheduledFor)}</td>
230 <td>
231 <form method="post" action={`/admin/deletions/${r.id}/purge-now`}>
232 <button type="submit" class="adm-del-btn"
233 onclick="return confirm('Hard-delete this user now, skipping the grace period? This cannot be undone.')">
234 Purge now
235 </button>
236 </form>
237 </td>
238 </tr>
239 ))}
240 </tbody>
241 </table>
242 )}
243 </div>
244 </div>
245 </Layout>
246 );
247});
248
249// Force-run deletion cascade immediately for a specific user.
250adminDeletions.post("/admin/deletions/:id/purge-now", async (c) => {
251 const g = await gate(c);
252 if (g instanceof Response) return g;
253 const { user: adminUser } = g;
254
255 const targetId = c.req.param("id");
256
257 // Fetch the target user to confirm they're scheduled for deletion.
258 const targetRows = await db
259 .select({ id: users.id, username: users.username, deletionScheduledFor: users.deletionScheduledFor, deletedAt: users.deletedAt })
260 .from(users)
261 .where(eq(users.id, targetId))
262 .limit(1);
263
264 const target = targetRows[0] ?? null;
265 if (target && !target.deletedAt) {
266 return c.redirect("/admin/deletions?err=" + encodeURIComponent("User is not scheduled for deletion."));
267 }
268 if (!target) {
269 return c.redirect("/admin/deletions?err=" + encodeURIComponent("User not found or not scheduled for deletion."));
270 }
271
272 // Force-run purge by setting deletion_scheduled_for to epoch so lt(now) picks it up.
273 try {
274 await db
275 .update(users)
276 .set({ deletionScheduledFor: new Date(0) })
277 .where(eq(users.id, targetId));
278 } catch (err) {
279 console.error("[admin-deletions] force-schedule update failed:", err);
280 }
281
282 // Use the import from account-deletion which already handles all cascade steps.
283 const result = await purgeScheduledAccounts({ cap: 1 });
284
285 await audit({
286 userId: adminUser.id,
287 action: "account.admin_purge",
288 targetType: "user",
289 targetId,
290 metadata: { username: target.username, triggeredBy: adminUser.id },
291 });
292
293 if (result.purged > 0) {
294 return c.redirect("/admin/deletions?msg=" + encodeURIComponent(`User "${target.username}" has been permanently purged.`));
295 } else {
296 return c.redirect("/admin/deletions?err=" + encodeURIComponent(`Purge encountered errors. Check server logs.`));
297 }
298});
299
300export default adminDeletions;
Addedsrc/routes/admin-stripe.tsx+410−0View fileUnifiedSplit
@@ -0,0 +1,410 @@
1/**
2 * GET /admin/stripe — Stripe subscription sync dashboard
3 * POST /admin/stripe/:userId/sync — update local plan to match Stripe
4 *
5 * Shows all non-free users, their local plan, and live Stripe status.
6 * Mismatches (local says pro/team but Stripe cancelled, or vice versa)
7 * are highlighted with a "Fix" button.
8 *
9 * If STRIPE_SECRET_KEY is not set, only local data is shown.
10 *
11 * Gated by isSiteAdmin (same pattern as all other admin sub-pages).
12 */
13
14import { Hono } from "hono";
15import { desc, eq, ne } from "drizzle-orm";
16import { db } from "../db";
17import { userQuotas, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { isSiteAdmin } from "../lib/admin";
22import { getSubscription, planSlugFromSubscription } from "../lib/stripe";
23import { audit } from "../lib/notify";
24
25const adminStripe = new Hono<AuthEnv>();
26adminStripe.use("*", softAuth);
27
28async function gate(c: any): Promise<{ user: any } | Response> {
29 const user = c.get("user");
30 if (!user) return c.redirect("/login?next=/admin/stripe");
31 if (!(await isSiteAdmin(user.id))) {
32 return c.html(
33 <Layout title="Forbidden" user={user}>
34 <div style="max-width:540px;margin:80px auto;padding:32px;text-align:center;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px;">
35 <h2 style="font-family:var(--font-display);font-size:22px;margin:0 0 8px;color:var(--text-strong);">403 — Not a site admin</h2>
36 <p style="color:var(--text-muted);margin:0;font-size:14px;">You don't have permission to view this page.</p>
37 </div>
38 </Layout>,
39 403
40 );
41 }
42 return { user };
43}
44
45const styles = `
46 .adm-stripe-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
47
48 .adm-stripe-hero {
49 position: relative;
50 margin-bottom: var(--space-5);
51 padding: var(--space-5) var(--space-6);
52 background: var(--bg-elevated);
53 border: 1px solid var(--border);
54 border-radius: 16px;
55 overflow: hidden;
56 }
57 .adm-stripe-hero::before {
58 content: '';
59 position: absolute;
60 top: 0; left: 0; right: 0;
61 height: 2px;
62 background: linear-gradient(90deg, transparent 0%, #818cf8 30%, #38bdf8 70%, transparent 100%);
63 opacity: 0.7;
64 pointer-events: none;
65 }
66 .adm-stripe-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
67 .adm-stripe-eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: #818cf8; margin-bottom: 6px; }
68 .adm-stripe-title { font-family: var(--font-display); font-size: clamp(24px,3.5vw,36px); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 4px; color: var(--text-strong); }
69 .adm-stripe-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
70 .adm-stripe-back { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 12.5px; color: var(--text-muted); background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px; text-decoration: none; font-weight: 500; transition: border-color 120ms,color 120ms,background 120ms; }
71 .adm-stripe-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
72
73 .adm-stripe-notice {
74 margin-bottom: var(--space-4);
75 padding: 12px 16px;
76 border-radius: 10px;
77 font-size: 13.5px;
78 border: 1px solid rgba(251,191,36,0.40);
79 background: rgba(251,191,36,0.06);
80 color: #fde68a;
81 line-height: 1.5;
82 }
83 .adm-stripe-notice code { font-family: var(--font-mono); font-size: 12.5px; background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 4px; }
84
85 .adm-stripe-banner { margin-bottom: var(--space-4); padding: 10px 14px; border-radius: 10px; font-size: 13.5px; border: 1px solid var(--border); background: rgba(255,255,255,0.025); color: var(--text); }
86 .adm-stripe-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
87 .adm-stripe-banner.is-err { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
88
89 .adm-stripe-table { width: 100%; border-collapse: collapse; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
90 .adm-stripe-table thead th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); padding: 10px 14px; background: rgba(255,255,255,0.015); border-bottom: 1px solid var(--border); }
91 .adm-stripe-table tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); font-size: 13px; color: var(--text); vertical-align: middle; }
92 .adm-stripe-table tbody tr:last-child td { border-bottom: none; }
93 .adm-stripe-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
94 .adm-stripe-table code { font-family: var(--font-mono); font-size: 11px; color: var(--text-strong); }
95
96 .adm-stripe-pill { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 700; }
97 .adm-stripe-pill-active { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
98 .adm-stripe-pill-warn { background: rgba(251,146,60,0.12); color: #fdba74; box-shadow: inset 0 0 0 1px rgba(251,146,60,0.32); }
99 .adm-stripe-pill-err { background: rgba(248,113,113,0.12); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35); }
100 .adm-stripe-pill-muted { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
101
102 .adm-stripe-mismatch-row td { background: rgba(251,146,60,0.05) !important; }
103
104 .adm-stripe-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; transition: background 120ms, border-color 120ms; border: 1px solid rgba(140,109,255,0.45); background: rgba(140,109,255,0.08); color: #c5b3ff; }
105 .adm-stripe-btn:hover { background: rgba(140,109,255,0.18); border-color: rgba(140,109,255,0.70); color: #e0d5ff; }
106
107 .adm-stripe-empty { padding: var(--space-6); text-align: center; color: var(--text-muted); font-size: 13.5px; background: var(--bg-elevated); border: 1px dashed var(--border); border-radius: 14px; }
108
109 @media (max-width: 720px) {
110 .adm-stripe-wrap { padding: var(--space-4) var(--space-3); }
111 .adm-stripe-hero { padding: var(--space-4); }
112 .adm-stripe-table { display: block; overflow-x: auto; }
113 }
114`;
115
116type StripeRow = {
117 userId: string;
118 username: string;
119 email: string;
120 localPlan: string;
121 stripeSubId: string | null;
122 stripeStatus: string | null;
123 stripePlan: string | null;
124 mismatch: boolean;
125 stripeError: string | null;
126};
127
128function statusPill(status: string | null, stripeKeySet: boolean) {
129 if (!stripeKeySet) {
130 return <span class="adm-stripe-pill adm-stripe-pill-muted">N/A</span>;
131 }
132 if (!status) {
133 return <span class="adm-stripe-pill adm-stripe-pill-muted">no sub</span>;
134 }
135 const lower = status.toLowerCase();
136 if (lower === "active" || lower === "trialing") {
137 return <span class="adm-stripe-pill adm-stripe-pill-active">{status}</span>;
138 }
139 if (lower === "past_due") {
140 return <span class="adm-stripe-pill adm-stripe-pill-warn">{status}</span>;
141 }
142 return <span class="adm-stripe-pill adm-stripe-pill-err">{status}</span>;
143}
144
145adminStripe.get("/admin/stripe", async (c) => {
146 const g = await gate(c);
147 if (g instanceof Response) return g;
148 const { user } = g;
149
150 const stripeKeySet = !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_SECRET_KEY.length >= 10);
151
152 // Fetch all non-free users (join users + user_quotas)
153 const nonFreeRows = await db
154 .select({
155 userId: userQuotas.userId,
156 planSlug: userQuotas.planSlug,
157 stripeCustomerId: userQuotas.stripeCustomerId,
158 stripeSubscriptionId: userQuotas.stripeSubscriptionId,
159 stripeSubscriptionStatus: userQuotas.stripeSubscriptionStatus,
160 username: users.username,
161 email: users.email,
162 })
163 .from(userQuotas)
164 .innerJoin(users, eq(userQuotas.userId, users.id))
165 .where(ne(userQuotas.planSlug, "free"))
166 .orderBy(desc(userQuotas.planSlug), users.username)
167 .limit(500);
168
169 // For each user, fetch live Stripe status (if key is set)
170 const rows: StripeRow[] = [];
171 for (const r of nonFreeRows) {
172 let stripeStatus: string | null = r.stripeSubscriptionStatus;
173 let stripePlan: string | null = null;
174 let stripeError: string | null = null;
175
176 if (stripeKeySet && r.stripeSubscriptionId) {
177 try {
178 const res = await getSubscription(r.stripeSubscriptionId);
179 if (res.ok) {
180 stripeStatus = res.subscription.status;
181 stripePlan = planSlugFromSubscription(res.subscription);
182 } else {
183 stripeError = res.error;
184 // Keep DB-cached status as fallback
185 }
186 } catch (err) {
187 stripeError = err instanceof Error ? err.message : String(err);
188 }
189 }
190
191 // Determine mismatch:
192 // - local plan is non-free but Stripe subscription is cancelled/past_due/missing
193 // - local plan is free but Stripe shows active (shouldn't happen but catch it)
194 const activeOnStripe =
195 stripeStatus === "active" || stripeStatus === "trialing";
196 const cancelledOnStripe =
197 stripeStatus && stripeStatus !== "active" && stripeStatus !== "trialing";
198
199 let mismatch = false;
200 if (stripeKeySet && r.stripeSubscriptionId) {
201 if (cancelledOnStripe && r.planSlug !== "free") mismatch = true;
202 }
203
204 rows.push({
205 userId: r.userId,
206 username: r.username,
207 email: r.email,
208 localPlan: r.planSlug,
209 stripeSubId: r.stripeSubscriptionId,
210 stripeStatus,
211 stripePlan,
212 mismatch,
213 stripeError,
214 });
215 }
216
217 const mismatches = rows.filter((r) => r.mismatch);
218
219 const msg = c.req.query("msg") || "";
220 const errMsg = c.req.query("err") || "";
221
222 return c.html(
223 <Layout title="Admin — Stripe Sync" user={user}>
224 <style dangerouslySetInnerHTML={{ __html: styles }} />
225 <div class="adm-stripe-wrap">
226 <div class="adm-stripe-hero">
227 <div class="adm-stripe-hero-inner">
228 <div>
229 <div class="adm-stripe-eyebrow">Admin / Billing</div>
230 <h1 class="adm-stripe-title">Stripe Sync</h1>
231 <p class="adm-stripe-sub">
232 Non-free users — local plan vs live Stripe subscription status.
233 {mismatches.length > 0 && ` ${mismatches.length} mismatch${mismatches.length > 1 ? "es" : ""} detected.`}
234 </p>
235 </div>
236 <a href="/admin" class="adm-stripe-back">← Admin</a>
237 </div>
238 </div>
239
240 {!stripeKeySet && (
241 <div class="adm-stripe-notice">
242 <strong>STRIPE_SECRET_KEY not configured</strong> — showing local plan data only.
243 Set <code>STRIPE_SECRET_KEY</code> in your environment to enable live Stripe sync.
244 </div>
245 )}
246
247 {msg && <div class="adm-stripe-banner is-ok">{msg}</div>}
248 {errMsg && <div class="adm-stripe-banner is-err">{errMsg}</div>}
249
250 {rows.length === 0 ? (
251 <div class="adm-stripe-empty">No non-free users found.</div>
252 ) : (
253 <table class="adm-stripe-table">
254 <thead>
255 <tr>
256 <th>Username</th>
257 <th>Email</th>
258 <th>Local plan</th>
259 <th>Stripe status</th>
260 <th>Stripe plan</th>
261 <th>Subscription ID</th>
262 <th>Mismatch</th>
263 {stripeKeySet && <th>Action</th>}
264 </tr>
265 </thead>
266 <tbody>
267 {rows.map((r) => (
268 <tr class={r.mismatch ? "adm-stripe-mismatch-row" : ""}>
269 <td>
270 <a href={`/${r.username}`} style="color:var(--accent);text-decoration:none;font-weight:600;">
271 {r.username}
272 </a>
273 </td>
274 <td style="color:var(--text-muted)">{r.email}</td>
275 <td>
276 <span class="adm-stripe-pill adm-stripe-pill-active">{r.localPlan}</span>
277 </td>
278 <td>{statusPill(r.stripeStatus, stripeKeySet)}</td>
279 <td>
280 {r.stripePlan
281 ? <span class="adm-stripe-pill adm-stripe-pill-active">{r.stripePlan}</span>
282 : <span style="color:var(--text-muted)">—</span>
283 }
284 </td>
285 <td>
286 {r.stripeSubId
287 ? <code>{r.stripeSubId.slice(0, 20)}…</code>
288 : <span style="color:var(--text-muted)">—</span>
289 }
290 {r.stripeError && (
291 <span style="color:#fca5a5;font-size:11px;display:block;margin-top:2px" title={r.stripeError}>
292 ⚠ fetch failed
293 </span>
294 )}
295 </td>
296 <td>
297 {r.mismatch
298 ? <span class="adm-stripe-pill adm-stripe-pill-warn">mismatch</span>
299 : <span class="adm-stripe-pill adm-stripe-pill-muted">ok</span>
300 }
301 </td>
302 {stripeKeySet && (
303 <td>
304 {r.mismatch ? (
305 <form method="post" action={`/admin/stripe/${r.userId}/sync`}>
306 <button type="submit" class="adm-stripe-btn"
307 title={`Update local plan to match Stripe (${r.stripeStatus ?? "no sub"})`}>
308 Fix
309 </button>
310 </form>
311 ) : (
312 <span style="color:var(--text-muted);font-size:12px">—</span>
313 )}
314 </td>
315 )}
316 </tr>
317 ))}
318 </tbody>
319 </table>
320 )}
321 </div>
322 </Layout>
323 );
324});
325
326// POST /admin/stripe/:userId/sync — update local plan to match Stripe
327adminStripe.post("/admin/stripe/:userId/sync", async (c) => {
328 const g = await gate(c);
329 if (g instanceof Response) return g;
330 const { user: adminUser } = g;
331
332 const targetUserId = c.req.param("userId");
333
334 // Fetch quota row
335 const quotaRows = await db
336 .select({
337 userId: userQuotas.userId,
338 planSlug: userQuotas.planSlug,
339 stripeSubscriptionId: userQuotas.stripeSubscriptionId,
340 stripeSubscriptionStatus: userQuotas.stripeSubscriptionStatus,
341 })
342 .from(userQuotas)
343 .where(eq(userQuotas.userId, targetUserId))
344 .limit(1);
345
346 const quota = quotaRows[0];
347 if (!quota) {
348 return c.redirect("/admin/stripe?err=" + encodeURIComponent("User quota row not found."));
349 }
350
351 // Determine the correct plan from Stripe
352 let newPlan: string = "free";
353 let stripeStatus: string | null = null;
354
355 if (!quota.stripeSubscriptionId) {
356 newPlan = "free";
357 } else {
358 try {
359 const res = await getSubscription(quota.stripeSubscriptionId);
360 if (res.ok) {
361 stripeStatus = res.subscription.status;
362 const isActive = stripeStatus === "active" || stripeStatus === "trialing";
363 if (isActive) {
364 const slug = planSlugFromSubscription(res.subscription);
365 newPlan = slug ?? "free";
366 } else {
367 newPlan = "free";
368 }
369 } else {
370 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`Stripe API error: ${res.error}`));
371 }
372 } catch (err) {
373 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`Stripe request failed: ${err instanceof Error ? err.message : String(err)}`));
374 }
375 }
376
377 // Update local plan
378 try {
379 await db
380 .update(userQuotas)
381 .set({
382 planSlug: newPlan,
383 stripeSubscriptionStatus: stripeStatus ?? quota.stripeSubscriptionStatus,
384 updatedAt: new Date(),
385 })
386 .where(eq(userQuotas.userId, targetUserId));
387 } catch (err) {
388 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`DB update failed: ${err instanceof Error ? err.message : String(err)}`));
389 }
390
391 await audit({
392 userId: adminUser.id,
393 action: "billing.admin_sync",
394 targetType: "user",
395 targetId: targetUserId,
396 metadata: {
397 oldPlan: quota.planSlug,
398 newPlan,
399 stripeStatus: stripeStatus ?? "unknown",
400 triggeredBy: adminUser.id,
401 },
402 });
403
404 return c.redirect(
405 "/admin/stripe?msg=" +
406 encodeURIComponent(`Plan updated: ${quota.planSlug} → ${newPlan} (Stripe status: ${stripeStatus ?? "unknown"})`)
407 );
408});
409
410export default adminStripe;
Modifiedsrc/routes/admin.tsx+8−0View fileUnifiedSplit
@@ -2872,6 +2872,14 @@ admin.get("/admin", async (c) => {
28722872 <span class="admin-action-icon">{Icons.bot}</span>
28732873 Connect Claude
28742874 </a>
2875 <a href="/admin/deletions" class="admin-action">
2876 <span class="admin-action-icon">{Icons.flag}</span>
2877 GDPR Deletions
2878 </a>
2879 <a href="/admin/stripe" class="admin-action">
2880 <span class="admin-action-icon">{Icons.key}</span>
2881 Stripe Sync
2882 </a>
28752883 <form
28762884 method="post"
28772885 action="/admin/demo/reseed"
28782886