Skip to content

Commit 571847d

Browse files
ecmadaoclaude
andauthored
fix: auto-refresh subscritpion after canceled (#20049)
* refactor: additional_bindings for GetActuatorInfo and ListIdentityProviders * proto: add EMAIL setting name, EmailSetting message, and TestEmailSetting RPC Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: register EMAIL setting in store and add converter functions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(api): add EMAIL validation in UpdateSetting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(plugin): add mail sender plugin with SMTP implementation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(api): implement TestEmailSetting RPC Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): inject EMAIL_CONFIG env var into workspace settings on creation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update * fix: conflict * chore: optimize group create panel * chore: update * chore: optimize * feat: init email code signin design * chore: update * chore: update * chore: update * chore: update * chore: update * feat: init proto * feat: implement email_verification_code store * chore: resolve comment * feat: implement email code login and reset password * feat: implement ux * chore: optimize ux * chore: optimize ux * fix: test * fix: lint * chore: update * chore: update * chore: update * chore: update * fix: test * fix: auto-refresh subscritpion after canceled --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 82de99f commit 571847d

4 files changed

Lines changed: 52 additions & 25 deletions

File tree

frontend/src/components/EmailCodeSigninForm.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ const sendCode = async () => {
171171
};
172172
173173
const handleSubmit = async () => {
174+
if (step.value === "email") {
175+
await sendCode();
176+
return;
177+
}
174178
const code = state.codeParts.join("");
175179
if (!state.email || code.length !== 6) return;
176180
emit(

frontend/src/react/pages/settings/PurchaseSection.tsx

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -117,26 +117,20 @@ export function PurchaseSection({ onRequireEnterprise }: PurchaseSectionProps) {
117117
const sessionId = params.get("session_id");
118118
if (!sessionId || !allowManage) return;
119119

120-
let cancelled = false;
120+
const controller = new AbortController();
121121
(async () => {
122122
try {
123123
const status = await subscriptionStore.verifyCheckoutSession(sessionId);
124-
if (status !== "complete" || cancelled) return;
124+
if (status !== "complete" || controller.signal.aborted) return;
125125

126-
// Poll without updating the store to avoid UI flash.
127-
// On first check, if subscription is already non-FREE (webhook arrived before page load),
128-
// we update the store and skip polling immediately.
129126
setPendingPayment(true);
130-
for (let i = 0; i < 30; i++) {
131-
if (cancelled) break;
132-
const sub = await subscriptionStore.fetchSubscription(false);
133-
if (sub && sub.plan !== PlanType.FREE) {
134-
subscriptionStore.setSubscription(sub);
135-
break;
136-
}
137-
await new Promise((r) => setTimeout(r, 2000));
127+
await subscriptionStore.pollSubscriptionUntil(
128+
(sub) => sub.plan !== PlanType.FREE,
129+
{ signal: controller.signal }
130+
);
131+
if (!controller.signal.aborted) {
132+
setPendingPayment(false);
138133
}
139-
setPendingPayment(false);
140134
} catch (e) {
141135
console.error("failed to verify checkout session", e);
142136
}
@@ -145,7 +139,7 @@ export function PurchaseSection({ onRequireEnterprise }: PurchaseSectionProps) {
145139
})();
146140

147141
return () => {
148-
cancelled = true;
142+
controller.abort();
149143
};
150144
}, []);
151145

@@ -306,16 +300,11 @@ export function PurchaseSection({ onRequireEnterprise }: PurchaseSectionProps) {
306300
if (paymentUrl) {
307301
window.location.href = paymentUrl;
308302
} else {
309-
// Direct update — poll without updating store to avoid UI flashing.
303+
// Direct update — wait for the webhook-driven reconciliation.
310304
setPendingPayment(true);
311-
for (let i = 0; i < 30; i++) {
312-
const sub = await subscriptionStore.fetchSubscription(false);
313-
if (sub && sub.plan !== PlanType.FREE && sub.seats === seats) {
314-
subscriptionStore.setSubscription(sub);
315-
break;
316-
}
317-
await new Promise((r) => setTimeout(r, 2000));
318-
}
305+
await subscriptionStore.pollSubscriptionUntil(
306+
(sub) => sub.plan !== PlanType.FREE && sub.seats === seats
307+
);
319308
setPendingPayment(false);
320309
}
321310
} catch (e) {
@@ -330,6 +319,11 @@ export function PurchaseSection({ onRequireEnterprise }: PurchaseSectionProps) {
330319
setCanceling(true);
331320
try {
332321
await subscriptionStore.cancelPurchase();
322+
// Wait for the Stripe webhook to reconcile before releasing the UI,
323+
// so the cached subscription/license reflects the new state.
324+
await subscriptionStore.pollSubscriptionUntil(
325+
(sub) => sub.plan === PlanType.FREE
326+
);
333327
pushNotification({
334328
module: "bytebase",
335329
style: "SUCCESS",

frontend/src/store/modules/v1/subscription.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,34 @@ export const useSubscriptionV1Store = defineStore("subscription_v1", () => {
231231
}
232232
};
233233

234+
// Poll GetSubscription until predicate returns true, timeout, or abort.
235+
// Used after webhook-driven state changes (purchase, update, cancel) to reflect
236+
// the new subscription in the store without requiring a manual page refresh.
237+
// On match, the store is updated and the subscription is returned. Returns
238+
// undefined on timeout or abort without mutating the store.
239+
const pollSubscriptionUntil = async (
240+
predicate: (sub: Subscription) => boolean,
241+
options: {
242+
timeoutMs?: number;
243+
intervalMs?: number;
244+
signal?: AbortSignal;
245+
} = {}
246+
): Promise<Subscription | undefined> => {
247+
const { timeoutMs = 60_000, intervalMs = 2_000, signal } = options;
248+
const deadline = Date.now() + timeoutMs;
249+
while (Date.now() < deadline) {
250+
if (signal?.aborted) return undefined;
251+
const sub = await fetchSubscription(false);
252+
if (signal?.aborted) return undefined;
253+
if (sub && predicate(sub)) {
254+
setSubscription(sub);
255+
return sub;
256+
}
257+
await new Promise((r) => setTimeout(r, intervalMs));
258+
}
259+
return undefined;
260+
};
261+
234262
const uploadLicense = async (license: string) => {
235263
const request = create(UploadLicenseRequestSchema, {
236264
license,
@@ -338,6 +366,7 @@ export const useSubscriptionV1Store = defineStore("subscription_v1", () => {
338366
instanceMissingLicense,
339367
getMinimumRequiredPlan,
340368
fetchSubscription,
369+
pollSubscriptionUntil,
341370
uploadLicense,
342371
setSubscription,
343372
// Purchase actions (SaaS)

frontend/src/views/auth/Signin.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ const showSignInForm = computed(() => {
280280
return (
281281
!serverInfo.value?.restriction?.disallowPasswordSignin ||
282282
groupedIdentityProviderList.value.length > 0 ||
283-
serverInfo.value.restriction.allowEmailCodeSignin
283+
serverInfo.value?.restriction?.allowEmailCodeSignin
284284
);
285285
});
286286

0 commit comments

Comments
 (0)