Email templates and project logos (#852)

https://www.loom.com/share/7628a0b5f14e4367bcde93e4817a50e8

<img width="811" height="437" alt="image"
src="https://github.com/user-attachments/assets/6b02aae0-c723-43d4-92ab-a26e97623d9c"
/>


<!--

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

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Enhance email templates with project branding and dark-mode support,
update schemas and API responses for new logo fields.
> 
>   - **Behavior**:
> - Email templates now support project branding with compact/full logos
and dark-mode variants in `email-rendering.tsx` and `emails.ts`.
> - Project name displays beside logos in Light and Dark themes in
`email-rendering.tsx`.
> - Email rendering groups branding and unsubscribe data for consistent
theming.
>   - **API Changes**:
> - Project payloads and admin/project schemas rename `full_logo` to
`logo_full` and add dark-mode logo fields in `projects.ts` and
`schema-fields.ts`.
> - Updates to `projects.tsx` and `page-client.tsx` to handle new logo
fields.
>   - **Style**:
>     - Improved unsubscribe link contrast in Dark theme in `emails.ts`.
>   - **Database**:
> - Adds `logoFullUrl`, `logoDarkModeUrl`, and `logoFullDarkModeUrl`
columns to `Project` table in `migration.sql`.
> 
> <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 0fbb79db5c. 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 dark‑mode logo support for projects with separate logo and
full‑logo variants.
* Email themes/templates now render project logos and automatically
select light/dark variants with fallbacks.

* **Refactor**
* Project logo fields and public payloads renamed/reorganized to support
the new dark‑mode variants and consistent naming.

* **Tests**
  * Updated test snapshots to reflect the new project logo fields.

* **Chores**
  * Database migration applied to add/rename logo columns.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds light/dark logo variants, renames full-logo fields, and passes
project logos into email themes; updates DB, schemas, API, UI, and
tests.
> 
> - **Database/Prisma**:
> - Rename `Project.fullLogoUrl` to `logoFullUrl`; add `logoDarkModeUrl`
and `logoFullDarkModeUrl`.
> - **Backend**:
> - Email rendering now accepts `themeProps` with `projectLogos` and
spreads into `EmailTheme` (single/batched).
> - Provide project logo URLs to email render/send and internal template
preview routes.
> - Add `@stackframe/emails` components (`Logo`, `FullLogo`,
`ProjectLogo`) with light/dark fallbacks.
> - Projects CRUD: map/upload new logo fields; rename API fields to
`logo_full_url` and add dark-mode fields.
> - **Emails (themes)**:
> - Light/Dark themes render `<ProjectLogo>`; improve dark-theme
unsubscribe link contrast.
> - **Dashboard/UI**:
> - Project settings support `logoFullUrl`, `logoDarkModeUrl`,
`logoFullDarkModeUrl` uploads.
> - Code editor types: add `ThemeProps.projectLogos`, relax TS option,
and Tailwind DTS fix.
> - **Shared Schemas/Types**:
> - Update `schema-fields` and CRUD read/update models to new/extra logo
fields; propagate through template app types.
> - **Tests**:
> - Update snapshots for new project logo fields and theme source
output.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
4d97561839. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
Co-authored-by: Bilal Godil <bg2002@gmail.com>
This commit is contained in:
Zai Shi 2025-11-20 04:12:20 +01:00 committed by GitHub
parent 49ce3c0cc7
commit 4b955ced3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 331 additions and 78 deletions

View File

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Project"
RENAME COLUMN "fullLogoUrl" TO "logoFullUrl";
ALTER TABLE "Project"
ADD COLUMN "logoDarkModeUrl" TEXT,
ADD COLUMN "logoFullDarkModeUrl" TEXT;

View File

@ -30,8 +30,11 @@ model Project {
description String @default("")
isProductionMode Boolean
ownerTeamId String? @db.Uuid
logoUrl String?
fullLogoUrl String?
logoUrl String?
logoFullUrl String?
logoDarkModeUrl String?
logoFullDarkModeUrl String?
projectConfigOverride Json?
stripeAccountId String?

View File

@ -75,6 +75,14 @@ export const POST = createSmartRouteHandler({
{
project: { displayName: tenancy.project.display_name },
previewMode: true,
themeProps: {
projectLogos: {
logoUrl: tenancy.project.logo_url ?? undefined,
logoFullUrl: tenancy.project.logo_full_url ?? undefined,
logoDarkModeUrl: tenancy.project.logo_dark_mode_url ?? undefined,
logoFullDarkModeUrl: tenancy.project.logo_full_dark_mode_url ?? undefined,
},
},
},
);
if (result.status === "error") {

View File

@ -195,6 +195,14 @@ export const POST = createSmartRouteHandler({
project: { displayName: auth.tenancy.project.display_name },
variables,
unsubscribeLink: unsubLinks.get(user.projectUserId),
themeProps: {
projectLogos: {
logoUrl: auth.tenancy.project.logo_url ?? undefined,
logoFullUrl: auth.tenancy.project.logo_full_url ?? undefined,
logoDarkModeUrl: auth.tenancy.project.logo_dark_mode_url ?? undefined,
logoFullDarkModeUrl: auth.tenancy.project.logo_full_dark_mode_url ?? undefined,
},
},
}));
const inputChunks = getChunks(finalInputs, BATCH_SIZE);

View File

@ -42,6 +42,14 @@ export const PATCH = createSmartRouteHandler({
const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, {
variables: { projectDisplayName: tenancy.project.display_name },
previewMode: true,
themeProps: {
projectLogos: {
logoUrl: tenancy.project.logo_url ?? undefined,
logoFullUrl: tenancy.project.logo_full_url ?? undefined,
logoDarkModeUrl: tenancy.project.logo_dark_mode_url ?? undefined,
logoFullDarkModeUrl: tenancy.project.logo_full_dark_mode_url ?? undefined,
},
},
});
if (result.status === "error") {
throw new KnownErrors.EmailRenderingError(result.error);

View File

@ -49,7 +49,15 @@ export async function renderEmailWithTemplate(
user?: { displayName: string | null },
project?: { displayName: string },
variables?: Record<string, any>,
unsubscribeLink?: string,
themeProps?: {
unsubscribeLink?: string,
projectLogos: {
logoUrl?: string,
logoFullUrl?: string,
logoDarkModeUrl?: string,
logoFullDarkModeUrl?: string,
},
},
previewMode?: boolean,
},
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
@ -86,9 +94,12 @@ export async function renderEmailWithTemplate(
if (variables instanceof type.errors) {
throw new Error(variables.summary)
}
const unsubscribeLink = ${previewMode ? "EmailTheme.PreviewProps?.unsubscribeLink" : JSON.stringify(options.unsubscribeLink)};
const themeProps = {
...${JSON.stringify(options.themeProps || {})},
...${previewMode ? "EmailTheme.PreviewProps" : "{}"},
}
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={${JSON.stringify(user)}} project={${JSON.stringify(project)}} />;
const Email = <EmailTheme unsubscribeLink={unsubscribeLink}>
const Email = <EmailTheme {...themeProps}>
{${previewMode ? "EmailTheme.PreviewProps?.children ?? " : ""} EmailTemplateWithProps}
</EmailTheme>;
return {
@ -145,6 +156,14 @@ export async function renderEmailsWithTemplateBatched(
project: { displayName: string },
variables?: Record<string, any>,
unsubscribeLink?: string,
themeProps?: {
projectLogos: {
logoUrl?: string,
logoFullUrl?: string,
logoDarkModeUrl?: string,
logoFullDarkModeUrl?: string,
},
},
}>,
): Promise<Result<Array<{ html: string, text: string, subject?: string, notificationCategory?: string }>, string>> {
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
@ -175,8 +194,12 @@ export async function renderEmailsWithTemplateBatched(
if (variables instanceof type.errors) {
throw new Error(variables.summary)
}
const themeProps = {
...{ projectLogos: input.themeProps?.projectLogos ?? {} },
unsubscribeLink: input.unsubscribeLink,
}
const EmailTemplateWithProps = <EmailTemplate variables={variables} user={input.user} project={input.project} />;
const Email = <EmailTheme unsubscribeLink={input.unsubscribeLink}>
const Email = <EmailTheme {...themeProps}>
{ EmailTemplateWithProps }
</EmailTheme>;
return {
@ -276,9 +299,61 @@ export function findComponentValue(element, targetStackComponent) {
return value;
}`;
// issues with using jsx in external packages, using React.createElement instead
const stackframeEmailsPackage = deindent`
import React from 'react';
import { Img } from '@react-email/components';
export const Subject = (props) => null;
Subject.__stackComponent = "Subject";
export const NotificationCategory = (props) => null;
NotificationCategory.__stackComponent = "NotificationCategory";
export function Logo(props) {
return React.createElement(
"div",
{ className: "flex gap-2 items-center" },
React.createElement(Img, {
src: props.logoUrl,
alt: "Logo",
className: "h-8",
}),
);
}
export function FullLogo(props) {
return React.createElement(Img, {
src: props.logoFullUrl,
alt: "Full Logo",
className: "h-16",
});
}
export function ProjectLogo(props) {
const { mode = "light" } = props;
const {
logoUrl,
logoFullUrl,
logoDarkModeUrl,
logoFullDarkModeUrl,
} = props.data ?? {};
if (mode === "dark" && logoFullDarkModeUrl) {
return React.createElement(FullLogo, { logoFullUrl: logoFullDarkModeUrl });
}
if (mode === "dark" && logoDarkModeUrl) {
return React.createElement(Logo, {
logoUrl: logoDarkModeUrl,
});
}
if (mode === "light" && logoFullUrl) {
return React.createElement(FullLogo, { logoFullUrl });
}
if (mode === "light" && logoUrl) {
return React.createElement(Logo, {
logoUrl,
});
}
return null;
}
`;

View File

@ -398,6 +398,14 @@ export async function sendEmailFromTemplate(options: {
user: { displayName: options.user?.display_name ?? null },
project: { displayName: options.tenancy.project.display_name },
variables,
themeProps: {
projectLogos: {
logoUrl: options.tenancy.project.logo_url ?? undefined,
logoFullUrl: options.tenancy.project.logo_full_url ?? undefined,
logoDarkModeUrl: options.tenancy.project.logo_dark_mode_url ?? undefined,
logoFullDarkModeUrl: options.tenancy.project.logo_full_dark_mode_url ?? undefined,
}
}
}
);
if (result.status === 'error') {

View File

@ -59,7 +59,9 @@ export function getProjectQuery(projectId: string): RawQuery<Promise<Omit<Projec
display_name: row.displayName,
description: row.description,
logo_url: row.logoUrl,
full_logo_url: row.fullLogoUrl,
logo_full_url: row.logoFullUrl,
logo_dark_mode_url: row.logoDarkModeUrl,
logo_full_dark_mode_url: row.logoFullDarkModeUrl,
created_at_millis: new Date(row.createdAt + "Z").getTime(),
is_production_mode: row.isProductionMode,
owner_team_id: row.ownerTeamId,
@ -88,14 +90,13 @@ 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");
}
const logoFields = ['logo_url', 'logo_full_url', 'logo_dark_mode_url', 'logo_full_dark_mode_url'] as const;
const logoUrls: Record<string, string | null | undefined> = {};
let fullLogoUrl: string | null | undefined;
if (options.data.full_logo_url !== undefined) {
fullLogoUrl = await uploadAndGetUrl(options.data.full_logo_url, "project-logos");
for (const field of logoFields) {
if (options.data[field] !== undefined) {
logoUrls[field] = await uploadAndGetUrl(options.data[field], "project-logos");
}
}
const [projectId, branchId] = await retryTransaction(globalPrismaClient, async (tx) => {
@ -110,8 +111,10 @@ export async function createOrUpdateProjectWithLegacyConfig(
description: options.data.description ?? "",
isProductionMode: options.data.is_production_mode ?? false,
ownerTeamId: options.data.owner_team_id,
logoUrl,
fullLogoUrl,
logoUrl: logoUrls['logo_url'],
logoFullUrl: logoUrls['logo_full_url'],
logoDarkModeUrl: logoUrls['logo_dark_mode_url'],
logoFullDarkModeUrl: logoUrls['logo_full_dark_mode_url'],
},
});
@ -142,8 +145,10 @@ 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,
logoUrl: logoUrls['logo_url'],
logoFullUrl: logoUrls['logo_full_url'],
logoDarkModeUrl: logoUrls['logo_dark_mode_url'],
logoFullDarkModeUrl: logoUrls['logo_full_dark_mode_url'],
},
});
branchId = options.branchId;

View File

@ -130,14 +130,34 @@ export default function PageClient() {
<LogoUpload
label="Full Logo"
value={project.fullLogoUrl}
onValueChange={async (fullLogoUrl) => {
await project.update({ fullLogoUrl });
value={project.logoFullUrl}
onValueChange={async (logoFullUrl) => {
await project.update({ logoFullUrl });
}}
description="Upload a full logo with text. Recommended size: At least 100px tall, landscape format"
type="full-logo"
/>
<LogoUpload
label="Logo (Dark Mode)"
value={project.logoDarkModeUrl}
onValueChange={async (logoDarkModeUrl) => {
await project.update({ logoDarkModeUrl });
}}
description="Upload a dark mode version of your logo. Recommended size: 200x200px"
type="logo"
/>
<LogoUpload
label="Full Logo (Dark Mode)"
value={project.logoFullDarkModeUrl}
onValueChange={async (logoFullDarkModeUrl) => {
await project.update({ logoFullDarkModeUrl });
}}
description="Upload a dark mode version of your full logo. 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>

View File

@ -46,6 +46,7 @@ export default function CodeEditor({
skipLibCheck: true,
strict: true,
strictNullChecks: true,
strictFunctionTypes: false,
exactOptionalPropertyTypes: true,
});
runAsynchronously(addTypeFiles(monaco));
@ -55,12 +56,14 @@ export default function CodeEditor({
monaco: Monaco,
moduleName: string,
url: string,
transform?: (content: string) => string,
) => {
try {
const response = await fetch(url);
if (response.ok) {
const content = await response.text();
monaco.languages.typescript.typescriptDefaults.addExtraLib(content, `file:///node_modules/${moduleName}/index.d.ts`);
const transformed = transform ? transform(content) : content;
monaco.languages.typescript.typescriptDefaults.addExtraLib(transformed, `file:///node_modules/${moduleName}/index.d.ts`);
}
} catch (error) {
console.warn(`Failed to fetch type definitions from ${url}:`, error);
@ -93,7 +96,14 @@ export default function CodeEditor({
type ThemeProps = {
children: React.ReactNode;
unsubscribeLink?: string;
projectLogos: {
logoUrl?: string;
logoFullUrl?: string;
logoDarkModeUrl?: string;
logoFullDarkModeUrl?: string;
};
};
const ProjectLogo: React.FC<{data: ThemeProps['projectLogos'], mode: 'light' | 'dark'}>;
}
`,
);
@ -104,18 +114,30 @@ export default function CodeEditor({
const reactEmailPackages = [
'components', 'body', 'button', 'code-block', 'code-inline', 'column',
'container', 'font', 'head', 'heading', 'hr', 'html', 'img', 'link',
'markdown', 'preview', 'render', 'row', 'section', 'tailwind', 'text'
'markdown', 'preview', 'row', 'section', 'tailwind', 'text'
];
await Promise.all([
// latest version of react causes type issue with rendering react-email components
fetchAndAddTypeDefinition(monaco, 'react', 'https://unpkg.com/@types/react@18.0.38/index.d.ts'),
fetchAndAddTypeDefinition(monaco, 'csstype', 'https://unpkg.com/csstype@3.1.3/index.d.ts'),
...reactEmailPackages.map(packageName =>
fetchAndAddTypeDefinition(monaco, `@react-email/${packageName}`, `https://unpkg.com/@react-email/${packageName}/dist/index.d.ts`)
fetchAndAddTypeDefinition(
monaco,
`@react-email/${packageName}`,
`https://unpkg.com/@react-email/${packageName}/dist/index.d.ts`,
packageName === "tailwind" ? transformTailwindTypeFile : undefined
)
),
]);
};
const transformTailwindTypeFile = (content: string) => {
return content.replace(
/}\s*:\s*TailwindProps\)\s*:\s*React\.ReactNode;/,
'}: TailwindProps): JSX.Element | null;'
);
};
return (
<>
<div className="p-3 flex justify-between items-center">

View File

@ -92,15 +92,16 @@ describe("get email theme", () => {
"display_name": "Default Light",
"tsx_source": deindent\`
import { Html, Head, Tailwind, Body, Container, Link } from '@react-email/components';
import { ThemeProps } from "@stackframe/emails"
import { ThemeProps, ProjectLogo } from "@stackframe/emails";
export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) {
return (
<Html>
<Head />
<Tailwind>
<Body className="bg-[#fafbfb] font-sans text-base">
<Container className="bg-white p-[45px] rounded-lg">
<ProjectLogo data={projectLogos} mode="light" />
{children}
</Container>
{unsubscribeLink && (
@ -116,7 +117,7 @@ describe("get email theme", () => {
}
EmailTheme.PreviewProps = {
unsubscribeLink: "https://example.com"
unsubscribeLink: "https://example.com",
} satisfies Partial<ThemeProps>
\` + "\\n",
},

View File

@ -80,9 +80,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": null,
},

View File

@ -126,9 +126,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},

View File

@ -39,9 +39,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -94,9 +96,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -165,9 +169,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},

View File

@ -1,6 +1,6 @@
import { decryptValue, hashKey } from "@stackframe/stack-shared/dist/helpers/vault/client-side";
import { it } from "../../../../../../../helpers";
import { Auth, InternalApiKey, InternalProjectKeys, Project, backendContext, niceBackendFetch } from "../../../../../../backend-helpers";
import { Auth, InternalApiKey, InternalProjectKeys, backendContext, niceBackendFetch } from "../../../../../../backend-helpers";
export async function provisionProject() {
return await niceBackendFetch("/api/v1/integrations/neon/projects/provision", {
@ -81,9 +81,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": null,
},

View File

@ -92,9 +92,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -145,9 +147,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -201,9 +205,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -248,9 +254,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -309,9 +317,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -372,9 +382,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -418,9 +430,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -470,9 +484,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -511,9 +527,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -562,9 +580,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -616,9 +636,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -687,9 +709,11 @@ it("lets user update logo_url to a valid image", 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": "http://localhost:<$NEXT_PUBLIC_STACK_PORT_PREFIX>21/stack-storage/project-logos/<stripped UUID>.png",
"owner_team_id": "<stripped UUID>",
},

View File

@ -237,9 +237,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},

View File

@ -98,9 +98,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -148,9 +150,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -203,9 +207,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -264,9 +270,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -319,9 +327,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -417,9 +427,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -477,9 +489,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -523,9 +537,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -569,9 +585,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -629,9 +647,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -802,9 +822,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -855,9 +877,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -912,9 +936,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -964,9 +990,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -1031,9 +1059,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},
@ -1098,9 +1128,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},

View File

@ -255,9 +255,11 @@ 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_dark_mode_url": null,
"logo_full_dark_mode_url": null,
"logo_full_url": null,
"logo_url": null,
"owner_team_id": "<stripped UUID>",
},

View File

@ -43,15 +43,16 @@ export const emptyEmailTheme = deindent`
`;
export const LightEmailTheme = `import { Html, Head, Tailwind, Body, Container, Link } from '@react-email/components';
import { ThemeProps } from "@stackframe/emails"
import { ThemeProps, ProjectLogo } from "@stackframe/emails";
export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) {
return (
<Html>
<Head />
<Tailwind>
<Body className="bg-[#fafbfb] font-sans text-base">
<Container className="bg-white p-[45px] rounded-lg">
<ProjectLogo data={projectLogos} mode="light" />
{children}
</Container>
{unsubscribeLink && (
@ -67,26 +68,27 @@ export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
}
EmailTheme.PreviewProps = {
unsubscribeLink: "https://example.com"
unsubscribeLink: "https://example.com",
} satisfies Partial<ThemeProps>
`;
const DarkEmailTheme = `import { Html, Head, Tailwind, Body, Container, Link } from '@react-email/components';
import { ThemeProps } from "@stackframe/emails"
import { ThemeProps, ProjectLogo } from "@stackframe/emails";
export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) {
return (
<Html>
<Head />
<Tailwind>
<Body className="bg-[#323232] font-sans text-white">
<Container className="bg-black p-[45px] rounded-lg">
<ProjectLogo data={projectLogos} mode="dark" />
{children}
</Container>
{unsubscribeLink && (
<div className="p-4">
<Link href={unsubscribeLink}>Click here{" "}</Link>
<Link href={unsubscribeLink} className="text-gray-300">Click here{" "}</Link>
to unsubscribe from these emails
</div>
)}
@ -97,7 +99,7 @@ export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
}
EmailTheme.PreviewProps = {
unsubscribeLink: "https://example.com"
unsubscribeLink: "https://example.com",
} satisfies Partial<ThemeProps>
`;

View File

@ -66,7 +66,9 @@ export const projectsCrudAdminReadSchema = yupObject({
display_name: schemaFields.projectDisplayNameSchema.defined(),
description: schemaFields.projectDescriptionSchema.nonNullable().defined(),
logo_url: schemaFields.projectLogoUrlSchema.nullable().optional(),
full_logo_url: schemaFields.projectFullLogoUrlSchema.nullable().optional(),
logo_full_url: schemaFields.projectLogoFullUrlSchema.nullable().optional(),
logo_dark_mode_url: schemaFields.projectLogoDarkModeUrlSchema.nullable().optional(),
logo_full_dark_mode_url: schemaFields.projectLogoFullDarkModeUrlSchema.nullable().optional(),
created_at_millis: schemaFields.projectCreatedAtMillisSchema.defined(),
is_production_mode: schemaFields.projectIsProductionModeSchema.defined(),
owner_team_id: schemaFields.yupString().nullable().defined(),
@ -117,7 +119,9 @@ 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(),
logo_full_url: schemaFields.projectLogoFullUrlSchema.nullable().optional(),
logo_dark_mode_url: schemaFields.projectLogoDarkModeUrlSchema.nullable().optional(),
logo_full_dark_mode_url: schemaFields.projectLogoFullDarkModeUrlSchema.nullable().optional(),
is_production_mode: schemaFields.projectIsProductionModeSchema.optional(),
config: yupObject({
sign_up_enabled: schemaFields.projectSignUpEnabledSchema.optional(),

View File

@ -503,7 +503,9 @@ export const projectIdSchema = yupString().test((v) => v === undefined || v ===
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 projectLogoFullUrlSchema = 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 projectLogoDarkModeUrlSchema = urlSchema.max(MAX_IMAGE_SIZE_BASE64_BYTES).meta({ openapiField: { description: 'URL of the dark mode logo for the project. This is usually a close to 1:1 image of the company logo optimized for dark backgrounds.', exampleValue: 'https://example.com/logo-dark.png' } });
export const projectLogoFullDarkModeUrlSchema = urlSchema.max(MAX_IMAGE_SIZE_BASE64_BYTES).meta({ openapiField: { description: 'URL of the dark mode full logo for the project. This is usually a vertical image with the logo and the company name optimized for dark backgrounds.', exampleValue: 'https://example.com/full-logo-dark.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 } });

View File

@ -127,7 +127,9 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
isProductionMode: data.is_production_mode,
ownerTeamId: data.owner_team_id,
logoUrl: data.logo_url,
fullLogoUrl: data.full_logo_url,
logoFullUrl: data.logo_full_url,
logoDarkModeUrl: data.logo_dark_mode_url,
logoFullDarkModeUrl: data.logo_full_dark_mode_url,
config: {
signUpEnabled: data.config.sign_up_enabled,
credentialEnabled: data.config.credential_enabled,

View File

@ -21,7 +21,9 @@ export type AdminProject = {
readonly isProductionMode: boolean,
readonly ownerTeamId: string | null,
readonly logoUrl: string | null | undefined,
readonly fullLogoUrl: string | null | undefined,
readonly logoFullUrl: string | null | undefined,
readonly logoDarkModeUrl: string | null | undefined,
readonly logoFullDarkModeUrl: string | null | undefined,
readonly config: AdminProjectConfig,
@ -47,7 +49,9 @@ export type AdminProjectUpdateOptions = {
description?: string,
isProductionMode?: boolean,
logoUrl?: string | null,
fullLogoUrl?: string | null,
logoFullUrl?: string | null,
logoDarkModeUrl?: string | null,
logoFullDarkModeUrl?: string | null,
config?: AdminProjectConfigUpdateOptions,
};
export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptions): ProjectsCrud["Admin"]["Update"] {
@ -56,7 +60,9 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
description: options.description,
is_production_mode: options.isProductionMode,
logo_url: options.logoUrl,
full_logo_url: options.fullLogoUrl,
logo_full_url: options.logoFullUrl,
logo_dark_mode_url: options.logoDarkModeUrl,
logo_full_dark_mode_url: options.logoFullDarkModeUrl,
config: {
domains: options.config?.domains?.map((d) => ({
domain: d.domain,