-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(dashboard): clerk auth, public and protected routes (#6595)
- Loading branch information
Showing
47 changed files
with
1,366 additions
and
698 deletions.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
VITE_SENTRY_DSN= | ||
VITE_LAUNCH_DARKLY_CLIENT_SIDE_ID= | ||
VITE_HUBSPOT_EMBED= | ||
VITE_API_HOSTNAME=http://localhost:3000 | ||
VITE_CLERK_PUBLISHABLE_KEY= | ||
VITE_NOVU_APP_ID= |
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
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import { getToken } from '@/utils/auth'; | ||
import { API_HOSTNAME } from '../config'; | ||
import { getEnvironmentId } from '@/utils/environment'; | ||
|
||
class NovuApiError extends Error { | ||
constructor( | ||
message: string, | ||
public error: unknown, | ||
public status: number | ||
) { | ||
super(message); | ||
} | ||
} | ||
|
||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; | ||
|
||
const request = async <T>( | ||
endpoint: string, | ||
method: HttpMethod = 'GET', | ||
data?: unknown, | ||
headers?: HeadersInit | ||
): Promise<T> => { | ||
try { | ||
const jwt = await getToken(); | ||
const environmentId = getEnvironmentId(); | ||
const config: RequestInit = { | ||
method, | ||
headers: { | ||
Authorization: `Bearer ${jwt}`, | ||
'Content-Type': 'application/json', | ||
...(environmentId && { 'Novu-Environment-Id': environmentId }), | ||
...headers, | ||
}, | ||
}; | ||
|
||
if (data) { | ||
config.body = JSON.stringify(data); | ||
} | ||
|
||
const baseUrl = API_HOSTNAME ?? 'https://api.novu.co'; | ||
const response = await fetch(`${baseUrl}/v1${endpoint}`, config); | ||
|
||
if (!response.ok) { | ||
const errorData = await response.json(); | ||
throw new NovuApiError(`Novu API error`, errorData, response.status); | ||
} | ||
|
||
if (response.status === 204) { | ||
return {} as T; | ||
} | ||
|
||
return await response.json(); | ||
} catch (error) { | ||
if (error instanceof NovuApiError) { | ||
throw error; | ||
} | ||
if (typeof error === 'object' && error && 'message' in error) { | ||
throw new Error(`Fetch error: ${error.message}`); | ||
} | ||
throw new Error(`Fetch error: ${JSON.stringify(error)}`); | ||
} | ||
}; | ||
|
||
export const get = <T>(endpoint: string) => request<T>(endpoint, 'GET'); | ||
|
||
export const post = <T>(endpoint: string, data: unknown) => request<T>(endpoint, 'POST', data); | ||
|
||
export const put = <T>(endpoint: string, data: unknown) => request<T>(endpoint, 'PUT', data); | ||
|
||
export const del = <T>(endpoint: string) => request<T>(endpoint, 'DELETE'); |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import type { IEnvironment } from '@novu/shared'; | ||
import { get } from './api.client'; | ||
|
||
export async function getEnvironments() { | ||
const { data } = await get<{ data: IEnvironment[] }>('/environments'); | ||
|
||
return data; | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { ReactNode } from 'react'; | ||
|
||
export const AuthLayout = ({ children }: { children: ReactNode }) => { | ||
return ( | ||
<div className="grid h-screen grid-cols-2 gap-8"> | ||
<div className="grow">Auth Layout</div> | ||
<div className="flex items-center justify-center">{children}</div> | ||
</div> | ||
); | ||
}; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import { ReactNode } from 'react'; | ||
import { UserProfile } from '@/components/user-profile'; | ||
import { InboxButton } from '@/components/inbox-button'; | ||
|
||
export const DashboardLayout = ({ children }: { children: ReactNode }) => { | ||
return ( | ||
<div className="relative min-h-dvh"> | ||
<div className="fixed left-0 top-0 flex h-16 w-full items-center justify-between bg-green-200 p-4"> | ||
<a | ||
href="/legacy/integrations" | ||
target="_self" | ||
className="text-blue-600 visited:text-purple-600 hover:border-b hover:border-current" | ||
> | ||
Integrations | ||
</a> | ||
<div className="flex gap-4"> | ||
<InboxButton /> | ||
<UserProfile /> | ||
</div> | ||
</div> | ||
|
||
<div className="pt-16">{children}</div> | ||
</div> | ||
); | ||
}; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { Helmet } from 'react-helmet-async'; | ||
|
||
type Props = { | ||
title?: string; | ||
}; | ||
|
||
export function PageMeta({ title }: Props) { | ||
return ( | ||
<Helmet> | ||
<title>{title ? `${title} | ` : ``}Novu Cloud Dashboard</title> | ||
</Helmet> | ||
); | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { UserButton } from '@clerk/clerk-react'; | ||
import { ROUTES } from '@/utils/routes'; | ||
|
||
export function UserProfile() { | ||
return <UserButton afterSignOutUrl={ROUTES.SIGN_IN} />; | ||
} |
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
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { createContextAndHook } from '@/utils/context'; | ||
import { AuthContextValue } from './types'; | ||
|
||
const [AuthContext, useAuth] = createContextAndHook<AuthContextValue>('AuthContext'); | ||
|
||
export { AuthContext, useAuth }; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
import { ReactNode, useCallback, useEffect, useMemo } from 'react'; | ||
import { useAuth, useOrganization, useOrganizationList, useUser } from '@clerk/clerk-react'; | ||
import type { UserResource } from '@clerk/types'; | ||
import { ROUTES } from '@/utils/routes'; | ||
import type { AuthContextValue } from './types'; | ||
import { toOrganizationEntity, toUserEntity } from './mappers'; | ||
import { AuthContext } from './auth-context'; | ||
|
||
export const AuthProvider = ({ children }: { children: ReactNode }) => { | ||
const { orgId } = useAuth(); | ||
const { user: clerkUser, isLoaded: isUserLoaded } = useUser(); | ||
const { organization: clerkOrganization, isLoaded: isOrganizationLoaded } = useOrganization(); | ||
const { setActive, isLoaded: isOrgListLoaded } = useOrganizationList({ userMemberships: { infinite: true } }); | ||
|
||
const redirectTo = useCallback( | ||
({ | ||
url, | ||
redirectURL, | ||
origin, | ||
anonymousId, | ||
}: { | ||
url: string; | ||
redirectURL?: string; | ||
origin?: string; | ||
anonymousId?: string | null; | ||
}) => { | ||
const finalURL = new URL(url, window.location.origin); | ||
|
||
if (redirectURL) { | ||
finalURL.searchParams.append('redirect_url', redirectURL); | ||
} | ||
|
||
if (origin) { | ||
finalURL.searchParams.append('origin', origin); | ||
} | ||
|
||
if (anonymousId) { | ||
finalURL.searchParams.append('anonymous_id', anonymousId); | ||
} | ||
|
||
// Note: Do not use react-router-dom. The version we have doesn't do instant cross origin redirects. | ||
window.location.replace(finalURL.href); | ||
}, | ||
[] | ||
); | ||
|
||
// check if user has active organization | ||
useEffect(() => { | ||
if (orgId) { | ||
return; | ||
} | ||
|
||
if (isOrgListLoaded && clerkUser) { | ||
const hasOrgs = clerkUser.organizationMemberships.length > 0; | ||
|
||
if (hasOrgs) { | ||
const firstOrg = clerkUser.organizationMemberships[0].organization; | ||
setActive({ organization: firstOrg }); | ||
} else if (!window.location.href.includes(ROUTES.SIGNUP_ORGANIZATION_LIST)) { | ||
redirectTo({ url: ROUTES.SIGNUP_ORGANIZATION_LIST }); | ||
} | ||
} | ||
}, [setActive, isOrgListLoaded, clerkUser, orgId, redirectTo]); | ||
|
||
const currentUser = useMemo(() => (clerkUser ? toUserEntity(clerkUser as UserResource) : undefined), [clerkUser]); | ||
const currentOrganization = useMemo( | ||
() => (clerkOrganization ? toOrganizationEntity(clerkOrganization) : undefined), | ||
[clerkOrganization] | ||
); | ||
|
||
const value = useMemo( | ||
() => | ||
({ | ||
isUserLoaded, | ||
isOrganizationLoaded, | ||
currentUser, | ||
currentOrganization, | ||
}) as AuthContextValue, | ||
[isUserLoaded, isOrganizationLoaded, currentUser, currentOrganization] | ||
); | ||
|
||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; | ||
}; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './auth-provider'; | ||
export * from './auth-context'; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import type { IOrganizationEntity, IUserEntity } from '@novu/shared'; | ||
import { OrganizationResource, UserResource } from '@clerk/types'; | ||
|
||
export const toUserEntity = (clerkUser: UserResource): IUserEntity => { | ||
/* | ||
* When mapping to IUserEntity, we have 2 cases: | ||
* - user exists and has signed in | ||
* - user is signing up | ||
* | ||
* In the case where the user is still signing up, we are using the clerk identifier for the id. | ||
* This however quickly gets update to the externalId (which is actually the novu internal | ||
* entity identifier) that gets used further in the app. There are a few consumers that | ||
* want to use this identifier before it is set to the internal value. These consumers | ||
* should make sure they only report with the correct value, a reference | ||
* implementation can be found in 'apps/web/src/hooks/useMonitoring.ts' | ||
*/ | ||
|
||
return { | ||
_id: clerkUser.externalId ?? clerkUser.id, | ||
firstName: clerkUser.firstName, | ||
lastName: clerkUser.lastName, | ||
email: clerkUser.emailAddresses[0].emailAddress, | ||
profilePicture: clerkUser.imageUrl, | ||
createdAt: clerkUser.createdAt?.toISOString() ?? '', | ||
showOnBoarding: !!clerkUser.publicMetadata.showOnBoarding, | ||
showOnBoardingTour: clerkUser.publicMetadata.showOnBoardingTour as any, | ||
servicesHashes: clerkUser.publicMetadata.servicesHashes as any, | ||
jobTitle: clerkUser.publicMetadata.jobTitle as any, | ||
hasPassword: clerkUser.passwordEnabled, | ||
}; | ||
}; | ||
|
||
export const toOrganizationEntity = (clerkOrganization: OrganizationResource): IOrganizationEntity => { | ||
/* | ||
* When mapping to IOrganizationEntity, we have 2 cases: | ||
* - user exists and has signed in | ||
* - user is signing up | ||
* | ||
* | ||
* In the case where the user is still signing up, we are using the clerk identifier for the id. | ||
* This however quickly gets update to the externalId (which is actually the novu internal | ||
* entity identifier) that gets used further in the app. There are a few consumers that | ||
* want to use this identifier before it is set to the internal value. These consumers | ||
* should make sure they only report with the correct value, a reference | ||
* implementation can be found in 'apps/web/src/hooks/useMonitoring.ts' | ||
*/ | ||
|
||
return { | ||
_id: (clerkOrganization.publicMetadata.externalOrgId as any) ?? clerkOrganization.id, | ||
name: clerkOrganization.name, | ||
createdAt: clerkOrganization.createdAt.toISOString(), | ||
updatedAt: clerkOrganization.updatedAt.toISOString(), | ||
domain: clerkOrganization.publicMetadata.domain as any, | ||
productUseCases: clerkOrganization.publicMetadata.productUseCases as any, | ||
language: clerkOrganization.publicMetadata.language as any, | ||
}; | ||
}; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import type { IOrganizationEntity, IUserEntity } from '@novu/shared'; | ||
|
||
type UserState = | ||
| { | ||
isUserLoaded: true; | ||
currentUser: IUserEntity; | ||
} | ||
| { | ||
isUserLoaded: false; | ||
currentUser: undefined; | ||
}; | ||
|
||
type OrganizationState = | ||
| { | ||
isOrganizationLoaded: true; | ||
currentOrganization: IOrganizationEntity; | ||
} | ||
| { | ||
isOrganizationLoaded: false; | ||
currentOrganization: undefined; | ||
}; | ||
|
||
export type AuthContextValue = UserState & OrganizationState; |
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
14 changes: 14 additions & 0 deletions
14
apps/dashboard/src/context/environment/environment-context.tsx
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import type { IEnvironment } from '@novu/shared'; | ||
import { createContextAndHook } from '@/utils/context'; | ||
|
||
export type EnvironmentContextValue = { | ||
currentEnvironment?: IEnvironment | null; | ||
environments?: IEnvironment[]; | ||
isLoaded: boolean; | ||
readOnly: boolean; | ||
}; | ||
|
||
const [EnvironmentContext, useEnvironmentContext] = | ||
createContextAndHook<EnvironmentContextValue>('Environment Context'); | ||
|
||
export { EnvironmentContext, useEnvironmentContext }; |
Oops, something went wrong.