feat: billing

This commit is contained in:
David Nguyen
2025-05-19 12:38:50 +10:00
parent 7abfc9e271
commit 2805478e0d
221 changed files with 8436 additions and 5847 deletions

View File

@ -3,18 +3,14 @@ import { Trans } from '@lingui/react/macro';
import { Link, Outlet, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getLimits } from '@documenso/ee/server-only/limits/client';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { Button } from '@documenso/ui/primitives/button';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import { OrganisationProvider } from '~/providers/organisation';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
@ -35,23 +31,23 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/signin');
}
const [limits, banner] = await Promise.all([
getLimits({ headers: requestHeaders }),
getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
),
]);
// const [limits, banner] = await Promise.all([
// getLimits({ headers: requestHeaders }),
// getSiteSettings().then((settings) =>
// settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
// ),
// ]);
return {
banner,
limits,
// banner,
// limits,
};
}
export default function Layout({ loaderData, params }: Route.ComponentProps) {
const { user, organisations } = useSession();
const { banner, limits } = loaderData;
// const { banner, limits } = loaderData;
const teamUrl = params.teamUrl;
const orgUrl = params.orgUrl;
@ -140,12 +136,12 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
return (
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<LimitsProvider initialValue={limits}>
<LimitsProvider>
<div id="portal-header"></div>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}
{/* {banner && <AppBanner banner={banner} />} */}
<Header />

View File

@ -1,5 +1,13 @@
import { Trans } from '@lingui/react/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import {
BarChart3,
Building2Icon,
FileStack,
Settings,
Trophy,
Users,
Wallet2,
} from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
@ -21,8 +29,12 @@ export default function AdminLayout() {
const { pathname } = useLocation();
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 md:mt-8 md:gap-8">
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Admin Panel</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<div
className={cn(
'col-span-12 flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:col-span-3 md:flex md:flex-col',
@ -42,6 +54,34 @@ export default function AdminLayout() {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/organisations') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/organisations">
<Building2Icon className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/claims') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/claims">
<Wallet2 className="mr-2 h-5 w-5" />
<Trans>Claims</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
@ -70,20 +110,6 @@ export default function AdminLayout() {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
<Trans>Subscriptions</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(

View File

@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminClaimsTable } from '~/components/tables/admin-claims-table';
export default function Claims() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
title={t`Subscription Claims`}
subtitle={t`Manage all subscription claims`}
hideDivider
>
<ClaimCreateDialog />
</SettingsHeader>
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by claim ID or name`}
className="mb-4"
/>
<AdminClaimsTable />
</div>
</div>
);
}

View File

@ -0,0 +1,571 @@
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router';
import type { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
import { AppError } from '@documenso/lib/errors/app-error';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/organisations.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const organisationId = params.id;
const { data: organisation, isLoading: isLoadingOrganisation } =
trpc.admin.organisation.get.useQuery({
organisationId,
});
const { mutateAsync: createStripeCustomer, isPending: isCreatingStripeCustomer } =
trpc.admin.stripe.createCustomer.useMutation({
onSuccess: async () => {
await navigate(0);
toast({
title: t`Success`,
description: t`Stripe customer created successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't create a Stripe customer. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => {
return [
{
header: t`Team`,
accessorKey: 'name',
},
{
header: t`Team url`,
accessorKey: 'url',
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['teams'][number]>[];
}, []);
const organisationMembersColumns = useMemo(() => {
return [
{
header: t`Member`,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.name}</Link>
{row.original.user.id === organisation?.ownerUserId && <Badge>Owner</Badge>}
</div>
),
},
{
header: t`Email`,
cell: ({ row }) => (
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
),
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);
if (isLoadingOrganisation) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (!organisation) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/admin/organisations`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div>
<SettingsHeader
title={t`Manage organisation`}
subtitle={t`Manage the ${organisation.name} organisation`}
/>
<GenericOrganisationAdminForm organisation={organisation} />
<SettingsHeader
title={t`Manage subscription`}
subtitle={t`Manage the ${organisation.name} organisation subscription`}
className="mt-16"
/>
<Alert
className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
{organisation.subscription ? (
<span>
{SUBSCRIPTION_STATUS_MAP[organisation.subscription.status]} subscription found
</span>
) : (
<span>No subscription found</span>
)}
</AlertDescription>
</div>
{!organisation.customerId && (
<div>
<Button
variant="outline"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
<Trans>Create Stripe customer</Trans>
</Button>
</div>
)}
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
>
<Trans>Create subscription</Trans>
<ExternalLinkIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)}
{organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
>
<Trans>Manage subscription</Trans>
<ExternalLinkIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)}
</Alert>
<OrganisationAdminForm organisation={organisation} />
<div className="mt-16 space-y-10">
<div>
<label className="text-sm font-medium leading-none">
<Trans>Organisation Members</Trans>
</label>
<div className="my-2">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<label className="text-sm font-medium leading-none">
<Trans>Organisation Teams</Trans>
</label>
<div className="my-2">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
</div>
</div>
);
}
const ZUpdateGenericOrganisationDataFormSchema =
ZUpdateAdminOrganisationRequestSchema.shape.data.pick({
name: true,
url: true,
});
type TUpdateGenericOrganisationDataFormSchema = z.infer<
typeof ZUpdateGenericOrganisationDataFormSchema
>;
type OrganisationAdminFormOptions = {
organisation: TGetAdminOrganisationResponse;
};
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
const { toast } = useToast();
const { t } = useLingui();
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
const form = useForm<TUpdateGenericOrganisationDataFormSchema>({
resolver: zodResolver(ZUpdateGenericOrganisationDataFormSchema),
defaultValues: {
name: organisation.name,
url: organisation.url,
},
});
const onSubmit = async (data: TUpdateGenericOrganisationDataFormSchema) => {
try {
await updateOrganisation({
organisationId: organisation.id,
data,
});
toast({
title: t`Success`,
description: t`Organisation has been updated successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: t`We couldn't update the organisation. Please try again.`,
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation URL</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/org/${field.value}`
) : (
<Trans>A unique URL to identify the organisation</Trans>
)}
</span>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</form>
</Form>
);
};
const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSchema.shape.data.pick({
claims: true,
customerId: true,
originalSubscriptionClaimId: true,
});
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
const { toast } = useToast();
const { t } = useLingui();
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
const form = useForm<TUpdateOrganisationBillingFormSchema>({
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
defaultValues: {
customerId: organisation.customerId || '',
claims: {
teamCount: organisation.organisationClaim.teamCount,
memberCount: organisation.organisationClaim.memberCount,
flags: organisation.organisationClaim.flags,
},
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
},
});
const onSubmit = async (values: TUpdateOrganisationBillingFormSchema) => {
try {
await updateOrganisation({
organisationId: organisation.id,
data: values,
});
toast({
title: t`Success`,
description: t`Organisation has been updated successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: t`We couldn't update the organisation. Please try again.`,
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="originalSubscriptionClaimId"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Inherited subscription claim</Trans>
</strong>
</h2>
<p>
<Trans>
This is the claim that this organisation was initially created with. Any
feature flag changes to this claim will be backported into this
organisation.
</Trans>
</p>
<p>
<Trans>
For example, if the claim has a new flag "FLAG_1" set to true, then this
organisation will get that flag added.
</Trans>
</p>
<p>
<Trans>
This will ONLY backport feature flags which are set to true, anything
disabled in the initial claim will not be backported
</Trans>
</p>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Stripe Customer ID</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={t`No Stripe customer attached`} />
</FormControl>
{!form.formState.errors.customerId && field.value && (
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${field.value}`}
className="text-foreground/50 text-xs font-normal"
>
{`https://dashboard.stripe.com/customers/${field.value}`}
</Link>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`claims.flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
</div>
</div>
<div className="flex justify-end">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</form>
</Form>
);
};

View File

@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
export default function Organisations() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
hideDivider
title={t`Manage organisations`}
subtitle={t`Search and manage all organisations`}
/>
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
/>
<AdminOrganisationsTable />
</div>
</div>
);
}

View File

@ -18,9 +18,8 @@ import {
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUserWithSignedDocumentMonthlyGrowth,
getOrganisationsWithSubscriptionsCount,
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
@ -34,31 +33,31 @@ import type { Route } from './+types/stats';
export async function loader() {
const [
usersCount,
usersWithSubscriptionsCount,
organisationsWithSubscriptionsCount,
docStats,
recipientStats,
signerConversionMonthly,
// userWithAtLeastOneDocumentPerMonth,
// userWithAtLeastOneDocumentSignedPerMonth,
MONTHLY_USERS_SIGNED,
// MONTHLY_USERS_SIGNED,
] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getOrganisationsWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
getSignerConversionMonthly(),
// getUserWithAtLeastOneDocumentPerMonth(),
// getUserWithAtLeastOneDocumentSignedPerMonth(),
getUserWithSignedDocumentMonthlyGrowth(),
// getUserWithSignedDocumentMonthlyGrowth(),
]);
return {
usersCount,
usersWithSubscriptionsCount,
organisationsWithSubscriptionsCount,
docStats,
recipientStats,
signerConversionMonthly,
MONTHLY_USERS_SIGNED,
// MONTHLY_USERS_SIGNED,
};
}
@ -67,11 +66,11 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
const {
usersCount,
usersWithSubscriptionsCount,
organisationsWithSubscriptionsCount,
docStats,
recipientStats,
signerConversionMonthly,
MONTHLY_USERS_SIGNED,
// MONTHLY_USERS_SIGNED,
} = loaderData;
return (
@ -86,7 +85,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
<CardMetric
icon={UserPlus}
title={_(msg`Active Subscriptions`)}
value={usersWithSubscriptionsCount}
value={organisationsWithSubscriptionsCount}
/>
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
@ -149,12 +148,14 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
</h3>
<div className="mt-5 grid grid-cols-2 gap-8">
<AdminStatsUsersWithDocumentsChart
data={MONTHLY_USERS_SIGNED}
data={[]}
// data={MONTHLY_USERS_SIGNED}
title={_(msg`MAU (created document)`)}
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
/>
<AdminStatsUsersWithDocumentsChart
data={MONTHLY_USERS_SIGNED}
data={[]}
// data={MONTHLY_USERS_SIGNED}
completed
title={_(msg`MAU (had document completed)`)}
tooltip={_(

View File

@ -1,84 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import type { Route } from './+types/subscriptions';
export async function loader() {
const subscriptions = await findSubscriptions();
return { subscriptions };
}
export default function Subscriptions({ loaderData }: Route.ComponentProps) {
const { subscriptions } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage subscriptions</Trans>
</h2>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>
<Trans>Status</Trans>
</TableHead>
<TableHead>
<Trans>Created At</Trans>
</TableHead>
<TableHead>
<Trans>Ends On</Trans>
</TableHead>
<TableHead>
<Trans>User ID</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((subscription, index) => (
<TableRow key={index}>
<TableCell>{subscription.id}</TableCell>
<TableCell>{subscription.status}</TableCell>
<TableCell>
{subscription.createdAt
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
{subscription.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
<Link to={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -1,7 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
@ -15,24 +13,20 @@ export async function loader({ request }: Route.LoaderArgs) {
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const [{ users, totalPages }, individualPrices] = await Promise.all([
const [{ users, totalPages }] = await Promise.all([
findUsers({ username: search, email: search, page, perPage }),
getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return {
users,
totalPages,
individualPriceIds,
page,
perPage,
};
}
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
const { users, totalPages, individualPriceIds, page, perPage } = loaderData;
const { users, totalPages, page, perPage } = loaderData;
return (
<div>
@ -42,7 +36,6 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp
<AdminDashboardUsersTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}

View File

@ -15,6 +15,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ScrollArea, ScrollBar } from '@documenso/ui/primitives/scroll-area';
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
import { InboxTable } from '~/components/tables/inbox-table';
import { appMetaTags } from '~/utils/meta';
@ -51,6 +52,8 @@ export default function DashboardPage() {
<p className="text-muted-foreground mt-1">
<Trans>Welcome back! Here's an overview of your account.</Trans>
</p>
<OrganisationInvitations className="mt-4" />
</div>
{/* Organisations Section */}

View File

@ -11,6 +11,7 @@ import {
} from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
@ -29,7 +30,6 @@ import {
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { useCurrentOrganisation } from '~/providers/organisation';
export default function OrganisationSettingsTeamsPage() {
const { t, i18n } = useLingui();

View File

@ -3,12 +3,13 @@ import { Outlet } from 'react-router';
import type { Route } from './+types/_layout';
export default function Layout({ params }: Route.ComponentProps) {
// Todo: orgs
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
{/* {currentOrganisation.subscription &&
currentOrganisation.subscription.status !== SubscriptionStatus.ACTIVE && (
<PortalComponent target="portal-header">
<TeamLayoutBillingBanner
<OrganisationBillingBanner
subscriptionStatus={currentOrganisation.subscription.status}
teamId={currentOrganisation.id}
userRole={currentOrganisation.currentTeamMember.role}

View File

@ -4,13 +4,13 @@ import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } f
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentOrganisation } from '~/providers/organisation';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@ -75,7 +75,7 @@ export default function SettingsLayout() {
</Button>
}
secondaryButton={null}
></GenericErrorLayout>
/>
);
}

View File

@ -1,5 +1,16 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { BillingPlans } from '~/components/general/billing-plans';
import { OrganisationBillingPortalButton } from '~/components/general/organisations/organisation-billing-portal-button';
import { OrganisationBillingInvoicesTable } from '~/components/tables/organisation-billing-invoices-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@ -7,6 +18,36 @@ export function meta() {
}
export default function TeamsSettingBillingPage() {
const { _, i18n } = useLingui();
const organisation = useCurrentOrganisation();
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.billing.subscription.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingSubscription || !subscriptionQuery) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
const { subscription, plans } = subscriptionQuery;
const canManageBilling = canExecuteOrganisationAction(
'MANAGE_BILLING',
organisation.currentOrganisationRole,
);
const { organisationSubscription, stripeSubscription } = subscription || {};
const currentProductName =
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(stripeSubscription?.items.data[0].price.product as Stripe.Product | undefined)?.name;
return (
<div>
<div className="flex flex-row items-end justify-between">
@ -16,120 +57,91 @@ export default function TeamsSettingBillingPage() {
</h3>
<div className="text-muted-foreground mt-2 text-sm">
<Trans>Billing has been moved to organisations</Trans>
{!organisationSubscription && (
<p>
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{organisationSubscription &&
match(organisationSubscription.status)
.with('ACTIVE', () => (
<p>
{currentProductName ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{currentProductName}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{organisationSubscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{organisationSubscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">
{i18n.date(organisationSubscription.periodEnd)}.
</span>
</span>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">
{i18n.date(organisationSubscription.periodEnd)}.
</span>
</span>
)}
</span>
)}
</p>
))
.with('INACTIVE', () => (
<p>
{currentProductName ? (
<Trans>
You currently have an inactive{' '}
<span className="font-semibold">{currentProductName}</span> subscription
</Trans>
) : (
<Trans>Your current plan is inactive.</Trans>
)}
</p>
))
.with('PAST_DUE', () => (
<p>
{currentProductName ? (
<Trans>
Your current {currentProductName} plan is past due. Please update your
payment information.
</Trans>
) : (
<Trans>Your current plan is past due.</Trans>
)}
</p>
))
.otherwise(() => null)}
</div>
</div>
<OrganisationBillingPortalButton />
</div>
<hr className="my-4" />
{!subscription && canManageBilling && <BillingPlans plans={plans} />}
<section className="mt-6">
<OrganisationBillingInvoicesTable
organisationId={organisation.id}
subscriptionExists={Boolean(subscription)}
/>
</section>
</div>
);
}
// import { msg } from '@lingui/core/macro';
// import { useLingui } from '@lingui/react';
// import { Plural, Trans } from '@lingui/react/macro';
// import { DateTime } from 'luxon';
// import type Stripe from 'stripe';
// import { match } from 'ts-pattern';
// import { getSession } from '@documenso/auth/server/lib/utils/get-session';
// import { stripe } from '@documenso/lib/server-only/stripe';
// import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
// import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
// import { Card, CardContent } from '@documenso/ui/primitives/card';
// import { SettingsHeader } from '~/components/general/settings-header';
// import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
// import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
// import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
// import type { Route } from './+types/settings.billing';
// export async function loader({ request, params }: Route.LoaderArgs) {
// const session = await getSession(request);
// const team = await getTeamByUrl({
// userId: session.user.id,
// teamUrl: params.teamUrl,
// });
// let teamSubscription: Stripe.Subscription | null = null;
// if (team.subscription) {
// teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
// }
// return superLoaderJson({
// team,
// teamSubscription,
// });
// }
// export default function TeamsSettingBillingPage() {
// const { _ } = useLingui();
// const { team, teamSubscription } = useSuperLoaderData<typeof loader>();
// const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
// const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
// if (!subscription) {
// return <Trans>No payment required</Trans>;
// }
// const numberOfSeats = subscription.items.data[0].quantity ?? 0;
// const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
// 'LLL dd, yyyy',
// );
// const subscriptionInterval = match(subscription?.items.data[0].plan.interval)
// .with('year', () => _(msg`Yearly`))
// .with('month', () => _(msg`Monthly`))
// .otherwise(() => _(msg`Unknown`));
// return (
// <span>
// <Plural value={numberOfSeats} one="# member" other="# members" />
// {' • '}
// <span>{subscriptionInterval}</span>
// {' • '}
// <Trans>Renews: {formattedDate}</Trans>
// </span>
// );
// };
// return (
// <div>
// <SettingsHeader
// title={_(msg`Billing`)}
// subtitle={_(msg`Your subscription is currently active.`)}
// />
// <Card gradient className="shadow-sm">
// <CardContent className="flex flex-row items-center justify-between p-4">
// <div className="flex flex-col text-sm">
// <p className="text-foreground font-semibold">
// {formatTeamSubscriptionDetails(teamSubscription)}
// </p>
// </div>
// {teamSubscription && (
// <div
// title={
// canManageBilling
// ? _(msg`Manage team subscription.`)
// : _(msg`You must be an admin of this team to manage billing.`)
// }
// >
// <TeamBillingPortalButton teamId={team.id} />
// </div>
// )}
// </CardContent>
// </Card>
// <section className="mt-6">
// <TeamSettingsBillingInvoicesTable teamId={team.id} />
// </section>
// </div>
// );
// }

View File

@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -9,7 +10,6 @@ import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-dele
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentOrganisation } from '~/providers/organisation';
import { appMetaTags } from '~/utils/meta';
export function meta() {

View File

@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import {
ORGANISATION_MEMBER_ROLE_HIERARCHY,
ORGANISATION_MEMBER_ROLE_MAP,
@ -44,7 +45,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentOrganisation } from '~/providers/organisation';
import type { Route } from './+types/org.$orgUrl.settings.groups.$id';

View File

@ -2,6 +2,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
@ -19,7 +20,6 @@ import {
type TDocumentPreferencesFormSchema,
} from '~/components/forms/document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentOrganisation } from '~/providers/organisation';
import type { Route } from './+types/org.$orgUrl.settings.preferences';

View File

@ -1,18 +1,14 @@
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Link, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { useLocation } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { OrganisationPendingTeamsTable } from '~/components/tables/organisation-pending-teams-table';
import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-table';
export default function OrganisationSettingsTeamsPage() {
@ -25,15 +21,6 @@ export default function OrganisationSettingsTeamsPage() {
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active';
const { data } = trpc.team.findTeamsPending.useQuery(
{},
{
placeholderData: (previousData) => previousData,
},
);
/**
* Handle debouncing the search query.
*/
@ -55,36 +42,14 @@ export default function OrganisationSettingsTeamsPage() {
<TeamCreateDialog />
</SettingsHeader>
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
/>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="active" asChild>
<Link to={pathname ?? '/'}>
<Trans>Active</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="pending" asChild>
<Link to={`${pathname}?tab=pending`}>
<Trans>Pending</Trans>
{data && data.count > 0 && (
<span className="ml-1 hidden opacity-50 md:inline-block">{data.count}</span>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{currentTab === 'pending' ? <OrganisationPendingTeamsTable /> : <OrganisationTeamsTable />}
</div>
<OrganisationTeamsTable />
</div>
);
}

View File

@ -1,25 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Billing');
}
export default function TeamsSettingBillingPage() {
return (
<div>
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
<Trans>Billing has been moved to organisations</Trans>
</div>
</div>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { useLingui } from '@lingui/react';
import { OrganisationCreateDialog } from '~/components/dialogs/organisation-create-dialog';
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
import { SettingsHeader } from '~/components/general/settings-header';
import { UserSettingsOrganisationsTable } from '~/components/tables/user-settings-organisations-table';
import { UserOrganisationsTable } from '~/components/tables/user-organisations-table';
export default function TeamsSettingsPage() {
const { _ } = useLingui();
@ -18,7 +18,7 @@ export default function TeamsSettingsPage() {
<OrganisationCreateDialog />
</SettingsHeader>
<UserSettingsOrganisationsTable />
<UserOrganisationsTable />
<div className="mt-8 space-y-8">
<OrganisationInvitations />

View File

@ -5,7 +5,6 @@ import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
@ -78,20 +77,14 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(`${documentRootPath}/${documentId}`);
}
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return superLoaderJson({
document,
documentRootPath,
isDocumentEnterprise,
});
}
export default function DocumentEditPage() {
const { document, documentRootPath, isDocumentEnterprise } = useSuperLoaderData<typeof loader>();
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
const { recipients } = document;
@ -133,7 +126,6 @@ export default function DocumentEditPage() {
className="mt-6"
initialDocument={document}
documentRootPath={documentRootPath}
isDocumentEnterprise={isDocumentEnterprise}
/>
</div>
);

View File

@ -11,6 +11,7 @@ import {
import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -30,7 +31,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamGroupsTable } from '~/components/tables/team-groups-table';
import { useCurrentOrganisation } from '~/providers/organisation';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsGroupsPage() {

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
@ -9,10 +8,10 @@ import { Input } from '@documenso/ui/primitives/input';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamMembersDataTable } from '~/components/tables/team-members-table';
import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
@ -43,21 +42,18 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader
title={_(msg`Team Members`)}
subtitle={_(msg`Manage the members of your team.`)}
>
<SettingsHeader title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
<TeamMemberCreateDialog />
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={_(msg`Search`)}
placeholder={t`Search`}
className="mb-4"
/>
<TeamMembersDataTable />
<TeamMembersTable />
</div>
);
}

View File

@ -2,6 +2,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
@ -14,7 +15,6 @@ import {
type TDocumentPreferencesFormSchema,
} from '~/components/forms/document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentOrganisation } from '~/providers/organisation';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {

View File

@ -3,7 +3,6 @@ import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
@ -43,20 +42,14 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(templateRootPath);
}
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return superLoaderJson({
template,
isTemplateEnterprise,
templateRootPath,
});
}
export default function TemplateEditPage() {
const { template, isTemplateEnterprise, templateRootPath } = useSuperLoaderData<typeof loader>();
const { template, templateRootPath } = useSuperLoaderData<typeof loader>();
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
@ -99,7 +92,6 @@ export default function TemplateEditPage() {
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
</div>
);