Harden platform security, reliability, and CI after full audit.
Close deployment IDOR and gate stub payment endpoints, add production secret validation, health probes, Redis-backed build progress, GitHub Actions CI, expanded tests, billing/k8s refactors, and ops runbooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { loginPath } from '@/lib/locale-url';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -55,7 +56,7 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = loginPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||
|
||||
export const deploymentStatusBadgeClass: Record<string, string> = {
|
||||
pending: 'badge-gray',
|
||||
building: 'badge-yellow',
|
||||
deploying: 'badge-blue',
|
||||
running: 'badge-green',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
export const lifecycleStatusClass: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
export function deploymentStatusLabel(status: string, t: Dictionary): string {
|
||||
return (t.dashboard.status as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { classifyApiError } from './errors';
|
||||
|
||||
describe('classifyApiError', () => {
|
||||
it('classifies network errors', () => {
|
||||
const err = new axios.AxiosError('Network Error');
|
||||
expect(classifyApiError(err).kind).toBe('network');
|
||||
});
|
||||
|
||||
it('classifies 403 forbidden', () => {
|
||||
const err = new axios.AxiosError('Forbidden', undefined, undefined, undefined, {
|
||||
status: 403,
|
||||
data: { message: 'Forbidden' },
|
||||
statusText: 'Forbidden',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
});
|
||||
expect(classifyApiError(err).kind).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('classifies generic errors', () => {
|
||||
expect(classifyApiError(new Error('oops')).kind).toBe('generic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatRemainingForLocale,
|
||||
isPersianLocale,
|
||||
parseCpuToMillicores,
|
||||
} from './format-utils';
|
||||
|
||||
describe('isPersianLocale', () => {
|
||||
it('matches fa-IR and legacy fa', () => {
|
||||
expect(isPersianLocale('fa-IR')).toBe(true);
|
||||
expect(isPersianLocale('fa')).toBe(true);
|
||||
expect(isPersianLocale('en-US')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRemainingForLocale', () => {
|
||||
it('uses Persian formatting for fa-IR', () => {
|
||||
const future = new Date(Date.now() + 2 * 86400000 + 3600000);
|
||||
const result = formatRemainingForLocale(future, 'fa-IR');
|
||||
expect(result).toMatch(/روز/);
|
||||
});
|
||||
|
||||
it('uses English formatting for en-US', () => {
|
||||
const future = new Date(Date.now() + 2 * 86400000);
|
||||
const result = formatRemainingForLocale(future, 'en-US');
|
||||
expect(result).toMatch(/\d+d/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCpuToMillicores', () => {
|
||||
it('parses millicores and cores', () => {
|
||||
expect(parseCpuToMillicores('500m')).toBe(500);
|
||||
expect(parseCpuToMillicores('1')).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,10 @@ export function parseMemoryToMi(mem: string): number {
|
||||
return parseFloat(mem);
|
||||
}
|
||||
|
||||
/** Human-readable time left (days, hours, minutes). */
|
||||
/** True when locale is Persian (fa-IR or legacy "fa"). */
|
||||
export function isPersianLocale(locale: string): boolean {
|
||||
return locale === 'fa' || locale.startsWith('fa-');
|
||||
}
|
||||
export function formatRemainingDurationMs(remainingMs: number): string {
|
||||
const ms = Math.max(0, remainingMs);
|
||||
const days = Math.floor(ms / 86400000);
|
||||
@@ -54,7 +57,7 @@ export function formatRemainingForLocale(
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const remainingMs = date.getTime() - Date.now();
|
||||
return locale === 'fa'
|
||||
return isPersianLocale(locale)
|
||||
? formatRemainingDurationFa(remainingMs)
|
||||
: formatRemainingDurationMs(remainingMs);
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function formatExpiresAtLocal(
|
||||
): string {
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
if (locale === 'fa') {
|
||||
if (isPersianLocale(locale || '')) {
|
||||
// Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
|
||||
// of the runtime's ICU pattern data.
|
||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getClientLocale, localizedPath, loginPath } from './locale-url';
|
||||
import { defaultLocale, LOCALE_COOKIE } from '@/i18n/config';
|
||||
|
||||
describe('locale-url', () => {
|
||||
const originalDocument = global.document;
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom-less: stub document.cookie
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: { cookie: '' },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: originalDocument,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default locale when cookie is missing', () => {
|
||||
expect(getClientLocale()).toBe(defaultLocale);
|
||||
});
|
||||
|
||||
it('reads locale from NEXT_LOCALE cookie', () => {
|
||||
document.cookie = `${LOCALE_COOKIE}=en-US`;
|
||||
expect(getClientLocale()).toBe('en-US');
|
||||
});
|
||||
|
||||
it('builds localized paths', () => {
|
||||
expect(localizedPath('/dashboard/wallet', 'fa-IR')).toBe('/fa-IR/dashboard/wallet');
|
||||
expect(loginPath('en-US')).toBe('/en-US/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defaultLocale, isLocale, LOCALE_COOKIE, type Locale } from '@/i18n/config';
|
||||
|
||||
/** Read the active locale from cookie (client-only). */
|
||||
export function getClientLocale(): Locale {
|
||||
if (typeof document === 'undefined') {
|
||||
return defaultLocale;
|
||||
}
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${LOCALE_COOKIE}=([^;]*)`));
|
||||
const value = match ? decodeURIComponent(match[1]) : '';
|
||||
return isLocale(value) ? value : defaultLocale;
|
||||
}
|
||||
|
||||
/** Build an absolute URL with the locale prefix, e.g. /fa-IR/dashboard/wallet */
|
||||
export function localizedPath(path: string, locale?: Locale): string {
|
||||
const loc = locale ?? getClientLocale();
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `/${loc}${normalized}`;
|
||||
}
|
||||
|
||||
/** Absolute origin + localized path for payment gateway callbacks. */
|
||||
export function localizedCallbackUrl(path: string, locale?: Locale): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return localizedPath(path, locale);
|
||||
}
|
||||
return `${window.location.origin}${localizedPath(path, locale)}`;
|
||||
}
|
||||
|
||||
/** Localized login path for auth redirects. */
|
||||
export function loginPath(locale?: Locale): string {
|
||||
return localizedPath('/login', locale);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Central React Query keys — keep invalidation consistent across pages. */
|
||||
export const queryKeys = {
|
||||
walletBalance: ['wallet-balance'] as const,
|
||||
walletTransactions: ['wallet-transactions'] as const,
|
||||
applications: (productType?: string) =>
|
||||
productType ? (['applications', productType] as const) : (['applications'] as const),
|
||||
};
|
||||
Reference in New Issue
Block a user