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 {
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));
const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };

View File

@@ -22,6 +22,9 @@ function saveTokens(data: T.AuthResponse) {
return data;
}
/** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
const seg = (value: string | number) => encodeURIComponent(String(value));
export const api = {
auth: {
isSignedIn: () => Boolean(http.storage.get()?.token),
@@ -85,37 +88,37 @@ export const api = {
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
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) =>
http.create<T.NodeQuizComplete>(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }),
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
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) =>
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()) =>
http.post<T.AnswerResult>(
`/questions/${questionId}/answer`,
`/questions/${seg(questionId)}/answer`,
{ selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true },
),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
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) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${questionId}/report`,
`/questions/${seg(questionId)}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
@@ -157,19 +160,19 @@ export const api = {
return done;
},
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,
...(reason ? { reason } : {}),
}),
history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
`/ai/conversations/${conversationId}/messages`,
`/ai/conversations/${seg(conversationId)}/messages`,
),
},
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'),
},
@@ -190,7 +193,7 @@ export const api = {
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) =>
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),
},
@@ -200,9 +203,9 @@ export const api = {
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
/** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */
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) =>
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) =>
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 }) {
const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true);
const [paid, setPaid] = useState(payment.status === 'paid');
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => {
api.subscription
.order(payment.orderId)
.then((order) => setPaid(order.status === 'paid'))
.catch(() => {});
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
.catch((err: unknown) => {
console.warn('payment result lookup failed', err);
setState('unknown');
});
void refreshSummary();
}, [payment.orderId, refreshSummary]);
if (state === 'checking') {
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<LoadingState text="دارم پرداخت رو بررسی می‌کنم…" />
</Sheet>
);
}
const paid = state === 'paid';
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<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" />}
<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">
{paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: state === 'unknown'
? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>

View File

@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
if (ref || orderId) {
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;
}
/** یک‌بار، هنگامِ بار شدنِ اپ. */