Skip to content

Commit 2e09c0e

Browse files
adriangohjwdcshzj
andauthored
Singpass fallback - Disabling singpass send notification (#1349)
* feat: add Singpass as OIDC provider * fix: adjust URL * feat: add uuid column and generate JWKS script * feat: place Singpass after email OTP login * feat: add Singpass login step using Growthbook * fix: reduce session TTL from 7 days to 12 hours (#1223) * fix: adjust based on comments * chore: fix types for userId * chore: fix package-lock * chore: completely remove SGID login functionality (#1209) * chore: completely remove SGID login functionality * chore: remove modalText * feat: add frontend for Singpass authentication (#1213) * feat: add frontend for Singpass authentication * fix: adjust design based on chromatic feedback * feat: update invitation email wording * fix: update based on Copilot review * chore: adjust based on comments * chore: update based on comments * chore: fix missing inviterName in mock data * feat: add error handling for Singpass authentication (#1231) * feat: add error handling for Singpass authentication * chore: update from comments * fix: rename getName to getUserProps * chore: rename uuid to singpassUuid * fix: update tests * chore: update script to have extractable keys * fix: remove Singpass error modal * fix: force body-2 on button * feat: add error infobox when not whitelisted * chore: use form error message for blacklisted email * fix: use switch statement and handle default for error * chore: update wording and text size * reduce TTL to 1 hour * implement isSingpassEnabled function to streamline feature flag usage * refactor: rename isSingpassEnabled to getIsSingpassEnabled and update usage in user router * refactor: rename isSingpassEnabled to getIsSingpassEnabled for clarity and update usage in context * remove * add isAuthenticatedWithSingpass flag to session data and update session on successful authentication * update default session TTL comment to reflect future change for Singpass launch * refactor: simplify session TTL management by removing Singpass check and updating session configuration based on authentication status * refactor: remove unused import of formatInTimeZone from email.router.ts * feat: add mock feature flag for Singpass to enable testing * update error msg * add custom error message for Singpass authentication failure * feat: add tooltip support to MenuItem component * integrate Singpass status checks in user management modals and buttons * add Singpass feature flag to user management modals for enhanced testing * refactor: replace useIsSingpassEnabled with useIsSingpassEnabledForCriticalActions in user management components and hooks * update mock feature flags for Singpass to include fallback value and clarify testing purpose * fix test * move back to original position * make props required * fix: do not reset TTL expiration * fix * remove unused props * update session TTL configuration for VAPT environment * add no-op updateConfig method for session in tests * hardcode for vapt only * remove unused improt * refactor support url and email * add getResourceStudioUrl function to generate resource-specific URLs * add email templates * implement email sending functions for publish alerts and update email template types * remove unused import * refactor email alert sending to use Promise.all for concurrent execution * refactor: replace useIsSingpassEnabledForCriticalActions with useIsSingpassEnabled + introduce SingpassConditionalTooltip * refactor: remove the need to pass in isSingpassEnabled * refactor: remove the need to pass in isSingpassEnabled * refactor: simplify Singpass feature check by removing deprecated function * fix: remove conditional hook * fix: add missing tooltip for Resend invite button * fix: add backend validation to block resending of invites * fix: add conditional isDisabled for isSingpassEnabled for button * chore: rename getResourceStudioUrl to getStudioResourceUrl for clarity in email templates * chore: return siteUrlPrefix instead of empty string * fix: replace with getIsSingpassEnabled * feat: add login alert email functionality and template * fix: missing throw new Error("Invalid email format") * enhance: move email sending out of DB TX * refactor: only pass in isSingpassEnabled directly * refactor: update Singpass feature flag usage in email router * fix(test): breaking test from updating default singpass value * test(auth.email): add tests for login alert email functionality --------- Co-authored-by: dcshzj <[email protected]>
1 parent 2a8ba21 commit 2e09c0e

File tree

13 files changed

+357
-21
lines changed

13 files changed

+357
-21
lines changed

apps/studio/src/constants/misc.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export const ISOMER_SUPPORT_LINK = "mailto:[email protected]"
1+
export const ISOMER_SUPPORT_EMAIL = "[email protected]"
2+
export const ISOMER_SUPPORT_LINK = `mailto:${ISOMER_SUPPORT_EMAIL}`

apps/studio/src/features/mail/service.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { InvitationEmailTemplateData } from "./templates"
1+
import type {
2+
InvitationEmailTemplateData,
3+
LoginAlertEmailTemplateData,
4+
PublishAlertContentPublisherEmailTemplateData,
5+
PublishAlertSiteAdminEmailTemplateData,
6+
} from "./templates"
27
import { createBaseLogger } from "~/lib/logger"
38
import { isValidEmail } from "~/utils/email"
49
import { sendMail } from "../../lib/mail"
@@ -34,3 +39,90 @@ export async function sendInvitation(
3439
throw error
3540
}
3641
}
42+
43+
export async function sendLoginAlertEmail(
44+
data: LoginAlertEmailTemplateData,
45+
): Promise<void> {
46+
if (!isValidEmail(data.recipientEmail)) {
47+
logger.error({
48+
error: "Invalid email format",
49+
email: data.recipientEmail,
50+
})
51+
throw new Error("Invalid email format")
52+
}
53+
54+
const template = templates.loginAlert(data)
55+
56+
try {
57+
await sendMail({
58+
recipient: data.recipientEmail,
59+
subject: template.subject,
60+
body: template.body,
61+
})
62+
} catch (error) {
63+
logger.error({
64+
error: "Failed to send login alert email",
65+
email: data.recipientEmail,
66+
originalError: error,
67+
})
68+
throw error
69+
}
70+
}
71+
72+
export async function sendPublishAlertContentPublisherEmail(
73+
data: PublishAlertContentPublisherEmailTemplateData,
74+
): Promise<void> {
75+
if (!isValidEmail(data.recipientEmail)) {
76+
logger.error({
77+
error: "Invalid email format",
78+
email: data.recipientEmail,
79+
})
80+
throw new Error("Invalid email format")
81+
}
82+
83+
const template = templates.publishAlertContentPublisher(data)
84+
85+
try {
86+
await sendMail({
87+
recipient: data.recipientEmail,
88+
subject: template.subject,
89+
body: template.body,
90+
})
91+
} catch (error) {
92+
logger.error({
93+
error: "Failed to send publish alert content publisher email",
94+
email: data.recipientEmail,
95+
originalError: error,
96+
})
97+
throw error
98+
}
99+
}
100+
101+
export async function sendPublishAlertSiteAdminEmail(
102+
data: PublishAlertSiteAdminEmailTemplateData,
103+
): Promise<void> {
104+
if (!isValidEmail(data.recipientEmail)) {
105+
logger.error({
106+
error: "Invalid email format",
107+
email: data.recipientEmail,
108+
})
109+
throw new Error("Invalid email format")
110+
}
111+
112+
const template = templates.publishAlertSiteAdmin(data)
113+
114+
try {
115+
await sendMail({
116+
recipient: data.recipientEmail,
117+
subject: template.subject,
118+
body: template.body,
119+
})
120+
} catch (error) {
121+
logger.error({
122+
error: "Failed to send publish alert site admin email",
123+
email: data.recipientEmail,
124+
originalError: error,
125+
})
126+
throw error
127+
}
128+
}

apps/studio/src/features/mail/templates/templates.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import type {
44
BaseEmailTemplateData,
55
EmailTemplate,
66
InvitationEmailTemplateData,
7+
LoginAlertEmailTemplateData,
8+
PublishAlertContentPublisherEmailTemplateData,
9+
PublishAlertSiteAdminEmailTemplateData,
710
} from "./types"
11+
import { ISOMER_SUPPORT_EMAIL, ISOMER_SUPPORT_LINK } from "~/constants/misc"
812
import { env } from "~/env.mjs"
13+
import { getStudioResourceUrl } from "~/utils/resources"
914

1015
export const invitationTemplate = (
1116
data: InvitationEmailTemplateData,
@@ -50,11 +55,65 @@ export const invitationTemplate = (
5055
}
5156
}
5257

58+
// FYI: Currently, we only send this email to users when Singpass has been disabled.
59+
export const loginAlertTemplate = (
60+
data: LoginAlertEmailTemplateData,
61+
): EmailTemplate => {
62+
const { recipientEmail } = data
63+
return {
64+
subject: `[Isomer Studio] Successful Login to Your Account`,
65+
body: `<p>Hi ${recipientEmail},</p>
66+
<p>We wanted to let you know that your account was accessed successfully.</p>
67+
<p>If this was you, no action is needed.</p>
68+
<p><strong>Note:</strong> You're receiving this notification because your account was logged into during a Singpass authentication outage. If you are not the one who logged in, please contact <a href="${ISOMER_SUPPORT_LINK}">${ISOMER_SUPPORT_EMAIL}</a> immediately.</p>
69+
<p>Best,</p>
70+
<p>Isomer team</p>`,
71+
}
72+
}
73+
74+
export const publishAlertContentPublisherTemplate = (
75+
data: PublishAlertContentPublisherEmailTemplateData,
76+
): EmailTemplate => {
77+
const { recipientEmail, siteName, resource } = data
78+
const studioResourceUrl = getStudioResourceUrl(resource)
79+
80+
return {
81+
subject: `[Isomer Studio] ${resource.title} has been published`,
82+
body: `<p>Hi ${recipientEmail},</p>
83+
<p>You have successfully published "${resource.title}" on ${siteName}. You can access your published content on Isomer Studio at <a href="${studioResourceUrl}">${studioResourceUrl}</a>.</p>
84+
<p><strong>Note:</strong> You're receiving this notification because content was published during a Singpass authentication outage. If you didn't authorize this publication, please contact <a href="${ISOMER_SUPPORT_LINK}">${ISOMER_SUPPORT_EMAIL}</a> immediately.</p>
85+
<p>Best,</p>
86+
<p>Isomer team</p>`,
87+
}
88+
}
89+
90+
export const publishAlertSiteAdminTemplate = (
91+
data: PublishAlertSiteAdminEmailTemplateData,
92+
): EmailTemplate => {
93+
const { recipientEmail, publisherEmail, siteName, resource } = data
94+
const studioResourceUrl = getStudioResourceUrl(resource)
95+
96+
return {
97+
subject: `[Isomer Studio] ${resource.title} has been published`,
98+
body: `<p>Hi ${recipientEmail},</p>
99+
<p>${publisherEmail} has published "${resource.title}" on ${siteName}. You can view the published content on Isomer Studio at <a href="${studioResourceUrl}">${studioResourceUrl}</a>.</p>
100+
<p><strong>Note:</strong> You're receiving this notification because content was published during a Singpass authentication outage. As a site admin, we want to keep you informed of all publishing activities. If you have any concerns, please contact <a href="${ISOMER_SUPPORT_LINK}">${ISOMER_SUPPORT_EMAIL}</a> immediately.</p>
101+
<p>Best,</p>
102+
<p>Isomer team</p>`,
103+
}
104+
}
105+
53106
type EmailTemplateFunction<
54107
T extends BaseEmailTemplateData = BaseEmailTemplateData,
55108
> = (data: T) => EmailTemplate
56109

57110
export const templates = {
58111
invitation:
59112
invitationTemplate satisfies EmailTemplateFunction<InvitationEmailTemplateData>,
113+
loginAlert:
114+
loginAlertTemplate satisfies EmailTemplateFunction<LoginAlertEmailTemplateData>,
115+
publishAlertContentPublisher:
116+
publishAlertContentPublisherTemplate satisfies EmailTemplateFunction<PublishAlertContentPublisherEmailTemplateData>,
117+
publishAlertSiteAdmin:
118+
publishAlertSiteAdminTemplate satisfies EmailTemplateFunction<PublishAlertSiteAdminEmailTemplateData>,
60119
} as const

apps/studio/src/features/mail/templates/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Resource } from "~/server/modules/database"
12
import type { RoleType } from "~prisma/generated/generatedEnums"
23

34
export interface BaseEmailTemplateData {
@@ -11,6 +12,21 @@ export interface InvitationEmailTemplateData extends BaseEmailTemplateData {
1112
isSingpassEnabled?: boolean
1213
}
1314

15+
export type LoginAlertEmailTemplateData = BaseEmailTemplateData
16+
17+
export interface PublishAlertContentPublisherEmailTemplateData
18+
extends BaseEmailTemplateData {
19+
siteName: string
20+
resource: Resource
21+
}
22+
23+
export interface PublishAlertSiteAdminEmailTemplateData
24+
extends BaseEmailTemplateData {
25+
publisherEmail: string
26+
siteName: string
27+
resource: Resource
28+
}
29+
1430
export interface EmailTemplate {
1531
subject: string
1632
body: string

apps/studio/src/features/sign-in/components/EmailLogin/EmailInput.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "@opengovsg/design-system-react"
1212

1313
import type { VfnStepData } from "../SignInContext"
14+
import { ISOMER_SUPPORT_LINK } from "~/constants/misc"
1415
import { useZodForm } from "~/lib/form"
1516
import { emailSignInSchema } from "~/schemas/auth/email/sign-in"
1617
import { trpc } from "~/utils/trpc"
@@ -23,7 +24,7 @@ const EmailInputErrorMessage = ({ type, message }: Partial<FieldError>) => {
2324
<Text>
2425
We are having trouble sending an OTP to this email address.{" "}
2526
<Link
26-
href="mailto:[email protected]?subject=I can't receive OTP on Isomer Studio"
27+
href={`${ISOMER_SUPPORT_LINK}?subject=I can't receive OTP on Isomer Studio`}
2728
color="utility.feedback.critical"
2829
_hover={{
2930
color: "unset",

apps/studio/src/pages/sites/[siteId]/admin.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ResourceType } from "~prisma/generated/generatedEnums"
1919
import { z } from "zod"
2020

2121
import { PermissionsBoundary } from "~/components/AuthWrappers"
22+
import { ISOMER_SUPPORT_EMAIL } from "~/constants/misc"
2223
import { BRIEF_TOAST_SETTINGS } from "~/constants/toast"
2324
import { UnsavedSettingModal } from "~/features/editing-experience/components/UnsavedSettingModal"
2425
import { useIsUserIsomerAdmin } from "~/hooks/useIsUserIsomerAdmin"
@@ -124,8 +125,7 @@ const SiteAdminPage: NextPageWithLayout = () => {
124125
onError: () => {
125126
toast({
126127
title: "Error saving site config!",
127-
description:
128-
"If this persists, please report this issue at [email protected]",
128+
description: `If this persists, please report this issue at ${ISOMER_SUPPORT_EMAIL}`,
129129
status: "error",
130130
...BRIEF_TOAST_SETTINGS,
131131
})

apps/studio/src/pages/sites/[siteId]/settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ResourceType } from "~prisma/generated/generatedEnums"
2020
import { z } from "zod"
2121

2222
import { PermissionsBoundary } from "~/components/AuthWrappers"
23+
import { ISOMER_SUPPORT_EMAIL } from "~/constants/misc"
2324
import { BRIEF_TOAST_SETTINGS } from "~/constants/toast"
2425
import { UnsavedSettingModal } from "~/features/editing-experience/components/UnsavedSettingModal"
2526
import { useQueryParse } from "~/hooks/useQueryParse"
@@ -53,8 +54,7 @@ const SiteSettingsPage: NextPageWithLayout = () => {
5354
onError: () => {
5455
toast({
5556
title: "Error saving site settings!",
56-
description:
57-
"If this persists, please report this issue at [email protected]",
57+
description: `If this persists, please report this issue at ${ISOMER_SUPPORT_EMAIL}`,
5858
status: "error",
5959
...BRIEF_TOAST_SETTINGS,
6060
})

apps/studio/src/server/modules/auth/email/__tests__/email.router.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {
55
createMockRequest,
66
} from "tests/integration/helpers/iron-session"
77
import { setupUser, setUpWhitelist } from "tests/integration/helpers/seed"
8-
import { describe, expect, it } from "vitest"
8+
import { describe, expect, it, vi } from "vitest"
99

1010
import { env } from "~/env.mjs"
11+
import * as mailService from "~/features/mail/service"
1112
import * as growthbookLib from "~/lib/growthbook"
1213
import * as mailLib from "~/lib/mail"
1314
import { AuditLogEvent, db } from "~/server/modules/database"
@@ -249,6 +250,32 @@ describe("auth.email", () => {
249250
beforeLogin.getTime(),
250251
)
251252
})
253+
254+
it("should send login alert email", async () => {
255+
// Arrange
256+
const alertSpy = vi
257+
.spyOn(mailService, "sendLoginAlertEmail")
258+
.mockResolvedValue()
259+
await setupUser({ email: TEST_VALID_EMAIL })
260+
await prisma.verificationToken.create({
261+
data: {
262+
expires: new Date(Date.now() + env.OTP_EXPIRY * 1000),
263+
identifier: TEST_OTP_FINGERPRINT,
264+
token: VALID_TOKEN_HASH,
265+
},
266+
})
267+
268+
// Act
269+
await caller.verifyOtp({
270+
email: TEST_VALID_EMAIL,
271+
token: VALID_OTP,
272+
})
273+
274+
// Assert
275+
expect(alertSpy).toHaveBeenCalledWith({
276+
recipientEmail: TEST_VALID_EMAIL,
277+
})
278+
})
252279
})
253280

254281
describe("when singpass is enabled", () => {
@@ -381,6 +408,31 @@ describe("auth.email", () => {
381408
})
382409
expect(user?.lastLoginAt).toBeNull()
383410
})
411+
412+
// Note: it's only sent in singpass downtime
413+
it("should not send login alert email", async () => {
414+
// Arrange
415+
const alertSpy = vi
416+
.spyOn(mailService, "sendLoginAlertEmail")
417+
.mockResolvedValue()
418+
await setupUser({ email: TEST_VALID_EMAIL })
419+
await prisma.verificationToken.create({
420+
data: {
421+
expires: new Date(Date.now() + env.OTP_EXPIRY * 1000),
422+
identifier: TEST_OTP_FINGERPRINT,
423+
token: VALID_TOKEN_HASH,
424+
},
425+
})
426+
427+
// Act
428+
await caller.verifyOtp({
429+
email: TEST_VALID_EMAIL,
430+
token: VALID_OTP,
431+
})
432+
433+
// Assert
434+
expect(alertSpy).not.toHaveBeenCalled()
435+
})
384436
})
385437

386438
it("should throw 400 if OTP is not found", async () => {

apps/studio/src/server/modules/auth/email/email.router.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import set from "lodash/set"
55
import type { SessionData } from "~/lib/types/session"
66
import type { GrowthbookAttributes } from "~/types/growthbook"
77
import { env } from "~/env.mjs"
8+
import { sendLoginAlertEmail } from "~/features/mail/service"
89
import { getIsSingpassEnabled } from "~/lib/growthbook"
910
import { sendMail } from "~/lib/mail"
1011
import {
@@ -146,7 +147,7 @@ export const emailSessionRouter = router({
146147
const isSingpassEnabled = getIsSingpassEnabled({ gb: ctx.gb })
147148

148149
if (!isSingpassEnabled) {
149-
return db.transaction().execute(async (tx) => {
150+
const user = await db.transaction().execute(async (tx) => {
150151
const user = await upsertUser({
151152
tx,
152153
email,
@@ -164,6 +165,10 @@ export const emailSessionRouter = router({
164165
await ctx.session.save()
165166
return pick(user, defaultUserSelect)
166167
})
168+
169+
await sendLoginAlertEmail({ recipientEmail: email })
170+
171+
return user
167172
}
168173

169174
return db.transaction().execute(async (tx) => {

0 commit comments

Comments
 (0)