From 99e481226e411d7420c44f21266fc549a29de229 Mon Sep 17 00:00:00 2001 From: brage-andreas Date: Sun, 19 Apr 2026 21:27:39 +0200 Subject: [PATCH] feat: add email verification --- apps/rpc/src/modules/user/user-router.ts | 22 +++ apps/rpc/src/modules/user/user-service.ts | 65 +++++++ .../web/src/app/innstillinger/bruker/page.tsx | 163 +++++++++++++++++- infra/auth0/email_templates.tf | 57 ++++++ infra/auth0/ses.tf | 2 +- 5 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 infra/auth0/email_templates.tf diff --git a/apps/rpc/src/modules/user/user-router.ts b/apps/rpc/src/modules/user/user-router.ts index 22a964ffd3..43baf09543 100644 --- a/apps/rpc/src/modules/user/user-router.ts +++ b/apps/rpc/src/modules/user/user-router.ts @@ -209,6 +209,26 @@ const updateUserProcedure = procedure return ctx.userService.update(ctx.handle, input.id, { name, email, ...data }) }) +export type RequestEmailChangeInput = inferProcedureInput +export type RequestEmailChangeOutput = inferProcedureOutput +const requestEmailChangeProcedure = procedure + .input(z.object({ newEmail: z.string().email() })) + .use(withAuthentication()) + .use(withDatabaseTransaction()) + .use(withAuditLogEntry()) + .mutation(async ({ input, ctx }) => { + return ctx.userService.requestEmailChange(ctx.handle, ctx.principal.subject, input.newEmail) + }) + +export type SyncEmailFromAuth0Input = inferProcedureInput +export type SyncEmailFromAuth0Output = inferProcedureOutput +const syncEmailFromAuth0Procedure = procedure + .use(withAuthentication()) + .use(withDatabaseTransaction()) + .mutation(async ({ ctx }) => { + return ctx.userService.syncEmailFromAuth0(ctx.handle, ctx.principal.subject) + }) + export type IsStaffInput = inferProcedureInput export type IsStaffOutput = inferProcedureOutput const isStaffProcedure = procedure.query(async ({ ctx }) => { @@ -319,6 +339,8 @@ export const userRouter = t.router({ getMe: getMeProcedure, findMe: findMeProcedure, update: updateUserProcedure, + requestEmailChange: requestEmailChangeProcedure, + syncEmailFromAuth0: syncEmailFromAuth0Procedure, isStaff: isStaffProcedure, isAdmin: isAdminProcedure, confirmIdentityLink: confirmIdentityLinkProcedure, diff --git a/apps/rpc/src/modules/user/user-service.ts b/apps/rpc/src/modules/user/user-service.ts index a2638f128e..72c90b9ab1 100644 --- a/apps/rpc/src/modules/user/user-service.ts +++ b/apps/rpc/src/modules/user/user-service.ts @@ -41,6 +41,22 @@ import type { Auth0Connection } from "./user" export interface UserService { register(handle: DBHandle, subject: string): Promise update(handle: DBHandle, userId: UserId, data: Partial): Promise + /** + * Update the user's email in Auth0 and trigger a verification email to the new address. + * + * NOTE: We do not update `User#email` until the user verifies the new email address. Call + * {@link UserService#syncEmailFromAuth0} after the user has clicked the verification link to update the DB user. + * + * @throws {InvalidArgumentError} if the new email is identical to the current one. + * @throws {AlreadyExistsError} if another user in the database already has this email. + */ + requestEmailChange(handle: DBHandle, userId: UserId, newEmail: string): Promise + /** + * Synchronizes the DB user's email with the email in Auth0. + * + * The DB user is mutated if and only if the email is verified in Auth0 AND it differs from the current DB user email. + */ + syncEmailFromAuth0(handle: DBHandle, userId: UserId): Promise /** * Find a user by their ID, or null if not found. * @@ -416,6 +432,55 @@ export function getUserService( return await userRepository.update(handle, userId, data) }, + async requestEmailChange(handle, userId, newEmail) { + const user = await this.getById(handle, userId) + + if (user.email === newEmail) { + throw new InvalidArgumentError(`User(ID=${userId}) already has Email=${newEmail}`) + } + + const conflict = await handle.user.findFirst({ + where: { email: newEmail, id: { not: userId } }, + select: { id: true }, + }) + + if (conflict !== null) { + throw new AlreadyExistsError(`Email=${newEmail} is already in use by another user`) + } + + const response = await managementClient.users.update( + { id: userId }, + { email: newEmail, email_verified: false, verify_email: true } + ) + + if (response.status !== 200) { + throw new IllegalStateError( + `Received HTTP ${response.status} (${response.statusText}) when updating Email for User(ID=${userId}) in Auth0` + ) + } + + logger.info("Requested email change for User(ID=%s). Auth0 will send a verification email.", userId) + }, + + async syncEmailFromAuth0(handle, userId) { + const user = await this.getById(handle, userId) + const response = await managementClient.users.get({ id: userId }) + + if (response.status !== 200) { + throw new IllegalStateError( + `Received HTTP ${response.status} (${response.statusText}) when fetching User(ID=${userId}) from Auth0` + ) + } + + const auth0Email = response.data.email + if (!response.data.email_verified || !auth0Email || auth0Email === user.email) { + return user + } + + logger.info("Syncing verified Auth0 email to DB for User(ID=%s)", userId) + return await userRepository.update(handle, userId, { email: auth0Email }) + }, + async discoverMembership(handle, userId) { const accessToken = await this.findFeideAccessTokenByUserId(userId) const user = await this.getById(handle, userId) diff --git a/apps/web/src/app/innstillinger/bruker/page.tsx b/apps/web/src/app/innstillinger/bruker/page.tsx index 92fd2a2ec2..2ac30f24d9 100644 --- a/apps/web/src/app/innstillinger/bruker/page.tsx +++ b/apps/web/src/app/innstillinger/bruker/page.tsx @@ -4,11 +4,13 @@ import { FeideIcon } from "@/components/icons/FeideIcon" import { useTRPC } from "@/utils/trpc/client" import { useFullPathname } from "@/utils/use-full-pathname" import { useSession } from "@dotkomonline/oauth2/react" -import { Button, Text, Title } from "@dotkomonline/ui" +import { Button, Text, TextInput, Title, cn } from "@dotkomonline/ui" import { createAbsoluteLinkIdentityAuthorizeUrl, createAuthorizeUrl } from "@dotkomonline/utils" -import { IconCheck, IconLink, IconPassword, IconX } from "@tabler/icons-react" -import { useQuery } from "@tanstack/react-query" +import { IconAlertTriangle, IconCheck, IconCopy, IconLink, IconMail, IconPassword, IconX } from "@tabler/icons-react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { secondsToMilliseconds } from "date-fns" import { redirect, useSearchParams } from "next/navigation" +import { useEffect, useState } from "react" export default function MinBrukerPage() { const fullPathname = useFullPathname() @@ -19,14 +21,66 @@ export default function MinBrukerPage() { const linkStatus = searchParams.get("link_status") const isLinkStatusOk = linkStatus === "ok" const isLinkStatusFailed = linkStatus === "failed" + const returnedFromEmailVerification = searchParams.get("email_verified") === "1" const trpc = useTRPC() + const queryClient = useQueryClient() const { data: auth0Connections, isLoading: auth0ConnectionsIsLoading } = useQuery({ ...trpc.user.getAuth0Connections.queryOptions({ userId: session?.sub ?? "" }), enabled: session !== null, }) + const { data: user } = useQuery({ + ...trpc.user.getMe.queryOptions(), + enabled: session !== null, + }) + + const { mutate: synchronizeEmail } = useMutation( + trpc.user.syncEmailFromAuth0.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries(trpc.user.getMe.queryOptions()) + }, + }) + ) + + const [newEmail, setNewEmail] = useState("") + const [copyEmailIcon, setCopyEmailIcon] = useState<"copy" | "check">("copy") + + useEffect(() => { + let timeout: ReturnType | null = null + + if (copyEmailIcon === "check") { + timeout = setTimeout(() => { + setCopyEmailIcon("copy") + }, secondsToMilliseconds(2.5)) + } + + return () => { + if (timeout !== null) { + clearTimeout(timeout) + } + } + }, [copyEmailIcon]) + + const requestEmailChange = useMutation( + trpc.user.requestEmailChange.mutationOptions({ + onSuccess: () => { + setNewEmail("") + }, + }) + ) + + // We synchronize the email from Auth0 on mount, so that if the user returns here after clicking a verification link, + // the DB user also gets updated. + useEffect(() => { + if (session === null) { + return + } + + synchronizeEmail() + }, [session, synchronizeEmail]) + if (session === null) { redirect(createAuthorizeUrl({ redirectAfter: fullPathname })) } @@ -52,6 +106,8 @@ export default function MinBrukerPage() { const feideLinkButtonProps = isFeideLinked || auth0ConnectionsIsLoading ? { disabled: true } : { element: "a", href: linkFeideUrl } + const CopyEmailIcon = copyEmailIcon === "copy" ? IconCopy : IconCheck + return (
{isLinkStatusFailed ? ( @@ -81,10 +137,107 @@ export default function MinBrukerPage() { Min bruker -
+
E-post - Du kan snart endre e-posten din. + + {returnedFromEmailVerification && ( +
+ +
+ + E-posten er bekreftet + + Vi har oppdatert e-posten din. +
+
+ )} + +
+ Nåværende e-post + {user?.email ? ( +
+ {user.email} + +
+ ) : ( +
+ )} +
+ +
{ + event.preventDefault() + + if (!newEmail) { + return + } + + requestEmailChange.mutate({ newEmail }) + }} + > + setNewEmail(event.target.value)} + className="w-full sm:max-w-sm" + required + /> + + +
+ {requestEmailChange.isSuccess && ( +
+ + + Vi har sendt en bekreftelseslenke. Klikk lenken i e-posten for å bekrefte den nye adressen. + +
+ )} + {requestEmailChange.isError && ( +
+ + Kunne ikke sende bekreftelse: {requestEmailChange.error?.message ?? "TEST"} +
+ )}
Innloggingsmetoder diff --git a/infra/auth0/email_templates.tf b/infra/auth0/email_templates.tf new file mode 100644 index 0000000000..68d85e79ed --- /dev/null +++ b/infra/auth0/email_templates.tf @@ -0,0 +1,57 @@ +locals { + web_origin = { + "dev" = "http://localhost:3000" + "stg" = "https://dev.online.ntnu.no" + "prd" = "https://online.ntnu.no" + }[terraform.workspace] +} + +resource "auth0_email_template" "verify_email" { + depends_on = [auth0_email_provider.amazon_ses_email_provider] + + template = "verify_email" + enabled = true + from = "Linjeforeningen Online " + subject = "(Online) Bekreft e-postadressen din" + syntax = "liquid" + result_url = "${local.web_origin}/innstillinger/bruker?email_verified=1" + url_lifetime_in_seconds = 86400 # 24 hours + + body = <<-EOT + + + + + + + + + + + + + + + + + + + + + +
+

Bekreft e-postadressen din

+

Hei, {{ user.name | default: user.email | split: " " | first }}. Klikk på lenken under for å bekrefte e-postadressen din hos Linjeforeningen Online.

+
+ Bekreft e-postadressen min +

Lenken er gyldig i 24 timer. Dersom du ikke ba om denne e-posten, kan du trygt ignorere den.

+
+

Linjeforeningen Online

+

Du mottar denne e-posten fordi noen har bedt om å bekrefte denne e-postadressen hos Online.

+

Org. Nr. 992 548 045 – Høgskoleringen 5, 7034 Trondheim

+

Alle datoer er i norsk tid.

+
+ + + EOT +} diff --git a/infra/auth0/ses.tf b/infra/auth0/ses.tf index d45f0f9588..b5ec22eb5c 100644 --- a/infra/auth0/ses.tf +++ b/infra/auth0/ses.tf @@ -1,7 +1,7 @@ resource "auth0_email_provider" "amazon_ses_email_provider" { name = "ses" enabled = true - default_from_address = "online@online.ntnu.no" + default_from_address = "Linjeforeningen Online " credentials { access_key_id = aws_iam_access_key.auth0_ses_emailer.id