Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/rpc/src/modules/user/user-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,26 @@ const updateUserProcedure = procedure
return ctx.userService.update(ctx.handle, input.id, { name, email, ...data })
})

export type RequestEmailChangeInput = inferProcedureInput<typeof requestEmailChangeProcedure>
export type RequestEmailChangeOutput = inferProcedureOutput<typeof requestEmailChangeProcedure>
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<typeof syncEmailFromAuth0Procedure>
export type SyncEmailFromAuth0Output = inferProcedureOutput<typeof syncEmailFromAuth0Procedure>
const syncEmailFromAuth0Procedure = procedure
.use(withAuthentication())
.use(withDatabaseTransaction())
.mutation(async ({ ctx }) => {
return ctx.userService.syncEmailFromAuth0(ctx.handle, ctx.principal.subject)
})

export type IsStaffInput = inferProcedureInput<typeof isStaffProcedure>
export type IsStaffOutput = inferProcedureOutput<typeof isStaffProcedure>
const isStaffProcedure = procedure.query(async ({ ctx }) => {
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions apps/rpc/src/modules/user/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ import type { Auth0Connection } from "./user"
export interface UserService {
register(handle: DBHandle, subject: string): Promise<User>
update(handle: DBHandle, userId: UserId, data: Partial<UserWrite>): Promise<User>
/**
* 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<void>
/**
* 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<User>
/**
* Find a user by their ID, or null if not found.
*
Expand Down Expand Up @@ -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)
Expand Down
163 changes: 158 additions & 5 deletions apps/web/src/app/innstillinger/bruker/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"
Comment thread
brage-andreas marked this conversation as resolved.

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<typeof setTimeout> | 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 }))
}
Expand All @@ -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 (
<div className="flex flex-col gap-6">
{isLinkStatusFailed ? (
Expand Down Expand Up @@ -81,10 +137,107 @@ export default function MinBrukerPage() {

<Title size="xl">Min bruker</Title>

<div className="flex flex-col gap-3">
<div className="flex flex-col gap-6">
<Title size="md">E-post</Title>
<Text>Du kan snart endre e-posten din.</Text>

{returnedFromEmailVerification && (
<div className="flex flex-row gap-3 items-center bg-green-100 dark:bg-green-900 p-3 rounded-lg">
<IconCheck size="1.25em" className="text-green-600 dark:text-green-400" />
<div className="flex flex-col">
<Title size="sm" className="text-sm">
E-posten er bekreftet
</Title>
<Text className="text-xs">Vi har oppdatert e-posten din.</Text>
</div>
</div>
)}

<div className="flex flex-col gap-3">
<Text>Nåværende e-post</Text>
{user?.email ? (
<div
className={cn(
"flex gap-3 px-3 py-2 h-10 rounded-lg items-center w-fit",
"bg-gray-50 dark:bg-stone-800 border border-gray-200 dark:border-stone-700"
)}
>
<Text className="text-sm">{user.email}</Text>
<Button
variant="unstyled"
size="sm"
className="group -m-1.5 p-1.5 rounded-lg transition-colors hover:text-inherit hover:bg-gray-100"
onClick={() => {
if (!user.email) {
return
}

navigator.clipboard.writeText(user.email).then(() => {
setCopyEmailIcon("check")
})
}}
>
<CopyEmailIcon
size="1em"
className={cn(
"shrink-0 transition-colors text-gray-500 dark:text-stone-400 group-hover:text-inherit",
copyEmailIcon === "check" && "text-green-600 dark:text-green-400"
)}
/>
</Button>
</div>
) : (
<div className="h-10 w-28 bg-gray-100 dark:bg-stone-800 rounded-lg animate-pulse" />
)}
</div>

<form
className="flex flex-col gap-2 sm:grid sm:grid-cols-[minmax(0,calc(var(--spacing)*64))_auto] sm:grid-rows-[auto_auto] sm:gap-x-2 sm:gap-y-3 sm:[&>div:first-child]:col-start-1 sm:[&>div:first-child]:grid! sm:[&>div:first-child]:row-span-2 sm:[&>div:first-child]:grid-rows-subgrid"
onSubmit={(event) => {
event.preventDefault()

if (!newEmail) {
return
}

requestEmailChange.mutate({ newEmail })
}}
>
<TextInput
label="Ny e-post"
type="email"
placeholder="min.epost@gmail.com"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
className="w-full sm:max-w-sm"
required
/>

<Button
type="submit"
className="w-fit sm:col-start-2 sm:row-start-2"
disabled={requestEmailChange.isPending || !newEmail}
>
<div className="flex gap-2 items-center">
<IconMail className="size-4" />
<Text className="text-sm">Send bekreftelse</Text>
</div>
</Button>
</form>
</div>
{requestEmailChange.isSuccess && (
<div className="flex items-center gap-2">
<IconCheck className="size-4 text-green-600 dark:text-green-400" />
<Text className="text-sm">
Vi har sendt en bekreftelseslenke. Klikk lenken i e-posten for å bekrefte den nye adressen.
</Text>
</div>
)}
{requestEmailChange.isError && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<IconAlertTriangle className="size-4" />
<Text className="text-sm">Kunne ikke sende bekreftelse: {requestEmailChange.error?.message ?? "TEST"}</Text>
</div>
)}

<div className="flex flex-col gap-3">
<Title size="md">Innloggingsmetoder</Title>
Expand Down
57 changes: 57 additions & 0 deletions infra/auth0/email_templates.tf
Original file line number Diff line number Diff line change
@@ -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 <online@online.ntnu.no>"
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
<!DOCTYPE html>
<html dir="ltr" lang="nb">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
<meta name="x-apple-disable-message-reformatting"/>
</head>
<body style="background-color: rgb(255, 255, 255);">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 37.5em">
<tbody>
<tr style="width: 100%">
<td>
<h2>Bekreft e-postadressen din</h2>
<p>Hei, {{ user.name | default: user.email | split: " " | first }}. Klikk på lenken under for å bekrefte e-postadressen din hos Linjeforeningen Online.</p>
</td>
</tr>

<tr style="width: 100%">
<td>
<a href="{{ url }}">Bekreft e-postadressen min</a>
<p style="font-size: 0.75em; color: gray">Lenken er gyldig i 24 timer. Dersom du ikke ba om denne e-posten, kan du trygt ignorere den.</p>
</td>
</tr>

<tr style="width: 100%">
<td>
<h3 style="font-size: 0.9em; margin-top: 3rem">Linjeforeningen Online</h3>
<p style="font-size: 0.75em; color: gray">Du mottar denne e-posten fordi noen har bedt om å bekrefte denne e-postadressen hos Online.</p>
<p style="font-size: 0.75em; color: gray">Org. Nr. 992 548 045 &ndash; Høgskoleringen 5, 7034 Trondheim</p>
<p style="font-size: 0.75em; color: gray">Alle datoer er i norsk tid.</p>
</td>
</tr>
</tbody>
</table>
</body>
</html>
EOT
}
2 changes: 1 addition & 1 deletion infra/auth0/ses.tf
Original file line number Diff line number Diff line change
@@ -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 <online@online.ntnu.no>"

credentials {
access_key_id = aws_iam_access_key.auth0_ses_emailer.id
Expand Down
Loading