mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge dev into added_docs
This commit is contained in:
commit
bf759151d8
2
.github/workflows/docker-server-test.yaml
vendored
2
.github/workflows/docker-server-test.yaml
vendored
@ -13,7 +13,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
@ -53,3 +53,4 @@ STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access to
|
||||
STACK_SETUP_ADMIN_GITHUB_ID=# enter the account ID of the admin user here, and after running the seed script they will be able to access the internal project in the Stack dashboard. Optional, don't specify it for default value
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=# enter the OpenTelemetry endpoint here. Optional, default is `http://localhost:4318`
|
||||
STACK_INTEGRATION_CLIENTS_CONFIG=# a list of oidc-provider clients for integrations. If not provided, disables integrations
|
||||
STACK_FREESTYLE_API_KEY=# enter you freestyle.sh api key
|
||||
|
||||
@ -43,3 +43,4 @@ STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes
|
||||
|
||||
STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": "neon-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}, {"client_id": "custom-local", "client_secret": "custom-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}]
|
||||
CRON_SECRET=mock_cron_secret
|
||||
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
"@vercel/otel": "^1.10.4",
|
||||
"bcrypt": "^5.1.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"freestyle-sandboxes": "^0.0.92",
|
||||
"jose": "^5.2.2",
|
||||
"json-diff": "^1.0.6",
|
||||
"next": "15.2.3",
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { EMAIL_THEMES, renderEmailWithTheme } from "@/lib/email-themes";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
|
||||
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
summary: "Render email theme",
|
||||
description: "Renders HTML content using the specified email theme",
|
||||
tags: ["Emails"],
|
||||
},
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: yupString().oneOf(["admin"]).defined(),
|
||||
}).nullable(),
|
||||
body: yupObject({
|
||||
theme: yupString().oneOf(Object.keys(EMAIL_THEMES) as (keyof typeof EMAIL_THEMES)[]).defined(),
|
||||
preview_html: yupString().defined(),
|
||||
}),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
html: yupString().defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
async handler({ body }) {
|
||||
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
|
||||
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
|
||||
}
|
||||
const result = await renderEmailWithTheme(body.preview_html, body.theme);
|
||||
if ("error" in result) {
|
||||
captureError('render-email', new StackAssertionError("Error rendering email with theme", { result }));
|
||||
throw new KnownErrors.EmailRenderingError(result.error);
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: {
|
||||
html: result.html,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -1,11 +1,19 @@
|
||||
import { renderEmailWithTheme } from "@/lib/email-themes";
|
||||
import { getEmailConfig, sendEmail } from "@/lib/emails";
|
||||
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
|
||||
import { prismaClient } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { getUser } from "../../users/crud";
|
||||
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
|
||||
import { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
|
||||
|
||||
type UserResult = {
|
||||
user_id: string,
|
||||
user_email?: string,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
};
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
@ -17,7 +25,7 @@ export const POST = createSmartRouteHandler({
|
||||
tenancy: adaptSchema.defined(),
|
||||
}).defined(),
|
||||
body: yupObject({
|
||||
user_id: yupString().defined(),
|
||||
user_ids: yupArray(yupString().defined()).defined(),
|
||||
html: yupString().defined(),
|
||||
subject: yupString().defined(),
|
||||
notification_category_name: yupString().defined(),
|
||||
@ -28,60 +36,108 @@ export const POST = createSmartRouteHandler({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
user_email: yupString().defined(),
|
||||
results: yupArray(yupObject({
|
||||
user_id: yupString().defined(),
|
||||
user_email: yupString().optional(),
|
||||
success: yupBoolean().defined(),
|
||||
error: yupString().optional(),
|
||||
})).defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
handler: async ({ body, auth }) => {
|
||||
if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) {
|
||||
throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set");
|
||||
}
|
||||
if (auth.tenancy.config.email_config.type === "shared") {
|
||||
throw new StatusError(400, "Cannot send custom emails when using shared email config");
|
||||
}
|
||||
const user = await getUser({ userId: body.user_id, tenancyId: auth.tenancy.id });
|
||||
if (!user) {
|
||||
throw new StatusError(404, "User not found");
|
||||
}
|
||||
if (!user.primary_email) {
|
||||
throw new StatusError(400, "User does not have a primary email");
|
||||
}
|
||||
const emailConfig = await getEmailConfig(auth.tenancy);
|
||||
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
|
||||
if (!notificationCategory) {
|
||||
throw new StatusError(404, "Notification category not found");
|
||||
}
|
||||
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.id, notificationCategory.id);
|
||||
if (!isNotificationEnabled) {
|
||||
throw new StatusError(400, "User has disabled notifications for this category");
|
||||
}
|
||||
|
||||
let html = body.html;
|
||||
if (notificationCategory.can_disable) {
|
||||
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
|
||||
tenancy: auth.tenancy,
|
||||
method: {},
|
||||
data: {
|
||||
user_id: user.id,
|
||||
notification_category_id: notificationCategory.id,
|
||||
const users = await prismaClient.projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
projectUserId: {
|
||||
in: body.user_ids,
|
||||
},
|
||||
callbackUrl: undefined
|
||||
});
|
||||
const unsubscribeLink = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
|
||||
unsubscribeLink.pathname = "/api/v1/emails/unsubscribe-link";
|
||||
unsubscribeLink.searchParams.set("code", code);
|
||||
html += `<br /><a href="${unsubscribeLink.toString()}">Click here to unsubscribe</a>`;
|
||||
},
|
||||
include: {
|
||||
contactChannels: true,
|
||||
},
|
||||
});
|
||||
const userMap = new Map(users.map(user => [user.projectUserId, user]));
|
||||
const userSendErrors: Map<string, string> = new Map();
|
||||
const userPrimaryEmails: Map<string, string> = new Map();
|
||||
|
||||
for (const userId of body.user_ids) {
|
||||
const user = userMap.get(userId);
|
||||
if (!user) {
|
||||
userSendErrors.set(userId, "User not found");
|
||||
continue;
|
||||
}
|
||||
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.projectUserId, notificationCategory.id);
|
||||
if (!isNotificationEnabled) {
|
||||
userSendErrors.set(userId, "User has disabled notifications for this category");
|
||||
continue;
|
||||
}
|
||||
const primaryEmail = user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value;
|
||||
if (!primaryEmail) {
|
||||
userSendErrors.set(userId, "User does not have a primary email");
|
||||
continue;
|
||||
}
|
||||
userPrimaryEmails.set(userId, primaryEmail);
|
||||
|
||||
let unsubscribeLink: string | null = null;
|
||||
if (notificationCategory.can_disable) {
|
||||
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
|
||||
tenancy: auth.tenancy,
|
||||
method: {},
|
||||
data: {
|
||||
user_id: user.projectUserId,
|
||||
notification_category_id: notificationCategory.id,
|
||||
},
|
||||
callbackUrl: undefined
|
||||
});
|
||||
const unsubUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
|
||||
unsubUrl.pathname = "/api/v1/emails/unsubscribe-link";
|
||||
unsubUrl.searchParams.set("code", code);
|
||||
unsubscribeLink = unsubUrl.toString();
|
||||
}
|
||||
|
||||
const renderedEmail = await renderEmailWithTheme(body.html, auth.tenancy.config.email_theme, unsubscribeLink);
|
||||
if ("error" in renderedEmail) {
|
||||
userSendErrors.set(userId, "There was an error rendering the email");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
tenancyId: auth.tenancy.id,
|
||||
emailConfig,
|
||||
to: primaryEmail,
|
||||
subject: body.subject,
|
||||
html: renderedEmail.html,
|
||||
text: renderedEmail.text,
|
||||
});
|
||||
} catch {
|
||||
userSendErrors.set(userId, "Failed to send email");
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
tenancyId: auth.tenancy.id,
|
||||
emailConfig: await getEmailConfig(auth.tenancy),
|
||||
to: user.primary_email,
|
||||
subject: body.subject,
|
||||
html,
|
||||
});
|
||||
const results: UserResult[] = body.user_ids.map((userId) => ({
|
||||
user_id: userId,
|
||||
user_email: userPrimaryEmails.get(userId),
|
||||
success: !userSendErrors.has(userId),
|
||||
error: userSendErrors.get(userId),
|
||||
}));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: 'json',
|
||||
body: {
|
||||
user_email: user.primary_email,
|
||||
},
|
||||
body: { results },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@ -394,6 +394,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza
|
||||
sender_name: renderedConfig.emails.server.senderName,
|
||||
sender_email: renderedConfig.emails.server.senderEmail,
|
||||
},
|
||||
email_theme: renderedConfig.emails.theme,
|
||||
|
||||
team_creator_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamCreator)
|
||||
.filter(([_, perm]) => perm)
|
||||
|
||||
77
apps/backend/src/lib/email-themes.tsx
Normal file
77
apps/backend/src/lib/email-themes.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
|
||||
import { Result } from "@stackframe/stack-shared/dist/utils/results";
|
||||
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import { FreestyleSandboxes } from 'freestyle-sandboxes';
|
||||
|
||||
export async function renderEmailWithTheme(
|
||||
htmlContent: string,
|
||||
theme: keyof typeof EMAIL_THEMES,
|
||||
unsubscribeLink: string | null = null,
|
||||
) {
|
||||
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
|
||||
const unsubscribeLinkHtml = unsubscribeLink ? `<br /><br /><a href="${unsubscribeLink}">Click here to unsubscribe</a>` : "";
|
||||
if (["development", "test"].includes(getNodeEnvironment()) && apiKey === "mock_stack_freestyle_key") {
|
||||
return {
|
||||
html: `<div>Mock api key detected, returning mock data ${unsubscribeLinkHtml}</div>`,
|
||||
text: "Mock api key detected, returning mock data",
|
||||
};
|
||||
}
|
||||
const freestyle = new FreestyleSandboxes({ apiKey });
|
||||
const TemplateComponent = EMAIL_THEMES[theme];
|
||||
const script = deindent`
|
||||
import React from 'react';
|
||||
import { render, Html, Tailwind, Body } from '@react-email/components';
|
||||
${TemplateComponent}
|
||||
export default async () => {
|
||||
const Email = <EmailTheme>${htmlContent + unsubscribeLinkHtml}</EmailTheme>
|
||||
return {
|
||||
html: await render(Email),
|
||||
text: await render(Email, { plainText: true }),
|
||||
};
|
||||
}
|
||||
`;
|
||||
const nodeModules = {
|
||||
"@react-email/components": "0.1.1",
|
||||
};
|
||||
const output = await freestyle.executeScript(script, { nodeModules });
|
||||
if ("error" in output) {
|
||||
return Result.error(output.error as string);
|
||||
}
|
||||
return output.result as { html: string, text: string };
|
||||
}
|
||||
|
||||
|
||||
const LightEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<div className="bg-white text-slate-800 p-4 rounded-lg max-w-[600px] mx-auto leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}`;
|
||||
|
||||
|
||||
const DarkEmailTheme = `function EmailTheme({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg max-w-[600px] mx-auto leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}`;
|
||||
|
||||
|
||||
export const EMAIL_THEMES = {
|
||||
'default-light': LightEmailTheme,
|
||||
'default-dark': DarkEmailTheme,
|
||||
} as const;
|
||||
@ -174,6 +174,7 @@ export async function createOrUpdateProject(
|
||||
senderName: dataOptions.email_config.sender_name,
|
||||
senderEmail: dataOptions.email_config.sender_email,
|
||||
} satisfies OrganizationRenderedConfig['emails']['server'] : undefined,
|
||||
'emails.theme': dataOptions.email_theme,
|
||||
// ======================= rbac =======================
|
||||
'rbac.defaultPermissions.teamMember': translateDefaultPermissions(dataOptions.team_member_default_permissions),
|
||||
'rbac.defaultPermissions.teamCreator': translateDefaultPermissions(dataOptions.team_creator_default_permissions),
|
||||
|
||||
@ -23,8 +23,8 @@ export class GithubProvider extends OAuthBaseProvider {
|
||||
baseScope: "user:email",
|
||||
// GitHub token does not expire except for lack of use in a year
|
||||
// We set a default of 1 year
|
||||
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation#token-expired-due-to-lack-of-use=
|
||||
defaultAccessTokenExpiresInMillis: 1000 * 60 * 60 * 24 * 365,
|
||||
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation#user-token-expired-due-to-github-app-configuration
|
||||
defaultAccessTokenExpiresInMillis: 1000 * 60 * 60 * 8, // 8 hours
|
||||
...options,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -11,3 +11,4 @@ NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local developm
|
||||
# Misc, optional
|
||||
NEXT_PUBLIC_STACK_HEAD_TAGS='[{ "tagName": "script", "attributes": {}, "innerHTML": "// insert head tags here" }]'
|
||||
STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate
|
||||
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
|
||||
|
||||
@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { SettingCard } from "@/components/settings";
|
||||
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import { ActionDialog, Button, Card, Separator, Typography } from "@stackframe/stack-ui";
|
||||
import { Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
|
||||
type ThemeType = 'default-light' | 'default-dark';
|
||||
|
||||
type Theme = {
|
||||
id: ThemeType,
|
||||
name: string,
|
||||
};
|
||||
|
||||
const themes: Theme[] = [
|
||||
{
|
||||
id: 'default-light',
|
||||
name: 'Light Theme',
|
||||
},
|
||||
{
|
||||
id: 'default-dark',
|
||||
name: 'Dark Theme',
|
||||
},
|
||||
];
|
||||
|
||||
export default function PageClient() {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const project = stackAdminApp.useProject();
|
||||
const activeTheme = project.config.emailTheme;
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogSelectedTheme, setDialogSelectedTheme] = useState<ThemeType>(activeTheme);
|
||||
|
||||
const handleThemeSelect = (themeId: ThemeType) => {
|
||||
setDialogSelectedTheme(themeId);
|
||||
};
|
||||
|
||||
const handleSaveTheme = async () => {
|
||||
await project.update({
|
||||
config: { emailTheme: dialogSelectedTheme }
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setDialogSelectedTheme(activeTheme);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const selectedThemeData = themes.find(t => t.id === activeTheme) ?? throwErr(`Unknown theme ${activeTheme}`, { activeTheme });
|
||||
|
||||
return (
|
||||
<PageLayout title="Email Themes" description="Customize email themes for your project">
|
||||
<SettingCard
|
||||
title="Active Theme"
|
||||
description={`Currently using ${selectedThemeData.name}`}
|
||||
>
|
||||
<ThemePreview themeId={activeTheme} />
|
||||
<ActionDialog
|
||||
trigger={<Button onClick={handleOpenDialog} className="ml-auto w-min">Set Theme</Button>}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="Select Email Theme"
|
||||
cancelButton
|
||||
okButton={{
|
||||
label: "Save Theme",
|
||||
onClick: handleSaveTheme
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{themes.map((theme) => (
|
||||
<ThemeOption
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={dialogSelectedTheme === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ActionDialog>
|
||||
</SettingCard>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeOption({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: Theme,
|
||||
isSelected: boolean,
|
||||
onSelect: (themeId: ThemeType) => void,
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:ring-1 transition-all"
|
||||
onClick={() => onSelect(theme.id)}
|
||||
>
|
||||
<div className="p-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography className="font-medium text-lg">{theme.name}</Typography>
|
||||
{isSelected && (
|
||||
<div className="bg-blue-500 text-white rounded-full w-6 h-6 p-1 flex items-center justify-center">
|
||||
<Check />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<ThemePreview themeId={theme.id} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreview({ themeId }: { themeId: ThemeType }) {
|
||||
const previewEmailHtml = deindent`
|
||||
<div>
|
||||
<h2 className="mb-4 text-2xl font-bold">
|
||||
Header text
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
Body text content with some additional information.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const stackAdminApp = useAdminApp();
|
||||
const previewHtml = stackAdminApp.useEmailThemePreview(themeId, previewEmailHtml);
|
||||
return (
|
||||
<iframe srcDoc={previewHtml} className="mx-auto pointer-events-none" />
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Email Themes",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
|
||||
import { FormDialog } from "@/components/form-dialog";
|
||||
import { InputField, SelectField, TextAreaField } from "@/components/form-fields";
|
||||
import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table";
|
||||
import { SettingCard, SettingText } from "@/components/settings";
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { AdminEmailConfig, AdminProject, AdminSentEmail, ServerUser, UserAvatar } from "@stackframe/stack";
|
||||
@ -333,40 +333,89 @@ function SendEmailDialog(props: {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sharedSmtpDialogOpen, setSharedSmtpDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<ServerUser | null>(null);
|
||||
const [selectedUsers, setSelectedUsers] = useState<ServerUser[]>([]);
|
||||
const [stage, setStage] = useState<'recipients' | 'data'>('recipients');
|
||||
|
||||
const handleSend = async (formData: { subject: string, content: string, notificationCategoryName: string }) => {
|
||||
if (!selectedUser) {
|
||||
const handleSend = async (formData: { subject?: string, content?: string, notificationCategoryName?: string }) => {
|
||||
if (!formData.subject || !formData.content || !formData.notificationCategoryName) {
|
||||
// Should never happen. These fields are only optional during recipient stage.
|
||||
throwErr("Missing required fields", { formData });
|
||||
}
|
||||
|
||||
await stackAdminApp.sendEmail({
|
||||
userIds: selectedUsers.map(user => user.id),
|
||||
subject: formData.subject,
|
||||
content: formData.content,
|
||||
notificationCategoryName: formData.notificationCategoryName,
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
setStage('recipients');
|
||||
toast({
|
||||
title: "Email sent",
|
||||
description: "Email was successfully sent",
|
||||
variant: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (selectedUsers.length === 0) {
|
||||
toast({
|
||||
title: "No recipients selected",
|
||||
description: "Please select at least one recipient to send the email.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return "prevent-close-and-prevent-reset";
|
||||
return "prevent-close" as const;
|
||||
}
|
||||
try {
|
||||
await stackAdminApp.sendEmail({
|
||||
userId: selectedUser.id,
|
||||
subject: formData.subject,
|
||||
content: formData.content,
|
||||
notificationCategoryName: formData.notificationCategoryName,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error sending email",
|
||||
description: "The email could not be sent. The user may have unsubscribed from this notification category.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedUser(null);
|
||||
toast({
|
||||
title: "Email sent",
|
||||
description: "Email was successfully sent.",
|
||||
variant: 'success',
|
||||
});
|
||||
setStage('data');
|
||||
return "prevent-close" as const;
|
||||
};
|
||||
|
||||
const handleBack = async () => {
|
||||
setStage('recipients');
|
||||
return "prevent-close" as const;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setStage('recipients');
|
||||
setSelectedUsers([]);
|
||||
};
|
||||
|
||||
const renderRecipientsBar = () => (
|
||||
<div className="mb-4">
|
||||
<Typography className="font-medium mb-2">Recipients</Typography>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{selectedUsers.map((user) => (
|
||||
<div key={user.id} className="relative group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<UserAvatar user={user} size={32} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
|
||||
{user.primaryEmail}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{stage === 'recipients' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute -top-2 -right-2 h-4 w-4 rounded-full p-0 hover:bg-red-100 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => setSelectedUsers(users => users.filter(u => u.id !== user.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -396,67 +445,70 @@ function SendEmailDialog(props: {
|
||||
</ActionDialog>
|
||||
<FormDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={handleClose}
|
||||
title="Send Email"
|
||||
cancelButton
|
||||
okButton={{ label: "Send" }}
|
||||
onSubmit={handleSend}
|
||||
formSchema={yup.object({
|
||||
subject: yup.string().defined(),
|
||||
content: yup.string().defined(),
|
||||
notificationCategoryName: yup.string().oneOf(['Transactional', 'Marketing']).label("notification category").defined(),
|
||||
})}
|
||||
cancelButton={stage === "recipients" ?
|
||||
{ label: 'Cancel', onClick: async () => handleClose() } :
|
||||
{ label: 'Back', onClick: handleBack }
|
||||
}
|
||||
okButton={stage === 'recipients' ?
|
||||
{ label: 'Next' } :
|
||||
{ label: 'Send' }
|
||||
}
|
||||
onSubmit={stage === 'recipients' ? handleNext : handleSend}
|
||||
formSchema={stage === "recipients" ?
|
||||
yup.object({
|
||||
subject: yup.string().optional(),
|
||||
content: yup.string().optional(),
|
||||
notificationCategoryName: yup.string().optional(),
|
||||
}) :
|
||||
yup.object({
|
||||
subject: yup.string().defined(),
|
||||
content: yup.string().defined(),
|
||||
notificationCategoryName: yup.string().oneOf(['Transactional', 'Marketing']).label("notification category").defined(),
|
||||
})
|
||||
}
|
||||
render={(form) => (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Typography className="font-medium mb-2">Recipient</Typography>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{selectedUser && (
|
||||
<Tooltip key={selectedUser.id}>
|
||||
<TooltipTrigger>
|
||||
<UserAvatar user={selectedUser} size={32} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
|
||||
{selectedUser.primaryEmail}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{renderRecipientsBar()}
|
||||
{stage === 'recipients' ? (
|
||||
<TeamMemberSearchTable
|
||||
action={(user) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
disabled={selectedUser?.id === user.id}
|
||||
onClick={() => setSelectedUsers(users =>
|
||||
users.some(u => u.id === user.id)
|
||||
? users.filter(u => u.id !== user.id)
|
||||
: [...users, user]
|
||||
)}
|
||||
>
|
||||
{selectedUser?.id === user.id ? 'Selected' : 'Select'}
|
||||
{selectedUsers.some(u => u.id === user.id) ? 'Remove' : 'Add'}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<InputField label="Subject" name="subject" control={form.control} type="text" required />
|
||||
{/* TODO: fetch notification categories here instead of hardcoding these two */}
|
||||
<SelectField
|
||||
label="Notification Category"
|
||||
name="notificationCategoryName"
|
||||
control={form.control}
|
||||
options={[
|
||||
{ label: "Transactional", value: 'Transactional' },
|
||||
{ label: "Marketing", value: 'Marketing' },
|
||||
]}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Email Content"
|
||||
name="content"
|
||||
control={form.control}
|
||||
rows={10}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<InputField label="Subject" name="subject" control={form.control} type="text" required />
|
||||
{/* TODO: fetch notification categories here instead of hardcoding these two */}
|
||||
<SelectField
|
||||
label="Notification Category"
|
||||
name="notificationCategoryName"
|
||||
control={form.control}
|
||||
options={[
|
||||
{ label: "Transactional", value: 'Transactional' },
|
||||
{ label: "Marketing", value: 'Marketing' },
|
||||
]}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Email Content"
|
||||
name="content"
|
||||
control={form.control}
|
||||
rows={10}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -32,13 +32,14 @@ import {
|
||||
LucideIcon,
|
||||
Mail,
|
||||
Menu,
|
||||
Palette,
|
||||
Settings,
|
||||
Settings2,
|
||||
ShieldEllipsis,
|
||||
SquarePen,
|
||||
User,
|
||||
Users,
|
||||
Webhook,
|
||||
SquarePen
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
@ -58,6 +59,7 @@ type Item = {
|
||||
icon: LucideIcon,
|
||||
regex: RegExp,
|
||||
type: 'item',
|
||||
requiresDevFeatureFlag?: boolean,
|
||||
};
|
||||
|
||||
type Hidden = {
|
||||
@ -183,6 +185,14 @@ const navigationItems: (Label | Item | Hidden)[] = [
|
||||
icon: SquarePen,
|
||||
type: 'item'
|
||||
},
|
||||
{
|
||||
name: "Themes",
|
||||
href: "/email-themes",
|
||||
regex: /^\/projects\/[^\/]+\/email-themes$/,
|
||||
icon: Palette,
|
||||
type: 'item',
|
||||
requiresDevFeatureFlag: true,
|
||||
},
|
||||
{
|
||||
name: "Configuration",
|
||||
type: 'label'
|
||||
@ -277,7 +287,7 @@ function UserBreadcrumbItem(props: { userId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function NavItem({ item, href, onClick }: { item: Item, href: string, onClick?: () => void}) {
|
||||
function NavItem({ item, href, onClick }: { item: Item, href: string, onClick?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const selected = useMemo(() => {
|
||||
let pathnameWithoutTrailingSlash = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||
@ -320,13 +330,19 @@ function SidebarContent({ projectId, onNavigate }: { projectId: string, onNaviga
|
||||
{item.name}
|
||||
</Typography>;
|
||||
} else if (item.type === 'item') {
|
||||
if (
|
||||
item.requiresDevFeatureFlag &&
|
||||
!JSON.parse(getPublicEnvVar("NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS") || "[]").includes(projectId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <div key={index} className="flex px-2">
|
||||
<NavItem item={item} onClick={onNavigate} href={`/projects/${projectId}${item.href}`}/>
|
||||
<NavItem item={item} onClick={onNavigate} href={`/projects/${projectId}${item.href}`} />
|
||||
</div>;
|
||||
}
|
||||
})}
|
||||
|
||||
<div className="flex-grow"/>
|
||||
<div className="flex-grow" />
|
||||
|
||||
<div className="py-2 px-2 flex">
|
||||
<NavItem
|
||||
@ -426,7 +442,7 @@ function HeaderBreadcrumb({
|
||||
{name.item}
|
||||
</Link>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator/>
|
||||
<BreadcrumbSeparator />
|
||||
</Fragment> :
|
||||
<BreadcrumbPage key={index}>
|
||||
<Link href={name.href}>
|
||||
@ -481,7 +497,7 @@ export default function SidebarLayout(props: { projectId: string, children?: Rea
|
||||
/>
|
||||
{getPublicEnvVar("NEXT_PUBLIC_STACK_EMULATOR_ENABLED") === "true" ?
|
||||
<ThemeToggle /> :
|
||||
<UserButton colorModeToggle={() => setTheme(resolvedTheme === 'light' ? 'dark' : 'light')}/>
|
||||
<UserButton colorModeToggle={() => setTheme(resolvedTheme === 'light' ? 'dark' : 'light')} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -17,6 +17,7 @@ const _inlineEnvVars = {
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY,
|
||||
NEXT_PUBLIC_STACK_URL: process.env.NEXT_PUBLIC_STACK_URL,
|
||||
NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: process.env.NEXT_PUBLIC_STACK_INBUCKET_WEB_URL,
|
||||
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: process.env.NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS,
|
||||
} as const;
|
||||
|
||||
// This will be replaced with the actual env vars after a docker build
|
||||
@ -37,6 +38,7 @@ const _postBuildEnvVars = {
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY",
|
||||
NEXT_PUBLIC_STACK_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_URL",
|
||||
NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_INBUCKET_WEB_URL",
|
||||
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS",
|
||||
} satisfies typeof _inlineEnvVars;
|
||||
|
||||
// If this is not replaced with "true", then we will not use inline env vars
|
||||
|
||||
@ -52,6 +52,7 @@ it("should be able to provision a new project if client details are correct", as
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [
|
||||
{ "id": "github" },
|
||||
{ "id": "google" },
|
||||
|
||||
@ -106,6 +106,7 @@ it("lists oauth providers", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [{ "id": "google" }],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
|
||||
@ -52,6 +52,7 @@ it("should be able to provision a new project if neon client details are correct
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [
|
||||
{ "id": "github" },
|
||||
{ "id": "google" },
|
||||
|
||||
@ -78,6 +78,7 @@ it("creates a new project", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -128,6 +129,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
|
||||
"credential_enabled": false,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": true,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -175,6 +177,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [{ "id": "google" }],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -224,6 +227,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -282,6 +286,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
|
||||
"type": "standard",
|
||||
"username": "test username",
|
||||
},
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -342,6 +347,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
|
||||
},
|
||||
],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -385,6 +391,7 @@ it("lists the current projects after creating a new project", async ({ expect })
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -408,3 +415,126 @@ it("lists the current projects after creating a new project", async ({ expect })
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("verifies email_theme update persists", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
const patchResponse = await niceBackendFetch("/api/v1/internal/projects/current", {
|
||||
method: "PATCH",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
config: {
|
||||
email_theme: "default-dark"
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(patchResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"config": {
|
||||
"allow_localhost": true,
|
||||
"allow_team_api_keys": false,
|
||||
"allow_user_api_keys": false,
|
||||
"client_team_creation_enabled": false,
|
||||
"client_user_deletion_enabled": false,
|
||||
"create_team_on_sign_up": false,
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-dark",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
"oauth_providers": [],
|
||||
"passkey_enabled": false,
|
||||
"sign_up_enabled": true,
|
||||
"team_creator_default_permissions": [{ "id": "team_admin" }],
|
||||
"team_member_default_permissions": [{ "id": "team_member" }],
|
||||
"user_default_permissions": [],
|
||||
},
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"description": "",
|
||||
"display_name": "New Project",
|
||||
"id": "<stripped UUID>",
|
||||
"is_production_mode": false,
|
||||
"user_count": 0,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/projects/current", {
|
||||
accessType: "admin"
|
||||
});
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"config": {
|
||||
"allow_localhost": true,
|
||||
"allow_team_api_keys": false,
|
||||
"allow_user_api_keys": false,
|
||||
"client_team_creation_enabled": false,
|
||||
"client_user_deletion_enabled": false,
|
||||
"create_team_on_sign_up": false,
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-dark",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
"oauth_providers": [],
|
||||
"passkey_enabled": false,
|
||||
"sign_up_enabled": true,
|
||||
"team_creator_default_permissions": [{ "id": "team_admin" }],
|
||||
"team_member_default_permissions": [{ "id": "team_member" }],
|
||||
"user_default_permissions": [],
|
||||
},
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"description": "",
|
||||
"display_name": "New Project",
|
||||
"id": "<stripped UUID>",
|
||||
"is_production_mode": false,
|
||||
"user_count": 0,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("gives an error when updating email_theme with an invalid value", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/projects/current", {
|
||||
method: "PATCH",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
config: {
|
||||
email_theme: "some-invalid-theme",
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "SCHEMA_ERROR",
|
||||
"details": {
|
||||
"message": deindent\`
|
||||
Request validation failed on PATCH /api/v1/internal/projects/current:
|
||||
- body.config.email_theme must be one of the following values: default-light, default-dark
|
||||
\`,
|
||||
},
|
||||
"error": deindent\`
|
||||
Request validation failed on PATCH /api/v1/internal/projects/current:
|
||||
- body.config.email_theme must be one of the following values: default-light, default-dark
|
||||
\`,
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "SCHEMA_ERROR",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -223,6 +223,7 @@ it("can customize default user permissions", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
|
||||
@ -83,6 +83,7 @@ it("creates and updates the basic project information of a project", async ({ ex
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -130,6 +131,7 @@ it("updates the basic project configuration", async ({ expect }) => {
|
||||
"credential_enabled": false,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": true,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -182,6 +184,7 @@ it("updates the project domains configuration", async ({ expect }) => {
|
||||
},
|
||||
],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -240,6 +243,7 @@ it("updates the project domains configuration", async ({ expect }) => {
|
||||
},
|
||||
],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -292,6 +296,7 @@ it("should allow insecure HTTP connections if insecureHttp is true", async ({ ex
|
||||
},
|
||||
],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -387,6 +392,7 @@ it("updates the project email configuration", async ({ expect }) => {
|
||||
"type": "standard",
|
||||
"username": "test username",
|
||||
},
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -444,6 +450,7 @@ it("updates the project email configuration", async ({ expect }) => {
|
||||
"type": "standard",
|
||||
"username": "test username2",
|
||||
},
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -487,6 +494,7 @@ it("updates the project email configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -530,6 +538,7 @@ it("updates the project email configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -587,6 +596,7 @@ it("updates the project email configuration", async ({ expect }) => {
|
||||
"type": "standard",
|
||||
"username": "control group",
|
||||
},
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -751,6 +761,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [{ "id": "google" }],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -800,6 +811,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [{ "id": "google" }],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -851,6 +863,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [{ "id": "google" }],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -901,6 +914,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [{ "id": "spotify" }],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
@ -956,6 +970,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [
|
||||
{ "id": "google" },
|
||||
{ "id": "spotify" },
|
||||
@ -1018,6 +1033,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [
|
||||
{ "id": "google" },
|
||||
{ "id": "spotify" },
|
||||
@ -1346,6 +1362,7 @@ it("should increment and decrement userCount when a user is added to a project",
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": true,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
|
||||
@ -21,7 +21,7 @@ describe("invalid requests", () => {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
user_id: randomUUID(),
|
||||
user_ids: [randomUUID()],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
}
|
||||
@ -49,20 +49,21 @@ describe("invalid requests", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 404 when user is not found", async ({ expect }) => {
|
||||
it("should return 200 with user not found error in results", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const userId = randomUUID();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: randomUUID(),
|
||||
user_ids: [userId],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
@ -71,8 +72,16 @@ describe("invalid requests", () => {
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 404,
|
||||
"body": "User not found",
|
||||
"status": 200,
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"error": "User not found",
|
||||
"success": false,
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
@ -92,7 +101,7 @@ describe("invalid requests", () => {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: createUserResponse.body.id,
|
||||
user_ids: [createUserResponse.body.id],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
@ -128,7 +137,7 @@ describe("invalid requests", () => {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: createUserResponse.body.id,
|
||||
user_ids: [createUserResponse.body.id],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Invalid",
|
||||
@ -145,7 +154,7 @@ describe("invalid requests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when user has disabled notifications for the category", async ({ expect }) => {
|
||||
it("should return 200 with disabled notifications error in results when user has disabled notifications for the category", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
@ -181,7 +190,7 @@ it("should return 400 when user has disabled notifications for the category", as
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
user_ids: [user.userId],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
@ -190,14 +199,22 @@ it("should return 400 when user has disabled notifications for the category", as
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "User has disabled notifications for this category",
|
||||
"status": 200,
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"error": "User has disabled notifications for this category",
|
||||
"success": false,
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when user does not have a primary email", async ({ expect }) => {
|
||||
it("should return 200 with no primary email error in results when user does not have a primary email", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
@ -217,7 +234,7 @@ it("should return 400 when user does not have a primary email", async ({ expect
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: createUserResponse.body.id,
|
||||
user_ids: [createUserResponse.body.id],
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
@ -226,8 +243,16 @@ it("should return 400 when user does not have a primary email", async ({ expect
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "User does not have a primary email",
|
||||
"status": 200,
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"error": "User does not have a primary email",
|
||||
"success": false,
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
@ -247,7 +272,7 @@ it("should return 200 and send email successfully", async ({ expect }) => {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
user_ids: [user.userId],
|
||||
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
||||
subject: "Custom Test Email Subject",
|
||||
notification_category_name: "Marketing",
|
||||
@ -258,7 +283,15 @@ it("should return 200 and send email successfully", async ({ expect }) => {
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
@ -267,6 +300,75 @@ it("should return 200 and send email successfully", async ({ expect }) => {
|
||||
const messages = await user.mailbox.fetchMessages();
|
||||
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toContain("<h1>Test Email</h1>");
|
||||
expect(sentEmail!.body?.html).toContain("<p>This is a test email with HTML content.</p>");
|
||||
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
|
||||
});
|
||||
|
||||
it("should handle mixed results for multiple users", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Mixed Results Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const userWithDisabledNotifications = await User.create();
|
||||
await niceBackendFetch(`/api/v1/emails/notification-preference/${userWithDisabledNotifications.userId}/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`, {
|
||||
method: "PATCH",
|
||||
accessType: "server",
|
||||
body: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
const nonExistentUserId = randomUUID();
|
||||
const successfulUser = await User.create();
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_ids: [userWithDisabledNotifications.userId, nonExistentUserId, successfulUser.userId],
|
||||
html: "<h1>Bulk Test Email</h1><p>This is a bulk test email.</p>",
|
||||
subject: "Bulk Test Email Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"error": "User has disabled notifications for this category",
|
||||
"success": false,
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
{
|
||||
"error": "User not found",
|
||||
"success": false,
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Verify only the successful user received the email
|
||||
const successfulUserMessages = await successfulUser.mailbox.fetchMessages();
|
||||
const sentEmail = successfulUserMessages.find(msg => msg.subject === "Bulk Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
|
||||
|
||||
// Verify the user with disabled notifications did not receive the email
|
||||
const disabledUserMessages = await userWithDisabledNotifications.mailbox.fetchMessages();
|
||||
const disabledUserEmail = disabledUserMessages.find(msg => msg.subject === "Bulk Test Email Subject");
|
||||
expect(disabledUserEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
@ -241,6 +241,7 @@ it("can customize default team permissions", async ({ expect }) => {
|
||||
"credential_enabled": true,
|
||||
"domains": [],
|
||||
"email_config": { "type": "shared" },
|
||||
"email_theme": "default-light",
|
||||
"enabled_oauth_providers": [],
|
||||
"magic_link_enabled": false,
|
||||
"oauth_account_merge_strategy": "link_method",
|
||||
|
||||
@ -23,7 +23,7 @@ it("unsubscribe link should be sent and update notification preference", async (
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
user_ids: [user.userId],
|
||||
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
||||
subject: "Custom Test Email Subject",
|
||||
notification_category_name: "Marketing",
|
||||
@ -34,7 +34,15 @@ it("unsubscribe link should be sent and update notification preference", async (
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
@ -43,7 +51,7 @@ it("unsubscribe link should be sent and update notification preference", async (
|
||||
const messages = await user.mailbox.fetchMessages();
|
||||
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toMatch(/<h1>Test Email<\/h1><p>This is a test email with HTML content\.<\/p><br \/><a href="http:\/\/localhost:8102\/api\/v1\/emails\/unsubscribe-link\?code=[a-zA-Z0-9]+">Click here to unsubscribe<\/a>/);
|
||||
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"http://localhost:8102/api/v1/emails/unsubscribe-link?code=%3Cstripped+query+param%3E"`);
|
||||
|
||||
// Extract the unsubscribe link and fetch it
|
||||
const unsubscribeLinkMatch = sentEmail!.body?.html.match(/href="([^"]+)"/);
|
||||
@ -110,7 +118,7 @@ it("unsubscribe link should not be sent for emails with transactional notificati
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
user_ids: [user.userId],
|
||||
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
||||
subject: "Custom Test Email Subject",
|
||||
notification_category_name: "Transactional",
|
||||
@ -121,7 +129,15 @@ it("unsubscribe link should not be sent for emails with transactional notificati
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
|
||||
"body": {
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
"user_id": "<stripped UUID>",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
@ -129,5 +145,5 @@ it("unsubscribe link should not be sent for emails with transactional notificati
|
||||
const messages = await user.mailbox.fetchMessages();
|
||||
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"<h1>Test Email</h1><p>This is a test email with HTML content.</p>\\n"`);
|
||||
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"<div>Mock api key detected, returning mock data </div>"`);
|
||||
});
|
||||
|
||||
0
packages/stack-emails/src/themes/index.tsx
Normal file
0
packages/stack-emails/src/themes/index.tsx
Normal file
@ -135,6 +135,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
|
||||
senderName: schemaFields.emailSenderNameSchema.optional().nonEmpty(),
|
||||
senderEmail: schemaFields.emailSenderEmailSchema.optional().nonEmpty(),
|
||||
}),
|
||||
theme: schemaFields.emailThemeSchema.optional(),
|
||||
}).optional()),
|
||||
|
||||
domains: branchConfigSchema.getNested("domains").concat(yupObject({
|
||||
@ -219,6 +220,7 @@ export const organizationConfigDefaults = {
|
||||
server: {
|
||||
isShared: true,
|
||||
},
|
||||
theme: 'default-light',
|
||||
},
|
||||
} satisfies DeepReplaceAllowFunctionsForObjects<OrganizationConfigStrippedNormalizedOverride>;
|
||||
|
||||
|
||||
@ -303,7 +303,7 @@ export class StackAdminInterface extends StackServerInterface {
|
||||
}
|
||||
|
||||
async sendEmail(options: {
|
||||
user_id: string,
|
||||
user_ids: string[],
|
||||
subject: string,
|
||||
html: string,
|
||||
notification_category_name: string,
|
||||
@ -336,4 +336,18 @@ export class StackAdminInterface extends StackServerInterface {
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async renderEmailThemePreview(theme: string, content: string): Promise<{ html: string }> {
|
||||
const response = await this.sendAdminRequest(`/emails/render-email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
theme,
|
||||
preview_html: content,
|
||||
}),
|
||||
}, null);
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,7 @@ export const projectsCrudAdminReadSchema = yupObject({
|
||||
enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.defined()).defined().meta({ openapiField: { hidden: true } }),
|
||||
domains: yupArray(domainSchema.defined()).defined(),
|
||||
email_config: emailConfigSchema.defined(),
|
||||
email_theme: schemaFields.emailThemeSchema.defined(),
|
||||
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.defined(),
|
||||
team_creator_default_permissions: yupArray(teamPermissionSchema.defined()).defined(),
|
||||
team_member_default_permissions: yupArray(teamPermissionSchema.defined()).defined(),
|
||||
@ -121,6 +122,7 @@ export const projectsCrudAdminUpdateSchema = yupObject({
|
||||
allow_user_api_keys: schemaFields.yupBoolean().optional(),
|
||||
allow_team_api_keys: schemaFields.yupBoolean().optional(),
|
||||
email_config: emailConfigSchema.optional().default(undefined),
|
||||
email_theme: schemaFields.emailThemeSchema.optional(),
|
||||
domains: yupArray(domainSchema.defined()).optional().default(undefined),
|
||||
oauth_providers: yupArray(oauthProviderSchema.defined()).optional().default(undefined),
|
||||
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.optional(),
|
||||
|
||||
@ -1367,6 +1367,17 @@ const PermissionIdAlreadyExists = createKnownErrorConstructor(
|
||||
(json: any) => [json.permission_id] as const,
|
||||
);
|
||||
|
||||
const EmailRenderingError = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"EMAIL_RENDERING_ERROR",
|
||||
(error: string) => [
|
||||
400,
|
||||
`Failed to render email with theme: ${error}`,
|
||||
{ error },
|
||||
] as const,
|
||||
(json: any) => [json.error] as const,
|
||||
);
|
||||
|
||||
export type KnownErrors = {
|
||||
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
|
||||
};
|
||||
@ -1477,6 +1488,7 @@ export const KnownErrors = {
|
||||
ApiKeyExpired,
|
||||
ApiKeyRevoked,
|
||||
WrongApiKeyType,
|
||||
EmailRenderingError,
|
||||
} satisfies Record<string, KnownErrorConstructor<any, any>>;
|
||||
|
||||
|
||||
|
||||
@ -364,6 +364,8 @@ export const emailSenderEmailSchema = emailSchema.meta({ openapiField: { descrip
|
||||
export const emailPasswordSchema = passwordSchema.meta({ openapiField: { description: 'Email password. Needs to be specified when using type="standard"', exampleValue: 'your-email-password' } });
|
||||
// Project domain config
|
||||
export const handlerPathSchema = yupString().test('is-handler-path', 'Handler path must start with /', (value) => value?.startsWith('/')).meta({ openapiField: { description: 'Handler path. If you did not setup a custom handler path, it should be "/handler" by default. It needs to start with /', exampleValue: '/handler' } });
|
||||
// Project email theme config
|
||||
export const emailThemeSchema = yupString().oneOf(['default-light', 'default-dark']).meta({ openapiField: { description: 'Email theme for the project. Determines the visual style of emails sent by the project.', exampleValue: 'default-light' } });
|
||||
|
||||
// Users
|
||||
export class ReplaceFieldWithOwnUserId extends Error {
|
||||
|
||||
@ -111,7 +111,7 @@ export function ActionDialog(props: ActionDialogProps) {
|
||||
}}
|
||||
{...cancelButton.props}
|
||||
>
|
||||
Cancel
|
||||
{cancelButton.label ?? "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
{okButton && (
|
||||
|
||||
@ -46,6 +46,9 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
private readonly _metricsCache = createCache(async () => {
|
||||
return await this._interface.getMetrics();
|
||||
});
|
||||
private readonly _emailThemePreviewCache = createCache(async ([theme, content]: [string, string]) => {
|
||||
return await this._interface.renderEmailThemePreview(theme, content);
|
||||
});
|
||||
|
||||
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>) {
|
||||
super({
|
||||
@ -128,6 +131,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
senderName: data.config.email_config.sender_name ?? throwErr("Email sender name is missing"),
|
||||
senderEmail: data.config.email_config.sender_email ?? throwErr("Email sender email is missing"),
|
||||
},
|
||||
emailTheme: data.config.email_theme,
|
||||
domains: data.config.domains.map((d) => ({
|
||||
domain: d.domain,
|
||||
handlerPath: d.handler_path,
|
||||
@ -386,13 +390,13 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
}
|
||||
|
||||
async sendEmail(options: {
|
||||
userId: string,
|
||||
userIds: string[],
|
||||
subject: string,
|
||||
content: string,
|
||||
notificationCategoryName: string,
|
||||
}): Promise<void> {
|
||||
await this._interface.sendEmail({
|
||||
user_id: options.userId,
|
||||
user_ids: options.userIds,
|
||||
subject: options.subject,
|
||||
html: options.content,
|
||||
notification_category_name: options.notificationCategoryName,
|
||||
@ -402,4 +406,11 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
async sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void> {
|
||||
await this._interface.sendSignInInvitationEmail(email, callbackUrl);
|
||||
}
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
useEmailThemePreview(theme: string, content: string): string {
|
||||
const crud = useAsyncCache(this._emailThemePreviewCache, [theme, content] as const, "useEmailThemePreview()");
|
||||
return crud.html;
|
||||
}
|
||||
// END_PLATFORM
|
||||
}
|
||||
|
||||
@ -148,6 +148,11 @@ export function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, Result<T>
|
||||
|
||||
const id = React.useId();
|
||||
|
||||
// whenever the dependencies change, we need to refresh the promise cache
|
||||
React.useEffect(() => {
|
||||
cachePromiseByHookId.delete(id);
|
||||
}, [...dependencies, id]);
|
||||
|
||||
const subscribe = useCallback((cb: () => void) => {
|
||||
const { unsubscribe } = cache.onStateChange(dependencies, () => {
|
||||
cachePromiseByHookId.delete(id);
|
||||
|
||||
@ -60,11 +60,13 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
|
||||
listSentEmails(): Promise<AdminSentEmail[]>,
|
||||
|
||||
sendEmail(options: {
|
||||
userId: string,
|
||||
userIds: string[],
|
||||
subject: string,
|
||||
content: string,
|
||||
notificationCategoryName: string,
|
||||
}): Promise<void>,
|
||||
|
||||
useEmailThemePreview(theme: string, content: string): string, // THIS_LINE_PLATFORM react-like
|
||||
}
|
||||
& StackServerApp<HasTokenStore, ProjectId>
|
||||
);
|
||||
|
||||
@ -27,6 +27,7 @@ export type AdminProjectConfig = {
|
||||
readonly allowLocalhost: boolean,
|
||||
readonly oauthProviders: AdminOAuthProviderConfig[],
|
||||
readonly emailConfig?: AdminEmailConfig,
|
||||
readonly emailTheme: 'default-light' | 'default-dark',
|
||||
readonly domains: AdminDomainConfig[],
|
||||
readonly createTeamOnSignUp: boolean,
|
||||
readonly teamCreatorDefaultPermissions: AdminTeamPermission[],
|
||||
@ -85,6 +86,7 @@ export type AdminProjectConfigUpdateOptions = {
|
||||
allowLocalhost?: boolean,
|
||||
createTeamOnSignUp?: boolean,
|
||||
emailConfig?: AdminEmailConfig,
|
||||
emailTheme?: 'default-light' | 'default-dark',
|
||||
teamCreatorDefaultPermissions?: { id: string }[],
|
||||
teamMemberDefaultPermissions?: { id: string }[],
|
||||
userDefaultPermissions?: { id: string }[],
|
||||
|
||||
@ -70,6 +70,7 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
|
||||
sender_email: options.config.emailConfig.senderEmail,
|
||||
}
|
||||
),
|
||||
email_theme: options.config?.emailTheme,
|
||||
sign_up_enabled: options.config?.signUpEnabled,
|
||||
credential_enabled: options.config?.credentialEnabled,
|
||||
magic_link_enabled: options.config?.magicLinkEnabled,
|
||||
|
||||
4550
pnpm-lock.yaml
4550
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user