Payment return decided by the server only; encoded path ids; no sign-out on a two-tab refresh race

- ?orderId must be numeric; every id in an API path is encodeURIComponent'd.
- The payment sheet shows success only from GET /payment/orders/{id}; a failed lookup says the
  status is unknown instead of "paid".
- A 401 from /auth/refresh is not a sign-out when another tab already stored a rotated token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 04:52:25 +03:30
parent 647ccf2c88
commit e850f6dfbd
4 changed files with 50 additions and 22 deletions

View File

@@ -202,7 +202,13 @@ export function createHttpClient(options: HttpClientOptions) {
} catch { } catch {
return new ApiError(0, 'ارتباط با سرور برقرار نشد'); return new ApiError(0, 'ارتباط با سرور برقرار نشد');
} }
if (response.status === 401) return 'invalid'; if (response.status === 401) {
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل می‌کند) و توکنِ تازه
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
const latest = storage.get();
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
return 'invalid';
}
if (!response.ok) return errorFrom(response, await parseBody(response)); if (!response.ok) return errorFrom(response, await parseBody(response));
const data = (await response.json()) as Tokens; const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken }; const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };

View File

@@ -22,6 +22,9 @@ function saveTokens(data: T.AuthResponse) {
return data; return data;
} }
/** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
const seg = (value: string | number) => encodeURIComponent(String(value));
export const api = { export const api = {
auth: { auth: {
isSignedIn: () => Boolean(http.storage.get()?.token), isSignedIn: () => Boolean(http.storage.get()?.token),
@@ -85,37 +88,37 @@ export const api = {
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'), home: () => http.get<T.HomeResponse>('/home'),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`), nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/** /**
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر * آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر
* کاربر ثابت است، پس رفرش همان آزمون را می‌دهد. * کاربر ثابت است، پس رفرش همان آزمون را می‌دهد.
*/ */
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${nodeId}/quiz`), nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
/** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */ /** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.NodeQuizComplete>(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }), http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
lesson: { lesson: {
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`), intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
questions: (lessonId: number) => questions: (lessonId: number) =>
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions), http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions),
/** /**
* کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا * کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا
* امتیاز و قلب دوبار حساب نشود. * امتیاز و قلب دوبار حساب نشود.
*/ */
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) => answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
http.post<T.AnswerResult>( http.post<T.AnswerResult>(
`/questions/${questionId}/answer`, `/questions/${seg(questionId)}/answer`,
{ selectedOptionId: Number(selectedOptionId) }, { selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true }, { idempotencyKey, retry: true },
), ),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */ /** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.LessonComplete>(`/lessons/${lessonId}/complete`, result, { idempotencyKey }), http.create<T.LessonComplete>(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }),
report: (questionId: string, reason: T.ReportReason, note?: string) => report: (questionId: string, reason: T.ReportReason, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>( http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${questionId}/report`, `/questions/${seg(questionId)}/report`,
{ reason, ...(note ? { note } : {}) }, { reason, ...(note ? { note } : {}) },
), ),
}, },
@@ -157,19 +160,19 @@ export const api = {
return done; return done;
}, },
feedback: (messageId: string, isHelpful: boolean, reason?: string) => feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { http.post<{ success: boolean }>(`/ai/messages/${seg(messageId)}/feedback`, {
isHelpful, isHelpful,
...(reason ? { reason } : {}), ...(reason ? { reason } : {}),
}), }),
history: (conversationId: string) => history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>( http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
`/ai/conversations/${conversationId}/messages`, `/ai/conversations/${seg(conversationId)}/messages`,
), ),
}, },
notifications: { notifications: {
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'), list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
markRead: (id: number) => http.patch(`/notifications/${id}/read`), markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
markAllRead: () => http.patch('/notifications/read-all'), markAllRead: () => http.patch('/notifications/read-all'),
}, },
@@ -190,7 +193,7 @@ export const api = {
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) => redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }), http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`), order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
/** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */ /** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices), invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
}, },
@@ -200,9 +203,9 @@ export const api = {
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }), http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
/** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */ /** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */
tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets), tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${ticketId}`), ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
reply: (ticketId: string, text: string) => reply: (ticketId: string, text: string) =>
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }), http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }),
bugReport: (title: string, description: string) => bugReport: (title: string, description: string) =>
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
}, },

View File

@@ -144,28 +144,46 @@ function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
); );
} }
/** نتیجه‌ی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده می‌شود. */ /**
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد.
*/
function PaymentResult({ payment }: { payment: PaymentReturn }) { function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData(); const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [paid, setPaid] = useState(payment.status === 'paid'); const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => { useEffect(() => {
api.subscription api.subscription
.order(payment.orderId) .order(payment.orderId)
.then((order) => setPaid(order.status === 'paid')) .then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
.catch(() => {}); .catch((err: unknown) => {
console.warn('payment result lookup failed', err);
setState('unknown');
});
void refreshSummary(); void refreshSummary();
}, [payment.orderId, refreshSummary]); }, [payment.orderId, refreshSummary]);
if (state === 'checking') {
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<LoadingState text="دارم پرداخت رو بررسی می‌کنم…" />
</Sheet>
);
}
const paid = state === 'paid';
return ( return (
<Sheet open={open} onClose={() => setOpen(false)}> <Sheet open={open} onClose={() => setOpen(false)}>
<div className="flex flex-col items-center gap-3 pb-2 text-center"> <div className="flex flex-col items-center gap-3 pb-2 text-center">
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />} {paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2> <h2 className="text-lg font-black text-ink">
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2>
<p className="text-sm leading-7 text-muted"> <p className="text-sm leading-7 text-muted">
{paid {paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.' ? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: state === 'unknown'
? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'} : 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p> </p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton> <PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>

View File

@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
if (ref || orderId) { if (ref || orderId) {
window.history.replaceState(null, '', window.location.pathname); window.history.replaceState(null, '', window.location.pathname);
} }
return orderId ? { orderId, status: status ?? '' } : null; // فقط شماره‌ی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است.
return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null;
} }
/** یک‌بار، هنگامِ بار شدنِ اپ. */ /** یک‌بار، هنگامِ بار شدنِ اپ. */