mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Allow configurable issue attachment limits
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
559a843a25
commit
e3869bda4c
17 files changed
+16020
-14
No files matched your search
@@ -0,0 +1 @@
|
||||
ALTER TABLE "companies" ADD COLUMN "attachment_max_bytes" integer DEFAULT 1073741824 NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,13 @@
|
||||
"when": 1777305216238,
|
||||
"tag": "0072_large_sandman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1777382021347,
|
||||
"tag": "0073_shiny_salo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ export const companies = pgTable(
|
||||
issueCounter: integer("issue_counter").notNull().default(0),
|
||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
attachmentMaxBytes: integer("attachment_max_bytes")
|
||||
.notNull()
|
||||
.default(1024 * 1024 * 1024),
|
||||
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
||||
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
||||
|
||||
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
export const MAX_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
|
||||
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
DEPLOYMENT_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
BIND_MODES,
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CompanyPortabilityCompanyManifestEntry {
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
logoPath: string | null;
|
||||
attachmentMaxBytes: number | null;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Company {
|
||||
issueCounter: number;
|
||||
budgetMonthlyCents: number;
|
||||
spentMonthlyCents: number;
|
||||
attachmentMaxBytes?: number;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: Date | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
|
||||
export const portabilityIncludeSchema = z
|
||||
@@ -37,6 +38,7 @@ export const portabilityCompanyManifestEntrySchema = z.object({
|
||||
description: z.string().nullable(),
|
||||
brandColor: z.string().nullable(),
|
||||
logoPath: z.string().nullable(),
|
||||
attachmentMaxBytes: z.number().int().min(1).max(MAX_COMPANY_ATTACHMENT_MAX_BYTES).nullable().default(null),
|
||||
requireBoardApprovalForNewAgents: z.boolean(),
|
||||
feedbackDataSharingEnabled: z.boolean().default(false),
|
||||
feedbackDataSharingConsentAt: z.string().datetime().nullable().default(null),
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { COMPANY_STATUSES } from "../constants.js";
|
||||
import {
|
||||
COMPANY_STATUSES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "../constants.js";
|
||||
|
||||
const logoAssetIdSchema = z.string().uuid().nullable().optional();
|
||||
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
|
||||
const feedbackDataSharingTermsVersionSchema = z.string().min(1).nullable().optional();
|
||||
const attachmentMaxBytesSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
|
||||
export const createCompanySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
|
||||
});
|
||||
|
||||
export type CreateCompany = z.infer<typeof createCompanySchema>;
|
||||
@@ -25,6 +34,7 @@ export const updateCompanySchema = createCompanySchema
|
||||
feedbackDataSharingTermsVersion: feedbackDataSharingTermsVersionSchema,
|
||||
brandColor: brandColorSchema,
|
||||
logoAssetId: logoAssetIdSchema,
|
||||
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
|
||||
});
|
||||
|
||||
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
createAttachment: vi.fn(),
|
||||
getAttachmentById: vi.fn(),
|
||||
}));
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
@@ -39,6 +42,7 @@ function registerRouteMocks() {
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
companyService: () => mockCompanyService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
@@ -180,6 +184,10 @@ describe("issue attachment routes", () => {
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 1024 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts zip uploads for issue attachments", async () => {
|
||||
@@ -215,6 +223,49 @@ describe("issue attachment routes", () => {
|
||||
expect(res.body.contentType).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("allows issue attachments larger than the legacy 10 MiB process default when company limit allows it", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
|
||||
filename: "large.bin",
|
||||
contentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(storage.__calls.putFile?.body.length).toBe(10 * 1024 * 1024 + 1);
|
||||
});
|
||||
|
||||
it("enforces the configured per-company issue attachment limit", async () => {
|
||||
const storage = createStorageService();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 4,
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
|
||||
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves html attachments as downloads with nosniff", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* - Exact types: "application/pdf"
|
||||
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||
*/
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||
"image/png",
|
||||
@@ -92,3 +96,10 @@ export function isAllowedContentType(contentType: string): boolean {
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES =
|
||||
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
|
||||
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
}
|
||||
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import * as serviceIndex from "../services/index.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
companyService,
|
||||
executionWorkspaceService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
isInlineAttachmentContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeIssueAttachmentMaxBytes,
|
||||
normalizeContentType,
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
@@ -404,6 +405,7 @@ export function issueRoutes(
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
const feedback = feedbackService(db);
|
||||
const companiesSvc = companyService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
@@ -427,11 +429,6 @@ export function issueRoutes(
|
||||
};
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
const environmentsSvc = environmentService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
function withContentPath<T extends { id: string }>(attachment: T) {
|
||||
return {
|
||||
...attachment,
|
||||
@@ -493,7 +490,11 @@ export function issueRoutes(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
async function runSingleFileUpload(req: Request, res: Response, fileSizeLimit: number) {
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: fileSizeLimit, files: 1 },
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
@@ -3692,12 +3693,15 @@ export function issueRoutes(
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
|
||||
const company = await companiesSvc.getById(companyId);
|
||||
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
await runSingleFileUpload(req, res, attachmentMaxBytes);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
|
||||
@@ -45,6 +45,7 @@ export function companyService(db: Db) {
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
attachmentMaxBytes: companies.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
||||
|
||||
@@ -2428,6 +2428,10 @@ function buildManifestFromPackageFiles(
|
||||
description: asString(companyFrontmatter.description),
|
||||
brandColor: asString(paperclipCompany.brandColor),
|
||||
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
|
||||
attachmentMaxBytes:
|
||||
typeof paperclipCompany.attachmentMaxBytes === "number" && Number.isFinite(paperclipCompany.attachmentMaxBytes)
|
||||
? Math.max(1, Math.floor(paperclipCompany.attachmentMaxBytes))
|
||||
: null,
|
||||
requireBoardApprovalForNewAgents:
|
||||
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
||||
? paperclipCompany.requireBoardApprovalForNewAgents
|
||||
@@ -3465,6 +3469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
company: stripEmptyValues({
|
||||
brandColor: company.brandColor ?? null,
|
||||
logoPath: companyLogoPath,
|
||||
attachmentMaxBytes: company.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined,
|
||||
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
|
||||
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
|
||||
@@ -3963,6 +3968,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
id: string;
|
||||
name: string;
|
||||
requireBoardApprovalForNewAgents?: boolean | null;
|
||||
attachmentMaxBytes?: number | null;
|
||||
} | null = null;
|
||||
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
||||
|
||||
@@ -3985,6 +3991,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
name: companyName,
|
||||
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
||||
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
|
||||
attachmentMaxBytes: include.company
|
||||
? (sourceManifest.company?.attachmentMaxBytes ?? undefined)
|
||||
: undefined,
|
||||
requireBoardApprovalForNewAgents: include.company
|
||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false)
|
||||
: false,
|
||||
@@ -4016,6 +4025,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
name: sourceManifest.company.name,
|
||||
description: sourceManifest.company.description,
|
||||
brandColor: sourceManifest.company.brandColor,
|
||||
attachmentMaxBytes: sourceManifest.company.attachmentMaxBytes ?? undefined,
|
||||
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
|
||||
|
||||
@@ -32,6 +32,7 @@ export const companiesApi = {
|
||||
| "description"
|
||||
| "status"
|
||||
| "budgetMonthlyCents"
|
||||
| "attachmentMaxBytes"
|
||||
| "requireBoardApprovalForNewAgents"
|
||||
| "feedbackDataSharingEnabled"
|
||||
| "brandColor"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
getAdapterEnvironmentSupport,
|
||||
type Environment,
|
||||
type EnvironmentProbeResult,
|
||||
@@ -54,6 +56,9 @@ const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
|
||||
adapterType,
|
||||
support: getAdapterEnvironmentSupport(adapterType),
|
||||
}));
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
|
||||
function buildEnvironmentPayload(form: EnvironmentFormState) {
|
||||
return {
|
||||
@@ -183,6 +188,7 @@ export function CompanySettings() {
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB));
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
|
||||
@@ -195,6 +201,7 @@ export function CompanySettings() {
|
||||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB)));
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
@@ -227,17 +234,24 @@ export function CompanySettings() {
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
|
||||
const attachmentMaxValid =
|
||||
Number.isInteger(attachmentMaxBytes)
|
||||
&& attachmentMaxBytes >= BYTES_PER_MIB
|
||||
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
const generalDirty =
|
||||
!!selectedCompany &&
|
||||
(companyName !== selectedCompany.name ||
|
||||
description !== (selectedCompany.description ?? "") ||
|
||||
brandColor !== (selectedCompany.brandColor ?? ""));
|
||||
brandColor !== (selectedCompany.brandColor ?? "") ||
|
||||
attachmentMaxBytes !== (selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES));
|
||||
|
||||
const generalMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
attachmentMaxBytes: number;
|
||||
}) => companiesApi.update(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
@@ -477,7 +491,8 @@ export function CompanySettings() {
|
||||
generalMutation.mutate({
|
||||
name: companyName.trim(),
|
||||
description: description.trim() || null,
|
||||
brandColor: brandColor || null
|
||||
brandColor: brandColor || null,
|
||||
attachmentMaxBytes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -716,6 +731,30 @@ export function CompanySettings() {
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachment size limit"
|
||||
hint={`Accepted range: 1-${MAX_COMPANY_ATTACHMENT_MAX_MIB} MiB.`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COMPANY_ATTACHMENT_MAX_MIB}
|
||||
step={1}
|
||||
value={attachmentMaxMiB}
|
||||
onChange={(e) => setAttachmentMaxMiB(e.target.value)}
|
||||
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">MiB</span>
|
||||
</div>
|
||||
{!attachmentMaxValid && (
|
||||
<span className="text-xs text-destructive">
|
||||
Enter a whole number from 1 to {MAX_COMPANY_ATTACHMENT_MAX_MIB}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -727,7 +766,7 @@ export function CompanySettings() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={generalMutation.isPending || !companyName.trim()}
|
||||
disabled={generalMutation.isPending || !companyName.trim() || !attachmentMaxValid}
|
||||
>
|
||||
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user