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:
keyhan
2026-06-29 20:59:49 +03:30
parent a87bc49393
commit 837f0fa63f
83 changed files with 3953 additions and 1308 deletions
+15
View File
@@ -0,0 +1,15 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const compat = new FlatCompat({
baseDirectory: dirname(fileURLToPath(import.meta.url)),
});
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: ['.next/**', 'node_modules/**'],
},
...compat.extends('next/core-web-vitals', 'next/typescript'),
];
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1457 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
@@ -36,8 +39,10 @@
"@types/three": "^0.184.1",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9",
"@eslint/eslintrc": "^3.3.1",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import type { Application, Deployment, ResourceUsage } from '@/types';
/** Core data queries for the application detail page. */
export function useAppQueries(appId: string, enabled = true) {
const appQuery = useQuery<Application>({
queryKey: ['application', appId],
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
enabled: enabled && !!appId,
});
const deploymentsQuery = useQuery<Deployment[]>({
queryKey: ['deployments', appId],
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
enabled: enabled && !!appId,
});
const usageQuery = useQuery<ResourceUsage>({
queryKey: ['resource-usage', appId],
queryFn: () => api.get(`/applications/${appId}/resources/usage`).then((r) => r.data),
enabled: enabled && !!appId && appQuery.data?.lifecycleStatus === 'active',
refetchInterval: 30_000,
});
const walletQuery = useQuery<{ balance: number }>({
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
enabled,
});
return { appQuery, deploymentsQuery, usageQuery, walletQuery };
}
@@ -3,6 +3,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import api from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { notify } from '@/lib/notify';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react';
@@ -250,7 +251,7 @@ export default function AppDetailPage() {
// ─── Billing & Renewal ──────────────────────────────
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
@@ -271,7 +272,7 @@ export default function AppDetailPage() {
onSuccess: (res) => {
notify.success(res.data.message || 'Application renewed successfully!');
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
setShowRenewalModal(false);
setRenewCoupon('');
},
@@ -646,7 +647,7 @@ export default function AppDetailPage() {
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
@@ -2134,22 +2135,22 @@ export default function AppDetailPage() {
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-200">
<th className="pb-1 font-medium">{ad.name}</th>
<th className="pb-1 font-medium">{ad.status}</th>
<th className="pb-1 font-medium">{ad.ready}</th>
<th className="pb-1 font-medium">R</th>
<tr className="text-start text-gray-500 border-b border-gray-200">
<th className="pb-1 font-medium text-start">{ad.name}</th>
<th className="pb-1 font-medium text-start">{ad.status}</th>
<th className="pb-1 font-medium text-start">{ad.ready}</th>
<th className="pb-1 font-medium text-start">{ad.restarts}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{w.pods.map((pod) => (
<tr key={pod.name} className="text-gray-700">
<td className="py-1 font-mono truncate max-w-[140px]" title={pod.name}>{pod.name}</td>
<td className="py-1">
<td className="py-1 text-start font-mono truncate max-w-[140px]" dir="ltr" title={pod.name}>{pod.name}</td>
<td className="py-1 text-start">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${pod.status === 'Running' ? 'bg-green-100 text-green-700' : pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>{pod.status}</span>
</td>
<td className="py-1">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
<td className="py-1">{pod.restarts}</td>
<td className="py-1 text-start">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
<td className="py-1 text-start">{pod.restarts}</td>
</tr>
))}
</tbody>
@@ -15,29 +15,19 @@ import {
} from '@/components/deleting-overlay';
import { filterApplications } from '@/lib/product-type';
import { useApplicationDelete } from '@/lib/use-application-delete';
import {
deploymentStatusBadgeClass,
deploymentStatusLabel,
lifecycleStatusClass,
} from '@/lib/app-list-utils';
const statusColors: Record<string, string> = {
running: 'badge-green',
pending: 'badge-yellow',
building: 'badge-blue',
deploying: 'badge-blue',
failed: 'badge-red',
build_failed: 'badge-red',
cancelled: 'badge-gray',
stopped: 'badge-gray',
};
const lifecycleColors: 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',
};
const statusColors = deploymentStatusBadgeClass;
const lifecycleColors = lifecycleStatusClass;
type AppsDict = Dictionary['dashboard']['apps'];
function statusLabel(status: string, t: Dictionary): string {
return (t.dashboard.status as Record<string, string>)[status] ?? status;
return deploymentStatusLabel(status, t);
}
function lifecycleLabel(lifecycle: string, a: AppsDict): string {
@@ -3,6 +3,8 @@
import { useState, useRef, useCallback, useMemo } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { localizedCallbackUrl } from '@/lib/locale-url';
import { queryKeys } from '@/lib/query-keys';
import { useT, useLocale } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
import { Select } from '@/components/ui/select';
@@ -402,7 +404,7 @@ export default function DeployPage() {
// Wallet balance for the review step payment
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
enabled: step >= 2,
});
@@ -492,7 +494,7 @@ export default function DeployPage() {
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Deploy: ${form.name} (${selectedCycle})`,
callbackUrl: `${window.location.origin}/dashboard/deploy`,
callbackUrl: localizedCallbackUrl('/dashboard/deploy', locale),
});
// In production, redirect to gw.gatewayUrl
@@ -6,6 +6,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
import { notify } from '@/lib/notify';
import api from '@/lib/api';
import { localizedCallbackUrl } from '@/lib/locale-url';
import { queryKeys } from '@/lib/query-keys';
import { useT, useLocale } from '@/i18n/I18nProvider';
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
@@ -44,7 +46,7 @@ export default function InvoicesPage() {
}, [searchParams]);
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
@@ -71,8 +73,8 @@ export default function InvoicesPage() {
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
};
const verifyGatewayMutation = useMutation({
@@ -99,7 +101,7 @@ export default function InvoicesPage() {
const payMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const callbackUrl = `${window.location.origin}/dashboard/invoices`;
const callbackUrl = localizedCallbackUrl('/dashboard/invoices', locale);
const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl });
if (data.gatewayUrl && data.gatewayAmount > 0) {
window.location.href = data.gatewayUrl;
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/lib/store';
import api from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { Link } from '@/i18n/Link';
import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
import { useT } from '@/i18n/I18nProvider';
@@ -151,7 +152,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
// Fetch wallet balance for all authenticated users (shown in header)
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
enabled: isAuthenticated,
refetchInterval: 60000,
@@ -0,0 +1,10 @@
export default function DashboardLoading() {
return (
<div className="min-h-[40vh] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-500">Loading</p>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { notify } from '@/lib/notify';
import { Link } from '@/i18n/Link';
import { useT, useLocale } from '@/i18n/I18nProvider';
@@ -103,7 +104,7 @@ export default function ManagedServiceDetailPage() {
});
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
@@ -3,6 +3,8 @@
import { useState, useMemo } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { localizedCallbackUrl } from '@/lib/locale-url';
import { queryKeys } from '@/lib/query-keys';
import { notify } from '@/lib/notify';
import { Link } from '@/i18n/Link';
import { useT } from '@/i18n/I18nProvider';
@@ -134,7 +136,7 @@ export default function NewManagedServicePage() {
});
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
enabled: step === 2,
});
@@ -236,7 +238,7 @@ export default function NewManagedServicePage() {
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
callbackUrl: `${window.location.origin}/dashboard/services/new`,
callbackUrl: localizedCallbackUrl('/dashboard/services/new'),
});
await api.post('/billing/gateway/verify', {
trackingCode: gw.trackingCode,
@@ -16,13 +16,9 @@ import {
} from '@/components/deleting-overlay';
import { filterManagedServices } from '@/lib/product-type';
import { useApplicationDelete } from '@/lib/use-application-delete';
import { lifecycleStatusClass } from '@/lib/app-list-utils';
const lifecycleColors: 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',
};
const lifecycleColors = lifecycleStatusClass;
type AppsDict = Dictionary['dashboard']['apps'];
@@ -3,6 +3,8 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { localizedCallbackUrl } from '@/lib/locale-url';
import { queryKeys } from '@/lib/query-keys';
import { notify } from '@/lib/notify';
import type { WalletTransaction, TransactionType } from '@/types';
import { Link } from '@/i18n/Link';
@@ -32,12 +34,12 @@ export default function WalletPage() {
const [showCharge, setShowCharge] = useState(false);
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
queryKey: ['wallet-transactions'],
queryKey: queryKeys.walletTransactions,
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
});
@@ -46,8 +48,8 @@ export default function WalletPage() {
mutationFn: (amount: number) =>
api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
notify.success(w.chargedSuccess);
setChargeAmount('');
setShowCharge(false);
@@ -61,7 +63,7 @@ export default function WalletPage() {
const { data } = await api.post('/billing/gateway/initiate', {
amount,
description: w.topUpGatewayDesc,
callbackUrl: `${window.location.origin}/dashboard/wallet`,
callbackUrl: localizedCallbackUrl('/dashboard/wallet', locale),
});
return data;
},
@@ -72,8 +74,8 @@ export default function WalletPage() {
trackingCode: data.trackingCode,
amount: Number(chargeAmount),
});
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
notify.success(w.paymentSuccess);
setChargeAmount('');
setShowCharge(false);
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
@@ -114,7 +115,7 @@ export function ManagedServiceResourcesPanel({
const isDatabase = app.productType === 'managed_database';
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet'],
queryKey: queryKeys.walletBalance,
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
@@ -181,7 +182,7 @@ export function ManagedServiceResourcesPanel({
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
+7 -1
View File
@@ -29,7 +29,13 @@ export function Providers({ children }: { children: React.ReactNode }) {
loadUser();
}, [loadUser]);
if (!mounted) return null;
if (!mounted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
+24
View File
@@ -0,0 +1,24 @@
import clsx from 'clsx';
type BadgeTone = 'gray' | 'green' | 'yellow' | 'red' | 'blue' | 'purple';
const toneClass: Record<BadgeTone, string> = {
gray: 'badge-gray',
green: 'badge-green',
yellow: 'badge-yellow',
red: 'badge-red',
blue: 'badge-blue',
purple: 'badge-purple',
};
export function Badge({
tone = 'gray',
className,
children,
}: {
tone?: BadgeTone;
className?: string;
children: React.ReactNode;
}) {
return <span className={clsx('badge', toneClass[tone], className)}>{children}</span>;
}
+24
View File
@@ -0,0 +1,24 @@
import clsx from 'clsx';
import type { ButtonHTMLAttributes } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
const variantClass: Record<ButtonVariant, string> = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger',
ghost: 'btn-ghost',
};
export function Button({
variant = 'primary',
className,
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
return (
<button type="button" className={clsx(variantClass[variant], className)} {...props}>
{children}
</button>
);
}
+10
View File
@@ -0,0 +1,10 @@
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx('card', className)} {...props}>
{children}
</div>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
export function PageHeader({
title,
description,
actions,
}: {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h1>
{description ? (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
);
}
+1 -1
View File
@@ -1482,7 +1482,7 @@ const en: Dictionary = {
platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide',
removeCustomDomain: 'Remove Custom Domain',
resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show',
liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready',
liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready', restarts: 'Restarts',
noMetrics: 'No metrics yet', storageUsage: 'Storage Usage', loadingStorageMetrics: 'Loading storage metrics...',
noStorageData: 'No storage data available', storageMetricsUnavailable: 'Storage metrics unavailable',
used: 'Used', applyChanges: 'Apply Changes', applying: 'Applying...', processing: 'Processing...',
+1 -1
View File
@@ -1488,7 +1488,7 @@ const fa = {
removeCustomDomain: 'حذف دامنهٔ اختصاصی',
// resources
resourcesScaling: 'منابع و مقیاس‌بندی', monitor: 'پایش', hide: 'پنهان', show: 'نمایش',
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریک‌ها…', metrics: 'متریک‌ها', pods: 'پادها', ready: 'آماده',
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریک‌ها…', metrics: 'متریک‌ها', pods: 'پادها', ready: 'آماده', restarts: 'ری‌استارت',
noMetrics: 'هنوز متریکی نیست', storageUsage: 'مصرف فضای ذخیره', loadingStorageMetrics: 'در حال بارگذاری متریک‌های فضای ذخیره…',
noStorageData: 'داده‌ی فضای ذخیره موجود نیست', storageMetricsUnavailable: 'متریک فضای ذخیره در دسترس نیست',
used: 'مصرف‌شده', applyChanges: 'اعمال تغییرات', applying: 'در حال اعمال…', processing: 'در حال پردازش…',
+2 -1
View File
@@ -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();
}
}
}
+23
View File
@@ -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;
}
+25
View File
@@ -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');
});
});
+35
View File
@@ -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);
});
});
+6 -3
View File
@@ -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', {
+36
View File
@@ -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');
});
});
+31
View File
@@ -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);
}
+7
View File
@@ -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),
};
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});