Skip to content

Instantly share code, notes, and snippets.

@izakfilmalter
Last active March 29, 2024 14:54
Show Gist options
  • Save izakfilmalter/54f9985723554dbf48fb16fe1c55e84b to your computer and use it in GitHub Desktop.
Save izakfilmalter/54f9985723554dbf48fb16fe1c55e84b to your computer and use it in GitHub Desktop.
Clerk org slug subdomain
'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
}
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>
)
}
// 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} />
</>
)
}
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)(.*)'],
}
'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(() => ''),
),
),
),
),
}),
)
},
}),
),
}),
)
}
'use client'
import type { FC, ReactNode } from 'react'
import Link from 'next/link'
import { useClerk } from '@clerk/nextjs'
import { Boolean, pipe } from 'effect'
import { AnimatePresence, motion } from 'framer-motion'
import { match } from '@steepleinc/shared'
import { SignInIcon } from '~/components/icons/signInIcon'
import { Button } from '~/components/ui/button'
import { Highlight } from '~/features/marketing/button'
import { useAuthState } from '~/shared/clerk/useAuthState'
type SignInButtonProps = {
userId?: string | null
activeOrgSlug?: string | null
}
export const SignInButton: FC<SignInButtonProps> = (props) => {
const { userId, activeOrgSlug } = props
const authState = useAuthState({ userId, activeOrgSlug })
const clerk = useClerk()
return (
<AnimatePresence>
{pipe(
authState,
match({
doNothing: () => null,
noUser: () => (
<ButtonWrapper>
<Button
className={'my-auto rounded-full'}
variant={'ghost'}
size={'lg'}
asChild
>
<Link href={'/sign-in'}>
<span className={'mr-2'}>Sign in </span>
<Highlight>
<SignInIcon />
</Highlight>
</Link>
</Button>
</ButtonWrapper>
),
noOrg: () => (
<ButtonWrapper>
<Button
className={'my-auto rounded-full'}
variant={'ghost'}
size={'lg'}
asChild
>
<Link href={'/create-organization'}>
<span className={'mr-2'}>Sign In </span>
<Highlight>
<SignInIcon />
</Highlight>
</Link>
</Button>
</ButtonWrapper>
),
needActiveOrg: (x) => (
<ButtonWrapper>
<Button
className={'my-auto rounded-full'}
variant={'ghost'}
size={'lg'}
// Cheeky hack to set active org before navigation happens.
onMouseEnter={() =>
x.setActive({ organization: x.organization })
}
onClick={() =>
clerk.redirectWithAuth(
`https://${x.organization.slug}.${pipe(process.env.NODE_ENV === 'development', Boolean.match({ onFalse: () => 'steeple.works', onTrue: () => 'localhost:3000' }))}/`,
)
}
>
<span className={'mr-2'}>Sign In </span>
<Highlight>
<SignInIcon />
</Highlight>
</Button>
</ButtonWrapper>
),
hasActiveOrg: (x) => (
<ButtonWrapper>
<Button
className={'my-auto rounded-full'}
variant={'ghost'}
size={'lg'}
onClick={() =>
clerk.redirectWithAuth(
`https://${x.slug}.${pipe(process.env.NODE_ENV === 'development', Boolean.match({ onFalse: () => 'steeple.works', onTrue: () => 'localhost:3000' }))}/`,
)
}
>
<span className={'mr-2'}>Sign In </span>
<Highlight>
<SignInIcon />
</Highlight>
</Button>
</ButtonWrapper>
),
}),
)}
</AnimatePresence>
)
}
type ButtonWrapperProps = {
children: ReactNode
}
const ButtonWrapper: FC<ButtonWrapperProps> = (props) => {
const { children } = props
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={'my-auto'}
>
{children}
</motion.div>
)
}
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