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
b7eff595d7
@ -1,3 +1,4 @@
|
||||
import { normalizeEmail } from "@/lib/emails";
|
||||
import { ensureContactChannelDoesNotExists, ensureContactChannelExists } from "@/lib/request-checks";
|
||||
import { prismaClient, retryTransaction } from "@/prisma-client";
|
||||
import { createCrudHandlers } from "@/route-handlers/crud-handler";
|
||||
@ -55,6 +56,14 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
return contactChannelToCrud(contactChannel);
|
||||
},
|
||||
onCreate: async ({ auth, data }) => {
|
||||
let value = data.value;
|
||||
switch (data.type) {
|
||||
case 'email': {
|
||||
value = normalizeEmail(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.type === 'client') {
|
||||
const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
|
||||
if (currentUserId !== data.user_id) {
|
||||
@ -67,7 +76,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
tenancyId: auth.tenancy.id,
|
||||
userId: data.user_id,
|
||||
type: data.type,
|
||||
value: data.value,
|
||||
value: value,
|
||||
});
|
||||
|
||||
// if usedForAuth is set to true, make sure no other account uses this channel for auth
|
||||
@ -77,13 +86,13 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
tenancyId_type_value_usedForAuth: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
type: crudContactChannelTypeToPrisma(data.type),
|
||||
value: data.value,
|
||||
value: value,
|
||||
usedForAuth: 'TRUE',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingWithSameChannel) {
|
||||
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse(data.type, data.value);
|
||||
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse(data.type, value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +101,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
tenancyId: auth.tenancy.id,
|
||||
projectUserId: data.user_id,
|
||||
type: typedToUppercase(data.type),
|
||||
value: data.value,
|
||||
value: value,
|
||||
isVerified: data.is_verified ?? false,
|
||||
usedForAuth: data.used_for_auth ? 'TRUE' : null,
|
||||
},
|
||||
@ -145,6 +154,17 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
}
|
||||
}
|
||||
|
||||
let value = data.value;
|
||||
switch (data.type) {
|
||||
case 'email': {
|
||||
value = value ? normalizeEmail(value) : undefined;
|
||||
break;
|
||||
}
|
||||
case undefined: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedContactChannel = await retryTransaction(async (tx) => {
|
||||
const existingContactChannel = await ensureContactChannelExists(tx, {
|
||||
tenancyId: auth.tenancy.id,
|
||||
@ -159,7 +179,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
tenancyId_type_value_usedForAuth: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
type: data.type !== undefined ? crudContactChannelTypeToPrisma(data.type) : existingContactChannel.type,
|
||||
value: data.value !== undefined ? data.value : existingContactChannel.value,
|
||||
value: value !== undefined ? value : existingContactChannel.value,
|
||||
usedForAuth: 'TRUE',
|
||||
},
|
||||
},
|
||||
@ -191,8 +211,8 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
|
||||
},
|
||||
},
|
||||
data: {
|
||||
value: data.value,
|
||||
isVerified: data.is_verified ?? (data.value ? false : undefined), // if value is updated and is_verified is not provided, set to false
|
||||
value: value,
|
||||
isVerified: data.is_verified ?? (value ? false : undefined), // if value is updated and is_verified is not provided, set to false
|
||||
usedForAuth: data.used_for_auth !== undefined ? (data.used_for_auth ? 'TRUE' : null) : undefined,
|
||||
isPrimary: data.is_primary !== undefined ? (data.is_primary ? 'TRUE' : null) : undefined,
|
||||
},
|
||||
|
||||
@ -10,7 +10,7 @@ type FailedEmailsQueryResult = {
|
||||
|
||||
type FailedEmailsByTenancyData = {
|
||||
emails: Array<{ subject: string, to: string[] }>,
|
||||
tenantOwnerEmail: string,
|
||||
tenantOwnerEmails: string[],
|
||||
projectId: string,
|
||||
}
|
||||
|
||||
@ -24,10 +24,11 @@ export const getFailedEmailsByTenancy = async (after: Date) => {
|
||||
cc."value" as "contactEmail"
|
||||
FROM "SentEmail" se
|
||||
INNER JOIN "Tenancy" t ON se."tenancyId" = t.id
|
||||
INNER JOIN "Project" p ON t."projectId" = p.id
|
||||
LEFT JOIN "ProjectUser" pu ON pu."mirroredProjectId" = 'internal'
|
||||
AND pu."mirroredBranchId" = 'main'
|
||||
AND pu."serverMetadata"->'managedProjectIds' ? t."projectId"
|
||||
LEFT JOIN "ContactChannel" cc ON pu."projectUserId" = cc."projectUserId"
|
||||
INNER JOIN "ContactChannel" cc ON pu."projectUserId" = cc."projectUserId"
|
||||
AND cc."isPrimary" = 'TRUE'
|
||||
AND cc."type" = 'EMAIL'
|
||||
WHERE se."error" IS NOT NULL
|
||||
@ -38,10 +39,11 @@ export const getFailedEmailsByTenancy = async (after: Date) => {
|
||||
for (const failedEmail of result) {
|
||||
let failedEmails = failedEmailsByTenancy.get(failedEmail.tenancyId) ?? {
|
||||
emails: [],
|
||||
tenantOwnerEmail: failedEmail.contactEmail,
|
||||
tenantOwnerEmails: [],
|
||||
projectId: failedEmail.projectId
|
||||
};
|
||||
failedEmails.emails.push({ subject: failedEmail.subject, to: failedEmail.to });
|
||||
failedEmails.tenantOwnerEmails.push(failedEmail.contactEmail);
|
||||
failedEmailsByTenancy.set(failedEmail.tenancyId, failedEmails);
|
||||
}
|
||||
return failedEmailsByTenancy;
|
||||
|
||||
@ -31,7 +31,7 @@ export const POST = createSmartRouteHandler({
|
||||
subject: yupString().defined(),
|
||||
to: yupArray(yupString().defined()).defined(),
|
||||
})).defined(),
|
||||
tenant_owner_email: yupString().defined(),
|
||||
tenant_owner_emails: yupArray(yupString().defined()).defined(),
|
||||
project_id: yupString().defined(),
|
||||
tenancy_id: yupString().defined(),
|
||||
})).optional(),
|
||||
@ -50,6 +50,10 @@ export const POST = createSmartRouteHandler({
|
||||
|
||||
let anyDigestsFailedToSend = false;
|
||||
for (const failedEmailsBatch of failedEmailsByTenancy.values()) {
|
||||
if (!failedEmailsBatch.tenantOwnerEmails.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const viewInStackAuth = `<a href="${dashboardUrl}/projects/${encodeURIComponent(failedEmailsBatch.projectId)}/emails">View all email logs on the Dashboard</a>`;
|
||||
const emailHtml = `
|
||||
<p>Thank you for using Stack Auth!</p>
|
||||
@ -57,10 +61,10 @@ export const POST = createSmartRouteHandler({
|
||||
<p>${viewInStackAuth}</p>
|
||||
<p>Last failing emails:</p>
|
||||
${failedEmailsBatch.emails.slice(-10).map((failedEmail) => {
|
||||
const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50);
|
||||
const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", ");
|
||||
return `<div><p>Subject: ${escapedSubject}<br />To: ${escapedTo}</p></div>`;
|
||||
}).join("")}
|
||||
const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50);
|
||||
const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", ");
|
||||
return `<div><p>Subject: ${escapedSubject}<br />To: ${escapedTo}</p></div>`;
|
||||
}).join("")}
|
||||
${failedEmailsBatch.emails.length > 10 ? `<div>...</div>` : ""}
|
||||
`;
|
||||
if (query.dry_run !== "true") {
|
||||
@ -68,7 +72,7 @@ export const POST = createSmartRouteHandler({
|
||||
await sendEmail({
|
||||
tenancyId: internalTenancy.id,
|
||||
emailConfig,
|
||||
to: failedEmailsBatch.tenantOwnerEmail,
|
||||
to: failedEmailsBatch.tenantOwnerEmails,
|
||||
subject: "Failed emails digest",
|
||||
html: emailHtml,
|
||||
});
|
||||
@ -87,7 +91,7 @@ export const POST = createSmartRouteHandler({
|
||||
failed_emails_by_tenancy: Array.from(failedEmailsByTenancy.entries()).map(([tenancyId, batch]) => (
|
||||
{
|
||||
emails: batch.emails,
|
||||
tenant_owner_email: batch.tenantOwnerEmail,
|
||||
tenant_owner_emails: batch.tenantOwnerEmails,
|
||||
project_id: batch.projectId,
|
||||
tenancy_id: tenancyId,
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { normalizeEmail } from "@/lib/emails";
|
||||
import { grantDefaultProjectPermissions } from "@/lib/permissions";
|
||||
import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks";
|
||||
import { getTenancy } from "@/lib/tenancies";
|
||||
@ -477,9 +478,11 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
};
|
||||
},
|
||||
onCreate: async ({ auth, data }) => {
|
||||
const primaryEmail = data.primary_email ? normalizeEmail(data.primary_email) : data.primary_email;
|
||||
|
||||
log("create_user_endpoint_primaryAuthEnabled", {
|
||||
value: data.primary_email_auth_enabled,
|
||||
email: data.primary_email ?? undefined,
|
||||
email: primaryEmail ?? undefined,
|
||||
projectId: auth.project.id,
|
||||
});
|
||||
|
||||
@ -487,7 +490,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
const result = await retryTransaction(async (tx) => {
|
||||
await checkAuthData(tx, {
|
||||
tenancyId: auth.tenancy.id,
|
||||
primaryEmail: data.primary_email,
|
||||
primaryEmail: primaryEmail,
|
||||
primaryEmailVerified: !!data.primary_email_verified,
|
||||
primaryEmailAuthEnabled: !!data.primary_email_auth_enabled,
|
||||
});
|
||||
@ -552,13 +555,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
|
||||
}
|
||||
|
||||
if (data.primary_email) {
|
||||
if (primaryEmail) {
|
||||
await tx.contactChannel.create({
|
||||
data: {
|
||||
projectUserId: newUser.projectUserId,
|
||||
tenancyId: auth.tenancy.id,
|
||||
type: 'EMAIL' as const,
|
||||
value: data.primary_email,
|
||||
value: primaryEmail,
|
||||
isVerified: data.primary_email_verified ?? false,
|
||||
isPrimary: "TRUE",
|
||||
usedForAuth: data.primary_email_auth_enabled ? BooleanTrue.TRUE : null,
|
||||
@ -629,8 +632,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
data: {
|
||||
display_name: data.display_name ?
|
||||
`${data.display_name}'s Team` :
|
||||
data.primary_email ?
|
||||
`${data.primary_email}'s Team` :
|
||||
primaryEmail ?
|
||||
`${primaryEmail}'s Team` :
|
||||
"Personal Team",
|
||||
creator_user_id: 'me',
|
||||
},
|
||||
@ -660,6 +663,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
return result;
|
||||
},
|
||||
onUpdate: async ({ auth, data, params }) => {
|
||||
const primaryEmail = data.primary_email ? normalizeEmail(data.primary_email) : data.primary_email;
|
||||
const passwordHash = await getPasswordHashFromData(data);
|
||||
const result = await retryTransaction(async (tx) => {
|
||||
await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id });
|
||||
@ -743,7 +747,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
await checkAuthData(tx, {
|
||||
tenancyId: auth.tenancy.id,
|
||||
oldPrimaryEmail: primaryEmailContactChannel?.value,
|
||||
primaryEmail: data.primary_email || primaryEmailContactChannel?.value,
|
||||
primaryEmail: primaryEmail || primaryEmailContactChannel?.value,
|
||||
primaryEmailVerified,
|
||||
primaryEmailAuthEnabled,
|
||||
});
|
||||
@ -753,8 +757,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
// - update the primary email contact channel if it exists
|
||||
// if the primary email is null
|
||||
// - delete the primary email contact channel if it exists (note that this will also delete the related auth methods)
|
||||
if (data.primary_email !== undefined) {
|
||||
if (data.primary_email === null) {
|
||||
if (primaryEmail !== undefined) {
|
||||
if (primaryEmail === null) {
|
||||
await tx.contactChannel.delete({
|
||||
where: {
|
||||
tenancyId_projectUserId_type_isPrimary: {
|
||||
@ -779,13 +783,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
projectUserId: params.user_id,
|
||||
tenancyId: auth.tenancy.id,
|
||||
type: 'EMAIL' as const,
|
||||
value: data.primary_email,
|
||||
value: primaryEmail,
|
||||
isVerified: false,
|
||||
isPrimary: "TRUE",
|
||||
usedForAuth: primaryEmailAuthEnabled ? BooleanTrue.TRUE : null,
|
||||
},
|
||||
update: {
|
||||
value: data.primary_email,
|
||||
value: primaryEmail,
|
||||
usedForAuth: primaryEmailAuthEnabled ? BooleanTrue.TRUE : null,
|
||||
}
|
||||
});
|
||||
@ -812,7 +816,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
|
||||
// if primary_email_auth_enabled is being updated without changing the email
|
||||
// - update the primary email contact channel's usedForAuth field
|
||||
if (data.primary_email_auth_enabled !== undefined && data.primary_email === undefined) {
|
||||
if (data.primary_email_auth_enabled !== undefined && primaryEmail === undefined) {
|
||||
await tx.contactChannel.update({
|
||||
where: {
|
||||
tenancyId_projectUserId_type_isPrimary: {
|
||||
|
||||
@ -367,3 +367,39 @@ export async function getSharedEmailConfig(displayName: string): Promise<EmailCo
|
||||
type: 'shared',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
if (typeof email !== 'string') {
|
||||
throw new TypeError('normalize-email expects a string');
|
||||
}
|
||||
|
||||
const removeDotsDomains = ['gmail.com', 'googlemail.com', 'live.com'];
|
||||
|
||||
const emailLower = email.trim().toLowerCase();
|
||||
const emailParts = emailLower.split(/@/);
|
||||
|
||||
if (emailParts.length !== 2) {
|
||||
throw new StackAssertionError('Invalid email address', { email });
|
||||
}
|
||||
|
||||
let [username, domain] = emailParts;
|
||||
|
||||
if (removeDotsDomains.includes(domain)) {
|
||||
username = username.replace(/\.+/g, '');
|
||||
}
|
||||
|
||||
return `${username}@${domain}`;
|
||||
}
|
||||
|
||||
import.meta.vitest?.test('normalizeEmail(...)', async ({ expect }) => {
|
||||
expect(normalizeEmail('Example.Test@gmail.com')).toBe('exampletest@gmail.com');
|
||||
expect(normalizeEmail('Example.Test+123@gmail.com')).toBe('exampletest+123@gmail.com');
|
||||
expect(normalizeEmail('exampletest@gmail.com')).toBe('exampletest@gmail.com');
|
||||
expect(normalizeEmail('EXAMPLETEST@gmail.com')).toBe('exampletest@gmail.com');
|
||||
|
||||
expect(normalizeEmail('user@example.com')).toBe('user@example.com');
|
||||
expect(normalizeEmail('user.name+tag@example.com')).toBe('user.name+tag@example.com');
|
||||
|
||||
expect(() => normalizeEmail('test@multiple@domains.com')).toThrow();
|
||||
expect(() => normalizeEmail('invalid.email')).toThrow();
|
||||
});
|
||||
|
||||
@ -22,6 +22,10 @@ export class GoogleProvider extends OAuthBaseProvider {
|
||||
openid: true,
|
||||
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
||||
baseScope: "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
|
||||
authorizationExtraParams: {
|
||||
prompt: "consent",
|
||||
include_granted_scopes: "true",
|
||||
},
|
||||
...options,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -330,6 +330,13 @@ function UserHeader({ user }: UserHeaderProps) {
|
||||
}}>
|
||||
<span>Impersonate</span>
|
||||
</DropdownMenuItem>
|
||||
{user.isMultiFactorRequired && (
|
||||
<DropdownMenuItem onClick={async () => {
|
||||
await user.update({ totpMultiFactorSecret: null });
|
||||
}}>
|
||||
<span>Remove 2FA</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setIsDeleteModalOpen(true)}>
|
||||
<Typography className="text-destructive">Delete</Typography>
|
||||
@ -373,7 +380,7 @@ function UserDetails({ user }: UserDetailsProps) {
|
||||
/>
|
||||
</UserInfo>
|
||||
<UserInfo icon={<Shield size={16}/>} name="2-factor auth">
|
||||
<EditableInput value={user.otpAuthEnabled ? 'Enabled' : ''} placeholder='Disabled' readOnly />
|
||||
<EditableInput value={user.isMultiFactorRequired ? 'Enabled' : ''} placeholder='Disabled' readOnly />
|
||||
</UserInfo>
|
||||
<UserInfo icon={<Calendar size={16}/>} name="Signed up at">
|
||||
<EditableInput value={user.signedUpAt.toDateString()} readOnly />
|
||||
|
||||
@ -18,6 +18,7 @@ export function BackgroundShine() {
|
||||
transition: 'transform 0.05s ease-in-out',
|
||||
transform: `translateY(${-scrollY * 0.2}px)`,
|
||||
}}
|
||||
inert={true}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
|
||||
@ -1055,14 +1055,16 @@ export namespace Project {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAndGetAdminToken(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>) {
|
||||
backendContext.set({
|
||||
projectKeys: InternalProjectKeys,
|
||||
userAuth: null,
|
||||
});
|
||||
export async function createAndGetAdminToken(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>, useExistingUser?: boolean) {
|
||||
backendContext.set({ projectKeys: InternalProjectKeys });
|
||||
const oldMailbox = backendContext.value.mailbox;
|
||||
await bumpEmailAddress({ unindexed: true });
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
let userId: string | undefined;
|
||||
if (!useExistingUser) {
|
||||
backendContext.set({ userAuth: null });
|
||||
await bumpEmailAddress({ unindexed: true });
|
||||
const { userId: newUserId } = await Auth.Otp.signIn();
|
||||
userId = newUserId;
|
||||
}
|
||||
const adminAccessToken = backendContext.value.userAuth?.accessToken;
|
||||
expect(adminAccessToken).toBeDefined();
|
||||
const { projectId, createProjectResponse } = await Project.create(body);
|
||||
@ -1083,13 +1085,14 @@ export namespace Project {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAndSwitch(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>) {
|
||||
const createResult = await Project.createAndGetAdminToken(body);
|
||||
export async function createAndSwitch(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>, useExistingUser?: boolean) {
|
||||
const createResult = await Project.createAndGetAdminToken(body, useExistingUser);
|
||||
backendContext.set({
|
||||
projectKeys: {
|
||||
projectId: createResult.projectId,
|
||||
adminAccessToken: createResult.adminAccessToken,
|
||||
},
|
||||
userAuth: null
|
||||
});
|
||||
return createResult;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Auth, backendContext, InternalProjectKeys, niceBackendFetch, Project } from "../../../../backend-helpers";
|
||||
import { Auth, backendContext, bumpEmailAddress, InternalProjectKeys, niceBackendFetch, Project, User } from "../../../../backend-helpers";
|
||||
|
||||
describe("unauthorized requests", () => {
|
||||
it("should return 401 when invalid authorization is provided", async ({ expect }) => {
|
||||
@ -62,35 +62,13 @@ describe("with valid credentials", () => {
|
||||
userAuth: null,
|
||||
});
|
||||
await Auth.Otp.signIn();
|
||||
const adminAccessToken = backendContext.value.userAuth?.accessToken;
|
||||
const { projectId } = await Project.create({
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Failed Emails Project",
|
||||
config: {
|
||||
email_config: {
|
||||
type: "standard",
|
||||
host: "invalid-smtp-host.example.com",
|
||||
port: 587,
|
||||
username: "invalid_user",
|
||||
password: "invalid_password",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@invalid-domain.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
backendContext.set({
|
||||
projectKeys: {
|
||||
projectId,
|
||||
},
|
||||
userAuth: null,
|
||||
});
|
||||
}, true);
|
||||
|
||||
const testEmailResponse = await niceBackendFetch("/api/v1/internal/send-test-email", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
headers: {
|
||||
"x-stack-admin-access-token": adminAccessToken,
|
||||
},
|
||||
body: {
|
||||
"recipient_email": "test-email-recipient@stackframe.co",
|
||||
"email_config": {
|
||||
@ -119,11 +97,10 @@ describe("with valid credentials", () => {
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
console.log(response.body);
|
||||
|
||||
const failedEmailsByTenancy = response.body.failed_emails_by_tenancy;
|
||||
const mockProjectFailedEmails = failedEmailsByTenancy.filter(
|
||||
(batch: any) => batch.tenant_owner_email === backendContext.value.mailbox.emailAddress
|
||||
(batch: any) => batch.tenant_owner_emails.includes(backendContext.value.mailbox.emailAddress)
|
||||
);
|
||||
expect(mockProjectFailedEmails).toMatchInlineSnapshot(`
|
||||
[
|
||||
@ -136,7 +113,7 @@ describe("with valid credentials", () => {
|
||||
],
|
||||
"project_id": "<stripped UUID>",
|
||||
"tenancy_id": "<stripped UUID>",
|
||||
"tenant_owner_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
"tenant_owner_emails": ["default-mailbox--<stripped UUID>@stack-generated.example.com"],
|
||||
},
|
||||
]
|
||||
`);
|
||||
@ -149,19 +126,8 @@ describe("with valid credentials", () => {
|
||||
|
||||
it("should return 200 and not send digest email when all emails are successful", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const { projectId } = await Project.create({
|
||||
await Project.create({
|
||||
display_name: "Test Successful Emails Project",
|
||||
config: {
|
||||
email_config: {
|
||||
type: "standard",
|
||||
host: "localhost",
|
||||
port: 2500,
|
||||
username: "test",
|
||||
password: "test",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
@ -180,4 +146,211 @@ describe("with valid credentials", () => {
|
||||
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(digestEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not send digest email when project owner has no primary email", async ({ expect }) => {
|
||||
backendContext.set({
|
||||
projectKeys: InternalProjectKeys,
|
||||
userAuth: null,
|
||||
});
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
|
||||
// Remove primary email from the user
|
||||
const updateEmailResponse = await niceBackendFetch(`/api/v1/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
"primary_email": null,
|
||||
},
|
||||
});
|
||||
expect(updateEmailResponse.status).toBe(200);
|
||||
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Project No Owner Email",
|
||||
});
|
||||
|
||||
// Send a test email that will fail
|
||||
await niceBackendFetch("/api/v1/internal/send-test-email", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
"recipient_email": "test-email-recipient@stackframe.co",
|
||||
"email_config": {
|
||||
"host": "this-is-not-a-valid-host.example.com",
|
||||
"port": 123,
|
||||
"username": "123",
|
||||
"password": "123",
|
||||
"sender_email": "123@g.co",
|
||||
"sender_name": "123"
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const messages = await backendContext.value.mailbox.fetchMessages();
|
||||
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(digestEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not send digest email when project has no owner (account deleted)", async ({ expect }) => {
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Project Deleted Owner",
|
||||
}, true);
|
||||
|
||||
// Send a test email that will fail
|
||||
await niceBackendFetch("/api/v1/internal/send-test-email", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
"recipient_email": "test-email-recipient@stackframe.co",
|
||||
"email_config": {
|
||||
"host": "this-is-not-a-valid-host.example.com",
|
||||
"port": 123,
|
||||
"username": "123",
|
||||
"password": "123",
|
||||
"sender_email": "123@g.co",
|
||||
"sender_name": "123"
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Delete the user account (project owner)
|
||||
backendContext.set({
|
||||
projectKeys: InternalProjectKeys,
|
||||
});
|
||||
const deleteUserResponse = await niceBackendFetch(`/api/v1/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
accessType: "admin",
|
||||
});
|
||||
expect(deleteUserResponse.body).toMatchInlineSnapshot(`{ "success": true }`);
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Should not send digest email when project owner is deleted
|
||||
const messages = await backendContext.value.mailbox.fetchMessages();
|
||||
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(digestEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not send digest email when project is deleted after email delivery failed", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Project To Be Deleted",
|
||||
}, true);
|
||||
|
||||
// Send a test email that will fail
|
||||
await niceBackendFetch("/api/v1/internal/send-test-email", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
"recipient_email": "test-email-recipient@stackframe.co",
|
||||
"email_config": {
|
||||
"host": "this-is-not-a-valid-host.example.com",
|
||||
"port": 123,
|
||||
"username": "123",
|
||||
"password": "123",
|
||||
"sender_email": "123@g.co",
|
||||
"sender_name": "123"
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Delete the project
|
||||
const deleteProjectResponse = await niceBackendFetch(`/api/v1/internal/projects/current`, {
|
||||
method: "DELETE",
|
||||
accessType: "admin",
|
||||
});
|
||||
expect(deleteProjectResponse.body).toMatchInlineSnapshot(`{ "success": true }`);
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Should not send digest email when project is deleted
|
||||
const messages = await backendContext.value.mailbox.fetchMessages();
|
||||
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(digestEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should send digest email to each owner when project has multiple owners", async ({ expect }) => {
|
||||
const firstOwnerMailbox = backendContext.value.mailbox;
|
||||
backendContext.set({
|
||||
projectKeys: InternalProjectKeys,
|
||||
});
|
||||
await Auth.Otp.signIn();
|
||||
const { projectId } = await Project.createAndSwitch({
|
||||
display_name: "Test Project Multiple Owners",
|
||||
}, true);
|
||||
const oldProjectKeys = backendContext.value.projectKeys;
|
||||
const oldAuth = backendContext.value.userAuth;
|
||||
const secondOwnerMailbox = await bumpEmailAddress();
|
||||
backendContext.set({
|
||||
projectKeys: InternalProjectKeys,
|
||||
});
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
|
||||
const updateUserResponse = await niceBackendFetch(`/api/v1/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
server_metadata: { managedProjectIds: [projectId] }
|
||||
},
|
||||
});
|
||||
expect(updateUserResponse.status).toBe(200);
|
||||
backendContext.set({ projectKeys: oldProjectKeys, userAuth: oldAuth });
|
||||
|
||||
// Send a test email that will fail
|
||||
const sendTestEmailResponse = await niceBackendFetch("/api/v1/internal/send-test-email", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
"recipient_email": "test-email-recipient@stackframe.co",
|
||||
"email_config": {
|
||||
"host": "this-is-not-a-valid-host.example.com",
|
||||
"port": 123,
|
||||
"username": "123",
|
||||
"password": "123",
|
||||
"sender_email": "123@g.co",
|
||||
"sender_name": "123"
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(sendTestEmailResponse.body).toMatchInlineSnapshot(`
|
||||
{
|
||||
"error_message": "Failed to connect to the email host. Please make sure the email host configuration is correct.",
|
||||
"success": false,
|
||||
}
|
||||
`);
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const currentResponses = response.body.failed_emails_by_tenancy.filter(
|
||||
(batch: any) => batch.project_id === projectId
|
||||
);
|
||||
expect(currentResponses.length).toBe(1);
|
||||
expect(currentResponses[0].tenant_owner_emails.length).toBe(2);
|
||||
expect(currentResponses[0].tenant_owner_emails.includes(firstOwnerMailbox.emailAddress)).toBe(true);
|
||||
expect(currentResponses[0].tenant_owner_emails.includes(secondOwnerMailbox.emailAddress)).toBe(true);
|
||||
|
||||
const firstMailboxMessages = await firstOwnerMailbox.fetchMessages();
|
||||
const secondMailboxMessages = await secondOwnerMailbox.fetchMessages();
|
||||
const firstMailboxDigestEmail = firstMailboxMessages.find(msg => msg.subject === "Failed emails digest");
|
||||
const secondMailboxDigestEmail = secondMailboxMessages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(firstMailboxDigestEmail).toBeDefined();
|
||||
expect(secondMailboxDigestEmail).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useUser, SelectedTeamSwitcher } from "@stackframe/stack";
|
||||
import { SelectedTeamSwitcher, useUser } from "@stackframe/stack";
|
||||
|
||||
export default function TeamPage({ params }: { params: { teamId: string } }) {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import {
|
||||
Button,
|
||||
@ -25,10 +26,13 @@ type MockTeam = {
|
||||
profileImageUrl?: string | null,
|
||||
};
|
||||
|
||||
type SelectedTeamSwitcherProps = {
|
||||
urlMap?: (team: Team) => string,
|
||||
type SelectedTeamSwitcherProps<AllowNull extends boolean = false> = {
|
||||
urlMap?: (team: AllowNull extends true ? Team | null : Team) => string,
|
||||
selectedTeam?: Team,
|
||||
noUpdateSelectedTeam?: boolean,
|
||||
allowNull?: AllowNull,
|
||||
nullLabel?: string,
|
||||
onChange?: (team: AllowNull extends true ? Team | null : Team) => void,
|
||||
// Mock data props
|
||||
mockUser?: {
|
||||
selectedTeam?: MockTeam,
|
||||
@ -41,7 +45,7 @@ type SelectedTeamSwitcherProps = {
|
||||
},
|
||||
};
|
||||
|
||||
export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) {
|
||||
export function SelectedTeamSwitcher<AllowNull extends boolean = false>(props: SelectedTeamSwitcherProps<AllowNull>) {
|
||||
return <Suspense fallback={<Fallback />}>
|
||||
<Inner {...props} />
|
||||
</Suspense>;
|
||||
@ -51,7 +55,7 @@ function Fallback() {
|
||||
return <Skeleton className="h-9 w-full max-w-64 stack-scope" />;
|
||||
}
|
||||
|
||||
function Inner(props: SelectedTeamSwitcherProps) {
|
||||
function Inner<AllowNull extends boolean>(props: SelectedTeamSwitcherProps<AllowNull>) {
|
||||
const { t } = useTranslation();
|
||||
const appFromHook = useStackApp();
|
||||
const userFromHook = useUser();
|
||||
@ -83,17 +87,27 @@ function Inner(props: SelectedTeamSwitcherProps) {
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedTeam?.id}
|
||||
value={selectedTeam?.id || (props.allowNull ? 'null-sentinel' : undefined)}
|
||||
onValueChange={(value) => {
|
||||
// Skip actual navigation/updates in mock mode
|
||||
if (props.mockUser) return;
|
||||
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
const team = teams?.find(team => team.id === value);
|
||||
if (!team) {
|
||||
throw new Error('Team not found, this should not happen');
|
||||
let team: MockTeam | null = null;
|
||||
if (value !== 'null-sentinel') {
|
||||
team = teams?.find(team => team.id === value) || null;
|
||||
if (!team) {
|
||||
throw new StackAssertionError('Team not found, this should not happen');
|
||||
}
|
||||
} else {
|
||||
team = null;
|
||||
}
|
||||
|
||||
// Call onChange callback if provided
|
||||
if (props.onChange) {
|
||||
props.onChange(team as Team);
|
||||
}
|
||||
|
||||
// Skip actual navigation/updates in mock mode
|
||||
if (props.mockUser) return;
|
||||
|
||||
if (!props.noUpdateSelectedTeam) {
|
||||
await user?.setSelectedTeam(team as Team);
|
||||
}
|
||||
@ -136,6 +150,15 @@ function Inner(props: SelectedTeamSwitcherProps) {
|
||||
</SelectItem>
|
||||
</SelectGroup> : undefined}
|
||||
|
||||
{props.allowNull && <SelectGroup>
|
||||
<SelectItem value="null-sentinel">
|
||||
<div className="flex items-center gap-2">
|
||||
<TeamIcon team='personal' />
|
||||
<Typography className="max-w-40 truncate">{props.nullLabel || t('No team')}</Typography>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>}
|
||||
|
||||
{teams?.length ?
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('Other teams')}</SelectLabel>
|
||||
@ -148,10 +171,12 @@ function Inner(props: SelectedTeamSwitcherProps) {
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup> :
|
||||
</SelectGroup> : null}
|
||||
|
||||
{!teams?.length && !props.allowNull ?
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('No teams yet')}</SelectLabel>
|
||||
</SelectGroup>}
|
||||
</SelectGroup> : null}
|
||||
|
||||
{project.config.clientTeamCreationEnabled && <>
|
||||
<SelectSeparator/>
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import { Avatar, AvatarImage, Typography } from "@stackframe/stack-ui";
|
||||
import { User2 } from "lucide-react";
|
||||
import { Team } from "..";
|
||||
|
||||
export function TeamIcon(props: { team: Team }) {
|
||||
export function TeamIcon(props: { team: Team | 'personal' }) {
|
||||
if (props.team === 'personal') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-w-6 min-h-6 max-w-6 max-h-6 rounded bg-zinc-200">
|
||||
<User2 className="w-4 h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (props.team.profileImageUrl) {
|
||||
return (
|
||||
<Avatar className="min-w-6 min-h-6 max-w-6 max-h-6 rounded">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user