diff --git a/apps/backend/prisma/migrations/20250801204029_logo_url/migration.sql b/apps/backend/prisma/migrations/20250801204029_logo_url/migration.sql new file mode 100644 index 0000000000..6f300e9fe9 --- /dev/null +++ b/apps/backend/prisma/migrations/20250801204029_logo_url/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "logoUrl" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "fullLogoUrl" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e5274a3126..e468ca7e95 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -19,6 +19,8 @@ model Project { displayName String description String @default("") isProductionMode Boolean + logoUrl String? + fullLogoUrl String? projectConfigOverride Json? diff --git a/apps/backend/src/lib/images.tsx b/apps/backend/src/lib/images.tsx index ce43ca2f70..ab7e154984 100644 --- a/apps/backend/src/lib/images.tsx +++ b/apps/backend/src/lib/images.tsx @@ -11,7 +11,7 @@ export async function parseBase64Image(input: string, options: { maxHeight?: number, allowTypes?: string[], } = { - maxBytes: 1024 * 300, + maxBytes: 1_000_000, // 1MB maxWidth: 4096, maxHeight: 4096, allowTypes: ['image/jpeg', 'image/png', 'image/webp'], diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 7b5c00702d..5e9581fd14 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -1,3 +1,4 @@ +import { uploadAndGetUrl } from "@/s3"; import { Prisma } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import { CompleteConfig, EnvironmentConfigOverrideOverride, ProjectConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; @@ -48,6 +49,8 @@ export function getProjectQuery(projectId: string): RawQuery { let project: Prisma.ProjectGetPayload<{}>; let branchId: string; @@ -87,6 +100,8 @@ export async function createOrUpdateProjectWithLegacyConfig( displayName: options.data.display_name, description: options.data.description ?? "", isProductionMode: options.data.is_production_mode ?? false, + logoUrl, + fullLogoUrl, }, }); @@ -117,6 +132,8 @@ export async function createOrUpdateProjectWithLegacyConfig( displayName: options.data.display_name, description: options.data.description === null ? "" : options.data.description, isProductionMode: options.data.is_production_mode, + logoUrl, + fullLogoUrl, }, }); branchId = options.branchId; diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx index 9e62002625..d292305131 100644 --- a/apps/backend/src/s3.tsx +++ b/apps/backend/src/s3.tsx @@ -36,7 +36,7 @@ export function getS3PublicUrl(key: string): string { async function uploadBase64Image({ input, - maxBytes = 1024 * 300, + maxBytes = 1_000_000, // 1MB folderName, }: { input: string, @@ -85,7 +85,7 @@ export function checkImageString(input: string) { export async function uploadAndGetUrl( input: string | null | undefined, - folderName: 'user-profile-images' | 'team-profile-images' | 'team-member-profile-images' + folderName: 'user-profile-images' | 'team-profile-images' | 'team-member-profile-images' | 'project-logos' ) { if (input) { const checkResult = checkImageString(input); @@ -97,7 +97,6 @@ export async function uploadAndGetUrl( } else { throw new StatusError(StatusError.BadRequest, "Invalid profile image URL"); } - } else if (input === null) { return null; } else { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 95baac09c5..2315798177 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -38,6 +38,7 @@ "@tanstack/react-table": "^8.20.5", "@vercel/analytics": "^1.2.2", "@vercel/speed-insights": "^1.0.12", + "browser-image-compression": "^2.0.2", "canvas-confetti": "^1.9.2", "clsx": "^2.0.0", "dotenv-cli": "^7.3.0", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx index 51daa33a16..2109af5d34 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx @@ -1,6 +1,7 @@ "use client"; import { InputField } from "@/components/form-fields"; import { StyledLink } from "@/components/link"; +import { LogoUpload } from "@/components/logo-upload"; import { FormSettingCard, SettingCard, SettingSwitch, SettingText } from "@/components/settings"; import { getPublicEnvVar } from '@/lib/env'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionDialog, Alert, Button, Typography } from "@stackframe/stack-ui"; @@ -63,6 +64,32 @@ export default function PageClient() { )} /> + + { + await project.update({ logoUrl }); + }} + description="Upload a logo for your project. Recommended size: 200x200px" + type="logo" + /> + + { + await project.update({ fullLogoUrl }); + }} + description="Upload a full logo with text. Recommended size: At least 100px tall, landscape format" + type="full-logo" + /> + + + Logo images will be displayed in your application (e.g. login page) and emails. The logo should be a square image, while the full logo can include text and be wider. + + + void | Promise, + description?: string, + acceptedTypes?: string[], + type: 'logo' | 'full-logo', +}) { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + function upload() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = props.acceptedTypes?.join(',') || 'image/*'; + + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + setUploading(true); + setError(null); + + runAsynchronouslyWithAlert(async () => { + try { + // Compress the image first + const compressedFile = await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 800, + useWebWorker: true, + fileType: file.type.startsWith('image/svg') ? file.type : 'image/jpeg', + }); + + const base64Url = await fileToBase64(compressedFile); + + if (await checkImageUrl(base64Url)) { + await props.onValueChange(base64Url); + setError(null); + } else { + setError('Invalid image format'); + } + } catch (err) { + setError('Failed to process image'); + console.error('Logo upload error:', err); + } finally { + setUploading(false); + input.remove(); + } + }); + }; + + input.click(); + } + + async function remove() { + setError(null); + await props.onValueChange(null); + } + + const logoContainerClasses = props.type === 'full-logo' + ? "relative h-16 w-48 rounded border overflow-hidden bg-muted" + : "relative h-16 w-16 rounded border overflow-hidden bg-muted"; + + const placeholderContainerClasses = props.type === 'full-logo' + ? "h-16 w-48 rounded border-2 border-dashed border-muted-foreground/25 flex items-center justify-center bg-muted/50" + : "h-16 w-16 rounded border-2 border-dashed border-muted-foreground/25 flex items-center justify-center bg-muted/50"; + + return ( +
+ + {props.label} + + +
+ {props.value ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {props.label} +
+ +
+ ) : ( +
+
+ +
+ {props.description && ( + + {props.description} + + )} +
+ )} +
+ + {error && ( + + {error} + + )} +
+ ); +} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts index d3c797d413..a8057fa649 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/projects/provision.test.ts @@ -80,8 +80,10 @@ it("should be able to provision a new project if client details are correct", as "created_at_millis": , "description": "Project created by an external integration", "display_name": "Test project", + "full_logo_url": null, "id": "", "is_production_mode": false, + "logo_url": null, }, "headers": Headers {