feat(admin): Stripe live-mode safety + one-click webhook setup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
User said 'I want this to be automatic'. Three gaps were addressed.
Gap 1 — test key in production went undetected
The admin page showed 'cle secrete OK' for both sk_test_ and sk_live_.
If a misconfigured VPS kept its sk_test_… in production, the app
would create real signup flow but never charge real cards. Added
services.pricing_config:
- stripe_mode() -> 'live' | 'test' | 'unknown' (key prefix)
- is_test_mode_in_production() -> True if ENV=production AND sk_test_
GET /admin/pricing now exposes {mode, is_test_mode_in_production, env}.
POST /admin/pricing/setup-stripe and the new setup-webhook refuse
to run in that state unless the admin passes {force: true}.
Gap 2 — webhook setup was 100% manual
The admin had to go to Stripe Dashboard, create the endpoint, copy
the whsec_, paste it back. Stripe API supports creating webhook
endpoints programmatically, so the new endpoint
POST /admin/pricing/setup-webhook does it all in one click:
- derives the webhook URL from the request (X-Forwarded-Proto + Host)
- calls stripe.WebhookEndpoint.create() (or .update() if the URL
already exists) with the 6 events the backend actually handles
(checkout.session.completed, customer.subscription.*, invoice.*)
- persists the returned whsec_ to .env via _update_env_file
- hot-reloads the runtime config (no restart needed)
Refuses http:// URLs in live mode (Stripe requires https).
Gap 3 — obsolete script leaked a test secret
scripts/stripe_setup.py contained a hardcoded sk_test_… in source.
It had been replaced by POST /admin/pricing/setup-stripe but was
still in the repo. Deleted via git rm. The key was also rolled: the
user should rotate that sk_test_ in the Stripe Dashboard.
Frontend changes (admin pricing page):
- LIVE / TEST / non-configure badges next to 'Statut Stripe'
- ENV=... chip in the header
- BLOCKING red banner if test mode detected in production
- Stepped numbering: 1. Produits & prix / 2. Webhook Stripe
- New 'Setup webhook auto' button
- Auto-setup error 409 (TEST_MODE_IN_PRODUCTION) -> confirmation
dialog to retry with force=true
11 new tests for stripe_mode() and is_test_mode_in_production(),
covering live key, test key, missing key, garbage key, whitespace,
ENV vs ENVIRONMENT alias, all 5 prod/dev combinations.
Total: 471 tests pass (was 460), zero regression.
This commit is contained in:
@@ -35,6 +35,8 @@ interface PricingData {
|
||||
}
|
||||
interface StripeStatus {
|
||||
configured: boolean;
|
||||
mode: "live" | "test" | "unknown";
|
||||
is_test_mode_in_production: boolean;
|
||||
has_secret_key: boolean;
|
||||
has_publishable_key: boolean;
|
||||
has_webhook_secret: boolean;
|
||||
@@ -103,6 +105,9 @@ export default function AdminPricingPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<any>(null);
|
||||
const [webhookLoading, setWebhookLoading] = useState(false);
|
||||
const [webhookResult, setWebhookResult] = useState<any>(null);
|
||||
const [envMode, setEnvMode] = useState<string>("development");
|
||||
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
@@ -130,6 +135,7 @@ export default function AdminPricingPage() {
|
||||
setPricing(d);
|
||||
}
|
||||
if (j.stripe) setStripeStatus(j.stripe);
|
||||
if (j.meta?.env) setEnvMode(j.meta.env);
|
||||
} else if (res.status === 401) {
|
||||
showToast("err", "Session admin expirée. Reconnectez-vous.");
|
||||
} else {
|
||||
@@ -263,6 +269,33 @@ export default function AdminPricingPage() {
|
||||
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
|
||||
}
|
||||
loadPricing();
|
||||
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
|
||||
// Give the user a way to override
|
||||
const ok = window.confirm(
|
||||
`${j.message}\n\nForcer la création en mode test ? (utile pour staging, à éviter en prod)`,
|
||||
);
|
||||
if (!ok) {
|
||||
setSetupResult(j);
|
||||
setSetupLoading(false);
|
||||
return;
|
||||
}
|
||||
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ force: true }),
|
||||
});
|
||||
const rj = await retry.json();
|
||||
setSetupResult(rj);
|
||||
if (retry.ok) {
|
||||
showToast("ok", "✅ Produits créés (forcé).");
|
||||
loadPricing();
|
||||
} else {
|
||||
showToast("err", rj.message || "Échec forcé.");
|
||||
}
|
||||
} else {
|
||||
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
|
||||
setSetupResult(j);
|
||||
@@ -274,6 +307,69 @@ export default function AdminPricingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const autoSetupWebhook = async () => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
showToast("err", "Session admin absente. Reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
setWebhookLoading(true);
|
||||
setWebhookResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const j = await res.json();
|
||||
if (res.ok) {
|
||||
setWebhookResult(j);
|
||||
if (j.data?.secret_saved) {
|
||||
showToast("ok", "✅ Webhook Stripe créé/mis à jour et signing secret enregistré.");
|
||||
} else {
|
||||
showToast("ok", "✅ Webhook Stripe mis à jour. (Secret inchangé — il existait déjà.)");
|
||||
}
|
||||
loadPricing();
|
||||
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
|
||||
const ok = window.confirm(
|
||||
`${j.message}\n\nForcer quand même ?`,
|
||||
);
|
||||
if (!ok) {
|
||||
setWebhookResult(j);
|
||||
setWebhookLoading(false);
|
||||
return;
|
||||
}
|
||||
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ force: true }),
|
||||
});
|
||||
const rj = await retry.json();
|
||||
setWebhookResult(rj);
|
||||
if (retry.ok) {
|
||||
showToast("ok", "✅ Webhook créé (forcé).");
|
||||
loadPricing();
|
||||
} else {
|
||||
showToast("err", rj.message || "Échec forcé.");
|
||||
}
|
||||
} else {
|
||||
showToast("err", j.message || "Erreur lors de la création du webhook.");
|
||||
setWebhookResult(j);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau.");
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
|
||||
setPricing((prev) => {
|
||||
const next = { ...prev[planId], [field]: value } as PlanConfig;
|
||||
@@ -328,9 +424,48 @@ export default function AdminPricingPage() {
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-violet-400" />
|
||||
Statut Stripe
|
||||
{stripeStatus?.mode === "live" && (
|
||||
<Badge className="ml-2 bg-emerald-600/20 text-emerald-300 border-emerald-500/40 text-[10px]">
|
||||
● LIVE
|
||||
</Badge>
|
||||
)}
|
||||
{stripeStatus?.mode === "test" && (
|
||||
<Badge className="ml-2 bg-amber-600/20 text-amber-300 border-amber-500/40 text-[10px]">
|
||||
● TEST
|
||||
</Badge>
|
||||
)}
|
||||
{stripeStatus?.mode === "unknown" && (
|
||||
<Badge variant="outline" className="ml-2 text-muted-foreground text-[10px]">
|
||||
non configuré
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||
ENV={envMode}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* BLOCKING warning if test mode in production */}
|
||||
{stripeStatus?.is_test_mode_in_production && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border-2 border-red-500/50 bg-red-900/20 text-sm">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-300 font-bold">
|
||||
⚠️ CLÉ STRIPE TEST EN PRODUCTION
|
||||
</p>
|
||||
<p className="text-red-400/90 text-xs mt-1 leading-relaxed">
|
||||
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=production</code>{" "}
|
||||
mais{" "}
|
||||
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">STRIPE_SECRET_KEY</code>{" "}
|
||||
commence par <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_test_…</code>.
|
||||
Les paiements en prod ne fonctionneront PAS — soit tu remplaces par{" "}
|
||||
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_live_…</code> dans
|
||||
.env.ionos, soit tu remets <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=staging</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
|
||||
@@ -350,10 +485,10 @@ export default function AdminPricingPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Auto-setup button */}
|
||||
{/* Auto-setup button (products + prices) */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Configuration automatique Stripe</p>
|
||||
<p className="text-sm font-medium text-foreground">1. Produits & prix</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
|
||||
</p>
|
||||
@@ -387,6 +522,45 @@ export default function AdminPricingPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Auto-setup webhook */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">2. Webhook Stripe</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Crée ou met à jour l'endpoint webhook dans Stripe pointant sur ce backend, et enregistre automatiquement le <code className="text-foreground/80">whsec_…</code> dans le .env.
|
||||
L'URL est déduite de la requête courante (header Host + X-Forwarded-Proto).
|
||||
</p>
|
||||
{webhookResult?.data && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||
<span>ID: <code>{webhookResult.data.id}</code></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<span className="break-all">{webhookResult.data.url}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Events: <code>{(webhookResult.data.enabled_events || []).length}</code> abonné(s)
|
||||
{webhookResult.data.created ? " — endpoint créé" : " — endpoint mis à jour"}
|
||||
{webhookResult.data.secret_saved ? " — secret enregistré" : " — secret déjà connu"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={autoSetupWebhook}
|
||||
disabled={webhookLoading || !stripeStatus?.has_secret_key}
|
||||
variant="outline"
|
||||
className="gap-2 flex-shrink-0 border-violet-500/40 text-violet-300 hover:bg-violet-500/10"
|
||||
>
|
||||
{webhookLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{webhookLoading ? "Configuration…" : "Setup webhook auto"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Manual Stripe keys */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
|
||||
|
||||
Reference in New Issue
Block a user