Last active
March 29, 2024 14:54
-
-
Save izakfilmalter/54f9985723554dbf48fb16fe1c55e84b to your computer and use it in GitHub Desktop.
Clerk org slug subdomain
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use client' | |
import type { FC } from 'react' | |
import { useEffect } from 'react' | |
import { useRouter } from 'next/navigation' | |
import { useClerk } from '@clerk/nextjs' | |
import { Boolean, pipe } from 'effect' | |
import { asyncNoOp, match } from '@steepleinc/shared' | |
import { OnboardingSteps } from '~/features/onboarding/onboardingConductor' | |
import { useAuthState } from '~/shared/clerk/useAuthState' | |
type ChamferBitProps = { | |
userId?: string | null | |
activeOrgSlug?: string | null | |
} | |
export const ChamferBit: FC<ChamferBitProps> = (props) => { | |
const { userId, activeOrgSlug } = props | |
const authState = useAuthState({ userId, activeOrgSlug }) | |
console.log(authState) | |
const router = useRouter() | |
const clerk = useClerk() | |
useEffect(() => { | |
void pipe( | |
authState, | |
match({ | |
doNothing: asyncNoOp, | |
// eslint-disable-next-line @typescript-eslint/require-await | |
noUser: async () => { | |
router.replace('/') | |
}, | |
// eslint-disable-next-line @typescript-eslint/require-await | |
noOrg: async () => { | |
router.replace(`/${OnboardingSteps.CreateOrganization}`) | |
}, | |
needActiveOrg: async (x) => { | |
await x.setActive({ organization: x.organization }) | |
await clerk.redirectWithAuth( | |
`https://${x.organization.slug}.${pipe(process.env.NODE_ENV === 'development', Boolean.match({ onFalse: () => 'steeple.works', onTrue: () => 'localhost:3000' }))}/`, | |
) | |
}, | |
hasActiveOrg: async (x) => { | |
await clerk.redirectWithAuth( | |
`https://${x.slug}.${pipe(process.env.NODE_ENV === 'development', Boolean.match({ onFalse: () => 'steeple.works', onTrue: () => 'localhost:3000' }))}/`, | |
) | |
}, | |
}), | |
) | |
}, [authState, clerk, router]) | |
return null | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { ReactNode } from 'react' | |
import { | |
ClerkProvider, | |
RedirectToSignIn, | |
SignedIn, | |
SignedOut, | |
} from '@clerk/nextjs' | |
import { clerkAppearance } from '~/shared/clerk/clerkAppearance' | |
type ChamferLayoutProps = { | |
children: ReactNode | |
} | |
export default function ChamferLayout(props: ChamferLayoutProps) { | |
const { children } = props | |
return ( | |
<ClerkProvider appearance={clerkAppearance}> | |
<SignedIn>{children}</SignedIn> | |
<SignedOut> | |
<RedirectToSignIn /> | |
</SignedOut> | |
</ClerkProvider> | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// A chamfer is a transitional edge between two faces of an object. | |
// The chamfer page helps us transition the user between the marketing site and | |
// the correct place in the app based on their session. | |
// | |
// When a user logs in, we need to send them to a page in the app to figure out | |
// what to do with them. | |
// - Do they have an org? | |
// - Did they just accept an invite to an org? | |
// | |
// These actions can only be handled client side with clerk because changing | |
// active org is liked to the user's session. | |
import { getUserId } from '@steepleinc/be-shared' | |
import { getActiveOrgSlug } from '@steepleinc/be-shared/src/getActiveOrgSlug' | |
import { ChamferBit } from '~/app/chamfer/chamferBit' | |
import { AppLoading } from '~/components/appLoading' | |
export default function ChamferPage() { | |
const userId = getUserId() | |
const activeOrgSlug = getActiveOrgSlug() | |
return ( | |
<> | |
<AppLoading /> | |
<ChamferBit userId={userId} activeOrgSlug={activeOrgSlug} /> | |
</> | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { NextResponse } from 'next/server' | |
import { authMiddleware, redirectToSignIn } from '@clerk/nextjs' | |
import { Boolean, pipe, ReadonlyArray, ReadonlyRecord, String } from 'effect' | |
import { env } from '@steepleinc/be-shared' | |
import { onboardingStepToRoute } from '~/features/onboarding/onboardingConductor' | |
const publicRoutes = ['/sign-in', '/sign-up', '/chamfer', '/404'] | |
const onboardingRoutes = pipe(onboardingStepToRoute, ReadonlyRecord.values) | |
// DO NOT TOUCH THIS FILE!!!!!! | |
// This is a land of hacks to figure out if people are authed or not. We then rewrite their requests to go to marketing | |
// or app. | |
// This example protects all routes including api/trpc routes | |
// Please edit this to allow other routes to be public as needed. | |
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your middleware | |
export default authMiddleware({ | |
publicRoutes: ['/api/(.*)', '/', '/oauth2/(.*)'], | |
beforeAuth(req) { | |
// only rewrite non api / oauth2 requests. | |
if ( | |
!pipe(req.nextUrl.pathname, String.startsWith('/api')) && | |
!pipe(req.nextUrl.pathname, String.startsWith('/oauth2')) | |
) { | |
console.log('before auth') | |
const url = req.nextUrl | |
const searchParams = req.nextUrl.searchParams.toString() | |
// Get the pathname of the request (e.g. /, /about, /blog/first-post) | |
const path = `${url.pathname}${ | |
searchParams.length > 0 ? `?${searchParams}` : '' | |
}` | |
// Slim host down to the slug. | |
const slug = pipe( | |
req.headers.get('host')!, | |
String.replace('.localhost:3000', ''), | |
String.replace(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, ''), | |
String.replace('localhost:3000', ''), | |
String.replace(`${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, ''), | |
) | |
// If we don't have a slug. Go to the marketing site. | |
if ( | |
String.isEmpty(slug) && | |
!pipe( | |
publicRoutes, | |
ReadonlyArray.map((x) => pipe(path, String.startsWith(x))), | |
Boolean.some, | |
) | |
) { | |
// rewrite root application to `/marketing` folder | |
return NextResponse.rewrite( | |
new URL( | |
`/marketing${pipe( | |
path === '/', | |
Boolean.match({ onFalse: () => path, onTrue: () => '' }), | |
)}`, | |
req.url, | |
), | |
) | |
} | |
} | |
return | |
}, | |
afterAuth(auth, req) { | |
// only rewrite non api / oauth2 requests. | |
if ( | |
!pipe(req.nextUrl.pathname, String.startsWith('/api')) && | |
!pipe(req.nextUrl.pathname, String.startsWith('/oauth2')) | |
) { | |
const url = req.nextUrl | |
const searchParams = req.nextUrl.searchParams.toString() | |
const path = `${url.pathname}${ | |
searchParams.length > 0 ? `?${searchParams}` : '' | |
}` | |
console.log('after auth', { | |
userId: auth.userId, | |
orgId: auth.orgId, | |
url, | |
path, | |
searchParams, | |
}) | |
const slug = pipe( | |
req.headers.get('host')!, | |
String.replace('.localhost:3000', ''), | |
String.replace(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, ''), | |
String.replace('localhost:3000', ''), | |
String.replace(`${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, ''), | |
) | |
// If we don't have a slug. Go to the marketing site. | |
if ( | |
String.isEmpty(slug) && | |
!pipe( | |
publicRoutes, | |
ReadonlyArray.appendAll(onboardingRoutes), | |
ReadonlyArray.map((x) => pipe(path, String.startsWith(x))), | |
Boolean.some, | |
) | |
) { | |
console.log('rewrite to marketing') | |
// rewrite root application to `/marketing` folder | |
return NextResponse.rewrite( | |
new URL( | |
`/marketing${pipe( | |
path === '/', | |
Boolean.match({ onFalse: () => path, onTrue: () => '' }), | |
)}`, | |
req.url, | |
), | |
) | |
} | |
// handle users who aren't authenticated | |
if ( | |
!auth.userId && | |
(!auth.isPublicRoute || String.isNonEmpty(slug)) && | |
!pipe( | |
req.nextUrl.pathname, | |
String.includes(env.NEXT_PUBLIC_CLERK_SIGN_IN_URL), | |
) | |
) { | |
console.log('rewrite to sign in') | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-return | |
return redirectToSignIn({ returnBackUrl: req.url }) | |
} | |
// We need to check for onboarding first. If they are in the process of | |
// creating an org, we don't | |
if ( | |
pipe( | |
onboardingRoutes, | |
ReadonlyArray.map((x) => pipe(path, String.startsWith(x))), | |
Boolean.some, | |
) | |
) { | |
console.log('rewrite to onboarding', `/onboarding${path}`) | |
// rewrite everything else to `/[orgSlug]/[routeSlug] dynamic route | |
return NextResponse.rewrite(new URL(`/onboarding${path}`, req.url)) | |
} | |
// If they are trying to access a public route, something accessible from the root of the `app` folder, let them | |
// through. If not, we need to forward them to the app based on their orgSlug. | |
if ( | |
!pipe( | |
publicRoutes, | |
ReadonlyArray.appendAll(onboardingRoutes), | |
ReadonlyArray.map((x) => pipe(path, String.startsWith(x))), | |
Boolean.some, | |
) | |
) { | |
console.log('rewrite to app') | |
// rewrite everything else to `/[orgSlug]/[routeSlug] dynamic route | |
return NextResponse.rewrite(new URL(`/${slug}${path}`, req.url)) | |
} | |
} | |
console.log('do nothing') | |
}, | |
}) | |
export const config = { | |
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use client' | |
import type { FC } from 'react' | |
import { useMemo } from 'react' | |
import { useClerk, useOrganizationList } from '@clerk/nextjs' | |
import type { LoadedClerk } from '@clerk/types' | |
import { | |
Boolean, | |
Equivalence, | |
Option, | |
pipe, | |
ReadonlyArray, | |
String, | |
} from 'effect' | |
import { asyncNoOp, getSubdomain } from '@steepleinc/shared' | |
import { getOauthBaseUrl } from '~/shared/getBaseUrl' | |
import { useStableEffect } from '~/shared/hooks/effect' | |
type OrgSwitcherProps = { | |
urlOrgSlug?: string | |
activeOrgIdOpt: Option.Option<string> | |
activeOrgSlugOpt: Option.Option<string> | |
} | |
// Flow diagram: | |
// https://whimsical.com/R293hv5fLkiZ1UCyJh7KLi@or4CdLRbgroViawcvMCgP2aR5wtDRetr6Skxk6EyK | |
export const OrgSwitcher: FC<OrgSwitcherProps> = (props) => { | |
const { | |
urlOrgSlug = pipe( | |
// Get the subdomain from the hostname. | |
window.location.hostname, | |
getSubdomain, | |
), | |
activeOrgIdOpt, | |
activeOrgSlugOpt, | |
} = props | |
const { | |
setActive, | |
userMemberships: { data: organizations = [], isLoading, isFetching }, | |
isLoaded: organizationsIsLoaded, | |
} = useOrganizationList({ | |
userMemberships: { | |
infinite: true, | |
}, | |
}) | |
const canSeeOrgs = useMemo( | |
() => organizationsIsLoaded && !isLoading && !isFetching, | |
[isFetching, isLoading, organizationsIsLoaded], | |
) | |
// eslint-disable-next-line @typescript-eslint/unbound-method | |
const { redirectWithAuth } = useClerk() | |
useStableEffect( | |
() => { | |
void pipe( | |
Option.Do, | |
Option.bind('activeOrgId', () => activeOrgIdOpt), | |
Option.bind('activeOrgSlug', () => activeOrgSlugOpt), | |
Option.match({ | |
// No active org. | |
onNone: async () => { | |
// console.log('No active org.', activeOrgIdOpt, activeOrgSlugOpt) | |
return pipe( | |
canSeeOrgs, | |
Boolean.match({ | |
onFalse: async () => { | |
// console.log(`Can't see users orgs for no active org user.`) | |
}, | |
onTrue: async () => { | |
// console.log( | |
// 'Can see users orgs. Set active org for no active org user.', | |
// ) | |
return setActiveOrg({ | |
organizations, | |
setActive, | |
urlOrgSlug, | |
redirectWithAuth, | |
}) | |
}, | |
}), | |
) | |
}, | |
// Active org. | |
onSome: async ({ activeOrgSlug }) => { | |
// console.log('Has active org.', activeOrgIdOpt, activeOrgSlugOpt) | |
return pipe( | |
activeOrgSlug === urlOrgSlug, | |
Boolean.match({ | |
onFalse: async () => { | |
// console.log( | |
// `Active org doesn't match url slug`, | |
// activeOrgSlug, | |
// urlOrgSlug, | |
// ) | |
return pipe( | |
canSeeOrgs, | |
Boolean.match({ | |
onFalse: async () => { | |
// console.log( | |
// `Can't see users orgs for mismatch active org user.`, | |
// ) | |
}, | |
onTrue: async () => { | |
// console.log( | |
// 'Can see users orgs. Set active org for mismatch active org user.', | |
// ) | |
return setActiveOrg({ | |
organizations, | |
setActive, | |
urlOrgSlug, | |
redirectWithAuth, | |
}) | |
}, | |
}), | |
) | |
}, | |
onTrue: async () => { | |
// console.log( | |
// 'Active org slug matches url slug. noOp', | |
// activeOrgSlug, | |
// urlOrgSlug, | |
// ) | |
}, | |
}), | |
) | |
}, | |
}), | |
) | |
}, | |
[ | |
activeOrgIdOpt, | |
activeOrgSlugOpt, | |
canSeeOrgs, | |
organizations, | |
setActive, | |
urlOrgSlug, | |
redirectWithAuth, | |
], | |
Equivalence.tuple( | |
Option.getEquivalence(String.Equivalence), | |
Option.getEquivalence(String.Equivalence), | |
Boolean.Equivalence, | |
ReadonlyArray.getEquivalence( | |
Equivalence.struct({ | |
organization: Equivalence.struct({ | |
id: String.Equivalence, | |
}), | |
}), | |
), | |
Equivalence.strict(), | |
String.Equivalence, | |
Equivalence.strict(), | |
), | |
) | |
return null | |
} | |
const setActiveOrg = async (params: { | |
organizations: NonNullable< | |
ReturnType<typeof useOrganizationList>['userMemberships']['data'] | |
> | |
setActive: ReturnType<typeof useOrganizationList>['setActive'] | |
urlOrgSlug: string | |
redirectWithAuth: LoadedClerk['redirectWithAuth'] | |
}) => { | |
const { organizations, setActive, urlOrgSlug, redirectWithAuth } = params | |
return pipe( | |
organizations, | |
ReadonlyArray.match({ | |
// No orgs, send to onboarding. | |
onEmpty: () => redirectWithAuth(`${getOauthBaseUrl()}/onboarding`), | |
// We have orgs! | |
onNonEmpty: (x) => | |
pipe( | |
setActive, | |
Option.fromNullable, | |
Option.match({ | |
onNone: asyncNoOp, | |
onSome: async (y) => { | |
// Get the new active org. | |
const newActiveOrg = pipe( | |
x, | |
ReadonlyArray.findFirst( | |
(z) => z.organization.slug === urlOrgSlug, | |
), | |
Option.getOrElse(() => pipe(x, ReadonlyArray.headNonEmpty)), | |
).organization | |
// Set the org to be active. | |
await y({ | |
organization: newActiveOrg, | |
}) | |
// Check if the urlOrgSlug is different to the new activeOrgSlug. | |
await pipe( | |
newActiveOrg.slug !== urlOrgSlug, | |
Boolean.match({ | |
onFalse: asyncNoOp, | |
onTrue: async () => | |
redirectWithAuth( | |
pipe( | |
window.location.href, | |
String.replace( | |
urlOrgSlug, | |
pipe( | |
newActiveOrg.slug, | |
Option.fromNullable, | |
Option.getOrElse(() => ''), | |
), | |
), | |
), | |
), | |
}), | |
) | |
}, | |
}), | |
), | |
}), | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useOrganizationList } from '@clerk/nextjs' | |
import { Boolean, Option, pipe, ReadonlyArray } from 'effect' | |
export type AuthStateDoNothing = { | |
tag: 'doNothing' | |
} | |
export type AuthStateNoUser = { | |
tag: 'noUser' | |
} | |
export type AuthStateNoOrg = { | |
tag: 'noOrg' | |
} | |
export type AuthStateNeedActiveOrg = { | |
tag: 'needActiveOrg' | |
setActive: NonNullable<ReturnType<typeof useOrganizationList>['setActive']> | |
organization: NonNullable< | |
ReturnType<typeof useOrganizationList>['userMemberships']['data'] | |
>[0]['organization'] | |
} | |
export type AuthStateHasActiveOrg = { | |
tag: 'hasActiveOrg' | |
slug: string | |
} | |
export type AuthState = | |
| AuthStateDoNothing | |
| AuthStateNoUser | |
| AuthStateNoOrg | |
| AuthStateNeedActiveOrg | |
| AuthStateHasActiveOrg | |
export function useAuthState(params: { | |
userId?: string | null | |
activeOrgSlug?: string | null | |
}): AuthState { | |
const { userId, activeOrgSlug } = params | |
const { | |
userMemberships: { data: organizations = [], isLoading, isFetching }, | |
isLoaded: orgsIsLoaded, | |
setActive, | |
} = useOrganizationList({ | |
userMemberships: { | |
infinite: true, | |
}, | |
}) | |
const canSeeOrgs = pipe(orgsIsLoaded && !isLoading && !isFetching) | |
return pipe( | |
// Check if we have a userId. | |
userId, | |
Option.fromNullable, | |
Option.match({ | |
// No userId, show login button. | |
onNone: () => ({ | |
tag: 'noUser', | |
}), | |
// We have a user. | |
onSome: () => { | |
// Users can be in three states: | |
// 1. No orgs. | |
// 2. No active org. | |
// 3. Active org. | |
return pipe( | |
activeOrgSlug, | |
Option.fromNullable, | |
Option.match({ | |
onNone: () => | |
pipe( | |
canSeeOrgs, | |
Boolean.match({ | |
// Can't see orgs yet, show nothing. | |
onFalse: () => ({ tag: 'doNothing' }), | |
// We can see orgs! | |
onTrue: () => | |
pipe( | |
organizations, | |
ReadonlyArray.match({ | |
// 1. No orgs. Send them to onboarding | |
onEmpty: () => ({ | |
tag: 'noOrg', | |
}), | |
onNonEmpty: (x) => | |
// 2. They have orgs. Get the first org, then work through setActive being nullable. | |
pipe(x, ReadonlyArray.headNonEmpty, (y) => | |
pipe( | |
setActive, | |
Option.fromNullable, | |
Option.match({ | |
onNone: () => ({ tag: 'doNothing' }), | |
onSome: (z) => ({ | |
tag: 'needActiveOrg', | |
setActive: z, | |
organization: y.organization, | |
}), | |
}), | |
), | |
), | |
}), | |
), | |
}), | |
), | |
// 3. Active org. | |
onSome: (x) => ({ | |
tag: 'hasActiveOrg', | |
slug: x, | |
}), | |
}), | |
) | |
}, | |
}), | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment