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:
@@ -70,7 +70,7 @@ export class ApplicationsController {
|
||||
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@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(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -44,6 +44,13 @@ class UpdateRoleDto {
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
class AdminSetPasswordDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(64)
|
||||
password: string;
|
||||
}
|
||||
|
||||
@ApiTags('Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
@@ -77,6 +84,21 @@ export class UsersController {
|
||||
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')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update user role (Admin only)' })
|
||||
|
||||
@@ -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 { Repository, ILike } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
@@ -106,4 +111,25 @@ export class UsersService {
|
||||
async activate(id: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,24 @@ import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
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 [showForm, setShowForm] = useState(false);
|
||||
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
|
||||
const [pwdModalPassword, setPwdModalPassword] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
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 (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
@@ -223,12 +267,24 @@ export default function AdminUsersPage() {
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
{canResetPasswordFor(user) && (
|
||||
<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 })}
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -259,7 +315,7 @@ export default function AdminUsersPage() {
|
||||
}`}>{user.role}</span>
|
||||
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</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 ? (
|
||||
<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"
|
||||
@@ -274,7 +330,17 @@ export default function AdminUsersPage() {
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
|
||||
)}
|
||||
{canResetPasswordFor(user) && (
|
||||
<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 })}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
|
||||
>
|
||||
@@ -286,6 +352,46 @@ export default function AdminUsersPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale,
|
||||
import { useConfirm } from '@/components/confirm-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> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
@@ -778,8 +781,8 @@ export default function AppDetailPage() {
|
||||
toast.error('Please upload a .zip or .tar.gz file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 100MB');
|
||||
if (file.size > MAX_SOURCE_ARCHIVE_BYTES) {
|
||||
toast.error('File size must be at most 10 GB');
|
||||
return;
|
||||
}
|
||||
uploadMutation.mutate(file);
|
||||
@@ -1428,7 +1431,7 @@ export default function AppDetailPage() {
|
||||
<p className="text-xs text-gray-500">
|
||||
Drag & drop a <strong>.zip</strong> file here, or click to browse
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
@@ -206,6 +206,14 @@ const stageLabels: Record<DeployStage, string> = {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -262,6 +270,22 @@ export default function DeployPage() {
|
||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||
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[]>({
|
||||
queryKey: ['clusters-public'],
|
||||
queryFn: () => api.get('/clusters/public').then((r) => r.data),
|
||||
@@ -585,6 +609,7 @@ export default function DeployPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validateRestoreStorageOrShowModal()) return;
|
||||
const payload = { ...form };
|
||||
// Format dbStorageSize with Gi suffix
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
@@ -609,11 +634,16 @@ export default function DeployPage() {
|
||||
toast.error('Only .zip or .tar.gz files are allowed');
|
||||
return;
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 100MB');
|
||||
if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
|
||||
toast.error('File size must be at most 10 GB');
|
||||
return;
|
||||
}
|
||||
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}`);
|
||||
}, []);
|
||||
|
||||
@@ -641,15 +671,18 @@ export default function DeployPage() {
|
||||
toast.error('Only .zip or .tar.gz files are allowed');
|
||||
return;
|
||||
}
|
||||
if (file.size > 200 * 1024 * 1024) {
|
||||
toast.error('WordPress files must be less than 200MB');
|
||||
if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
|
||||
toast.error('Archive must be at most 10 GB');
|
||||
return;
|
||||
}
|
||||
setWpContentFile(file);
|
||||
// Auto-suggest app storage size based on file size (add 50% buffer, minimum 2GB)
|
||||
const fileSizeGb = file.size / (1024 * 1024 * 1024);
|
||||
const suggestedSize = Math.max(2, Math.ceil(fileSizeGb * 1.5));
|
||||
setForm((prev) => ({ ...prev, appStorageSize: String(suggestedSize) }));
|
||||
const minFromFile = minGiToFitFileBytes(file.size);
|
||||
const buffered = Math.max(2, Math.ceil((file.size / ONE_GIB) * 1.5));
|
||||
const target = Math.max(minFromFile, buffered);
|
||||
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}`);
|
||||
}, []);
|
||||
|
||||
@@ -677,6 +710,8 @@ export default function DeployPage() {
|
||||
const [showDnsModal, setShowDnsModal] = useState(false);
|
||||
const [dnsModalMessage, setDnsModalMessage] = useState('');
|
||||
const [checkingDnsOnNext, setCheckingDnsOnNext] = useState(false);
|
||||
const [showRestoreStorageErrorModal, setShowRestoreStorageErrorModal] = useState(false);
|
||||
const [restoreStorageErrorMessage, setRestoreStorageErrorMessage] = useState('');
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === 1 && enableCustomDomain && customDomainInput.trim()) {
|
||||
@@ -717,6 +752,47 @@ export default function DeployPage() {
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||||
<div>
|
||||
@@ -951,7 +1027,7 @@ export default function DeployPage() {
|
||||
Drag & drop your project ZIP here
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1089,7 +1165,7 @@ export default function DeployPage() {
|
||||
Drag & drop your WordPress ZIP here
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1166,7 +1242,7 @@ export default function DeployPage() {
|
||||
Drag & drop your public_html ZIP here
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1543,8 +1619,14 @@ export default function DeployPage() {
|
||||
toast.error('Max 500MB');
|
||||
} else {
|
||||
setDbDumpFile(f);
|
||||
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
|
||||
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
|
||||
const suggested = Math.max(
|
||||
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');
|
||||
} else {
|
||||
setDbDumpFile(f);
|
||||
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
|
||||
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
|
||||
const suggested = Math.max(
|
||||
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 = '';
|
||||
@@ -1601,20 +1689,22 @@ export default function DeployPage() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
min={minDbGiFromRestoreDump}
|
||||
max={100}
|
||||
value={form.dbStorageSize || '1'}
|
||||
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) });
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
@@ -2306,20 +2399,22 @@ export default function DeployPage() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
min={minAppGiFromRestoreFiles}
|
||||
max={100}
|
||||
value={form.appStorageSize || '2'}
|
||||
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) });
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
<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' && (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
@@ -2744,6 +2849,7 @@ export default function DeployPage() {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!validateRestoreStorageOrShowModal()) return;
|
||||
if (!costData || costData.monthly === 0) {
|
||||
// No pricing — deploy directly
|
||||
handleSubmit();
|
||||
@@ -2936,6 +3042,35 @@ export default function DeployPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user