Add staff password reset, raise code upload limit, and guard restore sizing.

Admins and technical staff can reset passwords via PATCH /users/:id/password with scoped permissions for technical users; deploy/source uploads allow up to 10GiB and block deploy when allocated storage is smaller than uploaded archive or DB dump, with an inline error modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-16 01:03:07 +03:30
parent 67b41ae311
commit a7293973d5
6 changed files with 332 additions and 40 deletions
@@ -70,7 +70,7 @@ export class ApplicationsController {
@ApiOperation({ summary: 'Upload application code (zip file)' }) @ApiOperation({ summary: 'Upload application code (zip file)' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 200 * 1024 * 1024 }, // 200MB (WordPress sites can be large) limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10 GiB max application archive
})) }))
async uploadCode( async uploadCode(
@Param('id') id: string, @Param('id') id: string,
+22
View File
@@ -44,6 +44,13 @@ class UpdateRoleDto {
role: UserRole; role: UserRole;
} }
class AdminSetPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(64)
password: string;
}
@ApiTags('Users') @ApiTags('Users')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('users') @Controller('users')
@@ -77,6 +84,21 @@ export class UsersController {
return this.usersService.adminCreate(dto); return this.usersService.adminCreate(dto);
} }
@Patch(':id/password')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({
summary:
'Set user password (Admin: any role; Technical: standard users only)',
})
async setPassword(
@Request() req: { user: { role: UserRole } },
@Param('id') id: string,
@Body() dto: AdminSetPasswordDto,
) {
await this.usersService.setPasswordByStaff(req.user.role, id, dto.password);
return { message: 'Password updated' };
}
@Patch(':id/role') @Patch(':id/role')
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update user role (Admin only)' }) @ApiOperation({ summary: 'Update user role (Admin only)' })
+27 -1
View File
@@ -1,4 +1,9 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike } from 'typeorm'; import { Repository, ILike } from 'typeorm';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
@@ -106,4 +111,25 @@ export class UsersService {
async activate(id: string): Promise<void> { async activate(id: string): Promise<void> {
await this.usersRepository.update(id, { isActive: true }); await this.usersRepository.update(id, { isActive: true });
} }
/**
* Staff-set password: admins may reset anyone; technical staff only standard (user) accounts.
*/
async setPasswordByStaff(
actorRole: UserRole,
targetUserId: string,
plainPassword: string,
): Promise<void> {
const user = await this.findById(targetUserId);
if (!user) {
throw new NotFoundException('User not found');
}
if (actorRole === UserRole.TECHNICAL && user.role !== UserRole.USER) {
throw new ForbiddenException(
'Technical staff can only reset passwords for standard (user) accounts',
);
}
user.password = await bcrypt.hash(plainPassword, 12);
await this.usersRepository.save(user);
}
} }
+108 -2
View File
@@ -6,14 +6,24 @@ import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import type { AdminUser } from '@/types'; import type { AdminUser } from '@/types';
import { Users, Search, X, Clock } from 'lucide-react'; import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.role === 'admin'; const isAdmin = currentUser?.role === 'admin';
const canStaffResetPassword =
currentUser?.role === 'admin' || currentUser?.role === 'technical';
const canResetPasswordFor = (user: AdminUser) => {
if (!canStaffResetPassword) return false;
if (currentUser?.role === 'admin') return true;
return user.role === 'user';
};
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
const [pwdModalPassword, setPwdModalPassword] = useState('');
const [form, setForm] = useState({ const [form, setForm] = useState({
email: '', email: '',
password: '', password: '',
@@ -59,6 +69,40 @@ export default function AdminUsersPage() {
}, },
}); });
const resetPassword = useMutation({
mutationFn: ({ id, password }: { id: string; password: string }) =>
api.patch(`/users/${id}/password`, { password }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Password updated');
setPwdModalUser(null);
setPwdModalPassword('');
},
onError: (err: unknown) => {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(typeof msg === 'string' ? msg : 'Failed to update password');
},
});
const openPwdModal = (user: AdminUser) => {
setPwdModalUser(user);
setPwdModalPassword('');
};
const closePwdModal = () => {
if (resetPassword.isPending) return;
setPwdModalUser(null);
setPwdModalPassword('');
};
const submitPwdModal = () => {
if (!pwdModalUser || pwdModalPassword.length < 8) return;
resetPassword.mutate({ id: pwdModalUser.id, password: pwdModalPassword });
};
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
<div className="page-header"> <div className="page-header">
@@ -223,12 +267,24 @@ export default function AdminUsersPage() {
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
{canResetPasswordFor(user) && (
<button <button
type="button"
onClick={() => openPwdModal(user)}
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-800"
>
<KeyRound className="w-3.5 h-3.5" /> Password
</button>
)}
<button
type="button"
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })} onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`} className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
> >
{user.isActive ? 'Deactivate' : 'Activate'} {user.isActive ? 'Deactivate' : 'Activate'}
</button> </button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -259,7 +315,7 @@ export default function AdminUsersPage() {
}`}>{user.role}</span> }`}>{user.role}</span>
<span>{new Date(user.createdAt).toLocaleDateString()}</span> <span>{new Date(user.createdAt).toLocaleDateString()}</span>
</div> </div>
<div className="flex items-center gap-2 pt-2 border-t border-gray-100"> <div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
{isAdmin ? ( {isAdmin ? (
<select <select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
@@ -274,7 +330,17 @@ export default function AdminUsersPage() {
) : ( ) : (
<span className="text-sm text-gray-500 capitalize">{user.role}</span> <span className="text-sm text-gray-500 capitalize">{user.role}</span>
)} )}
{canResetPasswordFor(user) && (
<button <button
type="button"
onClick={() => openPwdModal(user)}
className="btn-secondary text-xs inline-flex items-center gap-1"
>
<KeyRound className="w-3.5 h-3.5" /> Password
</button>
)}
<button
type="button"
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })} onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`} className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
> >
@@ -286,6 +352,46 @@ export default function AdminUsersPage() {
</div> </div>
</> </>
)} )}
{pwdModalUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="presentation"
onClick={(e) => e.target === e.currentTarget && closePwdModal()}
>
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Set password</h3>
<p className="text-sm text-gray-600">
New password for{' '}
<strong>
{pwdModalUser.firstName} {pwdModalUser.lastName}
</strong>{' '}
({pwdModalUser.email})
</p>
<input
type="password"
className="input-field w-full"
placeholder="Min 8 characters"
value={pwdModalPassword}
onChange={(e) => setPwdModalPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="flex justify-end gap-2 pt-2">
<button type="button" className="btn-ghost" onClick={closePwdModal}>
Cancel
</button>
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={pwdModalPassword.length < 8 || resetPassword.isPending}
onClick={submitPwdModal}
>
{resetPassword.isPending ? 'Saving...' : 'Save password'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -11,6 +11,9 @@ import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale,
import { useConfirm } from '@/components/confirm-modal'; import { useConfirm } from '@/components/confirm-modal';
import { BuildProgressModal } from '@/components/build-progress-modal'; import { BuildProgressModal } from '@/components/build-progress-modal';
/** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
running: 'badge-green', running: 'badge-green',
pending: 'badge-yellow', pending: 'badge-yellow',
@@ -778,8 +781,8 @@ export default function AppDetailPage() {
toast.error('Please upload a .zip or .tar.gz file'); toast.error('Please upload a .zip or .tar.gz file');
return; return;
} }
if (file.size > 100 * 1024 * 1024) { if (file.size > MAX_SOURCE_ARCHIVE_BYTES) {
toast.error('File size must be less than 100MB'); toast.error('File size must be at most 10 GB');
return; return;
} }
uploadMutation.mutate(file); uploadMutation.mutate(file);
@@ -1428,7 +1431,7 @@ export default function AppDetailPage() {
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Drag & drop a <strong>.zip</strong> file here, or click to browse Drag & drop a <strong>.zip</strong> file here, or click to browse
</p> </p>
<p className="text-xs text-gray-400">Max size: 100MB</p> <p className="text-xs text-gray-400">Max size: 10 GB</p>
</div> </div>
)} )}
</div> </div>
+162 -27
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
@@ -206,6 +206,14 @@ const stageLabels: Record<DeployStage, string> = {
error: 'An error occurred', error: 'An error occurred',
}; };
/** Kubernetes-style Gi (1024³ bytes), aligned with wizard “GB” fields */
const ONE_GIB = 1024 ** 3;
const MAX_SOURCE_CODE_UPLOAD_BYTES = 10 * ONE_GIB;
function minGiToFitFileBytes(bytes: number): number {
return Math.max(1, Math.ceil(bytes / ONE_GIB));
}
export default function DeployPage() { export default function DeployPage() {
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -262,6 +270,22 @@ export default function DeployPage() {
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet'); const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
const [isPaid, setIsPaid] = useState(false); const [isPaid, setIsPaid] = useState(false);
const minAppGiFromRestoreFiles = useMemo(() => {
let min = 1;
if (form.runtime !== 'wordpress' && sourceMethod === 'upload' && zipFile) {
min = Math.max(min, minGiToFitFileBytes(zipFile.size));
}
if (form.runtime === 'wordpress' && (wpMode === 'migrate' || wpMode === 'public_html') && wpContentFile) {
min = Math.max(min, minGiToFitFileBytes(wpContentFile.size));
}
return min;
}, [form.runtime, sourceMethod, zipFile, wpMode, wpContentFile]);
const minDbGiFromRestoreDump = useMemo(() => {
if (form.databaseType === 'none' || !dbDumpFile) return 1;
return Math.max(1, minGiToFitFileBytes(dbDumpFile.size));
}, [form.databaseType, dbDumpFile]);
const { data: clusters = [] } = useQuery<ClusterPublic[]>({ const { data: clusters = [] } = useQuery<ClusterPublic[]>({
queryKey: ['clusters-public'], queryKey: ['clusters-public'],
queryFn: () => api.get('/clusters/public').then((r) => r.data), queryFn: () => api.get('/clusters/public').then((r) => r.data),
@@ -585,6 +609,7 @@ export default function DeployPage() {
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!validateRestoreStorageOrShowModal()) return;
const payload = { ...form }; const payload = { ...form };
// Format dbStorageSize with Gi suffix // Format dbStorageSize with Gi suffix
if (payload.databaseType !== 'none' && payload.dbStorageSize) { if (payload.databaseType !== 'none' && payload.dbStorageSize) {
@@ -609,11 +634,16 @@ export default function DeployPage() {
toast.error('Only .zip or .tar.gz files are allowed'); toast.error('Only .zip or .tar.gz files are allowed');
return; return;
} }
if (file.size > 100 * 1024 * 1024) { if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
toast.error('File size must be less than 100MB'); toast.error('File size must be at most 10 GB');
return; return;
} }
setZipFile(file); setZipFile(file);
setForm((prev) => {
const minG = minGiToFitFileBytes(file.size);
const cur = parseInt(prev.appStorageSize || '2', 10) || 2;
return { ...prev, appStorageSize: String(Math.max(cur, minG)) };
});
toast.success(`Selected: ${file.name}`); toast.success(`Selected: ${file.name}`);
}, []); }, []);
@@ -641,15 +671,18 @@ export default function DeployPage() {
toast.error('Only .zip or .tar.gz files are allowed'); toast.error('Only .zip or .tar.gz files are allowed');
return; return;
} }
if (file.size > 200 * 1024 * 1024) { if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
toast.error('WordPress files must be less than 200MB'); toast.error('Archive must be at most 10 GB');
return; return;
} }
setWpContentFile(file); setWpContentFile(file);
// Auto-suggest app storage size based on file size (add 50% buffer, minimum 2GB) const minFromFile = minGiToFitFileBytes(file.size);
const fileSizeGb = file.size / (1024 * 1024 * 1024); const buffered = Math.max(2, Math.ceil((file.size / ONE_GIB) * 1.5));
const suggestedSize = Math.max(2, Math.ceil(fileSizeGb * 1.5)); const target = Math.max(minFromFile, buffered);
setForm((prev) => ({ ...prev, appStorageSize: String(suggestedSize) })); setForm((prev) => {
const cur = parseInt(prev.appStorageSize || '2', 10) || 2;
return { ...prev, appStorageSize: String(Math.max(cur, target)) };
});
toast.success(`WordPress files selected: ${file.name}`); toast.success(`WordPress files selected: ${file.name}`);
}, []); }, []);
@@ -677,6 +710,8 @@ export default function DeployPage() {
const [showDnsModal, setShowDnsModal] = useState(false); const [showDnsModal, setShowDnsModal] = useState(false);
const [dnsModalMessage, setDnsModalMessage] = useState(''); const [dnsModalMessage, setDnsModalMessage] = useState('');
const [checkingDnsOnNext, setCheckingDnsOnNext] = useState(false); const [checkingDnsOnNext, setCheckingDnsOnNext] = useState(false);
const [showRestoreStorageErrorModal, setShowRestoreStorageErrorModal] = useState(false);
const [restoreStorageErrorMessage, setRestoreStorageErrorMessage] = useState('');
const handleNext = async () => { const handleNext = async () => {
if (step === 1 && enableCustomDomain && customDomainInput.trim()) { if (step === 1 && enableCustomDomain && customDomainInput.trim()) {
@@ -717,6 +752,47 @@ export default function DeployPage() {
setStep(step + 1); setStep(step + 1);
}; };
/** Blocks deploy if allocated Gi is smaller than the uploaded restore/archive file size. */
const validateRestoreStorageOrShowModal = (): boolean => {
const appGi = parseInt(form.appStorageSize || '2', 10) || 2;
const dbGi = parseInt(form.dbStorageSize || '1', 10) || 1;
if (form.runtime !== 'wordpress' && sourceMethod === 'upload' && zipFile) {
const need = minGiToFitFileBytes(zipFile.size);
if (appGi < need) {
setRestoreStorageErrorMessage(
`Your uploaded archive is about ${(zipFile.size / ONE_GIB).toFixed(2)} GiB. Application storage must be at least ${need} GiB (you selected ${appGi} GiB). Increase application storage on the Resources step, then try again.`,
);
setShowRestoreStorageErrorModal(true);
return false;
}
}
if (form.runtime === 'wordpress' && (wpMode === 'migrate' || wpMode === 'public_html') && wpContentFile) {
const need = minGiToFitFileBytes(wpContentFile.size);
if (appGi < need) {
setRestoreStorageErrorMessage(
`Your uploaded WordPress archive is about ${(wpContentFile.size / ONE_GIB).toFixed(2)} GiB. Upload storage must be at least ${need} GiB (you selected ${appGi} GiB). Increase storage on the Resources step, then try again.`,
);
setShowRestoreStorageErrorModal(true);
return false;
}
}
if (form.databaseType !== 'none' && dbDumpFile) {
const need = minGiToFitFileBytes(dbDumpFile.size);
if (dbGi < need) {
setRestoreStorageErrorMessage(
`Your database dump is about ${(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB. Database storage must be at least ${need} GiB (you selected ${dbGi} GiB). Increase database storage on the Versions & Database step, then try again.`,
);
setShowRestoreStorageErrorModal(true);
return false;
}
}
return true;
};
return ( return (
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in"> <div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
<div> <div>
@@ -951,7 +1027,7 @@ export default function DeployPage() {
Drag & drop your project ZIP here Drag & drop your project ZIP here
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
or click to browse <strong>.zip</strong> files only Max 100MB or click to browse <strong>.zip</strong> or <strong>.tar.gz</strong> Max 10 GB
</p> </p>
</div> </div>
</div> </div>
@@ -1089,7 +1165,7 @@ export default function DeployPage() {
Drag & drop your WordPress ZIP here Drag & drop your WordPress ZIP here
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
ZIP with <strong>wp-content/</strong> folder Max 200MB ZIP with <strong>wp-content/</strong> folder Max 10 GB
</p> </p>
</div> </div>
</div> </div>
@@ -1166,7 +1242,7 @@ export default function DeployPage() {
Drag & drop your public_html ZIP here Drag & drop your public_html ZIP here
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
ZIP with full WordPress root (<strong>wp-admin/</strong>, <strong>wp-content/</strong>, ...) Max 200MB ZIP with full WordPress root (<strong>wp-admin/</strong>, <strong>wp-content/</strong>, ...) Max 10 GB
</p> </p>
</div> </div>
</div> </div>
@@ -1543,8 +1619,14 @@ export default function DeployPage() {
toast.error('Max 500MB'); toast.error('Max 500MB');
} else { } else {
setDbDumpFile(f); setDbDumpFile(f);
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3)); const suggested = Math.max(
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) })); minGiToFitFileBytes(f.size),
Math.ceil((f.size / ONE_GIB) * 3),
);
setForm((prev) => {
const cur = parseInt(prev.dbStorageSize || '1', 10) || 1;
return { ...prev, dbStorageSize: String(Math.max(cur, suggested)) };
});
} }
} }
}} }}
@@ -1566,8 +1648,14 @@ export default function DeployPage() {
toast.error('Max 500MB'); toast.error('Max 500MB');
} else { } else {
setDbDumpFile(f); setDbDumpFile(f);
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3)); const suggested = Math.max(
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) })); minGiToFitFileBytes(f.size),
Math.ceil((f.size / ONE_GIB) * 3),
);
setForm((prev) => {
const cur = parseInt(prev.dbStorageSize || '1', 10) || 1;
return { ...prev, dbStorageSize: String(Math.max(cur, suggested)) };
});
} }
} }
e.target.value = ''; e.target.value = '';
@@ -1601,20 +1689,22 @@ export default function DeployPage() {
type="button" type="button"
onClick={() => { onClick={() => {
const current = parseInt(form.dbStorageSize || '1', 10); const current = parseInt(form.dbStorageSize || '1', 10);
if (current > 1) setForm({ ...form, dbStorageSize: String(current - 1) }); const min = minDbGiFromRestoreDump;
if (current > min) setForm({ ...form, dbStorageSize: String(current - 1) });
}} }}
disabled={parseInt(form.dbStorageSize || '1', 10) <= 1} disabled={parseInt(form.dbStorageSize || '1', 10) <= minDbGiFromRestoreDump}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
</button> </button>
<input <input
type="number" type="number"
min={1} min={minDbGiFromRestoreDump}
max={100} max={100}
value={form.dbStorageSize || '1'} value={form.dbStorageSize || '1'}
onChange={(e) => { onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)); const min = minDbGiFromRestoreDump;
const val = Math.max(min, Math.min(100, parseInt(e.target.value, 10) || min));
setForm({ ...form, dbStorageSize: String(val) }); setForm({ ...form, dbStorageSize: String(val) });
}} }}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
@@ -1638,7 +1728,10 @@ export default function DeployPage() {
</span> </span>
)} )}
</div> </div>
<p className="mt-1 text-xs text-gray-400">Minimum 1GB Only expansion is allowed after creation</p> <p className="mt-1 text-xs text-gray-400">
Minimum {minDbGiFromRestoreDump} GB for this configuration
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} Only expansion is allowed after creation
</p>
</div> </div>
</div> </div>
)} )}
@@ -2306,20 +2399,22 @@ export default function DeployPage() {
type="button" type="button"
onClick={() => { onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10); const current = parseInt(form.appStorageSize || '2', 10);
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) }); const min = minAppGiFromRestoreFiles;
if (current > min) setForm({ ...form, appStorageSize: String(current - 1) });
}} }}
disabled={parseInt(form.appStorageSize || '2', 10) <= 1} disabled={parseInt(form.appStorageSize || '2', 10) <= minAppGiFromRestoreFiles}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
</button> </button>
<input <input
type="number" type="number"
min={1} min={minAppGiFromRestoreFiles}
max={100} max={100}
value={form.appStorageSize || '2'} value={form.appStorageSize || '2'}
onChange={(e) => { onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2)); const min = minAppGiFromRestoreFiles;
const val = Math.max(min, Math.min(100, parseInt(e.target.value, 10) || min));
setForm({ ...form, appStorageSize: String(val) }); setForm({ ...form, appStorageSize: String(val) });
}} }}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
@@ -2337,13 +2432,23 @@ export default function DeployPage() {
</button> </button>
</div> </div>
<span className="text-sm font-medium text-gray-700">GB</span> <span className="text-sm font-medium text-gray-700">GB</span>
{zipFile && form.runtime !== 'wordpress' && (
<span className="text-xs text-gray-600">
Archive size {(zipFile.size / ONE_GIB).toFixed(2)} GiB storage must be at least{' '}
{minGiToFitFileBytes(zipFile.size)} GB
</span>
)}
{wpContentFile && form.runtime === 'wordpress' && ( {wpContentFile && form.runtime === 'wordpress' && (
<span className="text-xs text-gray-600"> <span className="text-xs text-gray-600">
Suggested from wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB) Archive {(wpContentFile.size / ONE_GIB).toFixed(2)} GiB storage must be at least{' '}
{minGiToFitFileBytes(wpContentFile.size)} GB
</span> </span>
)} )}
</div> </div>
<p className="mt-2 text-xs text-gray-500">Minimum 1 GB Recommended: 2 GB or more</p> <p className="mt-2 text-xs text-gray-500">
Minimum {minAppGiFromRestoreFiles} GB (covers uploaded archive size){' '}
Recommended: at least the archive size or more
</p>
</div> </div>
</div> </div>
@@ -2744,6 +2849,7 @@ export default function DeployPage() {
) : ( ) : (
<button <button
onClick={() => { onClick={() => {
if (!validateRestoreStorageOrShowModal()) return;
if (!costData || costData.monthly === 0) { if (!costData || costData.monthly === 0) {
// No pricing — deploy directly // No pricing — deploy directly
handleSubmit(); handleSubmit();
@@ -2936,6 +3042,35 @@ export default function DeployPage() {
</div> </div>
</div> </div>
)} )}
{/* Uploaded restore file vs allocated storage */}
{showRestoreStorageErrorModal && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
onClick={() => setShowRestoreStorageErrorModal(false)}
role="presentation"
/>
<div className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full animate-modal-enter">
<div className="p-6 pb-0">
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
<AlertCircle className="w-6 h-6 text-red-600" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Storage too small</h3>
<p className="text-sm text-gray-600 leading-relaxed">{restoreStorageErrorMessage}</p>
</div>
<div className="flex items-center justify-end gap-3 p-6">
<button
type="button"
onClick={() => setShowRestoreStorageErrorModal(false)}
className="btn-primary"
>
OK
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }