Remove billing and checkout redirect buttons and related services

Deleted the billing-redirect-button, checkout-redirect-button, and embedded-stripe-checkout components. Additionally, removed the shadcn directory, which encompassed billing-related icons. This change streamlines the subscription settings interface and organizes the system's payment management. This update is a stepping stone towards improving the billing system's overall architecture.
This commit is contained in:
giancarlo
2024-03-25 11:39:41 +08:00
parent 78c704e54d
commit cb8b23e8c0
123 changed files with 1674 additions and 3071 deletions

View File

@@ -1,8 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>

View File

@@ -1,9 +1,9 @@
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
function PersonalAccountSettingsPage() {
return (
<div

View File

@@ -1,13 +1,12 @@
'use server';
import { URL } from 'next/dist/compiled/@edge-runtime/primitives';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { getProductPlanPairFromId } from '@kit/billing';
import { getGatewayProvider } from '@kit/billing-gateway';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import billingConfig from '~/config/billing.config';
@@ -30,7 +29,7 @@ export async function createPersonalAccountCheckoutSession(params: {
}
const planId = z.string().min(1).parse(params.planId);
const service = await getGatewayProvider(client);
const service = await getBillingGatewayProvider(client);
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
if (!productPlanPairFromId) {
@@ -77,7 +76,7 @@ export async function createBillingPortalSession() {
throw new Error('Authentication required');
}
const service = await getGatewayProvider(client);
const service = await getBillingGatewayProvider(client);
const accountId = data.user.id;
const customerId = await getCustomerIdFromAccountId(accountId);

View File

@@ -1,8 +1,8 @@
import { Page } from '@kit/ui/page';
import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Page } from '@kit/ui/page';
function UserHomeLayout({ children }: React.PropsWithChildren) {
return <Page sidebar={<HomeSidebar />}>{children}</Page>;
}

View File

@@ -1,8 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
function UserHomePage() {
return (
<>

View File

@@ -1,7 +1,7 @@
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
import { PageHeader } from '@kit/ui/page';
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
export function AppHeader({
children,
title,

View File

@@ -1,10 +1,10 @@
'use client';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
import { Trans } from '@kit/ui/trans';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
export function AppSidebarNavigation({
account,
}: React.PropsWithChildren<{

View File

@@ -3,9 +3,6 @@
import { useRouter } from 'next/navigation';
import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector';
import { Sidebar, SidebarContent } from '@kit/ui/sidebar';
@@ -18,6 +15,10 @@ import {
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AppSidebarNavigation } from './app-sidebar-navigation';
type AccountModel = {

View File

@@ -4,9 +4,6 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react';
import featureFlagsConfig from '~/config/feature-flags.config';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
@@ -26,6 +23,10 @@ import {
} from '@kit/ui/dropdown-menu';
import { Trans } from '@kit/ui/trans';
import featureFlagsConfig from '~/config/feature-flags.config';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import pathsConfig from '~/config/paths.config';
export const MobileAppNavigation = (
props: React.PropsWithChildren<{
slug: string;

View File

@@ -3,10 +3,11 @@ import { cache } from 'react';
import { redirect } from 'next/navigation';
import 'server-only';
import pathsConfig from '~/config/paths.config';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import pathsConfig from '~/config/paths.config';
/**
* Load the organization workspace data.
* We place this function into a separate file so it can be reused in multiple places across the server components.

View File

@@ -1,8 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
function OrganizationAccountBillingPage() {
return (
<>

View File

@@ -1,9 +1,9 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie';
import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie';
import { Page } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
import { AppSidebar } from './(components)/app-sidebar';
import { loadOrganizationWorkspace } from './(lib)/load-workspace';

View File

@@ -1,6 +1,4 @@
import { PlusCircledIcon } from '@radix-ui/react-icons';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import {
@@ -19,6 +17,9 @@ import {
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Params {
params: {
account: string;

View File

@@ -1,14 +1,15 @@
import loadDynamic from 'next/dynamic';
import { PlusIcon } from 'lucide-react';
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import { PageBody } from '@kit/ui/page';
import Spinner from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
import { withI18n } from '~/lib/i18n/with-i18n';
const DashboardDemo = loadDynamic(
() => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'),
{

View File

@@ -1,3 +0,0 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

View File

@@ -1,87 +0,0 @@
import { use } from 'react';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import featureFlagsConfig from '~/config/feature-flags.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
TeamAccountDangerZone,
UpdateOrganizationForm,
} from '@kit/team-accounts/components';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
export const metadata = {
title: 'Organization Settings',
};
const allowOrganizationDelete = featureFlagsConfig.enableOrganizationDeletion;
interface Params {
params: {
account: string;
};
}
function OrganizationSettingsPage({ params }: Params) {
const { account, user } = use(loadOrganizationWorkspace(params.account));
return (
<>
<PageHeader
title={<Trans i18nKey={'organization:settingsPageTitle'} />}
description={<Trans i18nKey={'organization:settingsPageDescription'} />}
/>
<PageBody>
<div className={'mx-auto flex max-w-5xl flex-col space-y-4'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'organization:generalTabLabel'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'organization:generalTabLabelSubheading'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateOrganizationForm
accountId={account.id}
accountName={account.name}
/>
</CardContent>
</Card>
<If condition={allowOrganizationDelete}>
<Card className={'border-2 border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'organization:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'organization:dangerZoneSubheading'} />
</CardDescription>
</CardHeader>
<CardContent>
<TeamAccountDangerZone userId={user.id} account={account} />
</CardContent>
</Card>
</If>
</div>
</PageBody>
</>
);
}
export default withI18n(OrganizationSettingsPage);

View File

@@ -1,32 +0,0 @@
'use client';
import { ArrowUpRightIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
export function BillingPortalRedirectButton({
children,
customerId,
className,
}: React.PropsWithChildren<{
customerId: string;
className?: string;
}>) {
return (
<form action={createBillingPortalSessionAction}>
<input type={'hidden'} name={'customerId'} value={customerId} />
<Button
data-test={'manage-billing-redirect-button'}
variant={'outline'}
className={className}
>
<span className={'flex items-center space-x-2'}>
<span>{children}</span>
<ArrowUpRightIcon className={'h-3'} />
</span>
</Button>
</form>
);
}

View File

@@ -1,101 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { ChevronRightIcon } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
export function CheckoutRedirectButton({
children,
onCheckoutCreated,
...props
}): React.PropsWithChildren<{
disabled?: boolean;
stripePriceId?: string;
recommended?: boolean;
organizationUid: string;
onCheckoutCreated?: (clientSecret: string) => void;
}> {
const [state, formAction] = useFormState(createCheckoutAction, {
clientSecret: '',
});
useEffect(() => {
if (state.clientSecret && onCheckoutCreated) {
onCheckoutCreated(state.clientSecret);
}
}, [state.clientSecret, onCheckoutCreated]);
return (
<form data-test={'checkout-form'} action={formAction}>
<CheckoutFormData
organizationUid={props.organizationUid}
priceId={props.stripePriceId}
/>
<SubmitCheckoutButton
disabled={props.disabled}
recommended={props.recommended}
>
{children}
</SubmitCheckoutButton>
</form>
);
}
function SubmitCheckoutButton(
props: React.PropsWithChildren<{
recommended?: boolean;
disabled?: boolean;
}>,
) {
const { pending } = useFormStatus();
return (
<Button
className={cn({
'bg-primary text-primary-foreground dark:bg-white dark:text-gray-900':
props.recommended,
})}
variant={props.recommended ? 'custom' : 'outline'}
disabled={props.disabled ?? pending}
>
<span className={'flex items-center space-x-2'}>
<span>{props.children}</span>
<ChevronRightIcon className={'h-4'} />
</span>
</Button>
);
}
function CheckoutFormData(
props: React.PropsWithChildren<{
organizationUid: string | undefined;
priceId: string | undefined;
}>,
) {
return (
<>
<input
type="hidden"
name={'organizationUid'}
defaultValue={props.organizationUid}
/>
<input type="hidden" name={'returnUrl'} defaultValue={getReturnUrl()} />
<input type="hidden" name={'priceId'} defaultValue={props.priceId} />
</>
);
}
function getReturnUrl() {
return isBrowser()
? [window.location.origin, window.location.pathname].join('')
: undefined;
}

View File

@@ -1,137 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Close as DialogPrimitiveClose } from '@radix-ui/react-dialog';
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { XIcon } from 'lucide-react';
import pricingConfig, {
StripeCheckoutDisplayMode,
} from '@/config/pricing.config';
import { cn } from '@/lib/utils';
import If from '@/components/app/If';
import LogoImage from '@/components/app/Logo/LogoImage';
import Trans from '@/components/app/Trans';
const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_PUBLISHABLE_KEY) {
throw new Error(
'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?',
);
}
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
export default function EmbeddedStripeCheckout({
clientSecret,
onClose,
}: React.PropsWithChildren<{
clientSecret: string;
onClose?: () => void;
}>) {
return (
<EmbeddedCheckoutPopup key={clientSecret} onClose={onClose}>
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ clientSecret }}
>
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
</EmbeddedCheckoutProvider>
</EmbeddedCheckoutPopup>
);
}
function EmbeddedCheckoutPopup({
onClose,
children,
}: React.PropsWithChildren<{
onClose?: () => void;
}>) {
const [open, setOpen] = useState(true);
const displayMode = pricingConfig.displayMode;
const isPopup = displayMode === StripeCheckoutDisplayMode.Popup;
const isOverlay = displayMode === StripeCheckoutDisplayMode.Overlay;
const className = cn({
[`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]:
isPopup,
[`bg-background !flex flex-col flex-1 fixed top-0 !max-h-full !max-w-full left-0 w-screen h-screen border-transparent shadow-transparent py-4 px-8`]:
isOverlay,
});
const close = () => {
setOpen(false);
if (onClose) {
onClose();
}
};
return (
<Dialog
defaultOpen
open={open}
onOpenChange={(open) => {
if (!open && onClose) {
onClose();
}
setOpen(open);
}}
>
<DialogContent
className={className}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<If condition={isOverlay}>
<div className={'mb-8'}>
<div className={'flex items-center justify-between'}>
<LogoImage />
<Button onClick={close} variant={'outline'}>
<Trans i18nKey={'common:cancel'} />
</Button>
</div>
</div>
</If>
<If condition={isPopup}>
<DialogPrimitiveClose asChild>
<Button
size={'icon'}
className={'absolute right-4 top-2 flex items-center'}
aria-label={'Close Checkout'}
onClick={close}
>
<XIcon className={'h-6 text-gray-900'} />
<span className="sr-only">
<Trans i18nKey={'common:cancel'} />
</span>
</Button>
</DialogPrimitiveClose>
</If>
<div
className={cn({
[`flex-1 rounded-xl bg-white p-8`]: isOverlay,
})}
>
{children}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,112 +0,0 @@
'use client';
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import type Organization from '@/lib/organizations/types/organization';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import If from '@/components/app/If';
import PricingTable from '@/components/app/PricingTable';
import Trans from '@/components/app/Trans';
import BillingPortalRedirectButton from './billing-redirect-button';
import CheckoutRedirectButton from './checkout-redirect-button';
const EmbeddedStripeCheckout = dynamic(
() => import('./embedded-stripe-checkout'),
{
ssr: false,
},
);
const PlanSelectionForm: React.FC<{
organization: WithId<Organization>;
customerId: Maybe<string>;
}> = ({ organization, customerId }) => {
const [clientSecret, setClientSecret] = useState<string>();
const [retry, setRetry] = useState(0);
return (
<div className={'flex flex-col space-y-6'}>
<If condition={clientSecret}>
<EmbeddedStripeCheckout clientSecret={clientSecret!} />
</If>
<div className={'flex w-full flex-col justify-center space-y-8'}>
<PricingTable
CheckoutButton={(props) => {
return (
<ErrorBoundary
key={retry}
fallback={
<CheckoutErrorMessage
onRetry={() => setRetry((retry) => retry + 1)}
/>
}
>
<CheckoutRedirectButton
organizationUid={organization.uuid}
stripePriceId={props.stripePriceId}
recommended={props.recommended}
onCheckoutCreated={setClientSecret}
>
<Trans
i18nKey={'subscription:checkout'}
defaults={'Checkout'}
/>
</CheckoutRedirectButton>
</ErrorBoundary>
);
}}
/>
<If condition={customerId}>
<div className={'flex flex-col space-y-2'}>
<BillingPortalRedirectButton customerId={customerId as string}>
<Trans i18nKey={'subscription:manageBilling'} />
</BillingPortalRedirectButton>
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:manageBillingDescription'} />
</span>
</div>
</If>
</div>
</div>
);
};
export default PlanSelectionForm;
function NoPermissionsAlert() {
return (
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'subscription:noPermissionsAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:noPermissionsAlertBody'} />
</AlertDescription>
</Alert>
);
}
function CheckoutErrorMessage({ onRetry }: { onRetry: () => void }) {
return (
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-medium text-red-500'}>
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
</span>
<Button onClick={onRetry} variant={'ghost'}>
<Trans i18nKey={'common:retry'} />
</Button>
</div>
);
}

View File

@@ -1,99 +0,0 @@
'use client';
import React from 'react';
import type { ReadonlyURLSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import Trans from '@/components/app/Trans';
enum SubscriptionStatusQueryParams {
Success = 'success',
Cancel = 'cancel',
Error = 'error',
}
function PlansStatusAlertContainer() {
const status = useSubscriptionStatus();
if (status === undefined) {
return null;
}
return <PlansStatusAlert status={status as SubscriptionStatusQueryParams} />;
}
export default PlansStatusAlertContainer;
function PlansStatusAlert({
status,
}: {
status: SubscriptionStatusQueryParams;
}) {
switch (status) {
case SubscriptionStatusQueryParams.Cancel:
return (
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'subscription:checkOutCanceledAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:checkOutCanceledAlert'} />
</AlertDescription>
</Alert>
);
case SubscriptionStatusQueryParams.Error:
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:unknownErrorAlert'} />
</AlertDescription>
</Alert>
);
case SubscriptionStatusQueryParams.Success:
return (
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'subscription:checkOutCompletedAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:checkOutCompletedAlert'} />
</AlertDescription>
</Alert>
);
}
}
function useSubscriptionStatus() {
const params = useSearchParams();
return getStatus(params);
}
function getStatus(params: ReadonlyURLSearchParams | null) {
if (!params) {
return;
}
const error = params.has(SubscriptionStatusQueryParams.Error);
const canceled = params.has(SubscriptionStatusQueryParams.Cancel);
const success = params.has(SubscriptionStatusQueryParams.Success);
if (canceled) {
return SubscriptionStatusQueryParams.Cancel;
} else if (success) {
return SubscriptionStatusQueryParams.Success;
} else if (error) {
return SubscriptionStatusQueryParams.Error;
}
}

View File

@@ -1,57 +0,0 @@
'use client';
import useCurrentOrganization from '@/lib/organizations/hooks/use-current-organization';
import If from '@/components/app/If';
import Trans from '@/components/app/Trans';
import BillingPortalRedirectButton from './billing-redirect-button';
import PlanSelectionForm from './plan-selection-form';
import SubscriptionCard from './subscription-card';
const PlansContainer: React.FC = () => {
const organization = useCurrentOrganization();
if (!organization) {
return null;
}
const customerId = organization.subscription?.customerId;
const subscription = organization.subscription?.data;
if (!subscription) {
return (
<PlanSelectionForm customerId={customerId} organization={organization} />
);
}
return (
<div className={'flex flex-col space-y-4'}>
<div>
<div
className={'w-full divide-y rounded-xl border lg:w-9/12 xl:w-6/12'}
>
<div className={'p-6'}>
<SubscriptionCard subscription={subscription} />
</div>
<If condition={customerId}>
<div className={'flex justify-end p-6'}>
<div className={'flex flex-col items-end space-y-2'}>
<BillingPortalRedirectButton customerId={customerId as string}>
<Trans i18nKey={'subscription:manageBilling'} />
</BillingPortalRedirectButton>
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:manageBillingDescription'} />
</span>
</div>
</div>
</If>
</div>
</div>
</div>
);
};
export default PlansContainer;

View File

@@ -1,141 +0,0 @@
import React, { useMemo } from 'react';
import Heading from '@/components/ui/heading';
import { CheckCircleIcon, XCircleIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import SubscriptionStatusBadge from '~/(dashboard)/home/[account]/(components)/organizations/SubscriptionStatusBadge';
import pricingConfig from '@/config/pricing.config';
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
import If from '@/components/app/If';
import PricingTable from '@/components/app/PricingTable';
import Trans from '@/components/app/Trans';
import SubscriptionStatusAlert from './subscription-status-alert';
const SubscriptionCard: React.FC<{
subscription: OrganizationSubscription;
}> = ({ subscription }) => {
const details = useSubscriptionDetails(subscription.priceId);
const cancelAtPeriodEnd = subscription.cancelAtPeriodEnd;
const isActive = subscription.status === 'active';
const language = getI18n().language;
const dates = useMemo(() => {
const endDate = new Date(subscription.periodEndsAt);
const trialEndDate =
subscription.trialEndsAt && new Date(subscription.trialEndsAt);
return {
endDate: endDate.toLocaleDateString(language),
trialEndDate: trialEndDate
? trialEndDate.toLocaleDateString(language)
: null,
};
}, [language, subscription]);
if (!details) {
return null;
}
return (
<div
className={'flex space-x-2'}
data-test={'subscription-card'}
data-test-status={subscription.status}
>
<div className={'flex w-9/12 flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<div className={'flex items-center space-x-4'}>
<Heading level={4}>
<span data-test={'subscription-name'}>
{details.product.name}
</span>
</Heading>
<div>
<SubscriptionStatusBadge subscription={subscription} />
</div>
</div>
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
{details.product.description}
</span>
</div>
<If condition={isActive}>
<RenewStatusDescription
dates={dates}
cancelAtPeriodEnd={cancelAtPeriodEnd}
/>
</If>
<SubscriptionStatusAlert subscription={subscription} values={dates} />
</div>
<div className={'w-3/12'}>
<span className={'flex items-center justify-end space-x-1'}>
<PricingTable.Price>{details.plan.price}</PricingTable.Price>
<span className={'lowercase text-gray-500 dark:text-gray-400'}>
/{details.plan.name}
</span>
</span>
</div>
</div>
);
};
function RenewStatusDescription(
props: React.PropsWithChildren<{
cancelAtPeriodEnd: boolean;
dates: {
endDate: string;
trialEndDate: string | null;
};
}>,
) {
return (
<span className={'flex items-center space-x-1.5 text-sm'}>
<If condition={props.cancelAtPeriodEnd}>
<XCircleIcon className={'h-5 text-yellow-700'} />
<span>
<Trans
i18nKey={'subscription:cancelAtPeriodEndDescription'}
values={props.dates}
/>
</span>
</If>
<If condition={!props.cancelAtPeriodEnd}>
<CheckCircleIcon className={'h-5 text-green-700'} />
<span>
<Trans
i18nKey={'subscription:renewAtPeriodEndDescription'}
values={props.dates}
/>
</span>
</If>
</span>
);
}
function useSubscriptionDetails(priceId: string) {
const products = pricingConfig.products;
return useMemo(() => {
for (const product of products) {
for (const plan of product.plans) {
if (plan.stripePriceId === priceId) {
return { plan, product };
}
}
}
}, [products, priceId]);
}
export default SubscriptionCard;

View File

@@ -1,68 +0,0 @@
import classNames from 'clsx';
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
import Trans from '@/components/app/Trans';
function SubscriptionStatusAlert(
props: React.PropsWithChildren<{
subscription: OrganizationSubscription;
values: {
endDate: string;
trialEndDate: string | null;
};
}>,
) {
const status = props.subscription.status;
let message = '';
let type: 'success' | 'error' | 'warn';
switch (status) {
case 'active':
message = 'subscription:status.active.description';
type = 'success';
break;
case 'trialing':
message = 'subscription:status.trialing.description';
type = 'success';
break;
case 'canceled':
message = 'subscription:status.canceled.description';
type = 'warn';
break;
case 'incomplete':
message = 'subscription:status.incomplete.description';
type = 'warn';
break;
case 'incomplete_expired':
message = 'subscription:status.incomplete_expired.description';
type = 'error';
break;
case 'unpaid':
message = 'subscription:status.unpaid.description';
type = 'error';
break;
case 'past_due':
message = 'subscription:status.past_due.description';
type = 'error';
break;
default:
return null;
}
return (
<span
className={classNames('text-sm', {
'text-orange-700 dark:text-gray-400': type === 'warn',
'text-red-700 dark:text-red-400': type === 'error',
'text-green-700 dark:text-green-400': type === 'success',
})}
>
<Trans i18nKey={message} values={props.values} />
</span>
);
}
export default SubscriptionStatusAlert;

View File

@@ -1,34 +0,0 @@
import Heading from '@/components/ui/heading';
import { withI18n } from '@packages/i18n/with-i18n';
import Trans from '@/components/app/Trans';
import PlansStatusAlertContainer from './components/plan-status-alert-container';
import PlansContainer from './components/plans-container';
export const metadata = {
title: 'Subscription',
};
const SubscriptionSettingsPage = () => {
return (
<div className={'flex w-full flex-col space-y-4'}>
<div className={'flex flex-col space-y-1 px-2'}>
<Heading level={4}>
<Trans i18nKey={'common:subscriptionSettingsTabLabel'} />
</Heading>
<span className={'text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:subscriptionTabSubheading'} />
</span>
</div>
<PlansStatusAlertContainer />
<PlansContainer />
</div>
);
};
export default withI18n(SubscriptionSettingsPage);

View File

@@ -4,12 +4,13 @@ import Link from 'next/link';
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
import type { Stripe } from 'stripe';
import pathsConfig from '~/config/paths.config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
/**
* Retrieves the session status for a Stripe checkout session.
* Since we should only arrive here for a successful checkout, we only check

View File

@@ -1,12 +1,12 @@
import { notFound, redirect } from 'next/navigation';
import requireSession from '@/lib/user/require-session';
import { withI18n } from '@packages/i18n/with-i18n';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import createStripeClient from '@kit/stripe/get-stripe';
import requireSession from '@/lib/user/require-session';
import { BillingSessionStatus } from './components/billing-session-status';
import RecoverCheckout from './components/recover-checkout';

View File

@@ -2,11 +2,11 @@
import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector';
const features = {
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,

View File

@@ -2,13 +2,13 @@ import { use } from 'react';
import { cookies } from 'next/headers';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
export function HomeSidebar() {
const collapsed = getSidebarCollapsed();
const accounts = use(loadUserAccounts());

View File

@@ -1,11 +1,11 @@
'use client';
import pathsConfig from '~/config/paths.config';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
import pathsConfig from '~/config/paths.config';
export function ProfileDropdownContainer(props: { collapsed: boolean }) {
const userSession = useUserSession();
const signOut = useSignOut();

View File

@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import Script from 'next/script';
import { allPosts } from 'contentlayer/generated';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';

View File

@@ -1,10 +1,11 @@
import type { Post } from 'contentlayer/generated';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
const PostHeader: React.FC<{
post: Post;
}> = ({ post }) => {

View File

@@ -1,11 +1,12 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
import { If } from '@kit/ui/if';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
type Props = {
post: Post;
preloadImage?: boolean;

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import PostPreview from '~/(marketing)/blog/components/post-preview';
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import appConfig from '~/config/app.config';

View File

@@ -3,13 +3,14 @@
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import pathsConfig from '~/config/paths.config';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
import { Button } from '@kit/ui/button';
import pathsConfig from '~/config/paths.config';
export function SiteHeaderAccountSection() {
const signOut = useSignOut();
const userSession = useUserSession();

View File

@@ -4,15 +4,16 @@ import { notFound } from 'next/navigation';
import { allDocumentationPages } from 'contentlayer/generated';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
import { withI18n } from '~/lib/i18n/with-i18n';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
const getPageBySlug = cache((slug: string) => {
return allDocumentationPages.find((post) => post.resolvedPath === slug);
});

View File

@@ -1,4 +1,5 @@
import { allDocumentationPages } from 'contentlayer/generated';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';

View File

@@ -1,4 +1,5 @@
import { ChevronDownIcon } from 'lucide-react';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../components/site-page-header';

View File

@@ -2,11 +2,12 @@ import Image from 'next/image';
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { withI18n } from '~/lib/i18n/with-i18n';
function Home() {
return (
<div className={'flex flex-col space-y-16'}>

View File

@@ -1,9 +1,9 @@
import { PricingTable } from '@kit/billing/components/pricing-table';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PricingTable } from '@kit/billing/components/pricing-table';
import { SitePageHeader } from '../components/site-page-header';
export const metadata = {

View File

@@ -1,9 +1,9 @@
import { notFound } from 'next/navigation';
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
export function withAdminSession<Args extends unknown[], Response>(
fn: (...params: Args) => Response,
) {

View File

@@ -3,11 +3,11 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { withAdminSession } from '~/admin/lib/actions-utils';
const getClient = () => getSupabaseServerActionClient({ admin: true });
export const deleteOrganizationAction = withAdminSession(

View File

@@ -4,6 +4,8 @@ import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type Organization from '@/lib/organizations/types/organization';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button';
import {
@@ -15,8 +17,6 @@ import {
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import type Organization from '@/lib/organizations/types/organization';
import { deleteOrganizationAction } from '../actions.server';
function DeleteOrganizationModal({

View File

@@ -1,7 +1,7 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import DeleteOrganizationModal from '../components/DeleteOrganizationModal';

View File

@@ -3,6 +3,8 @@
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { DataTable } from '@/components/app/DataTable';
import type Membership from '@/lib/organizations/types/membership';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisVerticalIcon } from 'lucide-react';
@@ -15,10 +17,6 @@ import {
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import type Membership from '@/lib/organizations/types/membership';
import { DataTable } from '@/components/app/DataTable';
import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge';
type Data = {

View File

@@ -2,15 +2,13 @@ import { use } from 'react';
import Link from 'next/link';
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import { ChevronRightIcon } from 'lucide-react';
import AdminHeader from '@packages/admin/components/AdminHeader';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import getPageFromQueryParams from '../../../utils/get-page-from-query-param';
import { getMembershipsByOrganizationUid } from '../../queries';
import OrganizationsMembersTable from './components/OrganizationsMembersTable';

View File

@@ -1,198 +0,0 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import pricingConfig from '@/config/pricing.config';
import { DataTable } from '@/components/app/DataTable';
import SubscriptionStatusBadge from '../../../(app)/[account]/components/organizations/SubscriptionStatusBadge';
import type { getOrganizations } from '../queries';
type Response = Awaited<ReturnType<typeof getOrganizations>>;
type Organizations = Response['organizations'];
const columns: ColumnDef<Organizations[0]>[] = [
{
header: 'ID',
accessorKey: 'id',
id: 'id',
size: 10,
},
{
header: 'UUID',
accessorKey: 'uuid',
id: 'uuid',
size: 200,
},
{
header: 'Name',
accessorKey: 'name',
id: 'name',
},
{
header: 'Subscription',
id: 'subscription',
cell: ({ row }) => {
const priceId = row.original?.subscription?.data?.priceId;
const plan = pricingConfig.products.find((product) => {
return product.plans.some((plan) => plan.stripePriceId === priceId);
});
if (plan) {
const price = plan.plans.find((plan) => plan.stripePriceId === priceId);
if (!price) {
return 'Unknown Price';
}
return `${plan.name} - ${price.name}`;
}
return '-';
},
},
{
header: 'Subscription Status',
id: 'subscription-status',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
if (!subscription) {
return '-';
}
return <SubscriptionStatusBadge subscription={subscription} />;
},
},
{
header: 'Subscription Period',
id: 'subscription-period',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
const i18n = getI18n();
const language = i18n.language ?? 'en';
if (!subscription) {
return '-';
}
const canceled = subscription.cancelAtPeriodEnd;
const date = subscription.periodEndsAt;
const formattedDate = new Date(date).toLocaleDateString(language);
return canceled ? (
<span className={'text-orange-500'}>Stops on {formattedDate}</span>
) : (
<span className={'text-green-500'}>Renews on {formattedDate}</span>
);
},
},
{
header: 'Members',
id: 'members',
cell: ({ row }) => {
const memberships = row.original.memberships.filter((item) => !item.code);
const invites = row.original.memberships.length - memberships.length;
const uid = row.original.uuid;
const length = memberships.length;
return (
<Link
data-test={'organization-members-link'}
href={`organizations/${uid}/members`}
className={'cursor-pointer hover:underline'}
>
{length} member{length === 1 ? '' : 's'}{' '}
{invites ? `(${invites} invites)` : ''}
</Link>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const organization = row.original;
const uid = organization.uuid;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(uid)}
>
Copy UUID
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/organizations/${uid}/members`}>
View Members
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={'text-red-500'}
href={`/admin/organizations/${uid}/delete`}
>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function OrganizationsTable({
organizations,
pageCount,
perPage,
page,
}: React.PropsWithChildren<{
organizations: Organizations;
pageCount: number;
perPage: number;
page: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-organizations-table',
}}
pageSize={perPage}
pageIndex={page - 1}
pageCount={pageCount}
columns={columns}
data={organizations}
/>
);
}
export default OrganizationsTable;

View File

@@ -1,9 +1,9 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { PageBody } from '@/components/app/Page';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
function OrganizationsAdminPageError() {
return (
<PageBody>

View File

@@ -1,3 +1,5 @@
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import AdminGuard from '@/packages/admin/components/AdminGuard';
import AdminHeader from '@/packages/admin/components/AdminHeader';
@@ -5,10 +7,6 @@ import getSupabaseServerComponentClient from '@packages/supabase/server-componen
import { Input } from '@kit/ui/input';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import OrganizationsTable from './components/OrganizationsTable';
import { getOrganizations } from './queries';

View File

@@ -1,7 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/database.types';
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
import type { UserOrganizationData } from '@/lib/organizations/database/queries';
import type MembershipRole from '@/lib/organizations/types/membership-role';

View File

@@ -1,8 +1,7 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminDashboard from '../../packages/admin/components/AdminDashboard';
import AdminGuard from '../../packages/admin/components/AdminGuard';

View File

@@ -3,11 +3,11 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { withAdminSession } from '~/admin/lib/actions-utils';
const getClient = () => getSupabaseServerActionClient({ admin: true });
export const banUser = withAdminSession(async ({ userId }) => {

View File

@@ -8,6 +8,8 @@ import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
@@ -20,8 +22,6 @@ import {
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import { banUser } from '../actions.server';
function BanUserModal({

View File

@@ -4,10 +4,10 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import useSupabase from '@kit/hooks/use-supabase';
import Spinner from '@/components/app/Spinner';
import useSupabase from '@kit/hooks/use-supabase';
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{

View File

@@ -6,6 +6,9 @@ import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import If from '@/components/app/If';
import LoadingOverlay from '@/components/app/LoadingOverlay';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
@@ -16,9 +19,6 @@ import {
DialogTitle,
} from '@kit/ui/dialog';
import If from '@/components/app/If';
import LoadingOverlay from '@/components/app/LoadingOverlay';
import { impersonateUser } from '../actions.server';
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import If from '@/components/app/If';
import { EllipsisVerticalIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
@@ -12,8 +13,6 @@ import {
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import If from '@/components/app/If';
function UserActionsDropdown({
uid,
isBanned,

View File

@@ -1,5 +1,8 @@
import Link from 'next/link';
import { PageBody } from '@/components/app/Page';
import configuration from '@/config/app.config';
import type MembershipRole from '@/lib/organizations/types/membership-role';
import { ChevronRightIcon } from 'lucide-react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
@@ -17,12 +20,6 @@ import {
TableRow,
} from '@kit/ui/table';
import configuration from '@/config/app.config';
import type MembershipRole from '@/lib/organizations/types/membership-role';
import { PageBody } from '@/components/app/Page';
import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge';
import AdminGuard from '../../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../../packages/admin/components/AdminHeader';

View File

@@ -1,239 +0,0 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import type UserData from '@kit/session/types/user-data';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kit/ui/tooltip';
import { DataTable } from '@/components/app/DataTable';
import If from '@/components/app/If';
type UserRow = {
id: string;
email: string | undefined;
phone: string | undefined;
createdAt: string;
updatedAt: string | undefined;
lastSignInAt: string | undefined;
banDuration: string | undefined;
data: UserData;
};
const columns: ColumnDef<UserRow>[] = [
{
header: '',
id: 'avatar',
size: 10,
cell: ({ row }) => {
const user = row.original;
const data = user.data;
const displayName = data?.displayName;
const photoUrl = data?.photoUrl;
const displayText = displayName ?? user.email ?? user.phone ?? '';
return (
<Tooltip>
<TooltipTrigger>
<Avatar>
{photoUrl ? <AvatarImage src={photoUrl} /> : null}
<AvatarFallback>{displayText[0]}</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{displayText}</TooltipContent>
</Tooltip>
);
},
},
{
header: 'ID',
id: 'id',
size: 30,
cell: ({ row }) => {
const id = row.original.id;
return (
<Link className={'hover:underline'} href={`/admin/users/${id}`}>
{id}
</Link>
);
},
},
{
header: 'Email',
id: 'email',
cell: ({ row }) => {
const email = row.original.email;
return (
<span title={email} className={'block max-w-full truncate'}>
{email}
</span>
);
},
},
{
header: 'Name',
size: 50,
id: 'displayName',
cell: ({ row }) => {
return row.original.data?.displayName ?? '';
},
},
{
header: 'Created at',
id: 'createdAt',
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
const i18n = getI18n();
const language = i18n.language ?? 'en';
const createdAtLabel = date.toLocaleDateString(language);
return <span>{createdAtLabel}</span>;
},
},
{
header: 'Last sign in',
id: 'lastSignInAt',
cell: ({ row }) => {
const lastSignInAt = row.original.lastSignInAt;
if (!lastSignInAt) {
return <span>-</span>;
}
const date = new Date(lastSignInAt);
return <span suppressHydrationWarning>{date.toLocaleString()}</span>;
},
},
{
header: 'Status',
id: 'status',
cell: ({ row }) => {
const banDuration = row.original.banDuration;
if (!banDuration || banDuration === 'none') {
return (
<Badge className={'inline-flex'} color={'success'}>
Active
</Badge>
);
}
return (
<Badge className={'inline-flex'} color={'error'}>
Banned
</Badge>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const user = row.original;
const banDuration = row.original.banDuration;
const isBanned = banDuration && banDuration !== 'none';
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(user.id)}
>
Copy user ID
</DropdownMenuItem>
<If condition={!isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/impersonate`}>
Impersonate User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/5'
}
href={`/admin/users/${user.id}/ban`}
>
Ban User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/5'
}
href={`/admin/users/${user.id}/delete`}
>
Delete User
</Link>
</DropdownMenuItem>
</If>
<If condition={isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/reactivate`}>
Reactivate User
</Link>
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function UsersTable({
users,
page,
pageCount,
perPage,
}: React.PropsWithChildren<{
users: UserRow[];
pageCount: number;
page: number;
perPage: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-users-table',
}}
pageIndex={page - 1}
pageSize={perPage}
pageCount={pageCount}
data={users}
columns={columns}
/>
);
}
export default UsersTable;

View File

@@ -1,9 +1,9 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { PageBody } from '@/components/app/Page';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
function UsersAdminPageError() {
return (
<PageBody>

View File

@@ -1,15 +1,13 @@
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import type UserData from '@kit/session/types/user-data';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import AdminGuard from '../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../packages/admin/components/AdminHeader';
import getPageFromQueryParams from '../utils/get-page-from-query-param';
import UsersTable from './components/UsersTable';
import { getUsers } from './queries';
interface UsersAdminPageProps {
@@ -32,14 +30,7 @@ async function UsersAdminPage({ searchParams }: UsersAdminPageProps) {
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Users</AdminHeader>
<PageBody>
<UsersTable
users={users}
page={page}
pageCount={pageCount}
perPage={perPage}
/>
</PageBody>
<PageBody></PageBody>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { USERS_TABLE } from '@/lib/db-tables';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
export async function getUsers(ids: string[]) {
const client = getSupabaseServerComponentClient({ admin: true });

View File

@@ -0,0 +1,50 @@
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { Logger } from '@kit/shared/logger';
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
import billingConfig from '~/config/billing.config';
/**
* @description Handle the webhooks from Stripe related to checkouts
*/
export async function POST(request: Request) {
const client = getSupabaseRouteHandlerClient();
// we can infer the provider from the billing config or the request
// for simplicity, we'll use the billing config for now
// TODO: use dynamic provider from request?
const provider = billingConfig.provider;
Logger.info(
{
name: 'billing',
provider,
},
`Received billing webhook. Processing...`,
);
const service = await getBillingEventHandlerService(client, provider);
try {
await service.handleWebhookEvent(request);
Logger.info(
{
name: 'billing',
},
`Successfully processed billing webhook`,
);
return new Response('OK', { status: 200 });
} catch (e) {
Logger.error(
{
name: 'billing',
error: e,
},
`Failed to process billing webhook`,
);
return new Response('Error', { status: 500 });
}
}

View File

@@ -1,167 +0,0 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Stripe } from 'stripe';
import getSupabaseRouteHandlerClient from '@packages/supabase/route-handler-client';
import { Logger } from '@kit/logger';
import createStripeClient from '@kit/stripe/get-stripe';
import StripeWebhooks from '@kit/stripe/stripe-webhooks.enum';
import { setOrganizationSubscriptionData } from '@/lib/organizations/database/mutations';
import {
addSubscription,
deleteSubscription,
updateSubscriptionById,
} from '@/lib/subscriptions/mutations';
const STRIPE_SIGNATURE_HEADER = 'stripe-signature';
const webhookSecretKey = process.env.STRIPE_WEBHOOK_SECRET!;
const logName = 'stripe-webhook';
/**
* @description Handle the webhooks from Stripe related to checkouts
*/
export async function POST(request: Request) {
const signature = headers().get(STRIPE_SIGNATURE_HEADER);
Logger.info(`[Stripe] Received Stripe Webhook`);
if (!webhookSecretKey) {
Logger.error(
{
name: logName,
},
`The variable STRIPE_WEBHOOK_SECRET is unset. Please add the STRIPE_WEBHOOK_SECRET environment variable`,
);
return new Response(null, {
status: 500,
});
}
// verify signature header is not missing
if (!signature) {
return new Response('Invalid signature', {
status: 400,
});
}
const rawBody = await request.text();
const stripe = await createStripeClient();
// create an Admin client to write to the subscriptions table
const client = getSupabaseRouteHandlerClient({
admin: true,
});
try {
// build the event from the raw body and signature using Stripe
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecretKey,
);
Logger.info(
{
name: logName,
type: event.type,
},
`Processing Stripe Webhook...`,
);
switch (event.type) {
case StripeWebhooks.Completed: {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = session.subscription as string;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
await onCheckoutCompleted(client, session, subscription);
break;
}
case StripeWebhooks.SubscriptionDeleted: {
const subscription = event.data.object as Stripe.Subscription;
await deleteSubscription(client, subscription.id);
break;
}
case StripeWebhooks.SubscriptionUpdated: {
const subscription = event.data.object as Stripe.Subscription;
await updateSubscriptionById(client, subscription);
break;
}
}
return NextResponse.json({ success: true });
} catch (error) {
Logger.error(
{
error,
name: logName,
},
`Webhook handling failed`,
);
return new Response(null, {
status: 500,
});
}
}
/**
* @description When the checkout is completed, we store the order. The
* subscription is only activated if the order was paid successfully.
* Otherwise, we have to wait for a further webhook
*/
async function onCheckoutCompleted(
client: SupabaseClient,
session: Stripe.Checkout.Session,
subscription: Stripe.Subscription,
) {
const organizationUid = getOrganizationUidFromClientReference(session);
const customerId = session.customer as string;
// build organization subscription and set on the organization document
// we add just enough data in the DB, so we do not query
// Stripe for every bit of data
// if you need your DB record to contain further data
// add it to {@link buildOrganizationSubscription}
const { error, data } = await addSubscription(client, subscription);
if (error) {
return Promise.reject(
`Failed to add subscription to the database: ${error}`,
);
}
return setOrganizationSubscriptionData(client, {
organizationUid,
customerId,
subscriptionId: data.id,
});
}
/**
* @name getOrganizationUidFromClientReference
* @description Get the organization UUID from the client reference ID
* @param session
*/
function getOrganizationUidFromClientReference(
session: Stripe.Checkout.Session,
) {
return session.client_reference_id!;
}

View File

@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import pathsConfig from '~/config/paths.config';
import { Logger } from '@kit/shared/logger';
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
import pathsConfig from '~/config/paths.config';
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams;

View File

@@ -1,7 +1,7 @@
import { AppLogo } from '~/components/app-logo';
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
}

View File

@@ -1,14 +1,14 @@
import Link from 'next/link';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();

View File

@@ -1,15 +1,15 @@
import Link from 'next/link';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();

View File

@@ -1,15 +1,15 @@
import Link from 'next/link';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();

View File

@@ -1,13 +1,13 @@
import { redirect } from 'next/navigation';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();

View File

@@ -3,12 +3,13 @@
import Link from 'next/link';
import { ArrowLeftIcon } from 'lucide-react';
import { SiteHeader } from '~/(marketing)/components/site-header';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/components/site-header';
const ErrorPage = () => {
return (
<div className={'flex h-screen flex-1 flex-col'}>

View File

@@ -2,8 +2,6 @@
import { useCallback, useState, useTransition } from 'react';
import authConfig from '~/config/auth.config';
import { EmailOtpContainer } from '@kit/auth/src/components/email-otp-container';
import { OauthProviders } from '@kit/auth/src/components/oauth-providers';
import { PasswordSignInContainer } from '@kit/auth/src/components/password-sign-in-container';
@@ -14,6 +12,8 @@ import { If } from '@kit/ui/if';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
enum Mode {
SignUp,
SignIn,

View File

@@ -1,7 +1,7 @@
import { AppLogo } from '~/components/app-logo';
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
function InvitePageLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
}

View File

@@ -1,13 +1,13 @@
import { Inter as SansFont } from 'next/font/google';
import { cookies } from 'next/headers';
import { Toaster } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils';
import { RootProviders } from '~/components/root-providers';
import appConfig from '~/config/app.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { Toaster } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils';
import '../styles/globals.css';
const sans = SansFont({
@@ -51,7 +51,7 @@ function getClassName() {
export const metadata = {
title: appConfig.name,
description: appConfig.description,
metadataBase: new URL(appConfig.url!),
metadataBase: new URL(appConfig.url),
openGraph: {
url: appConfig.url,
siteName: appConfig.name,

View File

@@ -1,14 +1,15 @@
import Link from 'next/link';
import { ArrowLeftIcon } from '@radix-ui/react-icons';
import { SiteHeader } from '~/(marketing)/components/site-header';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/components/site-header';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
export const metadata = {
title: `Page not found - ${appConfig.name}`,
};

View File

@@ -1,10 +1,9 @@
import appConfig from '@/config/app.config';
import { invariant } from '@epic-web/invariant';
import { allDocumentationPages, allPosts } from 'contentlayer/generated';
import { getServerSideSitemap } from 'next-sitemap';
import { join } from 'path';
import appConfig from '@/config/app.config';
const siteUrl = appConfig.url;
export async function GET() {

View File

@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { AuthLayoutShell } from '@kit/auth/shared';
import PasswordResetForm from '@kit/auth/src/components/password-reset-form';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
async function PasswordResetPage() {
const client = getSupabaseServerComponentClient();
const user = await client.auth.getUser();