diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts
index 3acfbe7..9476d17 100644
--- a/backend/src/applications/applications.controller.ts
+++ b/backend/src/applications/applications.controller.ts
@@ -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,
diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts
index 25eafea..995cc51 100644
--- a/backend/src/users/users.controller.ts
+++ b/backend/src/users/users.controller.ts
@@ -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)' })
diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts
index 96aa4e6..ddec0c3 100644
--- a/backend/src/users/users.service.ts
+++ b/backend/src/users/users.service.ts
@@ -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 {
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 {
+ 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);
+ }
}
diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx
index dc885f2..d72301d 100644
--- a/frontend/src/app/dashboard/admin/users/page.tsx
+++ b/frontend/src/app/dashboard/admin/users/page.tsx
@@ -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(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 (
@@ -223,12 +267,24 @@ export default function AdminUsersPage() {
{new Date(user.createdAt).toLocaleDateString()}
- 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'}
-
+
+ {canResetPasswordFor(user) && (
+ openPwdModal(user)}
+ className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-800"
+ >
+ Password
+
+ )}
+ 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'}
+
+
))}
@@ -259,7 +315,7 @@ export default function AdminUsersPage() {
}`}>{user.role}
{new Date(user.createdAt).toLocaleDateString()}
-
+
{isAdmin ? (
{user.role}
)}
+ {canResetPasswordFor(user) && (
+ openPwdModal(user)}
+ className="btn-secondary text-xs inline-flex items-center gap-1"
+ >
+ Password
+
+ )}
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() {
>
)}
+
+ {pwdModalUser && (
+
e.target === e.currentTarget && closePwdModal()}
+ >
+
+
Set password
+
+ New password for{' '}
+
+ {pwdModalUser.firstName} {pwdModalUser.lastName}
+ {' '}
+ ({pwdModalUser.email})
+
+
setPwdModalPassword(e.target.value)}
+ autoComplete="new-password"
+ />
+
+
+ Cancel
+
+
+ {resetPassword.isPending ? 'Saving...' : 'Save password'}
+
+
+
+
+ )}
);
}
diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx
index 7eaa08f..6f15337 100644
--- a/frontend/src/app/dashboard/apps/[id]/page.tsx
+++ b/frontend/src/app/dashboard/apps/[id]/page.tsx
@@ -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
= {
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() {
Drag & drop a .zip file here, or click to browse
- Max size: 100MB
+ Max size: 10 GB
)}
diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx
index 14746ec..7d0fbac 100644
--- a/frontend/src/app/dashboard/deploy/page.tsx
+++ b/frontend/src/app/dashboard/deploy/page.tsx
@@ -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 = {
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({
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 (
@@ -951,7 +1027,7 @@ export default function DeployPage() {
Drag & drop your project ZIP here
- or click to browse • .zip files only • Max 100MB
+ or click to browse • .zip or .tar.gz • Max 10 GB
@@ -1089,7 +1165,7 @@ export default function DeployPage() {
Drag & drop your WordPress ZIP here
- ZIP with wp-content/ folder • Max 200MB
+ ZIP with wp-content/ folder • Max 10 GB
@@ -1166,7 +1242,7 @@ export default function DeployPage() {
Drag & drop your public_html ZIP here
- ZIP with full WordPress root (wp-admin/ , wp-content/ , ...) • Max 200MB
+ ZIP with full WordPress root (wp-admin/ , wp-content/ , ...) • Max 10 GB
@@ -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"
>
−
{
- 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() {
)}
- Minimum 1GB • Only expansion is allowed after creation
+
+ Minimum {minDbGiFromRestoreDump} GB for this configuration
+ {dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion is allowed after creation
+
)}
@@ -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"
>
−
{
- 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() {
GB
+ {zipFile && form.runtime !== 'wordpress' && (
+
+ Archive size {(zipFile.size / ONE_GIB).toFixed(2)} GiB — storage must be at least{' '}
+ {minGiToFitFileBytes(zipFile.size)} GB
+
+ )}
{wpContentFile && form.runtime === 'wordpress' && (
- 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
)}
- Minimum 1 GB • Recommended: 2 GB or more
+
+ Minimum {minAppGiFromRestoreFiles} GB (covers uploaded archive size){' '}
+ • Recommended: at least the archive size or more
+
@@ -2744,6 +2849,7 @@ export default function DeployPage() {
) : (
{
+ if (!validateRestoreStorageOrShowModal()) return;
if (!costData || costData.monthly === 0) {
// No pricing — deploy directly
handleSubmit();
@@ -2936,6 +3042,35 @@ export default function DeployPage() {
)}
+
+ {/* Uploaded restore file vs allocated storage */}
+ {showRestoreStorageErrorModal && (
+
+
setShowRestoreStorageErrorModal(false)}
+ role="presentation"
+ />
+
+
+
+
Storage too small
+
{restoreStorageErrorMessage}
+
+
+ setShowRestoreStorageErrorModal(false)}
+ className="btn-primary"
+ >
+ OK
+
+
+
+
+ )}
);
}