From a7293973d5a95ac7f97ecd68a028ee50a693d84b Mon Sep 17 00:00:00 2001 From: keyhan Date: Sat, 16 May 2026 01:03:07 +0330 Subject: [PATCH] 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 --- .../applications/applications.controller.ts | 2 +- backend/src/users/users.controller.ts | 22 ++ backend/src/users/users.service.ts | 28 ++- .../src/app/dashboard/admin/users/page.tsx | 122 ++++++++++- frontend/src/app/dashboard/apps/[id]/page.tsx | 9 +- frontend/src/app/dashboard/deploy/page.tsx | 189 +++++++++++++++--- 6 files changed, 332 insertions(+), 40 deletions(-) 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()} - +
+ {canResetPasswordFor(user) && ( + + )} + +
))} @@ -259,7 +315,7 @@ export default function AdminUsersPage() { }`}>{user.role} {new Date(user.createdAt).toLocaleDateString()}
-
+
{isAdmin ? ( setPwdModalPassword(e.target.value)} + autoComplete="new-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() { ) : ( + + + + )} ); }