Merged in feature/curity-social-login (pull request #2963)
feat(SW-3541): Do social login after login to SAS * feat(auth): wip social login via curity * Setup social login auth flow * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/curity-social-login * Added support for getting scandic tokens and refresh them * feat: Enhance social login and session management with auto-refresh and improved error handling * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/curity-social-login * wrap layout in suspense * revert app/layout.tsx * fix import * cleanup * merge * merge * dont pass client_secret in the url to curity * add state validation when doing social login through /authorize * remove debug logging Approved-by: Anton Gunnarsson
This commit is contained in:
59
apps/partner-sas/app/[lang]/(auth)/SocialLogin.tsx
Normal file
59
apps/partner-sas/app/[lang]/(auth)/SocialLogin.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { createContext, useContext } from "react"
|
||||||
|
|
||||||
|
import { useSocialSession } from "@/hooks/useSocialSession"
|
||||||
|
|
||||||
|
/* eslint-disable formatjs/no-literal-string-in-jsx */
|
||||||
|
|
||||||
|
type SocialLoginContextType = {
|
||||||
|
session: ReturnType<typeof useSocialSession>["session"]
|
||||||
|
refresh: ReturnType<typeof useSocialSession>["refresh"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocialLoginContext = createContext<SocialLoginContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
export function SocialLoginProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { session: socialSession, refresh } = useSocialSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocialLoginContext.Provider value={{ session: socialSession, refresh }}>
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<>
|
||||||
|
<span>Social login: </span>
|
||||||
|
{socialSession.data?.status}{" "}
|
||||||
|
{socialSession?.data?.status === "expired" && (
|
||||||
|
<>
|
||||||
|
<span>Expires@{socialSession?.data.expiresAt}</span>{" "}
|
||||||
|
<button onClick={() => refresh.mutate()}>Refresh</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{socialSession?.data?.status === "valid" && (
|
||||||
|
<>
|
||||||
|
<span>Expires@{socialSession?.data.expiresAt}</span>{" "}
|
||||||
|
<button onClick={() => refresh.mutate()}>Refresh</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</SocialLoginContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocialLogin() {
|
||||||
|
const ctx = useContext(SocialLoginContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useSocialLogin must be used within SocialLoginContextProvider"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -28,17 +28,13 @@ import { FontPreload } from "@/fonts/font-preloading"
|
|||||||
import { getMessages } from "@/i18n"
|
import { getMessages } from "@/i18n"
|
||||||
import ClientIntlProvider from "@/i18n/Provider"
|
import ClientIntlProvider from "@/i18n/Provider"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
import { routeToScandicWeb } from "@/util"
|
|
||||||
|
|
||||||
import { BookingFlowProviders } from "../../components/BookingFlowProviders"
|
import { BookingFlowProviders } from "../../components/BookingFlowProviders"
|
||||||
import { Footer } from "../../components/Footer/Footer"
|
import { Footer } from "../../components/Footer/Footer"
|
||||||
import { Header } from "../../components/Header/Header"
|
import { Header } from "../../components/Header/Header"
|
||||||
|
import { SocialLoginProvider } from "./(auth)/SocialLogin"
|
||||||
|
|
||||||
import type { Metadata } from "next"
|
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
description: "TODO This text should be updated.",
|
|
||||||
}
|
|
||||||
|
|
||||||
type LangParams = {
|
type LangParams = {
|
||||||
lang: Lang
|
lang: Lang
|
||||||
@@ -74,7 +70,7 @@ export default async function RootLayout(props: RootLayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang={lang}>
|
||||||
<head>
|
<head>
|
||||||
<FontPreload />
|
<FontPreload />
|
||||||
<AdobeSDKScript />
|
<AdobeSDKScript />
|
||||||
@@ -95,21 +91,23 @@ export default async function RootLayout(props: RootLayoutProps) {
|
|||||||
>
|
>
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<TrpcProvider>
|
<TrpcProvider>
|
||||||
<RACRouterProvider>
|
<SocialLoginProvider>
|
||||||
<BookingFlowConfig config={bookingFlowConfig}>
|
<RACRouterProvider>
|
||||||
<BookingFlowProviders>
|
<BookingFlowConfig config={bookingFlowConfig}>
|
||||||
<RouteChange />
|
<BookingFlowProviders>
|
||||||
<SiteWideAlert />
|
<RouteChange />
|
||||||
<Header />
|
<SiteWideAlert />
|
||||||
{props.bookingwidget}
|
<Header />
|
||||||
<main>{children}</main>
|
{props.bookingwidget}
|
||||||
<Footer />
|
<main>{children}</main>
|
||||||
<ToastHandler />
|
<Footer />
|
||||||
<CookieBotConsent />
|
<ToastHandler />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<CookieBotConsent />
|
||||||
</BookingFlowProviders>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</BookingFlowConfig>
|
</BookingFlowProviders>
|
||||||
</RACRouterProvider>
|
</BookingFlowConfig>
|
||||||
|
</RACRouterProvider>
|
||||||
|
</SocialLoginProvider>
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
</NuqsAdapter>
|
</NuqsAdapter>
|
||||||
</ClientIntlProvider>
|
</ClientIntlProvider>
|
||||||
@@ -129,3 +127,11 @@ export default async function RootLayout(props: RootLayoutProps) {
|
|||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeToScandicWeb(route: LangRoute) {
|
||||||
|
const url = `https://www.scandichotels.com`
|
||||||
|
return Object.entries(route).reduce((acc, [key, value]) => {
|
||||||
|
acc[key as Lang] = `${url}${value}`
|
||||||
|
return acc
|
||||||
|
}, {} as LangRoute)
|
||||||
|
}
|
||||||
|
|||||||
51
apps/partner-sas/app/api/web/auth/callback/curity/route.ts
Normal file
51
apps/partner-sas/app/api/web/auth/callback/curity/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
import { getToken } from "@/auth/scandic/getToken"
|
||||||
|
import { createSession } from "@/auth/scandic/session"
|
||||||
|
|
||||||
|
const logger = createLogger("curity-callback")
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const code = req.nextUrl.searchParams.get("code")
|
||||||
|
const state = req.nextUrl.searchParams.get("state")
|
||||||
|
const savedState = req.cookies.get("oauth_state")?.value
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
logger.error("Missing code or state", { url: req.nextUrl.toString() })
|
||||||
|
throw new Error("Missing code or state, auth failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!savedState) {
|
||||||
|
logger.error("No saved state cookie", { url: req.nextUrl.toString() })
|
||||||
|
throw new Error("Missing saved oauth state, auth failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== savedState) {
|
||||||
|
logger.error("State mismatch", {
|
||||||
|
provided: state,
|
||||||
|
saved: savedState,
|
||||||
|
url: req.nextUrl.toString(),
|
||||||
|
})
|
||||||
|
throw new Error("Invalid state, possible CSRF")
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await getToken({
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createSession({
|
||||||
|
access_token: tokenResponse.access_token,
|
||||||
|
refresh_token: tokenResponse.refresh_token,
|
||||||
|
expires_in: tokenResponse.expires_in,
|
||||||
|
})
|
||||||
|
|
||||||
|
const c = await cookies()
|
||||||
|
c.delete({ name: "oauth_state", path: "/" })
|
||||||
|
|
||||||
|
const url = new URL(env.PUBLIC_URL)
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
53
apps/partner-sas/app/api/web/auth/scandic/login/route.ts
Normal file
53
apps/partner-sas/app/api/web/auth/scandic/login/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { createHash, randomUUID } from "node:crypto"
|
||||||
|
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { config } from "@/auth/scandic/config"
|
||||||
|
import { endpoints } from "@/auth/scandic/endpoints"
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const authUrl = new URL(endpoints.authorization_endpoint.toString())
|
||||||
|
|
||||||
|
const configData: Omit<typeof config, "client_secret"> = {
|
||||||
|
client_id: config.client_id,
|
||||||
|
redirect_uri: config.redirect_uri,
|
||||||
|
acr_values: config.acr_values,
|
||||||
|
scope: config.scope,
|
||||||
|
response_type: config.response_type,
|
||||||
|
issuer: config.issuer,
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(configData).forEach(([key, value]) => {
|
||||||
|
authUrl.searchParams.set(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = generateState()
|
||||||
|
const redirectTo = req.nextUrl.searchParams.get("redirect_to") || "/"
|
||||||
|
authUrl.searchParams.set("redirect_to", redirectTo)
|
||||||
|
authUrl.searchParams.set("state", state)
|
||||||
|
|
||||||
|
const c = await cookies()
|
||||||
|
c.set({
|
||||||
|
name: "oauth_state",
|
||||||
|
value: state,
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
secure: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 300,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.redirect(authUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateState(length = 32) {
|
||||||
|
const seed = randomUUID()
|
||||||
|
|
||||||
|
const hashed = createHash("sha256")
|
||||||
|
.update(seed + Math.random())
|
||||||
|
.digest("hex")
|
||||||
|
return hashed.slice(0, length)
|
||||||
|
}
|
||||||
12
apps/partner-sas/app/api/web/auth/scandic/logout/route.ts
Normal file
12
apps/partner-sas/app/api/web/auth/scandic/logout/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { type NextRequest } from "next/server"
|
||||||
|
|
||||||
|
import { noContent } from "@/server/errors/next"
|
||||||
|
|
||||||
|
import { destroySession } from "@/auth/scandic/session"
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest) {
|
||||||
|
await destroySession()
|
||||||
|
// TODO: Should we call Scandic's logout endpoint?
|
||||||
|
|
||||||
|
return noContent()
|
||||||
|
}
|
||||||
122
apps/partner-sas/app/api/web/auth/scandic/refresh/route.ts
Normal file
122
apps/partner-sas/app/api/web/auth/scandic/refresh/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { type NextRequest } from "next/server"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
|
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||||
|
|
||||||
|
import {
|
||||||
|
badRequest,
|
||||||
|
internalServerError,
|
||||||
|
ok,
|
||||||
|
response,
|
||||||
|
} from "@/server/errors/next"
|
||||||
|
|
||||||
|
import { config } from "@/auth/scandic/config"
|
||||||
|
import { endpoints } from "@/auth/scandic/endpoints"
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
destroySession,
|
||||||
|
getSession,
|
||||||
|
} from "@/auth/scandic/session"
|
||||||
|
|
||||||
|
const logger = createLogger("scandic/refresh")
|
||||||
|
export async function POST(_req: NextRequest) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) {
|
||||||
|
return badRequest("No session found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.refresh_token) {
|
||||||
|
return badRequest("No refresh token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newTokens, error] = await safeTry(
|
||||||
|
refreshToken({ refreshToken: session.refresh_token })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!newTokens || error) {
|
||||||
|
console.error("Error refreshing token", error)
|
||||||
|
|
||||||
|
if (isResponseError(error)) {
|
||||||
|
if (error.status === 400 && error.cause === "invalid_grant") {
|
||||||
|
await destroySession()
|
||||||
|
return badRequest("invalid_grant")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({}, error.status, error.statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return internalServerError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Successfully refreshed, got new token(s)", {
|
||||||
|
expires_in: newTokens.expires_in,
|
||||||
|
got_new_refresh_token: newTokens.refresh_token !== session.refresh_token,
|
||||||
|
got_new_access_token: newTokens.access_token !== session.access_token,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createSession({
|
||||||
|
access_token: newTokens.access_token,
|
||||||
|
refresh_token: newTokens.refresh_token ?? session.refresh_token,
|
||||||
|
expires_in: newTokens.expires_in,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ok({ status: "refreshed token" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTokenResponse = z.object({
|
||||||
|
access_token: z.string(),
|
||||||
|
expires_in: z.number(),
|
||||||
|
refresh_token: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshToken({ refreshToken }: { refreshToken: string }) {
|
||||||
|
const response = await fetch(endpoints.token_endpoint, {
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.client_id,
|
||||||
|
client_secret: config.client_secret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
const new_tokens: Record<string, unknown> = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw (
|
||||||
|
responseErrorSchema.safeParse({
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
cause: new_tokens?.error,
|
||||||
|
}).data ?? {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
cause: "unknown",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = refreshTokenResponse.parse(new_tokens)
|
||||||
|
|
||||||
|
return tokenResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponseError = z.infer<typeof responseErrorSchema>
|
||||||
|
const responseErrorSchema = z.object({
|
||||||
|
status: z.number(),
|
||||||
|
statusText: z.string(),
|
||||||
|
cause: z.enum(["invalid_grant", "unknown"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
function isResponseError(error: unknown): error is ResponseError {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"status" in error &&
|
||||||
|
"statusText" in error
|
||||||
|
)
|
||||||
|
}
|
||||||
66
apps/partner-sas/app/api/web/auth/scandic/session/route.ts
Normal file
66
apps/partner-sas/app/api/web/auth/scandic/session/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
|
|
||||||
|
import { getSession } from "@/auth/scandic/session"
|
||||||
|
|
||||||
|
const logger = createLogger("scandic/session")
|
||||||
|
|
||||||
|
const socialSessionResponseSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({
|
||||||
|
status: z.literal("valid"),
|
||||||
|
user: z.null(),
|
||||||
|
expiresAt: z.string().datetime(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("expired"),
|
||||||
|
expiresAt: z.string().datetime(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("error"),
|
||||||
|
cause: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({ status: z.literal("no_session"), user: z.null() }),
|
||||||
|
])
|
||||||
|
|
||||||
|
export type SocialSessionResponse = z.infer<typeof socialSessionResponseSchema>
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse<SocialSessionResponse>> {
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session || !session.access_token) {
|
||||||
|
return createResponse({ status: "no_session", user: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExpired = dt(session.expires_at).isBefore(dt())
|
||||||
|
if (hasExpired) {
|
||||||
|
return createResponse({
|
||||||
|
status: "expired",
|
||||||
|
expiresAt: session.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return createResponse({
|
||||||
|
status: "valid",
|
||||||
|
user: null,
|
||||||
|
expiresAt: session.expires_at,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to get session", error)
|
||||||
|
return createResponse(
|
||||||
|
{ status: "error", cause: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResponse(
|
||||||
|
data: SocialSessionResponse,
|
||||||
|
options?: { status: number }
|
||||||
|
) {
|
||||||
|
return NextResponse.json(socialSessionResponseSchema.parse(data), {
|
||||||
|
status: options?.status ?? 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import NextAuth, { type NextAuthConfig } from "next-auth"
|
|||||||
import Auth0Provider from "next-auth/providers/auth0"
|
import Auth0Provider from "next-auth/providers/auth0"
|
||||||
|
|
||||||
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
|
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||||
|
import { getEuroBonusProfileData } from "@scandic-hotels/trpc/routers/partners/sas/getEuroBonusProfile"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
@@ -60,9 +62,17 @@ const config: NextAuthConfig = {
|
|||||||
if (!expiresAt) {
|
if (!expiresAt) {
|
||||||
throw new Error("AuthError: Missing expiry time")
|
throw new Error("AuthError: Missing expiry time")
|
||||||
}
|
}
|
||||||
|
const [eurobonusProfile, error] = await safeTry(
|
||||||
|
getEuroBonusProfileData({ accessToken, loginType: "sas" })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
authLogger.error("Failed to fetch EuroBonus profile", error)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...params.token,
|
...params.token,
|
||||||
|
isLinked: eurobonusProfile?.linkStatus === "LINKED",
|
||||||
loginType: "sas",
|
loginType: "sas",
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
@@ -79,6 +89,7 @@ const config: NextAuthConfig = {
|
|||||||
? {
|
? {
|
||||||
...session.user,
|
...session.user,
|
||||||
id: token.sub,
|
id: token.sub,
|
||||||
|
isLinked: token.isLinked,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
token: {
|
token: {
|
||||||
|
|||||||
13
apps/partner-sas/auth/scandic/config.ts
Normal file
13
apps/partner-sas/auth/scandic/config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
issuer: env.CURITY_ISSUER_USER,
|
||||||
|
client_id: "whitelabel-sas-social-login",
|
||||||
|
client_secret: env.CURITY_CLIENT_SECRET_USER,
|
||||||
|
redirect_uri: new URL("/api/web/auth/callback/curity", env.PUBLIC_URL).href,
|
||||||
|
acr_values: "urn:com:scandichotels:sas-eb",
|
||||||
|
scope: "openid profile availability availability_whitelabel_get",
|
||||||
|
response_type: "code",
|
||||||
|
} as const
|
||||||
13
apps/partner-sas/auth/scandic/endpoints.ts
Normal file
13
apps/partner-sas/auth/scandic/endpoints.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
import { config } from "@/auth/scandic/config"
|
||||||
|
|
||||||
|
export const endpoints = {
|
||||||
|
authorization_endpoint: new URL(
|
||||||
|
`/oauth/v2/authorize?allow=local&ui_locales=en&version=2&for_origin=${env.PUBLIC_URL}`,
|
||||||
|
config.issuer
|
||||||
|
),
|
||||||
|
token_endpoint: new URL("/oauth/v2/token?allow=local", config.issuer),
|
||||||
|
userinfo_endpoint: new URL("/oauth/v2/userinfo?allow=local", config.issuer),
|
||||||
|
end_session_endpoint: new URL("/oauth/v2/logout?allow=local", config.issuer),
|
||||||
|
} as const
|
||||||
38
apps/partner-sas/auth/scandic/getToken.ts
Normal file
38
apps/partner-sas/auth/scandic/getToken.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { config } from "./config"
|
||||||
|
import { endpoints } from "./endpoints"
|
||||||
|
|
||||||
|
export async function getToken({ code }: { code: string }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: config.redirect_uri,
|
||||||
|
client_id: config.client_id,
|
||||||
|
client_secret: config.client_secret,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch(endpoints.token_endpoint.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: params,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error(`Token endpoint returned ${res.status}: ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await res.json()
|
||||||
|
|
||||||
|
return payload as {
|
||||||
|
access_token: string
|
||||||
|
token_type?: string
|
||||||
|
expires_in: number
|
||||||
|
refresh_token?: string
|
||||||
|
id_token?: string
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/partner-sas/auth/scandic/session.ts
Normal file
46
apps/partner-sas/auth/scandic/session.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { getIronSession } from "iron-session"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
return getIronSession<{
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string | undefined
|
||||||
|
expires_at: string
|
||||||
|
}>(await cookies(), {
|
||||||
|
password: env.IRON_SESSION_SECRET,
|
||||||
|
cookieName: "scandic_session",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession({
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_in,
|
||||||
|
}: {
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
refresh_token?: string
|
||||||
|
}) {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
session.access_token = access_token
|
||||||
|
session.refresh_token = refresh_token
|
||||||
|
session.expires_at = dt()
|
||||||
|
.add(expires_in * 1000)
|
||||||
|
.toISOString()
|
||||||
|
|
||||||
|
await session.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
session.destroy()
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@
|
|||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
|
|
||||||
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
|
import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider"
|
||||||
import { logger } from "@scandic-hotels/common/logger"
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
import { trpc } from "@scandic-hotels/trpc/client"
|
import { trpc } from "@scandic-hotels/trpc/client"
|
||||||
|
|
||||||
import type { Session } from "next-auth"
|
import type { Session } from "next-auth"
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
|
|
||||||
|
const logger = createLogger("BookingFlowProviders")
|
||||||
|
|
||||||
export function BookingFlowProviders({ children }: { children: ReactNode }) {
|
export function BookingFlowProviders({ children }: { children: ReactNode }) {
|
||||||
const user = useBookingFlowUser()
|
const user = useBookingFlowUser()
|
||||||
|
|
||||||
@@ -74,8 +77,9 @@ function isValidClientSession(session: Session | null) {
|
|||||||
logger.error(`Session token error: ${session.token.error}`)
|
logger.error(`Session token error: ${session.token.error}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (session.token.expires_at && session.token.expires_at < Date.now()) {
|
const expiresAt = dt(session.token.expires_at)
|
||||||
logger.error(`Session expired: ${session.token.expires_at}`)
|
if (session.token.expires_at && expiresAt.isBefore(dt())) {
|
||||||
|
logger.warn(`Session expired: ${expiresAt.toISOString()}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
apps/partner-sas/env/server.ts
vendored
12
apps/partner-sas/env/server.ts
vendored
@@ -27,15 +27,21 @@ export const env = createEnv({
|
|||||||
.default("false"),
|
.default("false"),
|
||||||
SENTRY_ENVIRONMENT: z.string().default("development"),
|
SENTRY_ENVIRONMENT: z.string().default("development"),
|
||||||
SENTRY_SERVER_SAMPLERATE: z.coerce.number().default(0.001),
|
SENTRY_SERVER_SAMPLERATE: z.coerce.number().default(0.001),
|
||||||
|
IRON_SESSION_SECRET: z.string().min(32),
|
||||||
|
CURITY_ISSUER_USER: z.string(),
|
||||||
|
CURITY_CLIENT_SECRET_USER: z.string(),
|
||||||
},
|
},
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
ADOBE_SDK_SCRIPT_SRC: process.env.ADOBE_SDK_SCRIPT_SRC,
|
ADOBE_SDK_SCRIPT_SRC: process.env.ADOBE_SDK_SCRIPT_SRC,
|
||||||
|
CURITY_CLIENT_SECRET_USER: process.env.CURITY_CLIENT_SECRET_USER,
|
||||||
|
CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER,
|
||||||
ENABLE_GTMSCRIPT: process.env.ENABLE_GTMSCRIPT,
|
ENABLE_GTMSCRIPT: process.env.ENABLE_GTMSCRIPT,
|
||||||
PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
|
IRON_SESSION_SECRET: process.env.IRON_SESSION_SECRET,
|
||||||
SAS_AUTH_ENDPOINT: process.env.SAS_AUTH_ENDPOINT,
|
|
||||||
SAS_AUTH_CLIENTID: process.env.SAS_AUTH_CLIENTID,
|
|
||||||
NEXTAUTH_DEBUG: process.env.NEXTAUTH_DEBUG,
|
NEXTAUTH_DEBUG: process.env.NEXTAUTH_DEBUG,
|
||||||
|
PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
|
||||||
|
SAS_AUTH_CLIENTID: process.env.SAS_AUTH_CLIENTID,
|
||||||
|
SAS_AUTH_ENDPOINT: process.env.SAS_AUTH_ENDPOINT,
|
||||||
SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
|
SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
|
||||||
SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,
|
SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,
|
||||||
},
|
},
|
||||||
|
|||||||
184
apps/partner-sas/hooks/useSocialSession.ts
Normal file
184
apps/partner-sas/hooks/useSocialSession.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
|
|
||||||
|
import type { User } from "next-auth"
|
||||||
|
|
||||||
|
import type { SocialSessionResponse } from "@/app/api/web/auth/scandic/session/route"
|
||||||
|
|
||||||
|
const logger = createLogger("useSocialSession")
|
||||||
|
|
||||||
|
export function useSocialSession() {
|
||||||
|
const socialSession = useSocialSessionQuery()
|
||||||
|
const refresh = useRefresh()
|
||||||
|
useAutoLogin()
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: socialSession,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSocialSessionQuery() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["socialSession"],
|
||||||
|
queryFn: getSocialSession,
|
||||||
|
enabled: !!session,
|
||||||
|
refetchInterval: getTime(1, "m"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const autoLoginLogger = createLogger("useAutoLogin")
|
||||||
|
function useAutoLogin() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const { isSuccess, data: socialSession } = useSocialSessionQuery()
|
||||||
|
|
||||||
|
const isLinked = isLinkedUser(session?.user) ? session.user.isLinked : false
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLinked) {
|
||||||
|
autoLoginLogger.info("User is not linked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
autoLoginLogger.info("Social session is not loaded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess && socialSession.status !== "no_session") {
|
||||||
|
autoLoginLogger.info("Social session is already active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires = dt(session?.expires)
|
||||||
|
if (!expires || !expires.isValid()) {
|
||||||
|
autoLoginLogger.info("Session does not have a valid expiry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExpired = expires.isSameOrBefore(dt())
|
||||||
|
if (hasExpired) {
|
||||||
|
autoLoginLogger.info("Session has expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoLoginLogger.info("Autologin to Curity")
|
||||||
|
// TODO: Check if we can do this silently without redirect
|
||||||
|
window.location.href =
|
||||||
|
"/api/web/auth/scandic/login?redirect_to=" + window.location.href
|
||||||
|
}, [isLinked, isSuccess, session?.expires, socialSession?.status])
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRefresh() {
|
||||||
|
const socialSession = useSocialSessionQuery()
|
||||||
|
|
||||||
|
const refresh = useMutation({
|
||||||
|
mutationKey: ["refresh", "socialSession"],
|
||||||
|
mutationFn: refreshSession,
|
||||||
|
onSettled: () => {
|
||||||
|
socialSession.refetch()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Refresh the token if it has expired
|
||||||
|
if (socialSession.data?.status !== "expired") return
|
||||||
|
if (refresh.isPending) return
|
||||||
|
if (refresh.isError) return
|
||||||
|
|
||||||
|
logger.debug("Social session has expired, refreshing")
|
||||||
|
refresh.mutate()
|
||||||
|
}, [socialSession, refresh])
|
||||||
|
|
||||||
|
const expiresAt =
|
||||||
|
socialSession.data?.status === "valid" ? socialSession.data.expiresAt : null
|
||||||
|
useEffect(() => {
|
||||||
|
// Set up a timer to refresh the token 1 minute before it expires
|
||||||
|
if (!expiresAt) return
|
||||||
|
if (refresh.isPending) return
|
||||||
|
if (refresh.isError) return
|
||||||
|
|
||||||
|
const expiresAtDt = dt(expiresAt)
|
||||||
|
const timeToExpire = expiresAtDt.diff(dt(), "milliseconds")
|
||||||
|
const refreshAt = timeToExpire - getTime(1, "m")
|
||||||
|
|
||||||
|
if (refreshAt <= 0) {
|
||||||
|
// If it has already expired it's already being handled by the other useEffect
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Refreshing social session at`,
|
||||||
|
dt().add(refreshAt, "milliseconds").toISOString()
|
||||||
|
)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
refresh.mutate()
|
||||||
|
}, refreshAt)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [expiresAt, refresh])
|
||||||
|
|
||||||
|
return refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSocialSession(): Promise<SocialSessionResponse> {
|
||||||
|
const response = await fetch("/api/web/auth/scandic/session", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch social session", {
|
||||||
|
cause: { status: response.status, statusText: response.statusText },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as SocialSessionResponse
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession(): Promise<SocialSessionResponse> {
|
||||||
|
const response = await fetch("/api/web/auth/scandic/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch social session", {
|
||||||
|
cause: { status: response.status, statusText: response.statusText },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as SocialSessionResponse
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTime(value: number, unit: "m" | "s") {
|
||||||
|
switch (unit) {
|
||||||
|
case "m":
|
||||||
|
return value * 60 * 1000
|
||||||
|
case "s":
|
||||||
|
return value * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinkedUser(
|
||||||
|
user: User | undefined
|
||||||
|
): user is User & { isLinked: boolean } {
|
||||||
|
if (user && "isLinked" in user) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@
|
|||||||
"@scandic-hotels/trpc": "workspace:*",
|
"@scandic-hotels/trpc": "workspace:*",
|
||||||
"@sentry/nextjs": "^10.11.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@swc/plugin-formatjs": "^3.2.2",
|
"@swc/plugin-formatjs": "^3.2.2",
|
||||||
|
"@tanstack/react-query": "^5.75.5",
|
||||||
"@tanstack/react-query-devtools": "^5.75.5",
|
"@tanstack/react-query-devtools": "^5.75.5",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
"next": "15.3.4",
|
"next": "15.3.4",
|
||||||
"next-auth": "5.0.0-beta.29",
|
"next-auth": "5.0.0-beta.29",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export function ok<T>(data: T) {
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
status: 200,
|
||||||
|
statusText: "Ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noContent() {
|
||||||
|
return NextResponse.json(undefined, {
|
||||||
|
status: 204,
|
||||||
|
statusText: "No Content",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function badRequest(cause?: unknown) {
|
export function badRequest(cause?: unknown) {
|
||||||
const resInit = {
|
const resInit = {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -55,3 +69,10 @@ export function serviceUnavailable(cause?: unknown) {
|
|||||||
resInit
|
resInit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function response<T>(data: T, status: number, statusText: string) {
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { logger } from "@scandic-hotels/common/logger"
|
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||||
|
|
||||||
import type { Session } from "next-auth"
|
import type { Session } from "next-auth"
|
||||||
|
|
||||||
|
const logger = createLogger("clientSession")
|
||||||
export function isValidClientSession(session: Session | null) {
|
export function isValidClientSession(session: Session | null) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation"
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
import { parseAsInteger, useQueryState } from "nuqs"
|
import { parseAsInteger, useQueryState } from "nuqs"
|
||||||
import {
|
import { createContext, useCallback, useContext, useMemo } from "react"
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { type IntlShape, useIntl } from "react-intl"
|
import { type IntlShape, useIntl } from "react-intl"
|
||||||
|
|
||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
@@ -76,8 +70,7 @@ export function SelectRateProvider({
|
|||||||
parseAsInteger.withDefault(0)
|
parseAsInteger.withDefault(0)
|
||||||
)
|
)
|
||||||
|
|
||||||
const [_bookingCodeFilter, setBookingCodeFilter] =
|
const [_bookingCodeFilter, setBookingCodeFilter] = useBookingCodeFilter()
|
||||||
useState<BookingCodeFilterEnum>(BookingCodeFilterEnum.Discounted)
|
|
||||||
|
|
||||||
const selectRateBooking = parseSelectRateSearchParams(
|
const selectRateBooking = parseSelectRateSearchParams(
|
||||||
searchParamsToRecord(searchParams)
|
searchParamsToRecord(searchParams)
|
||||||
@@ -313,7 +306,7 @@ export function SelectRateProvider({
|
|||||||
_bookingCodeFilter === BookingCodeFilterEnum.Discounted &&
|
_bookingCodeFilter === BookingCodeFilterEnum.Discounted &&
|
||||||
!selectRateInput.data?.booking.bookingCode
|
!selectRateInput.data?.booking.bookingCode
|
||||||
? BookingCodeFilterEnum.All
|
? BookingCodeFilterEnum.All
|
||||||
: _bookingCodeFilter
|
: (_bookingCodeFilter as BookingCodeFilterEnum)
|
||||||
|
|
||||||
const roomAvailabilityWithAdjustedRoomCount: (AvailabilityWithRoomInfo | null)[][] =
|
const roomAvailabilityWithAdjustedRoomCount: (AvailabilityWithRoomInfo | null)[][] =
|
||||||
roomAvailability.map((availability, roomIndex) => {
|
roomAvailability.map((availability, roomIndex) => {
|
||||||
@@ -557,12 +550,15 @@ function getAvailabilityForRoom(
|
|||||||
|
|
||||||
function useUpdateBooking() {
|
function useUpdateBooking() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [bookingCodeFilter] = useBookingCodeFilter()
|
||||||
|
|
||||||
return function updateBooking(booking: SelectRateBooking) {
|
return function updateBooking(booking: SelectRateBooking) {
|
||||||
const newUrl = new URL(pathname, window.location.origin)
|
const newUrl = new URL(pathname, window.location.origin)
|
||||||
|
|
||||||
// TODO: Handle existing search params
|
// TODO: Handle existing search params
|
||||||
newUrl.search = serializeBookingSearchParams(booking).toString()
|
newUrl.search = serializeBookingSearchParams(booking).toString()
|
||||||
|
newUrl.searchParams.set(BookingCodeFilterQueryName, bookingCodeFilter)
|
||||||
|
|
||||||
// router.replace(newUrl.toString(), { scroll: false })
|
// router.replace(newUrl.toString(), { scroll: false })
|
||||||
window.history.replaceState({}, "", newUrl.toString())
|
window.history.replaceState({}, "", newUrl.toString())
|
||||||
}
|
}
|
||||||
@@ -575,3 +571,10 @@ function isRoomPackage(x: {
|
|||||||
x.code as RoomPackageCodeEnum
|
x.code as RoomPackageCodeEnum
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BookingCodeFilterQueryName = "bookingCodeFilter"
|
||||||
|
function useBookingCodeFilter() {
|
||||||
|
return useQueryState(BookingCodeFilterQueryName, {
|
||||||
|
defaultValue: BookingCodeFilterEnum.Discounted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
|||||||
import { env } from "../../../../env/server"
|
import { env } from "../../../../env/server"
|
||||||
import { protectedProcedure } from "../../../procedures"
|
import { protectedProcedure } from "../../../procedures"
|
||||||
|
|
||||||
import type { Session } from "next-auth"
|
import type { LoginType } from "@scandic-hotels/common/constants/loginType"
|
||||||
|
|
||||||
const outputSchema = z.object({
|
const outputSchema = z.object({
|
||||||
eurobonusNumber: z.string(),
|
eurobonusNumber: z.string(),
|
||||||
@@ -48,28 +48,39 @@ const outputSchema = z.object({
|
|||||||
const sasLogger = createLogger("SAS")
|
const sasLogger = createLogger("SAS")
|
||||||
const url = new URL("/api/scandic-partnership/v1/profile", env.SAS_API_ENDPOINT)
|
const url = new URL("/api/scandic-partnership/v1/profile", env.SAS_API_ENDPOINT)
|
||||||
|
|
||||||
export async function getEuroBonusProfileData(session: Session) {
|
const requiredLoginType: LoginType[] = ["sas"]
|
||||||
if (session.token.loginType !== "sas") {
|
|
||||||
return {
|
export const getEuroBonusProfile = protectedProcedure
|
||||||
error: {
|
.output(outputSchema)
|
||||||
message: `Failed to fetch EuroBonus profile, expected loginType to be "sas" but was ${session.token.loginType}`,
|
.query(async function ({ ctx }) {
|
||||||
},
|
return await getEuroBonusProfileData({
|
||||||
} as const
|
accessToken: ctx.session.token.access_token,
|
||||||
|
loginType: ctx.session.token.loginType,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getEuroBonusProfileData({
|
||||||
|
accessToken,
|
||||||
|
loginType,
|
||||||
|
}: {
|
||||||
|
loginType: LoginType
|
||||||
|
accessToken: string
|
||||||
|
}) {
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("Access token is required to fetch EuroBonus profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.token.expires_at || session.token.expires_at < Date.now()) {
|
if (!requiredLoginType.includes(loginType)) {
|
||||||
return {
|
throw new Error(
|
||||||
error: {
|
`Failed to fetch EuroBonus profile, expected loginType to be "${requiredLoginType}" but was "${loginType}"`
|
||||||
message: "Token expired sas",
|
)
|
||||||
},
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Ocp-Apim-Subscription-Key": env.SAS_OCP_APIM,
|
"Ocp-Apim-Subscription-Key": env.SAS_OCP_APIM,
|
||||||
Authorization: `Bearer ${session?.token?.access_token}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,12 +88,9 @@ export async function getEuroBonusProfileData(session: Session) {
|
|||||||
sasLogger.error(
|
sasLogger.error(
|
||||||
`Failed to get EuroBonus profile, status: ${response.status}, statusText: ${response.statusText}`
|
`Failed to get EuroBonus profile, status: ${response.status}, statusText: ${response.statusText}`
|
||||||
)
|
)
|
||||||
return {
|
throw new Error("Failed to fetch EuroBonus profile", {
|
||||||
error: {
|
cause: { status: response.status, statusText: response.statusText },
|
||||||
message: "Failed to fetch EuroBonus profile",
|
})
|
||||||
cause: { status: response.status, statusText: response.statusText },
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseJson = await response.json()
|
const responseJson = await response.json()
|
||||||
@@ -91,24 +99,8 @@ export async function getEuroBonusProfileData(session: Session) {
|
|||||||
sasLogger.error(
|
sasLogger.error(
|
||||||
`Failed to parse EuroBonus profile, cause: ${data.error.cause}, message: ${data.error.message}`
|
`Failed to parse EuroBonus profile, cause: ${data.error.cause}, message: ${data.error.message}`
|
||||||
)
|
)
|
||||||
return {
|
throw new Error(`Failed to parse EuroBonus profile: ${data.error.message}`)
|
||||||
error: {
|
|
||||||
message: `Failed to parse EuroBonus profile: ${data.error.message}`,
|
|
||||||
cause: { status: response.status, statusText: response.statusText },
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getEuroBonusProfile = protectedProcedure.query(async function ({
|
return data.data
|
||||||
ctx,
|
}
|
||||||
}) {
|
|
||||||
const verifiedSasUser = await getEuroBonusProfileData(ctx.session)
|
|
||||||
if ("error" in verifiedSasUser) {
|
|
||||||
throw new Error(verifiedSasUser.error?.message, {
|
|
||||||
cause: verifiedSasUser.error?.cause,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return verifiedSasUser.data
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export async function getUserPointsBalance(
|
|||||||
|
|
||||||
const verifiedUser =
|
const verifiedUser =
|
||||||
session.token.loginType === "sas"
|
session.token.loginType === "sas"
|
||||||
? await getEuroBonusProfileData(session)
|
? await getEuroBonusProfileData({
|
||||||
|
accessToken: session.token.access_token,
|
||||||
|
loginType: session.token.loginType,
|
||||||
|
})
|
||||||
: await getVerifiedUser({ session })
|
: await getVerifiedUser({ session })
|
||||||
|
|
||||||
if (!verifiedUser || "error" in verifiedUser) {
|
if (!verifiedUser || "error" in verifiedUser) {
|
||||||
@@ -19,8 +22,8 @@ export async function getUserPointsBalance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const points =
|
const points =
|
||||||
"points" in verifiedUser.data
|
"points" in verifiedUser
|
||||||
? verifiedUser.data.points.total
|
? verifiedUser.points.total
|
||||||
: verifiedUser.data.membership?.currentPoints
|
: verifiedUser.data.membership?.currentPoints
|
||||||
|
|
||||||
return points ?? 0
|
return points ?? 0
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sentry/nextjs": "^10",
|
"@sentry/nextjs": "^10",
|
||||||
|
"@tanstack/react-query": "^5.75.5",
|
||||||
|
"@tanstack/react-query-devtools": "^5.75.5",
|
||||||
"next": "^15",
|
"next": "^15",
|
||||||
"react": "^19"
|
"react": "^19"
|
||||||
},
|
},
|
||||||
|
|||||||
31
yarn.lock
31
yarn.lock
@@ -6038,6 +6038,7 @@ __metadata:
|
|||||||
"@scandic-hotels/typescript-config": "workspace:*"
|
"@scandic-hotels/typescript-config": "workspace:*"
|
||||||
"@sentry/nextjs": "npm:^10.11.0"
|
"@sentry/nextjs": "npm:^10.11.0"
|
||||||
"@swc/plugin-formatjs": "npm:^3.2.2"
|
"@swc/plugin-formatjs": "npm:^3.2.2"
|
||||||
|
"@tanstack/react-query": "npm:^5.75.5"
|
||||||
"@tanstack/react-query-devtools": "npm:^5.75.5"
|
"@tanstack/react-query-devtools": "npm:^5.75.5"
|
||||||
"@types/node": "npm:^20"
|
"@types/node": "npm:^20"
|
||||||
"@types/react": "npm:19.1.0"
|
"@types/react": "npm:19.1.0"
|
||||||
@@ -6051,6 +6052,7 @@ __metadata:
|
|||||||
eslint-plugin-import: "npm:^2.31.0"
|
eslint-plugin-import: "npm:^2.31.0"
|
||||||
eslint-plugin-simple-import-sort: "npm:^12.1.1"
|
eslint-plugin-simple-import-sort: "npm:^12.1.1"
|
||||||
graphql-tag: "npm:^2.12.6"
|
graphql-tag: "npm:^2.12.6"
|
||||||
|
iron-session: "npm:^8.0.4"
|
||||||
next: "npm:15.3.4"
|
next: "npm:15.3.4"
|
||||||
next-auth: "npm:5.0.0-beta.29"
|
next-auth: "npm:5.0.0-beta.29"
|
||||||
react: "npm:^19.0.0"
|
react: "npm:^19.0.0"
|
||||||
@@ -6283,6 +6285,8 @@ __metadata:
|
|||||||
zod: "npm:^3.24.4"
|
zod: "npm:^3.24.4"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@sentry/nextjs": ^10
|
"@sentry/nextjs": ^10
|
||||||
|
"@tanstack/react-query": ^5.75.5
|
||||||
|
"@tanstack/react-query-devtools": ^5.75.5
|
||||||
next: ^15
|
next: ^15
|
||||||
react: ^19
|
react: ^19
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
@@ -10084,7 +10088,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"cookie@npm:^0.7.1":
|
"cookie@npm:^0.7.1, cookie@npm:^0.7.2":
|
||||||
version: 0.7.2
|
version: 0.7.2
|
||||||
resolution: "cookie@npm:0.7.2"
|
resolution: "cookie@npm:0.7.2"
|
||||||
checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2
|
checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2
|
||||||
@@ -13325,6 +13329,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"iron-session@npm:^8.0.4":
|
||||||
|
version: 8.0.4
|
||||||
|
resolution: "iron-session@npm:8.0.4"
|
||||||
|
dependencies:
|
||||||
|
cookie: "npm:^0.7.2"
|
||||||
|
iron-webcrypto: "npm:^1.2.1"
|
||||||
|
uncrypto: "npm:^0.1.3"
|
||||||
|
checksum: 10c0/774ed93d964cec435455609120df18c8c8b21f29baeb5969dfb4677e5185eed289d2c31707bd5949dd16d5e10a9f06b7a0d1bc6ff57e8dbf50a50c0deef1b4ea
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"iron-webcrypto@npm:^1.2.1":
|
||||||
|
version: 1.2.1
|
||||||
|
resolution: "iron-webcrypto@npm:1.2.1"
|
||||||
|
checksum: 10c0/5cf27c6e2bd3ef3b4970e486235fd82491ab8229e2ed0ac23307c28d6c80d721772a86ed4e9fe2a5cabadd710c2f024b706843b40561fb83f15afee58f809f66
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5":
|
"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5":
|
||||||
version: 3.0.5
|
version: 3.0.5
|
||||||
resolution: "is-array-buffer@npm:3.0.5"
|
resolution: "is-array-buffer@npm:3.0.5"
|
||||||
@@ -19767,6 +19789,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"uncrypto@npm:^0.1.3":
|
||||||
|
version: 0.1.3
|
||||||
|
resolution: "uncrypto@npm:0.1.3"
|
||||||
|
checksum: 10c0/74a29afefd76d5b77bedc983559ceb33f5bbc8dada84ff33755d1e3355da55a4e03a10e7ce717918c436b4dfafde1782e799ebaf2aadd775612b49f7b5b2998e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"undici-types@npm:~6.19.2":
|
"undici-types@npm:~6.19.2":
|
||||||
version: 6.19.8
|
version: 6.19.8
|
||||||
resolution: "undici-types@npm:6.19.8"
|
resolution: "undici-types@npm:6.19.8"
|
||||||
|
|||||||
Reference in New Issue
Block a user