Skip to content

Commit b9585e4

Browse files
committed
feat: add confirmation page
1 parent 11eb203 commit b9585e4

6 files changed

Lines changed: 231 additions & 35 deletions

File tree

apps/rpc/src/modules/group/group-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ export function getGroupService(
335335
const membershipsByGroup = new Map<string, GroupMembership[]>()
336336

337337
for (const membership of memberships) {
338-
// @ts-ignore: getOrInsert is a function
338+
// @ts-expect-error: getOrInsert is a function
339339
const groupMemberships = membershipsByGroup.getOrInsert(membership.groupId, [])
340340
groupMemberships.push(membership)
341341
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client"
2+
3+
import { Button } from "@dotkomonline/ui"
4+
import { useRouter } from "next/navigation"
5+
import { useTransition } from "react"
6+
import { confirmIdentityLinkAction } from "./actions"
7+
8+
export function ConfirmIdentityLinkButton() {
9+
const router = useRouter()
10+
const [isPending, startTransition] = useTransition()
11+
12+
const onConfirm = () => {
13+
startTransition(async () => {
14+
try {
15+
await confirmIdentityLinkAction()
16+
17+
router.replace("/innstillinger/bruker?link_status=ok")
18+
} catch (error: unknown) {
19+
const errorMessage = error instanceof Error ? error.message : null
20+
const query = errorMessage ? `&error=${encodeURIComponent(errorMessage)}` : ""
21+
22+
router.replace(`/innstillinger/bruker?link_status=failed${query}`)
23+
}
24+
})
25+
}
26+
27+
return (
28+
<div className="flex flex-col gap-3">
29+
<div className="flex flex-wrap gap-3">
30+
<Button color="brand" onClick={onConfirm} disabled={isPending}>
31+
{isPending ? "Kobler brukere..." : "Bekreft og koble brukere"}
32+
</Button>
33+
<Button element="a" href="/innstillinger/bruker" variant="outline" disabled={isPending}>
34+
Avbryt
35+
</Button>
36+
</div>
37+
</div>
38+
)
39+
}

apps/web/src/app/innstillinger/bruker/link/actions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,24 @@ import { cookies } from "next/headers"
77
const isHttps = env.NEXT_PUBLIC_ORIGIN.startsWith("https://")
88
const cookiePrefix = isHttps ? "__Secure-" : ""
99

10-
export async function confirmIdentityLinkAction() {
10+
export async function getIdentityLinkCookies() {
1111
const cookieHandle = await cookies()
12+
1213
const secondaryUserId = cookieHandle.get(`${cookiePrefix}monoweb-pending-link-user-id`)?.value
1314
const secondaryIdToken = cookieHandle.get(`${cookiePrefix}monoweb-pending-link-id-token`)?.value
1415

1516
if (!secondaryUserId || !secondaryIdToken) {
1617
throw new Error("No pending identity link found. Please start the linking process again.")
1718
}
1819

20+
return { secondaryUserId, secondaryIdToken }
21+
}
22+
23+
export async function confirmIdentityLinkAction() {
24+
const cookieHandle = await cookies()
25+
26+
const { secondaryUserId, secondaryIdToken } = await getIdentityLinkCookies()
27+
1928
const mergedUser = await server.user.confirmIdentityLink.mutate({ secondaryIdToken, secondaryUserId })
2029

2130
cookieHandle.delete(`${cookiePrefix}monoweb-pending-link-user-id`)
Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,138 @@
1-
"use client"
2-
3-
import { useEffect } from "react"
4-
import { useRouter } from "next/navigation"
5-
import { confirmIdentityLinkAction } from "./actions"
6-
import { Text } from "@dotkomonline/ui"
7-
8-
export default function LinkIdentityPage() {
9-
const router = useRouter()
10-
11-
useEffect(() => {
12-
confirmIdentityLinkAction()
13-
.then(() => {
14-
router.replace("/innstillinger/profil")
15-
})
16-
.catch((error) => {
17-
console.error("Failed to link identity:", error)
18-
router.replace("/innstillinger/profil?error=link_failed")
19-
})
20-
}, [router])
21-
22-
return <Text>Kobler sammen kontoer...</Text>
1+
import { auth } from "@/auth"
2+
import { createAuthorizeUrl, getStudyGrade } from "@dotkomonline/utils"
3+
import { Avatar, AvatarFallback, AvatarImage, cn, Text, Title } from "@dotkomonline/ui"
4+
import { redirect } from "next/navigation"
5+
import { ConfirmIdentityLinkButton } from "./ConfirmIdentityLinkButton"
6+
import { getIdentityLinkCookies } from "./actions"
7+
import { server } from "@/utils/trpc/server"
8+
import { findActiveMembership, getMembershipTypeName, type User } from "@dotkomonline/types"
9+
import { IconArrowNarrowLeft, IconUser, IconUserFilled } from "@tabler/icons-react"
10+
11+
export default async function LinkIdentityPage() {
12+
const session = await auth.getServerSession()
13+
14+
if (!session) {
15+
redirect(createAuthorizeUrl({ redirectAfter: "/innstillinger/bruker/link" }))
16+
}
17+
18+
const { secondaryUserId } = await getIdentityLinkCookies().catch(() => {
19+
redirect("/innstillinger/bruker")
20+
})
21+
22+
const primaryUser = await server.user.getMe.query()
23+
const secondaryUser = await server.user.get.query(secondaryUserId)
24+
25+
return (
26+
<div className="flex max-w-3xl flex-col gap-6">
27+
<div className="flex flex-col gap-2">
28+
<Title size="xl">Bekreft kobling av brukere</Title>
29+
<Text className="text-sm">
30+
Du er i ferd med å koble en ekstra innloggingsmetode til den eksisterende brukeren din. Brukerprofilen som
31+
hører til denne innloggingsmetoden blir samtidig koblet til kontoen din. Kontroller at informasjonen under
32+
stemmer før du fortsetter.
33+
</Text>
34+
</div>
35+
36+
<div className="flex flex-col md:grid md:grid-cols-[1fr_auto_1fr] gap-4 items-center">
37+
<UserColumn type="primary" user={primaryUser} />
38+
39+
<div className="flex flex-col items-center gap-2 md:gap-0">
40+
<IconArrowNarrowLeft className="size-8 rotate-90 self-center md:rotate-0" strokeWidth={2} />
41+
<Text className="text-[0.55rem] font-bold">Sammenslås</Text>
42+
</div>
43+
44+
<UserColumn type="secondary" user={secondaryUser} />
45+
</div>
46+
47+
<Text className="text-xs text-gray-500 dark:text-stone-500">
48+
Denne handlingen slår sammen de to brukerene til én bruker. All data og historikk overføres til primærbruker.
49+
Hvis både primærbruker og sekundærbruker har verdi i samme felt, beholdes verdien til primærbruker, og verdien
50+
til sekundærbruker går tapt. Sammenslåingen kan ikke angres.
51+
</Text>
52+
53+
<ConfirmIdentityLinkButton />
54+
</div>
55+
)
56+
}
57+
58+
type UserColumnProps = {
59+
user: User
60+
type: "primary" | "secondary"
61+
}
62+
63+
function UserColumn({ user, type }: UserColumnProps) {
64+
const name = user.name ?? "<Ingen navn oppgitt>"
65+
const email = user.email ?? "<Ingen e-post oppgitt>"
66+
67+
const membership = findActiveMembership(user)
68+
const membershipType = membership && getMembershipTypeName(membership.type)
69+
const grade = membership?.semester != null ? getStudyGrade(membership.semester) : null
70+
71+
const title = type === "primary" ? "Primærbruker" : "Sekundærbruker"
72+
const caption = type === "primary" ? "Primærbrukeren beholdes." : "Sekundærbrukeren vil bli slått sammen."
73+
74+
const cardColor = cn(
75+
type === "primary" && "bg-violet-100 border-violet-200 dark:bg-violet-900/60 dark:border-white/10",
76+
type === "secondary" && "bg-yellow-100 border-yellow-200 dark:bg-yellow-900/60 dark:border-white/10"
77+
)
78+
79+
const titleColor = cn(
80+
type === "primary" && "text-violet-700 dark:text-violet-300",
81+
type === "secondary" && "text-yellow-700 dark:text-yellow-300"
82+
)
83+
84+
const cardHeaderColor = cn(
85+
type === "primary" && "bg-violet-200 dark:bg-violet-900/75 border-violet-200 dark:border-white/10",
86+
type === "secondary" && "bg-yellow-200 dark:bg-yellow-900/75 border-yellow-200 dark:border-white/10"
87+
)
88+
89+
const icon =
90+
type === "primary" ? (
91+
<IconUserFilled className={cn("size-3.5", titleColor)} />
92+
) : (
93+
<IconUser className={cn("size-3.5", titleColor)} strokeWidth={2.5} />
94+
)
95+
96+
return (
97+
<div className="flex flex-col gap-2">
98+
<div
99+
className={cn(
100+
"flex flex-col gap-1 rounded-xl border border-gray-200 bg-gray-50 dark:border-stone-700 dark:bg-stone-900",
101+
cardColor
102+
)}
103+
>
104+
<div className={cn("flex gap-2 items-center p-2 pb-1.5 rounded-t-xl", cardHeaderColor)}>
105+
{icon}
106+
<Title element="h3" size="sm" className={cn("text-sm", titleColor)}>
107+
{title}
108+
</Title>
109+
</div>
110+
111+
<div className="flex gap-3 items-center p-3 pt-1.5">
112+
<Avatar className="size-12">
113+
<AvatarImage className="object-cover" src={user.imageUrl ?? undefined} />
114+
<AvatarFallback className="bg-gray-500 dark:bg-stone-500">
115+
<IconUser className="size-[1.5em]" />
116+
</AvatarFallback>
117+
</Avatar>
118+
119+
<div className="flex flex-col gap-1">
120+
<Text className="font-medium text-sm truncate" title={name}>
121+
{name}
122+
</Text>
123+
124+
<Text className="text-xs truncate" title={email}>
125+
{email}
126+
</Text>
127+
128+
<Text className={cn("text-xs", membershipType === null && "text-gray-500 dark:text-stone-500")}>
129+
{membershipType ? `${membershipType} ${grade ? `(${grade}. klasse)` : ""}` : "Ingen medlemskap"}
130+
</Text>
131+
</div>
132+
</div>
133+
</div>
134+
135+
<Text className="text-xs">{caption}</Text>
136+
</div>
137+
)
23138
}

apps/web/src/app/innstillinger/bruker/page.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,23 @@ import { useFullPathname } from "@/utils/use-full-pathname"
66
import { useSession } from "@dotkomonline/oauth2/react"
77
import { Button, Text, Title } from "@dotkomonline/ui"
88
import { createAbsoluteLinkIdentityAuthorizeUrl, createAuthorizeUrl } from "@dotkomonline/utils"
9-
import { IconCheck, IconLink, IconPassword } from "@tabler/icons-react"
9+
import { IconCheck, IconLink, IconPassword, IconX } from "@tabler/icons-react"
1010
import { useQuery } from "@tanstack/react-query"
11-
import { redirect } from "next/navigation"
11+
import { redirect, useSearchParams } from "next/navigation"
1212

1313
export default function MinBrukerPage() {
14-
const session = useSession()
1514
const fullPathname = useFullPathname()
15+
const searchParams = useSearchParams()
16+
const session = useSession()
17+
18+
const linkErrorMessage = searchParams.get("error") ?? null
19+
const linkStatus = searchParams.get("link_status")
20+
const isLinkStatusOk = linkStatus === "ok"
21+
const isLinkStatusFailed = linkStatus === "failed"
1622

1723
const trpc = useTRPC()
1824

19-
const { data: auth0Connections } = useQuery({
25+
const { data: auth0Connections, isLoading: auth0ConnectionsIsLoading } = useQuery({
2026
...trpc.user.getAuth0Connections.queryOptions({ userId: session?.sub ?? "" }),
2127
enabled: session !== null,
2228
})
@@ -25,8 +31,8 @@ export default function MinBrukerPage() {
2531
redirect(createAuthorizeUrl({ redirectAfter: fullPathname }))
2632
}
2733

28-
const isFeideLinked = auth0Connections?.hasFeide ?? false
29-
const isUsernamePasswordLinked = auth0Connections?.hasUsernamePassword ?? false
34+
const isFeideLinked = auth0Connections?.hasFeide === true
35+
const isUsernamePasswordLinked = auth0Connections?.hasUsernamePassword === true
3036

3137
const linkFeideUrl = createAbsoluteLinkIdentityAuthorizeUrl(window.location.origin, {
3238
connection: "FEIDE",
@@ -38,14 +44,41 @@ export default function MinBrukerPage() {
3844
redirectAfter: `${fullPathname}/link`,
3945
})
4046

41-
const usernamePasswordLinkButtonProps = isUsernamePasswordLinked
42-
? { disabled: true }
43-
: { element: "a", href: linkUsernamePasswordUrl }
47+
const usernamePasswordLinkButtonProps =
48+
isUsernamePasswordLinked || auth0ConnectionsIsLoading
49+
? { disabled: true }
50+
: { element: "a", href: linkUsernamePasswordUrl }
4451

45-
const feideLinkButtonProps = isFeideLinked ? { disabled: true } : { element: "a", href: linkFeideUrl }
52+
const feideLinkButtonProps =
53+
isFeideLinked || auth0ConnectionsIsLoading ? { disabled: true } : { element: "a", href: linkFeideUrl }
4654

4755
return (
4856
<div className="flex flex-col gap-6">
57+
{isLinkStatusFailed ? (
58+
<div className="flex flex-row gap-3 items-center bg-red-100 dark:bg-red-900 p-3 rounded-lg">
59+
<IconX size="1.25em" className="text-red-600 dark:text-red-400" />
60+
<div className="flex flex-col">
61+
<Title size="sm" className="text-sm">
62+
Koblingen feilet
63+
</Title>
64+
<Text className="text-xs">Kunne ikke koble sammen kontoene. Kontakt dotkom dersom problemet vedvarer.</Text>
65+
{linkErrorMessage ? (
66+
<Text className="text-xs">Feilmelding: {decodeURIComponent(linkErrorMessage)}</Text>
67+
) : null}
68+
</div>
69+
</div>
70+
) : isLinkStatusOk ? (
71+
<div className="flex flex-row gap-3 items-center bg-green-100 dark:bg-green-900 p-3 rounded-lg">
72+
<IconCheck size="1.25em" className="text-green-600 dark:text-green-400" />
73+
<div className="flex flex-col">
74+
<Title size="sm" className="text-sm">
75+
Koblingen er vellykket
76+
</Title>
77+
<Text className="text-xs">Du kan nå bruke denne kontoen til å logge inn.</Text>
78+
</div>
79+
</div>
80+
) : null}
81+
4982
<Title size="xl">Min bruker</Title>
5083

5184
<div className="flex flex-col gap-3">

apps/web/src/app/innstillinger/components/settings-menu-item.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type SettingsMenuItemProps = {
1212

1313
export const SettingsMenuItem: FC<SettingsMenuItemProps> = ({ title, slug, icon: Icon }) => {
1414
const path = usePathname()
15-
const isCurrent = path === slug
15+
const isCurrent = path.startsWith(slug)
1616

1717
return (
1818
<Button

0 commit comments

Comments
 (0)