mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 02:32:00 +10:00
feat: add organisations (#1820)
This commit is contained in:
@ -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';
|
||||
@ -13,7 +21,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
throw redirect('/documents');
|
||||
throw redirect('/');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
65
apps/remix/app/routes/_authenticated+/admin+/claims.tsx
Normal file
65
apps/remix/app/routes/_authenticated+/admin+/claims.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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()}/o/${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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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={_(
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,10 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -18,11 +20,15 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organisation-create-dialog';
|
||||
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
|
||||
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
|
||||
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
|
||||
|
||||
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
|
||||
|
||||
@ -31,11 +37,7 @@ const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
|
||||
export default function UserPage({ params }: { params: { id: number } }) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { data: user } = trpc.profile.getUser.useQuery(
|
||||
const { data: user, isLoading: isLoadingUser } = trpc.profile.getUser.useQuery(
|
||||
{
|
||||
id: Number(params.id),
|
||||
},
|
||||
@ -44,7 +46,43 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
},
|
||||
);
|
||||
|
||||
const roles = user?.roles ?? [];
|
||||
if (isLoadingUser) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={404}
|
||||
errorCodeMap={{
|
||||
404: {
|
||||
heading: msg`User not found`,
|
||||
subHeading: msg`404 User not found`,
|
||||
message: msg`The user you are looking for may have been removed, renamed or may have never
|
||||
existed.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/admin/users`}>
|
||||
<Trans>Go back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminUserPage user={user} />;
|
||||
}
|
||||
|
||||
const AdminUserPage = ({ user }: { user: User }) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const roles = user.roles ?? [];
|
||||
|
||||
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
|
||||
|
||||
@ -151,13 +189,41 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<hr className="my-4" />
|
||||
<hr className="my-8" />
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight">
|
||||
<Trans>User Organisations</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1.5 text-sm">
|
||||
<Trans>Organisations that the user is a member of.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminOrganisationCreateDialog
|
||||
ownerUserId={user.id}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<Trans>Create Organisation</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminOrganisationsTable
|
||||
memberUserId={user.id}
|
||||
showOwnerColumn={false}
|
||||
hidePaginationUntilOverflow
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex flex-col items-center gap-4">
|
||||
{user && <AdminUserDeleteDialog user={user} />}
|
||||
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
|
||||
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user