Project logo upload (#817)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Add support for uploading and managing project logos with image
compression and validation in project settings.
> 
>   - **Behavior**:
> - Added support for uploading and managing project logos (`logoUrl`,
`fullLogoUrl`) in `Project` model.
> - New `LogoUpload` component in `logo-upload.tsx` for image upload
with compression and validation.
>     - Projects display and store logo URLs for branding.
>   - **Database**:
> - Added `logoUrl` and `fullLogoUrl` columns to `Project` table in
`migration.sql`.
> - Updated `schema.prisma` to include new fields in `Project` model.
>   - **Backend**:
> - Updated `createOrUpdateProjectWithLegacyConfig()` in `projects.tsx`
to handle logo uploads.
> - Increased max image upload size to 1 MB in `images.tsx` and
`s3.tsx`.
>     - Added `browser-image-compression` dependency in `package.json`.
>   - **Frontend**:
> - Integrated `LogoUpload` component in `page-client.tsx` for project
settings.
> - Updated `AdminProject` type in `projects/index.ts` to include logo
URLs.
>   - **Tests**:
> - Updated e2e tests in `projects.test.ts` and others to verify logo
upload functionality.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 1b0cdbf123. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for uploading and managing project logos, including both
square and full (with text) logos, in the project settings page.
* Introduced a new logo upload component with image compression, size
validation, and removal functionality.
* Projects now display and store logo URLs, allowing for enhanced
branding and customization.

* **Improvements**
* Increased maximum allowed image upload size to 1 MB for project logos.
* Added clear image size constraints and unified image validation across
the app.

* **Dependencies**
* Added "browser-image-compression" library to support client-side image
compression.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
Zai Shi 2025-08-19 04:45:16 +02:00 committed by GitHub
parent 2c237ef572
commit 16d99963fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 281 additions and 5 deletions

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "logoUrl" TEXT;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "fullLogoUrl" TEXT;

View File

@ -19,6 +19,8 @@ model Project {
displayName String
description String @default("")
isProductionMode Boolean
logoUrl String?
fullLogoUrl String?
projectConfigOverride Json?

View File

@ -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'],

View File

@ -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<Promise<Omit<Projec
id: row.id,
display_name: row.displayName,
description: row.description,
logo_url: row.logoUrl,
full_logo_url: row.fullLogoUrl,
created_at_millis: new Date(row.createdAt + "Z").getTime(),
is_production_mode: row.isProductionMode,
};
@ -76,6 +79,16 @@ export async function createOrUpdateProjectWithLegacyConfig(
data: ProjectsCrud["Admin"]["Update"],
})
) {
let logoUrl: string | null | undefined;
if (options.data.logo_url !== undefined) {
logoUrl = await uploadAndGetUrl(options.data.logo_url, "project-logos");
}
let fullLogoUrl: string | null | undefined;
if (options.data.full_logo_url !== undefined) {
fullLogoUrl = await uploadAndGetUrl(options.data.full_logo_url, "project-logos");
}
const [projectId, branchId] = await retryTransaction(globalPrismaClient, async (tx) => {
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;

View File

@ -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 {

View File

@ -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",

View File

@ -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() {
)}
/>
<SettingCard title="Project Logo">
<LogoUpload
label="Logo"
value={project.logoUrl}
onValueChange={async (logoUrl) => {
await project.update({ logoUrl });
}}
description="Upload a logo for your project. Recommended size: 200x200px"
type="logo"
/>
<LogoUpload
label="Full Logo"
value={project.fullLogoUrl}
onValueChange={async (fullLogoUrl) => {
await project.update({ fullLogoUrl });
}}
description="Upload a full logo with text. Recommended size: At least 100px tall, landscape format"
type="full-logo"
/>
<Typography variant="secondary" type="footnote">
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.
</Typography>
</SettingCard>
<SettingCard
title="API Key Settings"
description="Configure which types of API keys are allowed in your project."

View File

@ -0,0 +1,135 @@
"use client";
import { fileToBase64 } from '@stackframe/stack-shared/dist/utils/base64';
import { runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises';
import { Button, cn, Typography } from '@stackframe/stack-ui';
import imageCompression from 'browser-image-compression';
import { Upload, X } from 'lucide-react';
import { useState } from 'react';
export async function checkImageUrl(url: string) {
try {
const res = await fetch(url, { method: 'HEAD' });
const buff = await res.blob();
return buff.type.startsWith('image/');
} catch (e) {
return false;
}
}
export function LogoUpload(props: {
label: string,
value?: string | null,
onValueChange: (value: string | null) => void | Promise<void>,
description?: string,
acceptedTypes?: string[],
type: 'logo' | 'full-logo',
}) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-2">
<Typography variant="secondary" type="label">
{props.label}
</Typography>
<div className="flex items-center gap-3">
{props.value ? (
<div className="flex items-center gap-3">
<div className={logoContainerClasses}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={props.value}
alt={props.label}
className="h-full w-full object-contain"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={remove}
disabled={uploading}
>
<X className="h-4 w-4 mr-2" />
Remove
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<div className={cn(placeholderContainerClasses, "cursor-pointer", uploading && "opacity-50 pointer-events-none")} onClick={uploading ? undefined : upload}>
<Upload className="h-6 w-6 text-muted-foreground" />
</div>
{props.description && (
<Typography variant="secondary" type="footnote" className="mt-2">
{props.description}
</Typography>
)}
</div>
)}
</div>
{error && (
<Typography variant="destructive" type="footnote">
{error}
</Typography>
)}
</div>
);
}

View File

@ -80,8 +80,10 @@ it("should be able to provision a new project if client details are correct", as
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Project created by an external integration",
"display_name": "Test project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -126,8 +126,10 @@ it("lists oauth providers", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -39,8 +39,10 @@ it("get project details", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -91,8 +93,10 @@ it("creates and updates the basic project information of a project", async ({ ex
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Updated description",
"display_name": "Updated Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": true,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -159,8 +163,10 @@ it("creates and updates the email config of a project", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -80,8 +80,10 @@ it("should be able to provision a new project if neon client details are correct
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Created with Neon",
"display_name": "Test project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -92,8 +92,10 @@ it("creates a new project", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "Test Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -142,8 +144,10 @@ it("creates a new project with different configurations", async ({ expect }) =>
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Test description",
"display_name": "Test Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": true,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -195,8 +199,10 @@ it("creates a new project with different configurations", async ({ expect }) =>
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "Test Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -239,8 +245,10 @@ it("creates a new project with different configurations", async ({ expect }) =>
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "Test Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -297,8 +305,10 @@ it("creates a new project with different configurations", async ({ expect }) =>
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "Test Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -357,8 +367,10 @@ it("creates a new project with different configurations", async ({ expect }) =>
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "Test Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -400,8 +412,10 @@ it("lists the current projects after creating a new project", async ({ expect })
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
],
},
@ -449,8 +463,10 @@ it("verifies email_theme update persists", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -487,8 +503,10 @@ it("verifies email_theme update persists", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -535,8 +553,10 @@ it("updates trusted domains without modifying allow_localhost", async ({ expect
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -586,8 +606,10 @@ it("updates trusted domains without modifying allow_localhost", async ({ expect
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -237,8 +237,10 @@ it("can customize default user permissions", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -97,8 +97,10 @@ it("creates and updates the basic project information of a project", async ({ ex
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Updated description",
"display_name": "Updated Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": true,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -144,8 +146,10 @@ it("updates the basic project configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -196,8 +200,10 @@ it("updates the project domains configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -254,8 +260,10 @@ it("updates the project domains configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -306,8 +314,10 @@ it("should allow insecure HTTP connections if insecureHttp is true", async ({ ex
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -401,8 +411,10 @@ it("updates the project email configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -458,8 +470,10 @@ it("updates the project email configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -501,8 +515,10 @@ it("updates the project email configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -544,8 +560,10 @@ it("updates the project email configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -601,8 +619,10 @@ it("updates the project email configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -771,8 +791,10 @@ it("updates the project oauth configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -821,8 +843,10 @@ it("updates the project oauth configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -875,8 +899,10 @@ it("updates the project oauth configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -924,8 +950,10 @@ it("updates the project oauth configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -988,8 +1016,10 @@ it("updates the project oauth configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}
@ -1052,8 +1082,10 @@ it("updates the project oauth configuration", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -255,8 +255,10 @@ it("can customize default team permissions", async ({ expect }) => {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "",
"display_name": "New Project",
"full_logo_url": null,
"id": "<stripped UUID>",
"is_production_mode": false,
"logo_url": null,
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -65,6 +65,8 @@ export const projectsCrudAdminReadSchema = yupObject({
id: schemaFields.projectIdSchema.defined(),
display_name: schemaFields.projectDisplayNameSchema.defined(),
description: schemaFields.projectDescriptionSchema.nonNullable().defined(),
logo_url: schemaFields.projectLogoUrlSchema.nullable().optional(),
full_logo_url: schemaFields.projectFullLogoUrlSchema.nullable().optional(),
created_at_millis: schemaFields.projectCreatedAtMillisSchema.defined(),
is_production_mode: schemaFields.projectIsProductionModeSchema.defined(),
/** @deprecated */
@ -112,6 +114,8 @@ export const projectsCrudClientReadSchema = yupObject({
export const projectsCrudAdminUpdateSchema = yupObject({
display_name: schemaFields.projectDisplayNameSchema.optional(),
description: schemaFields.projectDescriptionSchema.optional(),
logo_url: schemaFields.projectLogoUrlSchema.nullable().optional(),
full_logo_url: schemaFields.projectFullLogoUrlSchema.nullable().optional(),
is_production_mode: schemaFields.projectIsProductionModeSchema.optional(),
config: yupObject({
sign_up_enabled: schemaFields.projectSignUpEnabledSchema.optional(),

View File

@ -11,6 +11,8 @@ import { deindent } from "./utils/strings";
import { isValidUrl } from "./utils/urls";
import { isUuid } from "./utils/uuids";
const MAX_IMAGE_SIZE_BASE64_BYTES = 1_000_000; // 1MB
declare module "yup" {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface StringSchema<TType, TContext, TDefault, TFlags> {
@ -433,6 +435,8 @@ export const adminAuthTypeSchema = yupString().oneOf(['admin']).defined();
export const projectIdSchema = yupString().test((v) => v === undefined || v === "internal" || isUuid(v)).meta({ openapiField: { description: _idDescription('project'), exampleValue: 'e0b52f4d-dece-408c-af49-d23061bb0f8d' } });
export const projectBranchIdSchema = yupString().nonEmpty().max(255).meta({ openapiField: { description: _idDescription('project branch'), exampleValue: 'main' } });
export const projectDisplayNameSchema = yupString().meta({ openapiField: { description: _displayNameDescription('project'), exampleValue: 'MyMusic' } });
export const projectLogoUrlSchema = urlSchema.max(MAX_IMAGE_SIZE_BASE64_BYTES).meta({ openapiField: { description: 'URL of the logo for the project. This is usually a close to 1:1 image of the company logo.', exampleValue: 'https://example.com/logo.png' } });
export const projectFullLogoUrlSchema = urlSchema.max(MAX_IMAGE_SIZE_BASE64_BYTES).meta({ openapiField: { description: 'URL of the full logo for the project. This is usually a vertical image with the logo and the company name.', exampleValue: 'https://example.com/full-logo.png' } });
export const projectDescriptionSchema = yupString().nullable().meta({ openapiField: { description: 'A human readable description of the project', exampleValue: 'A music streaming service' } });
export const projectCreatedAtMillisSchema = yupNumber().meta({ openapiField: { description: _createdAtMillisDescription('project'), exampleValue: 1630000000000 } });
export const projectIsProductionModeSchema = yupBoolean().meta({ openapiField: { description: 'Whether the project is in production mode', exampleValue: true } });
@ -567,7 +571,7 @@ export const primaryEmailAuthEnabledSchema = yupBoolean().meta({ openapiField: {
export const primaryEmailVerifiedSchema = yupBoolean().meta({ openapiField: { description: 'Whether the primary email has been verified to belong to this user', exampleValue: true } });
export const userDisplayNameSchema = yupString().nullable().meta({ openapiField: { description: _displayNameDescription('user'), exampleValue: 'John Doe' } });
export const selectedTeamIdSchema = yupString().uuid().meta({ openapiField: { description: 'ID of the team currently selected by the user', exampleValue: 'team-id' } });
export const profileImageUrlSchema = urlSchema.max(1000000).meta({ openapiField: { description: _profileImageUrlDescription('user'), exampleValue: 'https://example.com/image.jpg' } });
export const profileImageUrlSchema = urlSchema.max(MAX_IMAGE_SIZE_BASE64_BYTES).meta({ openapiField: { description: _profileImageUrlDescription('user'), exampleValue: 'https://example.com/image.jpg' } });
export const signedUpAtMillisSchema = yupNumber().meta({ openapiField: { description: _signedUpAtMillisDescription, exampleValue: 1630000000000 } });
export const userClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('user'), exampleValue: { key: 'value' } } });
export const userClientReadOnlyMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientReadOnlyMetaDataDescription('user'), exampleValue: { key: 'value' } } });

View File

@ -113,6 +113,8 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
description: data.description,
createdAt: new Date(data.created_at_millis),
isProductionMode: data.is_production_mode,
logoUrl: data.logo_url,
fullLogoUrl: data.full_logo_url,
config: {
signUpEnabled: data.config.sign_up_enabled,
credentialEnabled: data.config.credential_enabled,

View File

@ -18,6 +18,9 @@ export type AdminProject = {
readonly description: string | null,
readonly createdAt: Date,
readonly isProductionMode: boolean,
readonly logoUrl: string | null | undefined,
readonly fullLogoUrl: string | null | undefined,
readonly config: AdminProjectConfig,
update(this: AdminProject, update: AdminProjectUpdateOptions): Promise<void>,
@ -41,6 +44,8 @@ export type AdminProjectUpdateOptions = {
displayName?: string,
description?: string,
isProductionMode?: boolean,
logoUrl?: string | null,
fullLogoUrl?: string | null,
config?: AdminProjectConfigUpdateOptions,
};
export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptions): ProjectsCrud["Admin"]["Update"] {
@ -48,6 +53,8 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
display_name: options.displayName,
description: options.description,
is_production_mode: options.isProductionMode,
logo_url: options.logoUrl,
full_logo_url: options.fullLogoUrl,
config: {
domains: options.config?.domains?.map((d) => ({
domain: d.domain,

View File

@ -376,6 +376,9 @@ importers:
'@vercel/speed-insights':
specifier: ^1.0.12
version: 1.0.12(next@15.4.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
browser-image-compression:
specifier: ^2.0.2
version: 2.0.2
canvas-confetti:
specifier: ^1.9.2
version: 1.9.3