Skip to content

Commit

Permalink
feat(dashboard): clerk auth, public and protected routes (#6595)
Browse files Browse the repository at this point in the history
  • Loading branch information
LetItRock authored Oct 1, 2024
1 parent bfad8fd commit 4408daa
Show file tree
Hide file tree
Showing 47 changed files with 1,366 additions and 698 deletions.
6 changes: 6 additions & 0 deletions apps/dashboard/.env.example
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=
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"mixpanel-browser": "^2.52.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^1.3.0",
"react-hook-form": "7.43.9",
"react-router-dom": "6.26.2",
"tailwind-merge": "^2.4.0",
Expand Down
70 changes: 70 additions & 0 deletions apps/dashboard/src/api/api.client.ts
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');
8 changes: 8 additions & 0 deletions apps/dashboard/src/api/environments.ts
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;
}
10 changes: 10 additions & 0 deletions apps/dashboard/src/components/auth-layout.tsx
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>
);
};
25 changes: 25 additions & 0 deletions apps/dashboard/src/components/dashboard-layout.tsx
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>
);
};
13 changes: 13 additions & 0 deletions apps/dashboard/src/components/page-meta.tsx
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>
);
}
6 changes: 6 additions & 0 deletions apps/dashboard/src/components/user-profile.tsx
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} />;
}
4 changes: 3 additions & 1 deletion apps/dashboard/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY

export const APP_ID = import.meta.env.VITE_NOVU_APP_ID || '';

if (IS_EE_AUTH_ENABLED && !CLERK_PUBLISHABLE_KEY) {
if (!CLERK_PUBLISHABLE_KEY) {
throw new Error('Missing Clerk Publishable Key');
}

export const API_HOSTNAME = import.meta.env.VITE_API_HOSTNAME;
6 changes: 6 additions & 0 deletions apps/dashboard/src/context/auth/auth-context.tsx
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 };
83 changes: 83 additions & 0 deletions apps/dashboard/src/context/auth/auth-provider.tsx
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>;
};
2 changes: 2 additions & 0 deletions apps/dashboard/src/context/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './auth-provider';
export * from './auth-context';
57 changes: 57 additions & 0 deletions apps/dashboard/src/context/auth/mappers.ts
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,
};
};
23 changes: 23 additions & 0 deletions apps/dashboard/src/context/auth/types.ts
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;
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ const ALLOWED_REDIRECT_ORIGINS = ['http://localhost:*', window.location.origin];

type ClerkProviderProps = PropsWithChildren;
export const ClerkProvider = (props: ClerkProviderProps) => {
const { children } = props;

const navigate = useNavigate();
const { children } = props;

return (
<_ClerkProvider
Expand Down
14 changes: 14 additions & 0 deletions apps/dashboard/src/context/environment/environment-context.tsx
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 };
Loading

0 comments on commit 4408daa

Please sign in to comment.