Merge branch 'feat/SW-266-seo-loyalty-pages' of https://bitbucket.org/scandic-swap/web into feat/SW-266-seo-loyalty-pages
This commit is contained in:
13
Auth.md
Normal file
13
Auth.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Auth
|
||||
|
||||
The web is using OAuth 2.0 to handle auth. We host our own instance of [Curity](https://curity.io), which is our identity and access management solution.
|
||||
|
||||
## Session management in Next
|
||||
We use [Auth.js](https://authjs.dev) to handle everything regarding auth in the web. We use the JWT session strategy, which means that everything regarding the session is stored in a JWT, which is stored in the browser in an encrypted cookie.
|
||||
|
||||
## Keeping the access token alive
|
||||
When the user performs a navigation the web app often does multiple requests to Next. If the access token has expired Next will do a request to Curity to renew the tokens. Since we only allow a single refresh token to be used only once only the first request will succeed and the following requests will fail.
|
||||
|
||||
To avoid that we have a component whose only purpose is to keep the access token alive. As long as no other request is happening at the same time this will work fine.
|
||||
|
||||
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.
|
||||
@@ -17,6 +17,13 @@ export async function GET(
|
||||
const returnUrl = request.headers.get("x-returnurl")
|
||||
const isMFA = request.headers.get("x-mfa-login")
|
||||
|
||||
// This is to support seamless login when using magic link login
|
||||
const isMagicLinkUpdateLogin = !!request.headers.get("x-magic-link")
|
||||
|
||||
if (!env.PUBLIC_URL) {
|
||||
throw internalServerError("No value for env.PUBLIC_URL")
|
||||
}
|
||||
|
||||
if (returnUrl) {
|
||||
// Seamless login request from Current web
|
||||
redirectTo = returnUrl
|
||||
@@ -29,9 +36,6 @@ export async function GET(
|
||||
|
||||
// Make relative URL to absolute URL
|
||||
if (redirectTo.startsWith("/")) {
|
||||
if (!env.PUBLIC_URL) {
|
||||
throw internalServerError("No value for env.PUBLIC_URL")
|
||||
}
|
||||
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
|
||||
}
|
||||
|
||||
@@ -68,6 +72,14 @@ export async function GET(
|
||||
const redirectUrl = new URL(redirectUrlValue)
|
||||
redirectUrl.searchParams.set("returnurl", redirectTo)
|
||||
redirectTo = redirectUrl.toString()
|
||||
|
||||
/** Set cookie with redirect Url to appropriately redirect user when using magic link login */
|
||||
redirectHeaders.append(
|
||||
"set-cookie",
|
||||
"magicLinkRedirectTo=" +
|
||||
redirectTo +
|
||||
"; Max-Age=300; Path=/; HttpOnly; SameSite=Lax"
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Unable to create URL for seamless login, proceeding without it."
|
||||
@@ -86,27 +98,37 @@ export async function GET(
|
||||
console.log({ login_env: process.env })
|
||||
|
||||
console.log({ login_redirectTo: redirectTo })
|
||||
const params = isMFA
|
||||
? {
|
||||
ui_locales: context.params.lang,
|
||||
scope: ["profile_update", "openid", "profile"].join(" "),
|
||||
/**
|
||||
* The below acr value is required as for New Web same Curity Client is used for MFA
|
||||
* while in current web it is being setup using different Curity Client
|
||||
*/
|
||||
acr_values:
|
||||
"urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web",
|
||||
}
|
||||
: {
|
||||
ui_locales: context.params.lang,
|
||||
scope: ["openid", "profile"].join(" "),
|
||||
/**
|
||||
* The `acr_values` param is used to make Curity display the proper login
|
||||
* page for Scandic. Without the parameter Curity presents some choices
|
||||
* to the user which we do not want.
|
||||
*/
|
||||
acr_values: "acr",
|
||||
}
|
||||
const params = {
|
||||
ui_locales: context.params.lang,
|
||||
scope: ["openid", "profile"].join(" "),
|
||||
/**
|
||||
* The `acr_values` param is used to make Curity display the proper login
|
||||
* page for Scandic. Without the parameter Curity presents some choices
|
||||
* to the user which we do not want.
|
||||
*/
|
||||
acr_values: "acr",
|
||||
|
||||
/**
|
||||
* Both of the below two params are required to send for initiating login as well
|
||||
* because user might choose to do Email link login.
|
||||
* */
|
||||
// The `for_origin` param is used to make Curity email login functionality working.
|
||||
for_origin: env.PUBLIC_URL,
|
||||
// This is new param set for differentiate between the Magic link login of New web and current web
|
||||
version: "2",
|
||||
}
|
||||
if (isMFA) {
|
||||
// Append profile_update scope for MFA
|
||||
params.scope = params.scope + " profile_udpate"
|
||||
/**
|
||||
* The below acr value is required as for New Web same Curity Client is used for MFA
|
||||
* while in current web it is being setup using different Curity Client
|
||||
*/
|
||||
params.acr_values =
|
||||
"urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web"
|
||||
} else if (isMagicLinkUpdateLogin) {
|
||||
params.acr_values = "abc"
|
||||
}
|
||||
const redirectUrl = await signIn(
|
||||
"curity",
|
||||
{
|
||||
|
||||
75
app/[lang]/(live)/(public)/verifymagiclink/route.ts
Normal file
75
app/[lang]/(live)/(public)/verifymagiclink/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { AuthError } from "next-auth"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { login } from "@/constants/routes/handleAuth"
|
||||
import { env } from "@/env/server"
|
||||
import { badRequest, internalServerError } from "@/server/errors/next"
|
||||
|
||||
import { signIn } from "@/auth"
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: { lang: Lang } }
|
||||
) {
|
||||
let redirectTo: string
|
||||
|
||||
// Set redirect url from the magicLinkRedirect Cookie which is set when intiating login
|
||||
redirectTo =
|
||||
request.cookies.get("magicLinkRedirectTo")?.value ||
|
||||
"/" + context.params.lang
|
||||
|
||||
if (!env.PUBLIC_URL) {
|
||||
throw internalServerError("No value for env.PUBLIC_URL")
|
||||
}
|
||||
|
||||
// Make relative URL to absolute URL
|
||||
if (redirectTo.startsWith("/")) {
|
||||
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
|
||||
}
|
||||
|
||||
// Update Seamless login url as Magic link login has a different authenticator in Curity
|
||||
redirectTo = redirectTo.replace("updatelogin", "updateloginemail")
|
||||
|
||||
const loginKey = request.nextUrl.searchParams.get("loginKey")
|
||||
|
||||
if (!loginKey) {
|
||||
return badRequest()
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* Passing `redirect: false` to `signIn` will return the URL instead of
|
||||
* automatically redirecting to it inside of `signIn`.
|
||||
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76
|
||||
*/
|
||||
console.log({ login_redirectTo: redirectTo })
|
||||
let redirectUrl = await signIn(
|
||||
"curity",
|
||||
{
|
||||
redirectTo,
|
||||
redirect: false,
|
||||
},
|
||||
{
|
||||
ui_locales: context.params.lang,
|
||||
scope: ["openid", "profile"].join(" "),
|
||||
loginKey: loginKey,
|
||||
for_origin: env.PUBLIC_URL,
|
||||
acr_values: "abc",
|
||||
version: "2",
|
||||
}
|
||||
)
|
||||
|
||||
if (redirectUrl) {
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
console.error({ signInAuthError: error })
|
||||
} else {
|
||||
console.error({ signInError: error })
|
||||
}
|
||||
}
|
||||
|
||||
return internalServerError()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Script from "next/script"
|
||||
|
||||
import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
||||
import Footer from "@/components/Current/Footer"
|
||||
import VwoScript from "@/components/Current/VwoScript"
|
||||
@@ -55,6 +56,7 @@ export default async function RootLayout({
|
||||
{header}
|
||||
{children}
|
||||
<Footer />
|
||||
<TokenRefresher />
|
||||
</TrpcProvider>
|
||||
</ServerIntlProvider>
|
||||
</body>
|
||||
|
||||
@@ -3,6 +3,7 @@ import "@scandic-hotels/design-system/style.css"
|
||||
|
||||
import Script from "next/script"
|
||||
|
||||
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||
import AdobeScript from "@/components/Current/AdobeScript"
|
||||
import Footer from "@/components/Current/Footer"
|
||||
import Header from "@/components/Current/Header"
|
||||
@@ -72,6 +73,7 @@ export default async function RootLayout({
|
||||
/>
|
||||
{children}
|
||||
<Footer />
|
||||
<TokenRefresher />
|
||||
</ServerIntlProvider>
|
||||
<Script id="page-tracking">{`
|
||||
typeof _satellite !== "undefined" && _satellite.pageBottom();
|
||||
|
||||
143
auth.ts
143
auth.ts
@@ -1,25 +1,84 @@
|
||||
import NextAuth from "next-auth"
|
||||
|
||||
import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth"
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import { LoginTypeEnum } from "./types/components/tracking"
|
||||
|
||||
import type { NextAuthConfig, User } from "next-auth"
|
||||
import type { JWT } from "next-auth/jwt"
|
||||
import type { OIDCConfig } from "next-auth/providers"
|
||||
|
||||
function getLoginType(user: User) {
|
||||
// TODO: handle magic link, should be enough to just check for Nonce.
|
||||
// if (user?.nonce) {
|
||||
// return LoginTypeEnum.MagicLink
|
||||
// }
|
||||
|
||||
if (user?.login_with.includes("@")) {
|
||||
return LoginTypeEnum.email
|
||||
} else if (user?.login_with.toLowerCase() == LoginTypeEnum["email link"]) {
|
||||
return LoginTypeEnum["email link"]
|
||||
} else {
|
||||
return LoginTypeEnum["membership number"]
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokens(token: JWT) {
|
||||
try {
|
||||
console.log("token-debug Access token expired, trying to refresh it.", {
|
||||
expires_at: token.expires_at,
|
||||
sub: token.sub,
|
||||
token: token.access_token,
|
||||
})
|
||||
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
|
||||
body: new URLSearchParams({
|
||||
client_id: env.CURITY_CLIENT_ID_USER,
|
||||
client_secret: env.CURITY_CLIENT_SECRET_USER,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refresh_token,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
const new_tokens = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("token-debug Token response was not ok", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
sub: token.sub,
|
||||
})
|
||||
throw new_tokens
|
||||
}
|
||||
|
||||
console.log("token-debug Successfully got new token(s)", {
|
||||
expires_at: new_tokens.expires_at,
|
||||
got_new_refresh_token: new_tokens.refresh_token !== token.refresh_token,
|
||||
got_new_access_token: new_tokens.access_token !== token.access_token,
|
||||
sub: token.sub,
|
||||
})
|
||||
|
||||
const expiresAt = new_tokens.expires_in
|
||||
? Date.now() + new_tokens.expires_in * 1000
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...token,
|
||||
access_token: new_tokens.access_token,
|
||||
expires_at: expiresAt,
|
||||
refresh_token: new_tokens.refresh_token,
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("token-debug Error thrown when trying to refresh", {
|
||||
error,
|
||||
sub: token.sub,
|
||||
})
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError" as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const curityProvider = {
|
||||
id: "curity",
|
||||
name: "Curity",
|
||||
@@ -103,7 +162,7 @@ export const config = {
|
||||
async authorized({ auth, request }) {
|
||||
return true
|
||||
},
|
||||
async jwt({ account, token, trigger, user, profile }) {
|
||||
async jwt({ account, session, token, trigger, user, profile }) {
|
||||
const loginType = getLoginType(user)
|
||||
if (trigger === "signIn" && account) {
|
||||
const mfa_scope =
|
||||
@@ -121,71 +180,15 @@ export const config = {
|
||||
mfa_scope: mfa_scope,
|
||||
mfa_expires_at: mfa_expires_at,
|
||||
}
|
||||
} else if (Date.now() < token.expires_at) {
|
||||
return token
|
||||
} else {
|
||||
try {
|
||||
console.log(
|
||||
"token-debug Access token expired, trying to refresh it.",
|
||||
{
|
||||
expires_at: token.expires_at,
|
||||
sub: token.sub,
|
||||
token: token.access_token,
|
||||
}
|
||||
)
|
||||
const response = await fetch(
|
||||
`${env.CURITY_ISSUER_USER}/oauth/v2/token`,
|
||||
{
|
||||
body: new URLSearchParams({
|
||||
client_id: env.CURITY_CLIENT_ID_USER,
|
||||
client_secret: env.CURITY_CLIENT_SECRET_USER,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: token.refresh_token,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
)
|
||||
|
||||
const new_tokens = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("token-debug Token response was not ok", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
sub: token.sub,
|
||||
})
|
||||
throw new_tokens
|
||||
}
|
||||
|
||||
console.log("token-debug Successfully got new token(s)", {
|
||||
expires_at: new_tokens.expires_at,
|
||||
got_new_refresh_token:
|
||||
new_tokens.refresh_token !== token.refresh_token,
|
||||
got_new_access_token:
|
||||
new_tokens.access_token !== token.access_token,
|
||||
sub: token.sub,
|
||||
})
|
||||
|
||||
return {
|
||||
...token,
|
||||
access_token: new_tokens.access_token,
|
||||
expires_at: new_tokens.expires_at,
|
||||
refresh_token: new_tokens.refresh_token ?? token.refresh_token,
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("token-debug Error thrown when trying to refresh", {
|
||||
error,
|
||||
sub: token.sub,
|
||||
})
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError" as const,
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
token.expires_at &&
|
||||
Date.now() > token.expires_at - PRE_REFRESH_TIME_IN_SECONDS * 1000 &&
|
||||
session?.doRefresh
|
||||
) {
|
||||
return refreshTokens(token)
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
},
|
||||
// events: {
|
||||
|
||||
65
components/Auth/TokenRefresher.tsx
Normal file
65
components/Auth/TokenRefresher.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { SessionProvider, useSession } from "next-auth/react"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
|
||||
import {
|
||||
MAX_KEEP_ALIVE_TIME_IN_MINUTES,
|
||||
PRE_REFRESH_TIME_IN_SECONDS,
|
||||
} from "@/constants/auth"
|
||||
|
||||
/**
|
||||
* Keeps the access token alive by proactively refreshing it
|
||||
*
|
||||
*/
|
||||
export default function TokenRefresher() {
|
||||
return (
|
||||
<SessionProvider basePath="/api/web/auth">
|
||||
<Refresher />
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Refresher() {
|
||||
const session = useSession()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const timeoutId = useRef<NodeJS.Timeout>()
|
||||
|
||||
// Simple inactivity control. Reset when the URL changes.
|
||||
const stopPreRefreshAt = useMemo(
|
||||
() => Date.now() + MAX_KEEP_ALIVE_TIME_IN_MINUTES * 60 * 1000,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pathname, searchParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current)
|
||||
}
|
||||
|
||||
if (!session.data?.token.expires_at) {
|
||||
return
|
||||
}
|
||||
|
||||
const refreshIn =
|
||||
session.data.token.expires_at -
|
||||
Date.now() -
|
||||
PRE_REFRESH_TIME_IN_SECONDS * 1000
|
||||
|
||||
timeoutId.current = setTimeout(
|
||||
async () => {
|
||||
if (stopPreRefreshAt > Date.now()) {
|
||||
await session.update({ doRefresh: true })
|
||||
}
|
||||
},
|
||||
// If the token has already expired `refreshIn` will be
|
||||
// negative, and we will refresh immediately (in 1 ms)
|
||||
Math.max(refreshIn, 1)
|
||||
)
|
||||
|
||||
return () => clearTimeout(timeoutId.current)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.data?.token.expires_at, stopPreRefreshAt])
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { amenities } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import SidePeekContainer from "@/components/TempDesignSystem/SidePeek/Container"
|
||||
import SidePeekContent from "@/components/TempDesignSystem/SidePeek/Content"
|
||||
import { generateSidePeekLink } from "@/components/TempDesignSystem/SidePeek/data"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./amenitiesList.module.css"
|
||||
|
||||
@@ -17,15 +17,15 @@ export default async function AmenitiesList({
|
||||
}: {
|
||||
detailedFacilities: HotelData["data"]["attributes"]["detailedFacilities"]
|
||||
}) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const sidePeekLink = generateSidePeekLink("amenities")
|
||||
const intl = await getIntl()
|
||||
const sortedAmenities = detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
.slice(0, 5)
|
||||
const lang = getLang()
|
||||
return (
|
||||
<section className={styles.amenitiesContainer}>
|
||||
<Subtitle type="two" color="black">
|
||||
{formatMessage({ id: "At the hotel" })}
|
||||
{intl.formatMessage({ id: "At the hotel" })}
|
||||
</Subtitle>
|
||||
<div className={styles.amenityItemList}>
|
||||
{sortedAmenities.map((facility) => {
|
||||
@@ -38,18 +38,15 @@ export default async function AmenitiesList({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Link scroll={false} href={sidePeekLink} color="burgundy" variant="icon">
|
||||
{formatMessage({ id: "Show all amenities" })}
|
||||
<Link
|
||||
scroll={false}
|
||||
href={`?s=${amenities[lang]}`}
|
||||
color="burgundy"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({ id: "Show all amenities" })}
|
||||
<ChevronRightIcon />
|
||||
</Link>
|
||||
<SidePeekContainer>
|
||||
<SidePeekContent
|
||||
contentKey={"amenities"}
|
||||
title={formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
{/* TODO: Render amenities as per the design. */}
|
||||
</SidePeekContent>
|
||||
</SidePeekContainer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getLang } from "@/i18n/serverContext"
|
||||
import AmenitiesList from "./AmenitiesList"
|
||||
import IntroSection from "./IntroSection"
|
||||
import { Rooms } from "./Rooms"
|
||||
import SidePeeks from "./SidePeeks"
|
||||
import TabNavigation from "./TabNavigation"
|
||||
|
||||
import styles from "./hotelPage.module.css"
|
||||
@@ -16,10 +17,10 @@ export default async function HotelPage() {
|
||||
if (!hotelPageIdentifierData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lang = getLang()
|
||||
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
|
||||
hotelId: hotelPageIdentifierData.hotel_page_id,
|
||||
language: getLang(),
|
||||
language: lang,
|
||||
include: ["RoomCategories"],
|
||||
})
|
||||
|
||||
@@ -35,6 +36,7 @@ export default async function HotelPage() {
|
||||
address={attributes.address}
|
||||
tripAdvisor={attributes.ratings.tripAdvisor}
|
||||
/>
|
||||
<SidePeeks />
|
||||
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { about } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import ArrowRight from "@/components/Icons/ArrowRight"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
@@ -6,6 +8,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { IntroSectionProps } from "./types"
|
||||
|
||||
@@ -19,15 +22,15 @@ export default async function IntroSection({
|
||||
tripAdvisor,
|
||||
}: IntroSectionProps) {
|
||||
const intl = await getIntl()
|
||||
const { formatMessage } = intl
|
||||
const { streetAddress, city } = address
|
||||
const { distanceToCentre } = location
|
||||
const formattedDistanceText = formatMessage(
|
||||
const formattedDistanceText = intl.formatMessage(
|
||||
{ id: "Distance to city centre" },
|
||||
{ number: distanceToCentre }
|
||||
)
|
||||
const lang = getLang()
|
||||
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
||||
const formattedTripAdvisorText = formatMessage(
|
||||
const formattedTripAdvisorText = intl.formatMessage(
|
||||
{ id: "Tripadvisor reviews" },
|
||||
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
||||
)
|
||||
@@ -37,7 +40,7 @@ export default async function IntroSection({
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.titleContainer}>
|
||||
<BiroScript tilted="medium" color="red">
|
||||
{formatMessage({ id: "Welcome to" })}:
|
||||
{intl.formatMessage({ id: "Welcome to" })}:
|
||||
</BiroScript>
|
||||
<Title level="h2">{hotelName}</Title>
|
||||
</div>
|
||||
@@ -58,14 +61,12 @@ export default async function IntroSection({
|
||||
<Preamble>{hotelDescription}</Preamble>
|
||||
<Link
|
||||
className={styles.introLink}
|
||||
target="_blank"
|
||||
color="peach80"
|
||||
textDecoration="underline"
|
||||
variant="icon"
|
||||
href="#"
|
||||
href={`?s=${about[lang]}`}
|
||||
>
|
||||
{/*TODO: Ask content team where this should link to. */}
|
||||
{formatMessage({ id: "Read more about the hotel" })}
|
||||
{intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
<ArrowRight color="peach80" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
64
components/ContentType/HotelPage/SidePeeks.tsx
Normal file
64
components/ContentType/HotelPage/SidePeeks.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { about, amenities } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import SidePeekItem from "@/components/TempDesignSystem/SidePeek/Item"
|
||||
import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
function SidePeekContainer() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [activeSidePeek, setActiveSidePeek] =
|
||||
useState<SidePeekContentKey | null>(() => {
|
||||
const sidePeekParam = searchParams.get("s") as SidePeekContentKey | null
|
||||
return sidePeekParam || null
|
||||
})
|
||||
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
useEffect(() => {
|
||||
const sidePeekParam = searchParams.get("s") as SidePeekContentKey | null
|
||||
if (sidePeekParam !== activeSidePeek) {
|
||||
setActiveSidePeek(sidePeekParam)
|
||||
}
|
||||
}, [searchParams, activeSidePeek])
|
||||
|
||||
function handleClose(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setActiveSidePeek(null)
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString())
|
||||
nextSearchParams.delete("s")
|
||||
|
||||
router.push(`${pathname}?${nextSearchParams}`, { scroll: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePeek handleClose={handleClose} activeSidePeek={activeSidePeek}>
|
||||
<SidePeekItem
|
||||
contentKey={amenities[lang]}
|
||||
title={intl.formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
{/* TODO: Render amenities as per the design. */}
|
||||
Read more about the amenities here
|
||||
</SidePeekItem>
|
||||
<SidePeekItem
|
||||
contentKey={about[lang]}
|
||||
title={intl.formatMessage({ id: "Read more about the hotel" })}
|
||||
>
|
||||
Some additional information about the hotel
|
||||
</SidePeekItem>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidePeekContainer
|
||||
@@ -38,7 +38,7 @@
|
||||
"title": "Sen utcheckning – 1 timme, i mån av plats"
|
||||
},
|
||||
{
|
||||
"title": "Voucher 50 kr"
|
||||
"title": "Kupong 50 kr"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -49,13 +49,13 @@
|
||||
"requiredNights": 0,
|
||||
"benefits": [
|
||||
{
|
||||
"title": "25% poängboost"
|
||||
"title": "25 % poängboost"
|
||||
},
|
||||
{
|
||||
"title": "Tidig incheckning – 1 timme, i mån av plats"
|
||||
},
|
||||
{
|
||||
"title": "Voucher 75 kr"
|
||||
"title": "Kupong 75 kr"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -69,7 +69,7 @@
|
||||
"title": "Kostnadsfri uppgradering av rum, i mån av plats"
|
||||
},
|
||||
{
|
||||
"title": "Voucher 100 kr"
|
||||
"title": "Kupong 100 kr"
|
||||
},
|
||||
{
|
||||
"title": "Frukost 2 för 1"
|
||||
@@ -83,10 +83,10 @@
|
||||
"requiredNights": 0,
|
||||
"benefits": [
|
||||
{
|
||||
"title": "50% poängboost"
|
||||
"title": "50 % poängboost"
|
||||
},
|
||||
{
|
||||
"title": "Voucher 150 kr"
|
||||
"title": "Kupong 150 kr"
|
||||
},
|
||||
{
|
||||
"title": "48 timmars rumsgaranti"
|
||||
@@ -103,7 +103,7 @@
|
||||
"requiredNights": 100,
|
||||
"benefits": [
|
||||
{
|
||||
"title": "Voucher 200 kr"
|
||||
"title": "Kupong 200 kr"
|
||||
},
|
||||
{
|
||||
"title": "Spännande gåva varje år"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import React, { Children, cloneElement, useEffect, useState } from "react"
|
||||
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types"
|
||||
|
||||
export default function SidePeekContainer({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [activeSidePeek, setActiveSidePeek] =
|
||||
useState<SidePeekContentKey | null>(() => {
|
||||
const sidePeekParam = searchParams.get(
|
||||
"sidepeek"
|
||||
) as SidePeekContentKey | null
|
||||
return sidePeekParam || null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const sidePeekParam = searchParams.get(
|
||||
"sidepeek"
|
||||
) as SidePeekContentKey | null
|
||||
if (sidePeekParam !== activeSidePeek) {
|
||||
setActiveSidePeek(sidePeekParam)
|
||||
}
|
||||
}, [searchParams, activeSidePeek])
|
||||
|
||||
function handleClose(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setActiveSidePeek(null)
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString())
|
||||
nextSearchParams.delete("sidepeek")
|
||||
|
||||
router.push(`${pathname}?${nextSearchParams}`, { scroll: false })
|
||||
}
|
||||
}
|
||||
const childrenWithIsActive = Children.map(children, (child) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return child
|
||||
}
|
||||
return cloneElement(child as React.ReactElement, {
|
||||
isActive:
|
||||
(child.props.contentKey as SidePeekContentKey) === activeSidePeek,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<SidePeek activeContent={activeSidePeek} onClose={handleClose}>
|
||||
{childrenWithIsActive}
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Children, PropsWithChildren } from "react"
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
import { CloseIcon } from "@/components/Icons"
|
||||
import { SidePeekContentProps } from "@/components/TempDesignSystem/SidePeek/types"
|
||||
@@ -8,17 +8,16 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import Button from "../../Button"
|
||||
|
||||
import styles from "./content.module.css"
|
||||
import styles from "./sidePeekItem.module.css"
|
||||
|
||||
export default function Content({
|
||||
function SidePeekItem({
|
||||
title,
|
||||
children,
|
||||
contentKey,
|
||||
isActive = false,
|
||||
onClose,
|
||||
}: PropsWithChildren<SidePeekContentProps>) {
|
||||
return isActive ? (
|
||||
<aside className={styles.content}>
|
||||
<aside className={styles.sidePeekItem}>
|
||||
<header className={styles.header}>
|
||||
<Title color="burgundy" textTransform="uppercase" level="h2" as="h3">
|
||||
{title}
|
||||
@@ -36,3 +35,5 @@ export default function Content({
|
||||
</aside>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default SidePeekItem
|
||||
@@ -1,11 +1,11 @@
|
||||
.content {
|
||||
.sidePeekItem {
|
||||
display: grid;
|
||||
grid-template-rows: min-content auto;
|
||||
gap: var(--Spacing-x4);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content > * {
|
||||
.content>* {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media and screen (min-width: 1367px) {
|
||||
.content > * {
|
||||
@media screen and (min-width: 1367px) {
|
||||
.content>* {
|
||||
padding: var(--Spacing-x4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SidePeekContentKey } from "./types"
|
||||
|
||||
export const generateSidePeekLink = (key: SidePeekContentKey) => {
|
||||
return `?sidepeek=${key}`
|
||||
}
|
||||
@@ -1,36 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { Children, cloneElement, PropsWithChildren } from "react"
|
||||
import { Dialog, DialogTrigger, Modal } from "react-aria-components"
|
||||
import { useIsSSR } from "@react-aria/ssr"
|
||||
import React, { Children, cloneElement } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
|
||||
import { SidePeekProps } from "./types"
|
||||
import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types"
|
||||
|
||||
import styles from "./sidePeek.module.css"
|
||||
|
||||
export default function SidePeek({
|
||||
import type { SidePeekProps } from "./sidePeek"
|
||||
|
||||
function SidePeek({
|
||||
children,
|
||||
onClose,
|
||||
activeContent,
|
||||
}: PropsWithChildren<SidePeekProps>) {
|
||||
return (
|
||||
handleClose,
|
||||
activeSidePeek,
|
||||
}: React.PropsWithChildren<SidePeekProps>) {
|
||||
const sidePeekChildren = Children.map(children, (child) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return child
|
||||
}
|
||||
return cloneElement(child as React.ReactElement, {
|
||||
isActive:
|
||||
(child.props.contentKey as SidePeekContentKey) === activeSidePeek,
|
||||
onClose: handleClose,
|
||||
})
|
||||
})
|
||||
|
||||
const isSSR = useIsSSR()
|
||||
return isSSR ? (
|
||||
<div>{children}</div>
|
||||
) : (
|
||||
<DialogTrigger>
|
||||
<Modal
|
||||
className={styles.sidePeek}
|
||||
isOpen={!!activeContent}
|
||||
onOpenChange={onClose}
|
||||
<ModalOverlay
|
||||
className={styles.overlay}
|
||||
isOpen={!!activeSidePeek}
|
||||
onOpenChange={handleClose}
|
||||
isDismissable
|
||||
>
|
||||
<Dialog className={styles.dialog}>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{Children.map(children, (child) => {
|
||||
return cloneElement(child as React.ReactElement, {
|
||||
onClose: close,
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
<Modal className={styles.sidePeek}>
|
||||
<Dialog className={styles.dialog}>{sidePeekChildren}</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidePeek
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 70.047px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
right: -600px;
|
||||
@@ -56,4 +64,7 @@
|
||||
.sidePeek[data-exiting] {
|
||||
animation: slide-in 250ms reverse;
|
||||
}
|
||||
.overlay {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
4
components/TempDesignSystem/SidePeek/sidePeek.ts
Normal file
4
components/TempDesignSystem/SidePeek/sidePeek.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SidePeekProps {
|
||||
handleClose: (isOpen: boolean) => void
|
||||
activeSidePeek: string | null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SidePeekContentKey = "amenities" | "read_more_about_the_hotel"
|
||||
export type SidePeekContentKey = string
|
||||
|
||||
export type SidePeekProps = {
|
||||
activeContent: string | null
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thead {
|
||||
|
||||
12
constants/auth.ts
Normal file
12
constants/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* How many seconds before the expiry of the access token
|
||||
* the client should refresh the tokens
|
||||
*/
|
||||
export const PRE_REFRESH_TIME_IN_SECONDS = 30
|
||||
|
||||
/**
|
||||
* How many minutes the client should be allowed to be inactive
|
||||
* and still get the tokens refreshed. The inactivity control
|
||||
* gets reset when the URL changes.
|
||||
*/
|
||||
export const MAX_KEEP_ALIVE_TIME_IN_MINUTES = 2 * 60
|
||||
@@ -22,4 +22,18 @@ export const logout = {
|
||||
sv: "/sv/logga-ut",
|
||||
}
|
||||
|
||||
export const handleAuth = [...Object.values(login), ...Object.values(logout)]
|
||||
/** @type {import('@/types/routes').LangRoute} */
|
||||
export const verifymagiclink = {
|
||||
da: "/da/verifymagiclink",
|
||||
de: "/de/verifymagiclink",
|
||||
en: "/en/verifymagiclink",
|
||||
fi: "/fi/verifymagiclink",
|
||||
no: "/no/verifymagiclink",
|
||||
sv: "/sv/verifymagiclink",
|
||||
}
|
||||
|
||||
export const handleAuth = [
|
||||
...Object.values(login),
|
||||
...Object.values(logout),
|
||||
...Object.values(verifymagiclink),
|
||||
]
|
||||
|
||||
21
constants/routes/hotelPageParams.js
Normal file
21
constants/routes/hotelPageParams.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export const about = {
|
||||
en: "about",
|
||||
sv: "om-hotellet",
|
||||
no: "om-hotellet",
|
||||
fi: "hotellista",
|
||||
da: "om-hotellet",
|
||||
de: "uber-das-hotel",
|
||||
}
|
||||
|
||||
export const amenities = {
|
||||
en: "amenities",
|
||||
sv: "bekvamligheter",
|
||||
no: "fasiliteter",
|
||||
da: "faciliteter",
|
||||
fi: "palvelut",
|
||||
de: "annehmlichkeiten",
|
||||
}
|
||||
|
||||
const params = { about, amenities }
|
||||
|
||||
export default params
|
||||
@@ -25,7 +25,7 @@
|
||||
"Close": "Schließen",
|
||||
"Coming up": "Demnächst",
|
||||
"Compare all levels": "Vergleichen Sie alle Levels",
|
||||
"Contact us": "Kontaktiere uns",
|
||||
"Contact us": "Kontaktieren Sie uns",
|
||||
"Continue": "Weitermachen",
|
||||
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
|
||||
"Country": "Land",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"Address": "Adress",
|
||||
"All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alla våra sängar är från Bliss, med möjlighet att justera fastheten för perfekt komfort.",
|
||||
"All rooms comes with standard amenities": "Alla rum har standardbekvämligheter",
|
||||
"Already a friend?": "Redan en vän?",
|
||||
"Already a friend?": "Är du redan en vän?",
|
||||
"Amenities": "Bekvämligheter",
|
||||
"Arrival date": "Ankomstdatum",
|
||||
"as of today": "från och med idag",
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as authRequired from "./middlewares/authRequired"
|
||||
import * as bookingFlow from "./middlewares/bookingFlow"
|
||||
import * as cmsContent from "./middlewares/cmsContent"
|
||||
import * as currentWebLogin from "./middlewares/currentWebLogin"
|
||||
import * as currentWebLoginEmail from "./middlewares/currentWebLoginEmail"
|
||||
import * as currentWebLogout from "./middlewares/currentWebLogout"
|
||||
import * as handleAuth from "./middlewares/handleAuth"
|
||||
import * as myPages from "./middlewares/myPages"
|
||||
@@ -31,6 +32,7 @@ export const middleware: NextMiddleware = async (request, event) => {
|
||||
|
||||
const middlewares = [
|
||||
currentWebLogin,
|
||||
currentWebLoginEmail,
|
||||
currentWebLogout,
|
||||
authRequired,
|
||||
handleAuth,
|
||||
|
||||
33
middlewares/currentWebLoginEmail.ts
Normal file
33
middlewares/currentWebLoginEmail.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { badRequest } from "@/server/errors/next"
|
||||
|
||||
import { findLang } from "@/utils/languages"
|
||||
|
||||
import type { NextMiddleware } from "next/server"
|
||||
|
||||
import type { MiddlewareMatcher } from "@/types/middleware"
|
||||
|
||||
export const middleware: NextMiddleware = (request) => {
|
||||
const returnUrl = request.nextUrl.searchParams.get("returnurl")
|
||||
|
||||
if (!returnUrl) {
|
||||
return badRequest()
|
||||
}
|
||||
|
||||
const lang = findLang(request.nextUrl.pathname)!
|
||||
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("x-returnurl", returnUrl)
|
||||
headers.set("x-magic-link", "1")
|
||||
|
||||
return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), {
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const matcher: MiddlewareMatcher = (request) => {
|
||||
return request.nextUrl.pathname.endsWith("/updateloginemail")
|
||||
}
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@netlify/plugin-nextjs": "^5.1.1",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@react-aria/ssr": "^3.9.5",
|
||||
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
|
||||
"@t3-oss/env-nextjs": "^0.9.2",
|
||||
"@tanstack/react-query": "^5.28.6",
|
||||
@@ -4317,10 +4318,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.2.tgz",
|
||||
"integrity": "sha512-0gKkgDYdnq1w+ey8KzG9l+H5Z821qh9vVjztk55rUg71vTk/Eaebeir+WtzcLLwTjw3m/asIjx8Y59y1lJZhBw==",
|
||||
"peer": true,
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz",
|
||||
"integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
@@ -4328,7 +4328,7 @@
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/switch": {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@netlify/plugin-nextjs": "^5.1.1",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@react-aria/ssr": "^3.9.5",
|
||||
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
|
||||
"@t3-oss/env-nextjs": "^0.9.2",
|
||||
"@tanstack/react-query": "^5.28.6",
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
async function getVerifiedUser({ session }: { session: Session }) {
|
||||
const now = Date.now()
|
||||
|
||||
if (session.token.expires_at < now) {
|
||||
if (session.token.expires_at && session.token.expires_at < now) {
|
||||
return { error: true, cause: "token_expired" } as const
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export type TrackingSDKPageData = {
|
||||
export enum LoginTypeEnum {
|
||||
email = "email",
|
||||
"membership number" = "membership number",
|
||||
// MagicLink = "magic link",
|
||||
"email link" = "email link",
|
||||
}
|
||||
export type LoginType = keyof typeof LoginTypeEnum
|
||||
|
||||
|
||||
2
types/jwt.d.ts
vendored
2
types/jwt.d.ts
vendored
@@ -10,7 +10,7 @@ declare module "next-auth/jwt" {
|
||||
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
|
||||
interface JWT extends DefaultJWT, RefreshTokenError {
|
||||
access_token: string
|
||||
expires_at: number
|
||||
expires_at?: number
|
||||
refresh_token: string
|
||||
loginType: LoginType
|
||||
mfa_scope: boolean
|
||||
|
||||
Reference in New Issue
Block a user