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:
Joakim Jäderberg
2025-10-16 12:47:12 +00:00
parent 1850cfd20d
commit 291310e841
24 changed files with 827 additions and 84 deletions

View 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
}

View File

@@ -28,17 +28,13 @@ import { FontPreload } from "@/fonts/font-preloading"
import { getMessages } from "@/i18n"
import ClientIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
import { routeToScandicWeb } from "@/util"
import { BookingFlowProviders } from "../../components/BookingFlowProviders"
import { Footer } from "../../components/Footer/Footer"
import { Header } from "../../components/Header/Header"
import { SocialLoginProvider } from "./(auth)/SocialLogin"
import type { Metadata } from "next"
export const metadata: Metadata = {
description: "TODO This text should be updated.",
}
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
type LangParams = {
lang: Lang
@@ -74,7 +70,7 @@ export default async function RootLayout(props: RootLayoutProps) {
}
return (
<html lang="en">
<html lang={lang}>
<head>
<FontPreload />
<AdobeSDKScript />
@@ -95,21 +91,23 @@ export default async function RootLayout(props: RootLayoutProps) {
>
<NuqsAdapter>
<TrpcProvider>
<RACRouterProvider>
<BookingFlowConfig config={bookingFlowConfig}>
<BookingFlowProviders>
<RouteChange />
<SiteWideAlert />
<Header />
{props.bookingwidget}
<main>{children}</main>
<Footer />
<ToastHandler />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</BookingFlowProviders>
</BookingFlowConfig>
</RACRouterProvider>
<SocialLoginProvider>
<RACRouterProvider>
<BookingFlowConfig config={bookingFlowConfig}>
<BookingFlowProviders>
<RouteChange />
<SiteWideAlert />
<Header />
{props.bookingwidget}
<main>{children}</main>
<Footer />
<ToastHandler />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</BookingFlowProviders>
</BookingFlowConfig>
</RACRouterProvider>
</SocialLoginProvider>
</TrpcProvider>
</NuqsAdapter>
</ClientIntlProvider>
@@ -129,3 +127,11 @@ export default async function RootLayout(props: RootLayoutProps) {
</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)
}

View 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)
}

View 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)
}

View 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()
}

View 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
)
}

View 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,
})
}

View File

@@ -2,6 +2,8 @@ import NextAuth, { type NextAuthConfig } from "next-auth"
import Auth0Provider from "next-auth/providers/auth0"
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"
@@ -60,9 +62,17 @@ const config: NextAuthConfig = {
if (!expiresAt) {
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 {
...params.token,
isLinked: eurobonusProfile?.linkStatus === "LINKED",
loginType: "sas",
access_token: accessToken,
expires_at: expiresAt,
@@ -79,6 +89,7 @@ const config: NextAuthConfig = {
? {
...session.user,
id: token.sub,
isLinked: token.isLinked,
}
: undefined,
token: {

View 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

View 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

View 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
}
}

View 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()
}

View File

@@ -3,12 +3,15 @@
import { useSession } from "next-auth/react"
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 type { Session } from "next-auth"
import type { ComponentProps, ReactNode } from "react"
const logger = createLogger("BookingFlowProviders")
export function BookingFlowProviders({ children }: { children: ReactNode }) {
const user = useBookingFlowUser()
@@ -74,8 +77,9 @@ function isValidClientSession(session: Session | null) {
logger.error(`Session token error: ${session.token.error}`)
return false
}
if (session.token.expires_at && session.token.expires_at < Date.now()) {
logger.error(`Session expired: ${session.token.expires_at}`)
const expiresAt = dt(session.token.expires_at)
if (session.token.expires_at && expiresAt.isBefore(dt())) {
logger.warn(`Session expired: ${expiresAt.toISOString()}`)
return false
}

View File

@@ -27,15 +27,21 @@ export const env = createEnv({
.default("false"),
SENTRY_ENVIRONMENT: z.string().default("development"),
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,
runtimeEnv: {
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,
PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
SAS_AUTH_ENDPOINT: process.env.SAS_AUTH_ENDPOINT,
SAS_AUTH_CLIENTID: process.env.SAS_AUTH_CLIENTID,
IRON_SESSION_SECRET: process.env.IRON_SESSION_SECRET,
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_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,
},

View 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
}

View File

@@ -27,7 +27,9 @@
"@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.11.0",
"@swc/plugin-formatjs": "^3.2.2",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"iron-session": "^8.0.4",
"next": "15.3.4",
"next-auth": "5.0.0-beta.29",
"react": "^19.0.0",

View File

@@ -1,5 +1,19 @@
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) {
const resInit = {
status: 400,
@@ -55,3 +69,10 @@ export function serviceUnavailable(cause?: unknown) {
resInit
)
}
export function response<T>(data: T, status: number, statusText: string) {
return NextResponse.json(data, {
status,
statusText,
})
}

View File

@@ -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"]
}