Refactor authentication flow and improve code organization

The update implemented a redirect functionality in the multi-factor authentication flow for a better user experience. It also involved a refactoring of some parts of the code, substituting direct routing paths with path configs for easier future modifications. Import statements were adjusted for better code organization and readability.
This commit is contained in:
giancarlo
2024-03-27 15:07:15 +08:00
parent f0883c19ef
commit 7579ee9a2c
33 changed files with 103 additions and 151 deletions

View File

@@ -206,7 +206,9 @@ function generateDemoData() {
}); });
} }
return [data, data[data.length - 1].value] as [typeof data, string]; const lastValue = data[data.length - 1]?.value;
return [data, lastValue] as [typeof data, string];
} }
function Chart( function Chart(

View File

@@ -26,7 +26,6 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) {
if (checkoutToken) { if (checkoutToken) {
return ( return (
<EmbeddedCheckout <EmbeddedCheckout
load
checkoutToken={checkoutToken} checkoutToken={checkoutToken}
provider={billingConfig.provider} provider={billingConfig.provider}
/> />

View File

@@ -9,7 +9,7 @@ import Post from '~/(marketing)/blog/_components/post';
import appConfig from '~/config/app.config'; import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: { slug: string }; params: { slug: string };
@@ -17,7 +17,7 @@ export function generateMetadata({
const post = allPosts.find((post) => post.slug === params.slug); const post = allPosts.find((post) => post.slug === params.slug);
if (!post) { if (!post) {
return; notFound();
} }
const { title, date, description, image, slug } = post; const { title, date, description, image, slug } = post;

View File

@@ -5,8 +5,8 @@ import type { DocumentationPage } from 'contentlayer/generated';
export interface ProcessedDocumentationPage extends DocumentationPage { export interface ProcessedDocumentationPage extends DocumentationPage {
collapsible: boolean; collapsible: boolean;
pathSegments: string[]; pathSegments: string[];
nextPage: ProcessedDocumentationPage | DocumentationPage | null; nextPage: ProcessedDocumentationPage | DocumentationPage | undefined;
previousPage: ProcessedDocumentationPage | DocumentationPage | null; previousPage: ProcessedDocumentationPage | DocumentationPage | undefined;
children: DocsTree; children: DocsTree;
} }

View File

@@ -1,4 +1,4 @@
import { PricingTable } from '@kit/billing/components/pricing-table'; import { PricingTable } from '@kit/billing-gateway/components';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import billingConfig from '~/config/billing.config'; import billingConfig from '~/config/billing.config';

View File

@@ -6,30 +6,37 @@ import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-clien
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
const defaultNextUrl = pathsConfig.app.home;
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url); const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams; const searchParams = requestUrl.searchParams;
const authCode = searchParams.get('code'); const authCode = searchParams.get('code');
const inviteCode = searchParams.get('inviteCode');
const error = searchParams.get('error'); const error = searchParams.get('error');
const nextUrl = searchParams.get('next') ?? pathsConfig.app.home; const nextUrlPathFromParams = searchParams.get('next');
const inviteToken = searchParams.get('invite_token');
let userId: string | undefined = undefined; let nextUrl = nextUrlPathFromParams ?? defaultNextUrl;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
if (inviteToken) {
nextUrl = `${pathsConfig.app.joinTeam}?invite_token=${inviteToken}`;
}
if (authCode) { if (authCode) {
const client = getSupabaseRouteHandlerClient(); const client = getSupabaseRouteHandlerClient();
try { try {
const { error, data } = const { error } = await client.auth.exchangeCodeForSession(authCode);
await client.auth.exchangeCodeForSession(authCode);
// if we have an error, we redirect to the error page // if we have an error, we redirect to the error page
if (error) { if (error) {
return onError({ error: error.message }); return onError({ error: error.message });
} }
userId = data.user.id;
} catch (error) { } catch (error) {
Logger.error( Logger.error(
{ {
@@ -42,34 +49,6 @@ export async function GET(request: NextRequest) {
return onError({ error: message as string }); return onError({ error: message as string });
} }
if (inviteCode && userId) {
try {
Logger.info(
{
userId,
inviteCode,
},
`Attempting to accept user invite...`,
);
// if we have an invite code, we accept the invite
await acceptInviteFromEmailLink({ inviteCode, userId });
} catch (error) {
Logger.error(
{
userId,
inviteCode,
error,
},
`An error occurred while accepting user invite`,
);
const message = error instanceof Error ? error.message : error;
return onError({ error: message as string });
}
}
} }
if (error) { if (error) {
@@ -79,37 +58,6 @@ export async function GET(request: NextRequest) {
return redirect(nextUrl); return redirect(nextUrl);
} }
/**
* @name acceptInviteFromEmailLink
* @description If we find an invite code, we try to accept the invite
* received from the email link method
* @param params
*/
async function acceptInviteFromEmailLink(params: {
inviteCode: string;
userId: string | undefined;
}) {
if (!params.userId) {
Logger.error(params, `Attempted to accept invite, but no user id provided`);
return;
}
Logger.info(params, `Found invite code. Accepting invite...`);
await acceptInviteToOrganization(
getSupabaseRouteHandlerClient({
admin: true,
}),
{
code: params.inviteCode,
userId: params.userId,
},
);
Logger.info(params, `Invite successfully accepted`);
}
function onError({ error }: { error: string }) { function onError({ error }: { error: string }) {
const errorMessage = getAuthErrorMessage(error); const errorMessage = getAuthErrorMessage(error);

View File

@@ -1,9 +1,6 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -25,36 +22,7 @@ async function JoinTeamAccountPage({ searchParams }: Context) {
notFound(); notFound();
} }
return ( return <></>;
<>
<Heading level={4}>
<Trans
i18nKey={'auth:joinOrganizationHeading'}
values={{
organization: organization.name,
}}
/>
</Heading>
<div>
<p className={'text-center'}>
<Trans
i18nKey={'auth:joinOrganizationSubHeading'}
values={{
organization: organization.name,
}}
components={{ b: <b /> }}
/>
</p>
<p className={'text-center'}>
<If condition={!data.session}>
<Trans i18nKey={'auth:signUpToAcceptInvite'} />
</If>
</p>
</div>
</>
);
} }
export default withI18n(JoinTeamAccountPage); export default withI18n(JoinTeamAccountPage);
@@ -66,7 +34,7 @@ async function getInviteDataFromInviteToken(token: string) {
const { data: invitation, error } = await adminClient const { data: invitation, error } = await adminClient
.from('invitations') .from('invitations')
.select('*') .select()
.eq('invite_token', token) .eq('invite_token', token)
.single(); .single();

View File

@@ -24,7 +24,7 @@ const authConfig = AuthConfigSchema.parse({
magicLink: false, magicLink: false,
oAuth: ['google'], oAuth: ['google'],
}, },
}); } satisfies z.infer<typeof AuthConfigSchema>);
export default authConfig; export default authConfig;

View File

@@ -36,7 +36,7 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
process.env.NEXT_PUBLIC_ENABLE_ORGANIZATION_BILLING, process.env.NEXT_PUBLIC_ENABLE_ORGANIZATION_BILLING,
false, false,
), ),
}); } satisfies z.infer<typeof FeatureFlagsSchema>);
export default featuresFlagConfig; export default featuresFlagConfig;

View File

@@ -19,6 +19,7 @@ const PathsSchema = z.object({
accountBilling: z.string().min(1), accountBilling: z.string().min(1),
accountMembers: z.string().min(1), accountMembers: z.string().min(1),
accountBillingReturn: z.string().min(1), accountBillingReturn: z.string().min(1),
joinTeam: z.string().min(1),
}), }),
}); });
@@ -41,7 +42,8 @@ const pathsConfig = PathsSchema.parse({
accountBilling: `/home/[account]/billing`, accountBilling: `/home/[account]/billing`,
accountMembers: `/home/[account]/members`, accountMembers: `/home/[account]/members`,
accountBillingReturn: `/home/[account]/billing/return`, accountBillingReturn: `/home/[account]/billing/return`,
joinTeam: '/join',
}, },
}); } satisfies z.infer<typeof PathsSchema>);
export default pathsConfig; export default pathsConfig;

View File

@@ -57,11 +57,13 @@ export default withBundleAnalyzer({
})(config); })(config);
function getRemotePatterns() { function getRemotePatterns() {
// add here the remote patterns for your images /** @type {import('next').NextConfig['remotePatterns']} */
// add here the remote patterns for your images
const remotePatterns = []; const remotePatterns = [];
if (SUPABASE_URL) { if (SUPABASE_URL) {
const hostname = new URL(SUPABASE_URL).hostname; const hostname = new URL(SUPABASE_URL).hostname;
remotePatterns.push({ remotePatterns.push({
protocol: 'https', protocol: 'https',
hostname, hostname,

View File

@@ -2,7 +2,7 @@
"subscriptionTabSubheading": "Manage your Subscription and Billing", "subscriptionTabSubheading": "Manage your Subscription and Billing",
"planCardTitle": "Your Plan", "planCardTitle": "Your Plan",
"planCardDescription": "Below are the details of your current plan. You can change your plan or cancel your subscription at any time.", "planCardDescription": "Below are the details of your current plan. You can change your plan or cancel your subscription at any time.",
"planRenewal": "Renews every {{interval}} at {{currency}} {{price}}", "planRenewal": "Renews every {{interval}} at {{price}}",
"planDetails": "Plan Details", "planDetails": "Plan Details",
"checkout": "Proceed to Checkout", "checkout": "Proceed to Checkout",
"trialEndsOn": "Your trial ends on", "trialEndsOn": "Your trial ends on",

View File

@@ -3,6 +3,7 @@ import { BadgeCheck, CheckCircle2 } from 'lucide-react';
import { z } from 'zod'; import { z } from 'zod';
import { BillingSchema, getProductPlanPairFromId } from '@kit/billing'; import { BillingSchema, getProductPlanPairFromId } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import {
Accordion, Accordion,
@@ -65,8 +66,7 @@ export function CurrentPlanCard({
i18nKey="billing:planRenewal" i18nKey="billing:planRenewal"
values={{ values={{
interval: subscription.interval, interval: subscription.interval,
currency: product.currency, price: formatCurrency(product.currency, plan.price),
price: plan.price,
}} }}
/> />
</div> </div>

View File

@@ -11,7 +11,7 @@ export function EmbeddedCheckout(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
checkoutToken: string; checkoutToken: string;
provider: BillingProvider; provider: BillingProvider;
onClose: () => void; onClose?: () => void;
}>, }>,
) { ) {
const CheckoutComponent = useMemo( const CheckoutComponent = useMemo(

View File

@@ -3,3 +3,4 @@ export * from './current-plan-card';
export * from './embedded-checkout'; export * from './embedded-checkout';
export * from './billing-session-status'; export * from './billing-session-status';
export * from './billing-portal-card'; export * from './billing-portal-card';
export * from './pricing-table';

View File

@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { BillingSchema } from '@kit/billing'; import { BillingSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Form, Form,
@@ -233,10 +234,3 @@ function Price(props: React.PropsWithChildren) {
</span> </span>
); );
} }
function formatCurrency(currencyCode: string, value: string) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(value);
}

View File

@@ -7,14 +7,13 @@ import Link from 'next/link';
import { CheckCircle, Sparkles } from 'lucide-react'; import { CheckCircle, Sparkles } from 'lucide-react';
import { z } from 'zod'; import { z } from 'zod';
import { BillingSchema, getPlanIntervals } from '@kit/billing';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { BillingSchema, getPlanIntervals } from '../create-billing-schema';
type Config = z.infer<typeof BillingSchema>; type Config = z.infer<typeof BillingSchema>;
interface Paths { interface Paths {

View File

@@ -17,10 +17,14 @@
"@kit/prettier-config": "0.1.0", "@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0", "@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0", "@kit/tsconfig": "0.1.0",
"@kit/ui": "*" "@kit/ui": "*",
"@kit/supabase": "*",
"@supabase/supabase-js": "2.40.0",
"lucide-react": "^0.363.0"
}, },
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts",
"./components/*": "./src/components/*"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View File

@@ -7,7 +7,7 @@ interface Data {
trialSubscriptions: number; trialSubscriptions: number;
} }
function AdminDashboard({ export function AdminDashboard({
data, data,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
data: Data; data: Data;
@@ -70,8 +70,6 @@ function AdminDashboard({
); );
} }
export default AdminDashboard;
function Figure(props: React.PropsWithChildren) { function Figure(props: React.PropsWithChildren) {
return <div className={'text-3xl font-bold'}>{props.children}</div>; return <div className={'text-3xl font-bold'}>{props.children}</div>;
} }

View File

@@ -1,22 +1,23 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import isUserSuperAdmin from '../../../app/admin/utils/is-user-super-admin'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { isSuperAdmin } from '../lib/is-super-admin';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>; type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
function AdminGuard<Params extends object>( export function AdminGuard<Params extends object>(
Component: LayoutOrPageComponent<Params>, Component: LayoutOrPageComponent<Params>,
) { ) {
return async function AdminGuardServerComponentWrapper(params: Params) { return async function AdminGuardServerComponentWrapper(params: Params) {
const isAdmin = await isUserSuperAdmin(); const client = getSupabaseServerComponentClient();
const isUserSuperAdmin = await isSuperAdmin(client);
// if the user is not a super-admin, we redirect to a 404 // if the user is not a super-admin, we redirect to a 404
if (!isAdmin) { if (!isUserSuperAdmin) {
notFound(); notFound();
} }
return <Component {...params} />; return <Component {...params} />;
}; };
} }
export default AdminGuard;

View File

@@ -5,11 +5,10 @@ import { ArrowLeft } from 'lucide-react';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageHeader } from '@kit/ui/page'; import { PageHeader } from '@kit/ui/page';
function AdminHeader({ export function AdminHeader({
children, children,
paths, paths,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
appHome: string;
paths: { paths: {
appHome: string; appHome: string;
}; };
@@ -28,5 +27,3 @@ function AdminHeader({
</PageHeader> </PageHeader>
); );
} }
export default AdminHeader;

View File

@@ -1,10 +1,8 @@
'use client';
import { Home, User, Users } from 'lucide-react'; import { Home, User, Users } from 'lucide-react';
import { Sidebar, SidebarContent, SidebarItem } from '@kit/ui/sidebar'; import { Sidebar, SidebarContent, SidebarItem } from '@kit/ui/sidebar';
function AdminSidebar(props: { Logo: React.ReactNode }) { export function AdminSidebar(props: { Logo: React.ReactNode }) {
return ( return (
<Sidebar> <Sidebar>
<SidebarContent className={'mb-6 mt-4 pt-2'}>{props.Logo}</SidebarContent> <SidebarContent className={'mb-6 mt-4 pt-2'}>{props.Logo}</SidebarContent>
@@ -28,5 +26,3 @@ function AdminSidebar(props: { Logo: React.ReactNode }) {
</Sidebar> </Sidebar>
); );
} }
export default AdminSidebar;

View File

@@ -0,0 +1 @@
export * from './lib/is-super-admin';

View File

@@ -0,0 +1,19 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
export async function isSuperAdmin(client: SupabaseClient<Database>) {
const { data, error } = await client.auth.getUser();
if (error) {
throw error;
}
if (!data.user) {
return false;
}
const appMetadata = data.user.app_metadata;
return appMetadata?.role === 'super-admin';
}

View File

@@ -72,7 +72,7 @@ export class AccountInvitationsService {
role: params.role, role: params.role,
}) })
.match({ .match({
id: params.id, id: params.invitationId,
}); });
if (error) { if (error) {

View File

@@ -1,3 +1,10 @@
export function isBrowser() { export function isBrowser() {
return typeof window !== 'undefined'; return typeof window !== 'undefined';
} }
export function formatCurrency(currencyCode: string, value: string | number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(Number(value));
}

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { getSupabaseBrowserClient } from '../clients/browser.client'; import { getSupabaseBrowserClient } from '../clients/browser.client';
import { Database } from '../database.types';
export function useSupabase<Schema = Database>() { export function useSupabase() {
return useMemo(() => getSupabaseBrowserClient<Schema>(), []); return useMemo(() => getSupabaseBrowserClient(), []);
} }

View File

@@ -36,7 +36,7 @@ import {
TableRow, TableRow,
} from '../shadcn/table'; } from '../shadcn/table';
import { cn } from '../utils'; import { cn } from '../utils';
import Trans from './trans'; import { Trans } from './trans';
interface ReactTableProps<T extends object> { interface ReactTableProps<T extends object> {
data: T[]; data: T[];

View File

@@ -49,6 +49,11 @@ export const ImageUploadInput = forwardRef<React.ElementRef<'input'>, Props>(
if (files?.length) { if (files?.length) {
const file = files[0]; const file = files[0];
if (!file) {
return;
}
const data = URL.createObjectURL(file); const data = URL.createObjectURL(file);
setState({ setState({

View File

@@ -14,7 +14,7 @@ export default function isRouteActive(
depth: number, depth: number,
) { ) {
// we remove any eventual query param from the route's URL // we remove any eventual query param from the route's URL
const currentRoutePath = currentRoute.split('?')[0]; const currentRoutePath = currentRoute.split('?')[0] ?? '';
if (!isRoot(currentRoutePath) && isRoot(targetLink)) { if (!isRoot(currentRoutePath) && isRoot(targetLink)) {
return false; return false;

View File

@@ -2,7 +2,7 @@ import { forwardRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { cn } from '../../shadcn'; import { cn } from '../../utils';
import { LazyRender } from '../lazy-render'; import { LazyRender } from '../lazy-render';
const NextImage: React.FC<{ const NextImage: React.FC<{

View File

@@ -2,6 +2,7 @@ import type { MDXComponents } from 'mdx/types';
import { getMDXComponent } from 'next-contentlayer/hooks'; import { getMDXComponent } from 'next-contentlayer/hooks';
import Components from './mdx-components'; import Components from './mdx-components';
// @ts-ignore
import styles from './mdx-renderer.module.css'; import styles from './mdx-renderer.module.css';
export function Mdx({ export function Mdx({

9
pnpm-lock.yaml generated
View File

@@ -319,6 +319,9 @@ importers:
'@kit/prettier-config': '@kit/prettier-config':
specifier: 0.1.0 specifier: 0.1.0
version: link:../../../tooling/prettier version: link:../../../tooling/prettier
'@kit/supabase':
specifier: '*'
version: link:../../supabase
'@kit/tailwind-config': '@kit/tailwind-config':
specifier: 0.1.0 specifier: 0.1.0
version: link:../../../tooling/tailwind version: link:../../../tooling/tailwind
@@ -328,6 +331,12 @@ importers:
'@kit/ui': '@kit/ui':
specifier: '*' specifier: '*'
version: link:../../ui version: link:../../ui
'@supabase/supabase-js':
specifier: 2.40.0
version: 2.40.0
lucide-react:
specifier: ^0.363.0
version: 0.363.0(react@18.2.0)
packages/features/auth: packages/features/auth:
devDependencies: devDependencies: