mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 10:42:01 +10:00
feat: billing
This commit is contained in:
59
.cursorrules
59
.cursorrules
@ -1,4 +1,7 @@
|
|||||||
|
You are an expert in TypeScript, Node.js, Remix, React, Shadcn UI and Tailwind.
|
||||||
|
|
||||||
Code Style and Structure:
|
Code Style and Structure:
|
||||||
|
|
||||||
- Write concise, technical TypeScript code with accurate examples
|
- Write concise, technical TypeScript code with accurate examples
|
||||||
- Use functional and declarative programming patterns; avoid classes
|
- Use functional and declarative programming patterns; avoid classes
|
||||||
- Prefer iteration and modularization over code duplication
|
- Prefer iteration and modularization over code duplication
|
||||||
@ -6,20 +9,25 @@ Code Style and Structure:
|
|||||||
- Structure files: exported component, subcomponents, helpers, static content, types
|
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||||
|
|
||||||
Naming Conventions:
|
Naming Conventions:
|
||||||
|
|
||||||
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
||||||
- Favor named exports for components
|
- Favor named exports for components
|
||||||
|
|
||||||
TypeScript Usage:
|
TypeScript Usage:
|
||||||
- Use TypeScript for all code; prefer interfaces over types
|
|
||||||
- Avoid enums; use maps instead
|
- Use TypeScript for all code; prefer types over interfaces
|
||||||
- Use functional components with TypeScript interfaces
|
- Use functional components with TypeScript interfaces
|
||||||
|
|
||||||
Syntax and Formatting:
|
Syntax and Formatting:
|
||||||
- Use the "function" keyword for pure functions
|
|
||||||
|
- Create functions using `const fn = () => {}`
|
||||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
||||||
- Use declarative JSX
|
- Use declarative JSX
|
||||||
|
- Never use 'use client'
|
||||||
|
- Never use 1 line if statements
|
||||||
|
|
||||||
Error Handling and Validation:
|
Error Handling and Validation:
|
||||||
|
|
||||||
- Prioritize error handling: handle errors and edge cases early
|
- Prioritize error handling: handle errors and edge cases early
|
||||||
- Use early returns and guard clauses
|
- Use early returns and guard clauses
|
||||||
- Implement proper error logging and user-friendly messages
|
- Implement proper error logging and user-friendly messages
|
||||||
@ -28,21 +36,40 @@ Error Handling and Validation:
|
|||||||
- Use error boundaries for unexpected errors
|
- Use error boundaries for unexpected errors
|
||||||
|
|
||||||
UI and Styling:
|
UI and Styling:
|
||||||
|
|
||||||
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
||||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach
|
- Implement responsive design with Tailwind CSS; use a mobile-first approach
|
||||||
|
- When using Lucide icons, prefer the longhand names, for example HomeIcon instead of Home
|
||||||
|
|
||||||
Performance Optimization:
|
React forms
|
||||||
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC)
|
|
||||||
- Wrap client components in Suspense with fallback
|
|
||||||
- Use dynamic loading for non-critical components
|
|
||||||
- Optimize images: use WebP format, include size data, implement lazy loading
|
|
||||||
|
|
||||||
Key Conventions:
|
- Use zod for form validation react-hook-form for forms
|
||||||
- Use 'nuqs' for URL search parameter state management
|
- Look at TeamCreateDialog.tsx as an example of form usage
|
||||||
- Optimize Web Vitals (LCP, CLS, FID)
|
- Use <Form> <FormItem> elements, and also wrap the contents of form in a fieldset which should have the :disabled attribute when the form is loading
|
||||||
- Limit 'use client':
|
|
||||||
- Favor server components and Next.js SSR
|
|
||||||
- Use only for Web API access in small components
|
|
||||||
- Avoid for data fetching or state management
|
|
||||||
|
|
||||||
Follow Next.js docs for Data Fetching, Rendering, and Routing
|
TRPC Specifics
|
||||||
|
|
||||||
|
- Every route should be in it's own file, example routers/teams/create-team.ts
|
||||||
|
- Every route should have a types file associated with it, example routers/teams/create-team.types.ts. These files should have the OpenAPI meta, and request/response zod schemas
|
||||||
|
- The request/response schemas should be named like Z[RouteName]RequestSchema and Z[RouteName]ResponseSchema
|
||||||
|
- Use create-team.ts and create-team.types.ts as an example when creating new routes.
|
||||||
|
- When creating the OpenAPI meta, only use GET and POST requests, do not use any other REST methods
|
||||||
|
- Deconstruct the input argument on it's one line of code.
|
||||||
|
|
||||||
|
Toast usage
|
||||||
|
|
||||||
|
- Use the t`string` macro from @lingui/react/macro to display toast messages
|
||||||
|
|
||||||
|
Remix/ReactRouter Usage
|
||||||
|
|
||||||
|
- Use (params: Route.Params) to get the params from the route
|
||||||
|
- Use (loaderData: Route.LoaderData) to get the loader data from the route
|
||||||
|
- When using loaderdata, deconstruct the data you need from the loader data inside the function body
|
||||||
|
- Do not use json() to return data, directly return the data
|
||||||
|
|
||||||
|
Translations
|
||||||
|
|
||||||
|
- Use <Trans>string</Trans> to display translations in jsx code, this should be imported from @lingui/react/macro
|
||||||
|
- Use the t`string` macro from @lingui/react/macro to display translations in typescript code
|
||||||
|
- t should be imported as const { t } = useLingui() where useLingui is imported from @lingui/react/macro
|
||||||
|
- String in constants should be using the t`string` macro
|
||||||
|
|||||||
90
apps/remix/app/components/dialogs/claim-create-dialog.tsx
Normal file
90
apps/remix/app/components/dialogs/claim-create-dialog.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||||
|
|
||||||
|
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||||
|
|
||||||
|
export const ClaimCreateDialog = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createClaim, isPending } = trpc.admin.claims.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Subscription claim created successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Failed to create subscription claim.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
<Trans>Create claim</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Create Subscription Claim</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Fill in the details to create a new subscription claim.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<SubscriptionClaimForm
|
||||||
|
subscriptionClaim={{
|
||||||
|
...generateDefaultSubscriptionClaim(),
|
||||||
|
}}
|
||||||
|
onFormSubmit={createClaim}
|
||||||
|
formSubmitTrigger={
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
<Trans>Create Claim</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
apps/remix/app/components/dialogs/claim-delete-dialog.tsx
Normal file
96
apps/remix/app/components/dialogs/claim-delete-dialog.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type ClaimDeleteDialogProps = {
|
||||||
|
claimId: string;
|
||||||
|
claimName: string;
|
||||||
|
claimLocked: boolean;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClaimDeleteDialog = ({
|
||||||
|
claimId,
|
||||||
|
claimName,
|
||||||
|
claimLocked,
|
||||||
|
trigger,
|
||||||
|
}: ClaimDeleteDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: deleteClaim, isPending } = trpc.admin.claims.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Subscription claim deleted successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Failed to delete subscription claim.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
{trigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Delete Subscription Claim</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Are you sure you want to delete the following claim?</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription className="text-center font-semibold">
|
||||||
|
{claimLocked ? <Trans>This claim is locked and cannot be deleted.</Trans> : claimName}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!claimLocked && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={async () => deleteClaim({ id: claimId })}
|
||||||
|
>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
92
apps/remix/app/components/dialogs/claim-update-dialog.tsx
Normal file
92
apps/remix/app/components/dialogs/claim-update-dialog.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||||
|
|
||||||
|
export type ClaimUpdateDialogProps = {
|
||||||
|
claim: TFindSubscriptionClaimsResponse['data'][number];
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Subscription claim updated successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Failed to update subscription claim.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
{trigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Update Subscription Claim</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Modify the details of the subscription claim.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<SubscriptionClaimForm
|
||||||
|
subscriptionClaim={claim}
|
||||||
|
onFormSubmit={async (data) =>
|
||||||
|
await updateClaim({
|
||||||
|
id: claim.id,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
formSubmitTrigger={
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
<Trans>Update Claim</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,18 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { ExternalLinkIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
|
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -32,6 +39,8 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type OrganisationCreateDialogProps = {
|
export type OrganisationCreateDialogProps = {
|
||||||
@ -40,16 +49,24 @@ export type OrganisationCreateDialogProps = {
|
|||||||
|
|
||||||
const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
|
const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
|
||||||
name: true,
|
name: true,
|
||||||
url: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
||||||
|
|
||||||
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const actionSearchParam = searchParams?.get('action');
|
||||||
|
|
||||||
|
const [step, setStep] = useState<'billing' | 'create'>(
|
||||||
|
IS_BILLING_ENABLED() ? 'billing' : 'create',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -57,56 +74,50 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, url }: TCreateOrganisationFormSchema) => {
|
const { data: plansData } = trpc.billing.plans.get.useQuery();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const response = await createOrganisation({
|
const response = await createOrganisation({
|
||||||
name,
|
name,
|
||||||
url,
|
priceId: selectedPriceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.paymentRequired) {
|
||||||
|
window.open(response.checkoutUrl, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
// if (response.paymentRequired) {
|
|
||||||
// await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: t`Success`,
|
||||||
description: _(msg`Your organisation has been created.`),
|
description: t`Your organisation has been created.`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
console.error(error);
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(msg`This URL is already in use.`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: t`An unknown error occurred`,
|
||||||
description: _(
|
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
|
||||||
msg`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapTextToUrl = (text: string) => {
|
useEffect(() => {
|
||||||
return text.toLowerCase().replace(/\s+/g, '-');
|
if (actionSearchParam === 'add-organisation') {
|
||||||
};
|
setOpen(true);
|
||||||
|
updateSearchParams({ action: null });
|
||||||
|
}
|
||||||
|
}, [actionSearchParam, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
@ -127,95 +138,265 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
{match(step)
|
||||||
<DialogTitle>
|
.with('billing', () => (
|
||||||
<Trans>Create organisation</Trans>
|
<>
|
||||||
</DialogTitle>
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Select a plan</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>Create an organisation to collaborate with teams</Trans>
|
<Trans>Select a plan to continue</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<fieldset aria-label="Plan select">
|
||||||
<Form {...form}>
|
{plansData ? (
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<BillingPlanForm
|
||||||
<fieldset
|
value={selectedPriceId}
|
||||||
className="flex h-full flex-col space-y-4"
|
onChange={setSelectedPriceId}
|
||||||
disabled={form.formState.isSubmitting}
|
plans={plansData.plans}
|
||||||
>
|
canCreateFreeOrganisation={plansData.canCreateFreeOrganisation}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
) : (
|
||||||
name="name"
|
<SpinnerBox className="py-32" />
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="bg-background"
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => {
|
|
||||||
const oldGeneratedUrl = mapTextToUrl(field.value);
|
|
||||||
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
|
||||||
|
|
||||||
const urlField = form.getValues('url');
|
|
||||||
if (urlField === oldGeneratedUrl) {
|
|
||||||
form.setValue('url', newGeneratedUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<DialogFooter className="mt-4">
|
||||||
control={form.control}
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
name="url"
|
<Trans>Cancel</Trans>
|
||||||
render={({ field }) => (
|
</Button>
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...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 your organisation</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
<Button type="submit" onClick={() => setStep('create')}>
|
||||||
</FormItem>
|
<Trans>Continue</Trans>
|
||||||
)}
|
</Button>
|
||||||
/>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with('create', () => (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Create organisation</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogDescription>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Trans>Create an organisation to collaborate with teams</Trans>
|
||||||
<Trans>Cancel</Trans>
|
</DialogDescription>
|
||||||
</Button>
|
</DialogHeader>
|
||||||
|
|
||||||
<Button
|
<Form {...form}>
|
||||||
type="submit"
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
data-testid="dialog-create-organisation-button"
|
<fieldset
|
||||||
loading={form.formState.isSubmitting}
|
className="flex h-full flex-col space-y-4"
|
||||||
>
|
disabled={form.formState.isSubmitting}
|
||||||
<Trans>Create Organisation</Trans>
|
>
|
||||||
</Button>
|
<FormField
|
||||||
</DialogFooter>
|
control={form.control}
|
||||||
</fieldset>
|
name="name"
|
||||||
</form>
|
render={({ field }) => (
|
||||||
</Form>
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Organisation Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{IS_BILLING_ENABLED() ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setStep('billing')}
|
||||||
|
>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
data-testid="dialog-create-organisation-button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{selectedPriceId ? <Trans>Checkout</Trans> : <Trans>Create</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
|
||||||
|
.exhaustive()}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BillingPlanFormProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (priceId: string) => void;
|
||||||
|
plans: InternalClaimPlans;
|
||||||
|
canCreateFreeOrganisation: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingPlanForm = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
plans,
|
||||||
|
canCreateFreeOrganisation,
|
||||||
|
}: BillingPlanFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
||||||
|
|
||||||
|
const dynamicPlans = useMemo(() => {
|
||||||
|
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.PRO, INTERNAL_CLAIM_ID.PLATFORM].map(
|
||||||
|
(planId) => {
|
||||||
|
const plan = plans[planId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: planId,
|
||||||
|
name: plan.name,
|
||||||
|
description: parseMessageDescriptorMacro(t, plan.description),
|
||||||
|
monthlyPrice: plan.monthlyPrice,
|
||||||
|
yearlyPrice: plan.yearlyPrice,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [plans]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === '' && !canCreateFreeOrganisation) {
|
||||||
|
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||||
|
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
|
||||||
|
|
||||||
|
setBillingPeriod(billingPeriod);
|
||||||
|
|
||||||
|
onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Tabs
|
||||||
|
className="flex w-full items-center justify-center"
|
||||||
|
defaultValue="monthlyPrice"
|
||||||
|
value={billingPeriod}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
onValueChange={(value) => onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')}
|
||||||
|
>
|
||||||
|
<TabsList className="flex w-full justify-center">
|
||||||
|
<TabsTrigger className="w-full" value="monthlyPrice">
|
||||||
|
<Trans>Monthly</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="w-full" value="yearlyPrice">
|
||||||
|
<Trans>Yearly</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className={cn(
|
||||||
|
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
|
||||||
|
{
|
||||||
|
'ring-primary/10 border-primary ring-2 ring-offset-1': '' === value,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
disabled={!canCreateFreeOrganisation}
|
||||||
|
>
|
||||||
|
<div className="w-full text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-medium">
|
||||||
|
<Trans>Free</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Badge size="small" variant="neutral" className="ml-1.5">
|
||||||
|
{canCreateFreeOrganisation ? (
|
||||||
|
<Trans>1 Free organisations left</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>0 Free organisations left</Trans>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<Trans>5 documents a month</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dynamicPlans.map((plan) => (
|
||||||
|
<button
|
||||||
|
key={plan[billingPeriod]?.id}
|
||||||
|
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
|
||||||
|
className={cn(
|
||||||
|
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
|
||||||
|
{
|
||||||
|
'ring-primary/10 border-primary ring-2 ring-offset-1':
|
||||||
|
plan[billingPeriod]?.id === value,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full text-left">
|
||||||
|
<p className="font-medium">{plan.name}</p>
|
||||||
|
<p className="text-muted-foreground">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<p>{plan[billingPeriod]?.friendlyPrice}</p>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{billingPeriod === 'monthlyPrice' ? (
|
||||||
|
<Trans>per month</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>per year</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="https://documen.so/enterprise-cta"
|
||||||
|
target="_blank"
|
||||||
|
className="bg-muted/30 flex items-center space-x-2 rounded-md border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 font-normal">
|
||||||
|
<p className="text-muted-foreground font-medium">
|
||||||
|
<Trans>Enterprise</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground flex flex-row items-center gap-1">
|
||||||
|
<Trans>Contact sales here</Trans>
|
||||||
|
<ExternalLinkIcon className="h-4 w-4" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link
|
||||||
|
to="https://documenso.com/pricing"
|
||||||
|
className="text-primary hover:text-primary/80 flex items-center justify-center gap-1 text-sm hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Trans>Compare all plans and features in detail</Trans>
|
||||||
|
<ExternalLinkIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -31,8 +32,6 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export type OrganisationDeleteDialogProps = {
|
export type OrganisationDeleteDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import {
|
import {
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
ORGANISATION_MEMBER_ROLE_MAP,
|
ORGANISATION_MEMBER_ROLE_MAP,
|
||||||
@ -45,8 +46,6 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export type OrganisationGroupCreateDialogProps = {
|
export type OrganisationGroupCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -18,8 +19,6 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export type OrganisationGroupDeleteDialogProps = {
|
export type OrganisationGroupDeleteDialogProps = {
|
||||||
organisationGroupId: string;
|
organisationGroupId: string;
|
||||||
organisationGroupName: string;
|
organisationGroupName: string;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { OrganisationMemberRole } from '@prisma/client';
|
import type { OrganisationMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
@ -26,7 +25,6 @@ export type OrganisationLeaveDialogProps = {
|
|||||||
organisationId: string;
|
organisationId: string;
|
||||||
organisationName: string;
|
organisationName: string;
|
||||||
organisationAvatarImageId?: string | null;
|
organisationAvatarImageId?: string | null;
|
||||||
organisationMemberId: string;
|
|
||||||
role: OrganisationMemberRole;
|
role: OrganisationMemberRole;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
@ -36,20 +34,19 @@ export const OrganisationLeaveDialog = ({
|
|||||||
organisationId,
|
organisationId,
|
||||||
organisationName,
|
organisationName,
|
||||||
organisationAvatarImageId,
|
organisationAvatarImageId,
|
||||||
organisationMemberId,
|
|
||||||
role,
|
role,
|
||||||
}: OrganisationLeaveDialogProps) => {
|
}: OrganisationLeaveDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
|
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
|
||||||
trpc.organisation.member.delete.useMutation({
|
trpc.organisation.leave.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: t`Success`,
|
||||||
description: _(msg`You have successfully left this organisation.`),
|
description: t`You have successfully left this organisation.`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,10 +54,8 @@ export const OrganisationLeaveDialog = ({
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: t`An unknown error occurred`,
|
||||||
description: _(
|
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
|
||||||
msg`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
@ -94,7 +89,7 @@ export const OrganisationLeaveDialog = ({
|
|||||||
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
|
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
|
||||||
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
||||||
primaryText={organisationName}
|
primaryText={organisationName}
|
||||||
secondaryText={_(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
secondaryText={t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@ -108,7 +103,7 @@ export const OrganisationLeaveDialog = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={isLeavingOrganisation}
|
loading={isLeavingOrganisation}
|
||||||
onClick={async () => leaveOrganisation({ organisationId, organisationMemberId })}
|
onClick={async () => leaveOrganisation({ organisationId })}
|
||||||
>
|
>
|
||||||
<Trans>Leave</Trans>
|
<Trans>Leave</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
@ -19,8 +20,6 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export type OrganisationMemberDeleteDialogProps = {
|
export type OrganisationMemberDeleteDialogProps = {
|
||||||
organisationMemberId: string;
|
organisationMemberId: string;
|
||||||
organisationMemberName: string;
|
organisationMemberName: string;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import {
|
import {
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
ORGANISATION_MEMBER_ROLE_MAP,
|
ORGANISATION_MEMBER_ROLE_MAP,
|
||||||
@ -49,8 +50,6 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export type OrganisationMemberInviteDialogProps = {
|
export type OrganisationMemberInviteDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
@ -321,7 +320,7 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<FormLabel required>
|
<FormLabel required>
|
||||||
<Trans>Role</Trans>
|
<Trans>Organisation Role</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { Loader, TagIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamCheckoutCreateDialogProps = {
|
|
||||||
pendingTeamId: number | null;
|
|
||||||
onClose: () => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
|
||||||
|
|
||||||
export const TeamCheckoutCreateDialog = ({
|
|
||||||
pendingTeamId,
|
|
||||||
onClose,
|
|
||||||
...props
|
|
||||||
}: TeamCheckoutCreateDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
|
|
||||||
|
|
||||||
const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: createCheckout, isPending: isCreatingCheckout } =
|
|
||||||
trpc.team.createTeamPendingCheckout.useMutation({
|
|
||||||
onSuccess: (checkoutUrl) => {
|
|
||||||
window.open(checkoutUrl, '_blank');
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: () =>
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We were unable to create a checkout session. Please try again, or contact support`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedPrice = useMemo(() => {
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data[interval];
|
|
||||||
}, [data, interval]);
|
|
||||||
|
|
||||||
const handleOnOpenChange = (open: boolean) => {
|
|
||||||
if (pendingTeamId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pendingTeamId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog {...props} open={pendingTeamId !== null} onOpenChange={handleOnOpenChange}>
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Team checkout</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>Payment is required to finalise the creation of your team.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{(isLoading || !data) && (
|
|
||||||
<div className="flex h-20 items-center justify-center text-sm">
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader className="text-documenso h-6 w-6 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
<Trans>Something went wrong</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && selectedPrice && !isLoading && (
|
|
||||||
<div>
|
|
||||||
<Tabs
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
onValueChange={(value) => setInterval(value as 'monthly' | 'yearly')}
|
|
||||||
value={interval}
|
|
||||||
className="mb-4"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full">
|
|
||||||
{[data.monthly, data.yearly].map((price) => (
|
|
||||||
<TabsTrigger key={price.priceId} className="w-full" value={price.interval}>
|
|
||||||
{price.friendlyInterval}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<MotionCard
|
|
||||||
key={selectedPrice.priceId}
|
|
||||||
initial={{ opacity: 0, y: 15 }}
|
|
||||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
|
||||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
|
||||||
>
|
|
||||||
<CardContent className="flex h-full flex-col p-6">
|
|
||||||
{selectedPrice.interval === 'monthly' ? (
|
|
||||||
<div className="text-muted-foreground text-lg font-medium">
|
|
||||||
$50 USD <span className="text-xs">per month</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex items-center justify-between text-lg font-medium">
|
|
||||||
<span>
|
|
||||||
$480 USD <span className="text-xs">per year</span>
|
|
||||||
</span>
|
|
||||||
<div className="bg-primary text-primary-foreground ml-2 inline-flex flex-row items-center justify-center rounded px-2 py-1 text-xs">
|
|
||||||
<TagIcon className="mr-1 h-4 w-4" />
|
|
||||||
20% off
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
|
||||||
<p>
|
|
||||||
<Trans>This price includes minimum 5 seats.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-1">
|
|
||||||
<Trans>Adding and removing seats will adjust your invoice accordingly.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</MotionCard>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={isCreatingCheckout}
|
|
||||||
onClick={() => onClose()}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isCreatingCheckout}
|
|
||||||
onClick={async () =>
|
|
||||||
createCheckout({
|
|
||||||
interval: selectedPrice.interval,
|
|
||||||
pendingTeamId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Checkout</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@ -7,14 +7,16 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
|
import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
@ -35,10 +37,9 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export type TeamCreateDialogProps = {
|
export type TeamCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
onCreated?: () => Promise<void>;
|
onCreated?: () => Promise<void>;
|
||||||
@ -55,14 +56,18 @@ type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
|||||||
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
|
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
||||||
|
organisationReference: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
const actionSearchParam = searchParams?.get('action');
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@ -78,7 +83,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
|
|
||||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const response = await createTeam({
|
await createTeam({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
teamName,
|
teamName,
|
||||||
teamUrl,
|
teamUrl,
|
||||||
@ -87,12 +92,8 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
if (response.paymentRequired) {
|
|
||||||
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await onCreated?.();
|
await onCreated?.();
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -125,6 +126,22 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
return text.toLowerCase().replace(/\s+/g, '-');
|
return text.toLowerCase().replace(/\s+/g, '-');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dialogState = useMemo(() => {
|
||||||
|
if (!fullOrganisation) {
|
||||||
|
return 'loading';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullOrganisation.organisationClaim.teamCount === 0) {
|
||||||
|
return 'form';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) {
|
||||||
|
return 'alert';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'form';
|
||||||
|
}, [fullOrganisation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionSearchParam === 'add-team') {
|
if (actionSearchParam === 'add-team') {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@ -161,109 +178,136 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
{dialogState === 'alert' && (
|
||||||
className="flex h-full flex-col space-y-4"
|
<>
|
||||||
disabled={form.formState.isSubmitting}
|
<Alert
|
||||||
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<FormField
|
<AlertDescription className="mr-2">
|
||||||
control={form.control}
|
<Trans>
|
||||||
name="teamName"
|
You have reached the maximum number of teams for your plan. Please contact sales
|
||||||
render={({ field }) => (
|
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
|
||||||
<FormItem>
|
like to adjust your plan.
|
||||||
<FormLabel required>
|
</Trans>
|
||||||
<Trans>Team Name</Trans>
|
</AlertDescription>
|
||||||
</FormLabel>
|
</Alert>
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="bg-background"
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => {
|
|
||||||
const oldGeneratedUrl = mapTextToUrl(field.value);
|
|
||||||
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
|
||||||
|
|
||||||
const urlField = form.getValues('teamUrl');
|
<DialogFooter>
|
||||||
if (urlField === oldGeneratedUrl) {
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
form.setValue('teamUrl', newGeneratedUrl);
|
<Trans>Cancel</Trans>
|
||||||
}
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
field.onChange(event);
|
{dialogState === 'form' && (
|
||||||
}}
|
<Form {...form}>
|
||||||
/>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
</FormControl>
|
<fieldset
|
||||||
<FormMessage />
|
className="flex h-full flex-col space-y-4"
|
||||||
</FormItem>
|
disabled={form.formState.isSubmitting}
|
||||||
)}
|
>
|
||||||
/>
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="teamName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Team Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
const oldGeneratedUrl = mapTextToUrl(field.value);
|
||||||
|
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
||||||
|
|
||||||
<FormField
|
const urlField = form.getValues('teamUrl');
|
||||||
control={form.control}
|
if (urlField === oldGeneratedUrl) {
|
||||||
name="teamUrl"
|
form.setValue('teamUrl', newGeneratedUrl);
|
||||||
render={({ field }) => (
|
}
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
{!form.formState.errors.teamUrl && (
|
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
|
||||||
{field.value ? (
|
|
||||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
|
||||||
) : (
|
|
||||||
<Trans>A unique URL to identify your team</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
field.onChange(event);
|
||||||
</FormItem>
|
}}
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="inheritMembers"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center space-x-2">
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Checkbox
|
|
||||||
id="inherit-members"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<label
|
<FormField
|
||||||
className="text-muted-foreground ml-2 text-sm"
|
control={form.control}
|
||||||
htmlFor="inherit-members"
|
name="teamUrl"
|
||||||
>
|
render={({ field }) => (
|
||||||
<Trans>Allow all organisation members to access this team</Trans>
|
<FormItem>
|
||||||
</label>
|
<FormLabel required>
|
||||||
</div>
|
<Trans>Team URL</Trans>
|
||||||
</FormControl>
|
</FormLabel>
|
||||||
</FormItem>
|
<FormControl>
|
||||||
)}
|
<Input className="bg-background" {...field} />
|
||||||
/>
|
</FormControl>
|
||||||
|
{!form.formState.errors.teamUrl && (
|
||||||
|
<span className="text-foreground/50 text-xs font-normal">
|
||||||
|
{field.value ? (
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
||||||
|
) : (
|
||||||
|
<Trans>A unique URL to identify your team</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<FormMessage />
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
</FormItem>
|
||||||
<Trans>Cancel</Trans>
|
)}
|
||||||
</Button>
|
/>
|
||||||
|
|
||||||
<Button
|
<FormField
|
||||||
type="submit"
|
control={form.control}
|
||||||
data-testid="dialog-create-team-button"
|
name="inheritMembers"
|
||||||
loading={form.formState.isSubmitting}
|
render={({ field }) => (
|
||||||
>
|
<FormItem className="flex items-center space-x-2">
|
||||||
<Trans>Create Team</Trans>
|
<FormControl>
|
||||||
</Button>
|
<div className="flex items-center">
|
||||||
</DialogFooter>
|
<Checkbox
|
||||||
</fieldset>
|
id="inherit-members"
|
||||||
</form>
|
checked={field.value}
|
||||||
</Form>
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="text-muted-foreground ml-2 text-sm"
|
||||||
|
htmlFor="inherit-members"
|
||||||
|
>
|
||||||
|
<Trans>Allow all organisation members to access this team</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
data-testid="dialog-create-team-button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Create Team</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -50,6 +51,7 @@ export const TeamDeleteDialog = ({
|
|||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${teamName}`);
|
const deleteMessage = _(msg`delete ${teamName}`);
|
||||||
|
|
||||||
@ -72,6 +74,8 @@ export const TeamDeleteDialog = ({
|
|||||||
try {
|
try {
|
||||||
await deleteTeam({ teamId });
|
await deleteTeam({ teamId });
|
||||||
|
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Your team has been successfully deleted.`),
|
description: _(msg`Your team has been successfully deleted.`),
|
||||||
|
|||||||
@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createTeamEmailVerification, isPending } =
|
const { mutateAsync: sendTeamEmailVerification, isPending } =
|
||||||
trpc.team.createTeamEmailVerification.useMutation();
|
trpc.team.email.verification.send.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
|
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTeamEmailVerification({
|
await sendTeamEmailVerification({
|
||||||
teamId,
|
teamId,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
|||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
||||||
trpc.team.deleteTeamEmail.useMutation({
|
trpc.team.email.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
|
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
|
||||||
trpc.team.deleteTeamEmailVerification.useMutation({
|
trpc.team.email.verification.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
|
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -98,13 +98,6 @@ export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPref
|
|||||||
};
|
};
|
||||||
}, [previewUrl]);
|
}, [previewUrl]);
|
||||||
|
|
||||||
// Todo: orgs remove
|
|
||||||
useEffect(() => {
|
|
||||||
console.log({
|
|
||||||
errors: form.formState.errors,
|
|
||||||
});
|
|
||||||
}, [form.formState.errors]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -23,8 +24,6 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
|
|||||||
@ -55,7 +55,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
|||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = '/documents';
|
const LOGIN_REDIRECT_PATH = '/dashboard';
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FaIdCardClip } from 'react-icons/fa6';
|
import { FaIdCardClip } from 'react-icons/fa6';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
@ -15,7 +14,6 @@ import { z } from 'zod';
|
|||||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -33,11 +31,8 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
|
|
||||||
import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
||||||
|
|
||||||
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
|
|
||||||
|
|
||||||
export const ZSignUpFormSchema = z
|
export const ZSignUpFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z
|
||||||
@ -47,14 +42,6 @@ export const ZSignUpFormSchema = z
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: ZPasswordSchema,
|
password: ZPasswordSchema,
|
||||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.min(1, { message: msg`We need a username to create your profile`.id })
|
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
|
||||||
message: msg`Username can only container alphanumeric characters and dashes.`.id,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@ -71,8 +58,6 @@ export const signupErrorMessages: Record<string, MessageDescriptor> = {
|
|||||||
SIGNUP_DISABLED: msg`Signups are disabled.`,
|
SIGNUP_DISABLED: msg`Signups are disabled.`,
|
||||||
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
||||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||||
PROFILE_URL_TAKEN: msg`This username has already been taken`,
|
|
||||||
PREMIUM_PROFILE_URL: msg`Only subscribers can have a username shorter than 6 characters`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||||
@ -97,19 +82,14 @@ export const SignUpForm = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
|
|
||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||||
|
|
||||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
email: initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
url: '',
|
|
||||||
},
|
},
|
||||||
mode: 'onBlur',
|
mode: 'onBlur',
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
resolver: zodResolver(ZSignUpFormSchema),
|
||||||
@ -117,17 +97,13 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
const name = form.watch('name');
|
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||||
const url = form.watch('url');
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => {
|
|
||||||
try {
|
try {
|
||||||
await authClient.emailPassword.signUp({
|
await authClient.emailPassword.signUp({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
signature,
|
signature,
|
||||||
url,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`/unverified-account`);
|
await navigate(`/unverified-account`);
|
||||||
@ -150,26 +126,11 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||||
|
|
||||||
if (error.code === 'PROFILE_URL_TAKEN' || error.code === 'PREMIUM_PROFILE_URL') {
|
toast({
|
||||||
form.setError('url', {
|
title: _(msg`An error occurred`),
|
||||||
type: 'manual',
|
description: _(errorMessage),
|
||||||
message: _(errorMessage),
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An error occurred`),
|
|
||||||
description: _(errorMessage),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNextClick = async () => {
|
|
||||||
const valid = await form.trigger(['name', 'email', 'password', 'signature']);
|
|
||||||
|
|
||||||
if (valid) {
|
|
||||||
setStep('CLAIM_USERNAME');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -231,59 +192,30 @@ export const SignUpForm = ({
|
|||||||
<Trans>User profiles are here!</Trans>
|
<Trans>User profiles are here!</Trans>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<div className="w-full max-w-md">
|
||||||
{step === 'BASIC_DETAILS' ? (
|
<UserProfileTimur
|
||||||
<motion.div className="w-full max-w-md" layoutId="user-profile">
|
rows={2}
|
||||||
<UserProfileTimur
|
className="bg-background border-border rounded-2xl border shadow-md"
|
||||||
rows={2}
|
/>
|
||||||
className="bg-background border-border rounded-2xl border shadow-md"
|
</div>
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div className="w-full max-w-md" layoutId="user-profile">
|
|
||||||
<UserProfileSkeleton
|
|
||||||
user={{ name, url }}
|
|
||||||
rows={2}
|
|
||||||
className="bg-background border-border rounded-2xl border shadow-md"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div />
|
<div />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
||||||
{step === 'BASIC_DETAILS' && (
|
<div className="h-20">
|
||||||
<div className="h-20">
|
<h1 className="text-xl font-semibold md:text-2xl">
|
||||||
<h1 className="text-xl font-semibold md:text-2xl">
|
<Trans>Create a new account</Trans>
|
||||||
<Trans>Create a new account</Trans>
|
</h1>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Create your account and start using state-of-the-art document signing. Open and
|
Create your account and start using state-of-the-art document signing. Open and
|
||||||
beautiful signing is within your grasp.
|
beautiful signing is within your grasp.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
|
||||||
<div className="h-20">
|
|
||||||
<h1 className="text-xl font-semibold md:text-2xl">
|
|
||||||
<Trans>Claim your username now</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
|
||||||
<Trans>
|
|
||||||
You will get notified & be able to set up your documenso public profile when we
|
|
||||||
launch the feature.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<hr className="-mx-6 my-4" />
|
<hr className="-mx-6 my-4" />
|
||||||
|
|
||||||
@ -292,242 +224,147 @@ export const SignUpForm = ({
|
|||||||
className="flex w-full flex-1 flex-col gap-y-4"
|
className="flex w-full flex-1 flex-col gap-y-4"
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
{step === 'BASIC_DETAILS' && (
|
<fieldset
|
||||||
<fieldset
|
className={cn(
|
||||||
className={cn(
|
'flex h-[550px] w-full flex-col gap-y-4',
|
||||||
'flex h-[550px] w-full flex-col gap-y-4',
|
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
||||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Full Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
/>
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Email Address</Trans>
|
<Trans>Email Address</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="email" {...field} />
|
<Input type="email" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Password</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="signature"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Sign Here</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<SignaturePadDialog
|
|
||||||
disabled={isSubmitting}
|
|
||||||
value={value}
|
|
||||||
onChange={(v) => onChange(v ?? '')}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
|
||||||
<>
|
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
|
||||||
<div className="bg-border h-px flex-1" />
|
|
||||||
<span className="text-muted-foreground bg-transparent">
|
|
||||||
<Trans>Or</Trans>
|
|
||||||
</span>
|
|
||||||
<div className="bg-border h-px flex-1" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
<FormField
|
||||||
<>
|
control={form.control}
|
||||||
<Button
|
name="password"
|
||||||
type="button"
|
render={({ field }) => (
|
||||||
size="lg"
|
<FormItem>
|
||||||
variant={'outline'}
|
<FormLabel>
|
||||||
className="bg-background text-muted-foreground border"
|
<Trans>Password</Trans>
|
||||||
disabled={isSubmitting}
|
</FormLabel>
|
||||||
onClick={onSignUpWithGoogleClick}
|
|
||||||
>
|
<FormControl>
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<PasswordInput {...field} />
|
||||||
<Trans>Sign Up with Google</Trans>
|
</FormControl>
|
||||||
</Button>
|
|
||||||
</>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
<FormField
|
||||||
<>
|
control={form.control}
|
||||||
<Button
|
name="signature"
|
||||||
type="button"
|
render={({ field: { onChange, value } }) => (
|
||||||
size="lg"
|
<FormItem>
|
||||||
variant={'outline'}
|
<FormLabel>
|
||||||
className="bg-background text-muted-foreground border"
|
<Trans>Sign Here</Trans>
|
||||||
disabled={isSubmitting}
|
</FormLabel>
|
||||||
onClick={onSignUpWithOIDCClick}
|
<FormControl>
|
||||||
>
|
<SignaturePadDialog
|
||||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
disabled={isSubmitting}
|
||||||
<Trans>Sign Up with OIDC</Trans>
|
value={value}
|
||||||
</Button>
|
onChange={(v) => onChange(v ?? '')}
|
||||||
</>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">
|
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||||
<Trans>
|
<>
|
||||||
Already have an account?{' '}
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
<div className="bg-border h-px flex-1" />
|
||||||
Sign in instead
|
<span className="text-muted-foreground bg-transparent">
|
||||||
</Link>
|
<Trans>Or</Trans>
|
||||||
</Trans>
|
</span>
|
||||||
</p>
|
<div className="bg-border h-px flex-1" />
|
||||||
</fieldset>
|
</div>
|
||||||
)}
|
</>
|
||||||
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
|
||||||
<fieldset
|
|
||||||
className={cn(
|
|
||||||
'flex h-[550px] w-full flex-col gap-y-4',
|
|
||||||
isGoogleSSOEnabled && 'h-[650px]',
|
|
||||||
)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Public profile username</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
|
|
||||||
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block max-w-[16rem] truncate rounded-md border px-2 py-1 text-sm lowercase">
|
|
||||||
{baseUrl.host}/u/{field.value || '<username>'}
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{step === 'BASIC_DETAILS' && (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<span className="font-medium">
|
|
||||||
<Trans>Basic details</Trans>
|
|
||||||
</span>{' '}
|
|
||||||
1/2
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'CLAIM_USERNAME' && (
|
{isGoogleSSOEnabled && (
|
||||||
<p className="text-muted-foreground text-sm">
|
<>
|
||||||
<span className="font-medium">
|
<Button
|
||||||
<Trans>Claim username</Trans>
|
type="button"
|
||||||
</span>{' '}
|
size="lg"
|
||||||
2/2
|
variant={'outline'}
|
||||||
</p>
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithGoogleClick}
|
||||||
|
>
|
||||||
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Sign Up with Google</Trans>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
|
{isOIDCSSOEnabled && (
|
||||||
<motion.div
|
<>
|
||||||
layout="size"
|
<Button
|
||||||
layoutId="document-flow-container-step"
|
type="button"
|
||||||
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
|
size="lg"
|
||||||
style={{
|
variant={'outline'}
|
||||||
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
|
className="bg-background text-muted-foreground border"
|
||||||
}}
|
disabled={isSubmitting}
|
||||||
/>
|
onClick={onSignUpWithOIDCClick}
|
||||||
</div>
|
>
|
||||||
</div>
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Sign Up with OIDC</Trans>
|
||||||
<div className="flex items-center gap-x-4">
|
</Button>
|
||||||
{/* Go back button, disabled if step is basic details */}
|
</>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting}
|
|
||||||
onClick={() => setStep('BASIC_DETAILS')}
|
|
||||||
>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Continue button */}
|
|
||||||
{step === 'BASIC_DETAILS' && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
className="flex-1 disabled:cursor-not-allowed"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
onClick={onNextClick}
|
|
||||||
>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sign up button */}
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
{step === 'CLAIM_USERNAME' && (
|
<Trans>
|
||||||
<Button
|
Already have an account?{' '}
|
||||||
loading={form.formState.isSubmitting}
|
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||||
disabled={!form.formState.isValid}
|
Sign in instead
|
||||||
type="submit"
|
</Link>
|
||||||
size="lg"
|
</Trans>
|
||||||
className="flex-1"
|
</p>
|
||||||
>
|
</fieldset>
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
<Button
|
||||||
)}
|
loading={form.formState.isSubmitting}
|
||||||
</div>
|
disabled={!form.formState.isValid}
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="mt-6 w-full"
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<p className="text-muted-foreground mt-6 text-xs">
|
<p className="text-muted-foreground mt-6 text-xs">
|
||||||
|
|||||||
155
apps/remix/app/components/forms/subscription-claim-form.tsx
Normal file
155
apps/remix/app/components/forms/subscription-claim-form.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import type { SubscriptionClaim } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||||
|
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||||
|
|
||||||
|
type SubscriptionClaimFormProps = {
|
||||||
|
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
|
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
|
||||||
|
formSubmitTrigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubscriptionClaimForm = ({
|
||||||
|
subscriptionClaim,
|
||||||
|
onFormSubmit,
|
||||||
|
formSubmitTrigger,
|
||||||
|
}: SubscriptionClaimFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<SubscriptionClaimFormValues>({
|
||||||
|
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: subscriptionClaim.name,
|
||||||
|
teamCount: subscriptionClaim.teamCount,
|
||||||
|
memberCount: subscriptionClaim.memberCount,
|
||||||
|
flags: subscriptionClaim.flags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t`Enter claim name`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="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="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={`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>
|
||||||
|
|
||||||
|
{formSubmitTrigger}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,62 +1,48 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type Interval = keyof PriceIntervals;
|
|
||||||
|
|
||||||
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
|
||||||
|
|
||||||
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
|
|
||||||
day: msg`Daily`,
|
|
||||||
week: msg`Weekly`,
|
|
||||||
month: msg`Monthly`,
|
|
||||||
year: msg`Yearly`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
export type BillingPlansProps = {
|
export type BillingPlansProps = {
|
||||||
prices: PriceIntervals;
|
plans: InternalClaimPlans;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const [interval, setInterval] = useState<Interval>('month');
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
||||||
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation();
|
const { mutateAsync: createSubscription } = trpc.billing.subscription.create.useMutation();
|
||||||
|
|
||||||
const onSubscribeClick = async (priceId: string) => {
|
const onSubscribeClick = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
setCheckoutSessionPriceId(priceId);
|
setCheckoutSessionPriceId(priceId);
|
||||||
|
|
||||||
const url = await createCheckoutSession({ priceId });
|
const { redirectUrl } = await createSubscription({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
priceId,
|
||||||
|
});
|
||||||
|
|
||||||
if (!url) {
|
window.open(redirectUrl, '_blank');
|
||||||
throw new Error('Unable to create session');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(url);
|
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -68,24 +54,37 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pricesToDisplay = useMemo(() => {
|
||||||
|
const prices = [];
|
||||||
|
|
||||||
|
for (const plan of Object.values(plans)) {
|
||||||
|
if (plan[interval] && plan[interval].isVisibleInApp) {
|
||||||
|
prices.push(plan[interval]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
}, [plans, interval]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
<Tabs
|
||||||
|
value={interval}
|
||||||
|
onValueChange={(value) => setInterval(value as 'monthlyPrice' | 'yearlyPrice')}
|
||||||
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{INTERVALS.map(
|
<TabsTrigger className="min-w-[150px]" value="monthlyPrice">
|
||||||
(interval) =>
|
<Trans>Monthly</Trans>
|
||||||
prices[interval].length > 0 && (
|
</TabsTrigger>
|
||||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
<TabsTrigger className="min-w-[150px]" value="yearlyPrice">
|
||||||
{_(FRIENDLY_INTERVALS[interval])}
|
<Trans>Yearly</Trans>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
),
|
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{prices[interval].map((price) => (
|
{pricesToDisplay.map((price) => (
|
||||||
<MotionCard
|
<MotionCard
|
||||||
key={price.id}
|
key={price.id}
|
||||||
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||||
@ -96,8 +95,14 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
<CardTitle>{price.product.name}</CardTitle>
|
<CardTitle>{price.product.name}</CardTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||||
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
{price.friendlyPrice + ' '}
|
||||||
<span className="text-xs">per {interval}</span>
|
<span className="text-xs">
|
||||||
|
{interval === 'monthlyPrice' ? (
|
||||||
|
<Trans>per month</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>per year</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type BillingPortalButtonProps = {
|
|
||||||
buttonProps?: React.ComponentProps<typeof Button>;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: createBillingPortal, isPending } =
|
|
||||||
trpc.profile.createBillingPortal.useMutation({
|
|
||||||
onSuccess: (sessionUrl) => {
|
|
||||||
window.open(sessionUrl, '_blank');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
let description = _(
|
|
||||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (err.message === 'CUSTOMER_NOT_FOUND') {
|
|
||||||
description = _(
|
|
||||||
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description,
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button {...buttonProps} onClick={async () => createBillingPortal()} loading={isPending}>
|
|
||||||
{children || <Trans>Manage Subscription</Trans>}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -35,7 +35,6 @@ export type DocumentEditFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialDocument: TDocument;
|
initialDocument: TDocument;
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
isDocumentEnterprise: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
||||||
@ -45,7 +44,6 @@ export const DocumentEditForm = ({
|
|||||||
className,
|
className,
|
||||||
initialDocument,
|
initialDocument,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
isDocumentEnterprise,
|
|
||||||
}: DocumentEditFormProps) => {
|
}: DocumentEditFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -358,7 +356,6 @@ export const DocumentEditForm = ({
|
|||||||
currentTeamMemberRole={team.currentTeamRole}
|
currentTeamMemberRole={team.currentTeamRole}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
/>
|
/>
|
||||||
@ -370,7 +367,6 @@ export const DocumentEditForm = ({
|
|||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
@ -34,7 +35,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
import { useOptionalCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export const MenuSwitcher = () => {
|
export const MenuSwitcher = () => {
|
||||||
@ -152,10 +152,7 @@ export const MenuSwitcher = () => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className={cn(
|
className={cn('divide-border z-[60] ml-6 flex w-full min-w-[40rem] divide-x p-0 md:ml-0')}
|
||||||
'divide-border z-[60] ml-6 flex w-full min-w-[40rem] divide-x p-0 md:ml-0',
|
|
||||||
// organisations ? 'min-w-[24rem]' : 'min-w-[12rem]', // Todo: orgs
|
|
||||||
)}
|
|
||||||
align="end"
|
align="end"
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
@ -211,9 +208,9 @@ export const MenuSwitcher = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
<Link to="/settings/organisations?action=add-organization">
|
<Link to="/settings/organisations?action=add-organisation">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Trans>Create Organization</Trans>
|
<Trans>Create Organisation</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,12 +3,12 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -21,30 +21,28 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type TeamLayoutBillingBannerProps = {
|
export type OrganisationBillingBannerProps = {
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
teamId: number;
|
|
||||||
userRole: TeamMemberRole;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamLayoutBillingBanner = ({
|
export const OrganisationBillingBanner = ({
|
||||||
subscriptionStatus,
|
subscriptionStatus,
|
||||||
teamId,
|
}: OrganisationBillingBannerProps) => {
|
||||||
userRole,
|
|
||||||
}: TeamLayoutBillingBannerProps) => {
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createBillingPortal, isPending } =
|
const organisation = useCurrentOrganisation();
|
||||||
trpc.team.createBillingPortal.useMutation();
|
|
||||||
|
const { mutateAsync: manageSubscription, isPending } =
|
||||||
|
trpc.billing.subscription.manage.useMutation();
|
||||||
|
|
||||||
const handleCreatePortal = async () => {
|
const handleCreatePortal = async () => {
|
||||||
try {
|
try {
|
||||||
const sessionUrl = await createBillingPortal({ teamId });
|
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
|
||||||
|
|
||||||
window.open(sessionUrl, '_blank');
|
window.open(redirectUrl, '_blank');
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -125,7 +123,7 @@ export const TeamLayoutBillingBanner = ({
|
|||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
|
|
||||||
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button loading={isPending} onClick={handleCreatePortal}>
|
<Button loading={isPending} onClick={handleCreatePortal}>
|
||||||
<Trans>Resolve payment</Trans>
|
<Trans>Resolve payment</Trans>
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type OrganisationBillingPortalButtonProps = {
|
||||||
|
buttonProps?: React.ComponentProps<typeof Button>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationBillingPortalButton = ({
|
||||||
|
buttonProps,
|
||||||
|
}: OrganisationBillingPortalButtonProps) => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: manageSubscription, isPending } =
|
||||||
|
trpc.billing.subscription.manage.useMutation();
|
||||||
|
|
||||||
|
const canManageBilling = canExecuteOrganisationAction(
|
||||||
|
'MANAGE_BILLING',
|
||||||
|
organisation.currentOrganisationRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreatePortal = async () => {
|
||||||
|
try {
|
||||||
|
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
|
||||||
|
|
||||||
|
window.open(redirectUrl, '_blank');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
onClick={async () => handleCreatePortal()}
|
||||||
|
loading={isPending}
|
||||||
|
disabled={!canManageBilling}
|
||||||
|
>
|
||||||
|
<Trans>Manage billing</Trans>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,6 +5,7 @@ import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { BellIcon } from 'lucide-react';
|
import { BellIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
@ -21,7 +22,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const OrganisationInvitations = () => {
|
export const OrganisationInvitations = ({ className }: { className?: string }) => {
|
||||||
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
|
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
status: OrganisationMemberInviteStatus.PENDING,
|
||||||
});
|
});
|
||||||
@ -30,7 +31,7 @@ export const OrganisationInvitations = () => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{data && data.length > 0 && !isLoading && (
|
{data && data.length > 0 && !isLoading && (
|
||||||
<AnimateGenericFadeInOut>
|
<AnimateGenericFadeInOut>
|
||||||
<Alert variant="secondary">
|
<Alert variant="secondary" className={className}>
|
||||||
<div className="flex h-full flex-row items-center p-2">
|
<div className="flex h-full flex-row items-center p-2">
|
||||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||||
|
|
||||||
@ -83,23 +84,25 @@ export const OrganisationInvitations = () => {
|
|||||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
||||||
{data.map((invitation) => (
|
{data.map((invitation) => (
|
||||||
<li key={invitation.id}>
|
<li key={invitation.id}>
|
||||||
<AvatarWithText
|
<Alert variant="neutral" className="p-0 px-4">
|
||||||
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
|
<AvatarWithText
|
||||||
className="w-full max-w-none py-4"
|
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
|
||||||
avatarFallback={invitation.organisation.name.slice(0, 1)}
|
className="w-full max-w-none py-4"
|
||||||
primaryText={
|
avatarFallback={invitation.organisation.name.slice(0, 1)}
|
||||||
<span className="text-foreground/80 font-semibold">
|
primaryText={
|
||||||
{invitation.organisation.name}
|
<span className="text-foreground/80 font-semibold">
|
||||||
</span>
|
{invitation.organisation.name}
|
||||||
}
|
</span>
|
||||||
// secondaryText={formatOrganisationUrl(invitation.team.url)}
|
}
|
||||||
rightSideComponent={
|
secondaryText={`/orgs/${invitation.organisation.url}`}
|
||||||
<div className="ml-auto space-x-2">
|
rightSideComponent={
|
||||||
<DeclineOrganisationInvitationButton token={invitation.token} />
|
<div className="ml-auto space-x-2">
|
||||||
<AcceptOrganisationInvitationButton token={invitation.token} />
|
<DeclineOrganisationInvitationButton token={invitation.token} />
|
||||||
</div>
|
<AcceptOrganisationInvitationButton token={invitation.token} />
|
||||||
}
|
</div>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -116,13 +119,16 @@ export const OrganisationInvitations = () => {
|
|||||||
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
|
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: acceptOrganisationInvitation,
|
mutateAsync: acceptOrganisationInvitation,
|
||||||
isPending,
|
isPending,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
} = trpc.organisation.member.invite.accept.useMutation({
|
} = trpc.organisation.member.invite.accept.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Invitation accepted`),
|
description: _(msg`Invitation accepted`),
|
||||||
@ -153,13 +159,16 @@ const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
|
|||||||
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
|
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: declineOrganisationInvitation,
|
mutateAsync: declineOrganisationInvitation,
|
||||||
isPending,
|
isPending,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
} = trpc.organisation.member.invite.decline.useMutation({
|
} = trpc.organisation.member.invite.decline.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Invitation declined`),
|
description: _(msg`Invitation declined`),
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
import { Lock, User, Users } from 'lucide-react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -14,8 +13,6 @@ export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
|
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
<Link to="/settings/profile">
|
<Link to="/settings/profile">
|
||||||
@ -56,21 +53,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
|||||||
<Trans>Security</Trans>
|
<Trans>Security</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
|
||||||
<Link to="/settings/billing">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CreditCard className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Billing</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
import { Lock, User, Users } from 'lucide-react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -13,8 +12,6 @@ export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
|
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||||
@ -58,21 +55,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
|||||||
<Trans>Security</Trans>
|
<Trans>Security</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
|
||||||
<Link to="/settings/billing">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CreditCard className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Billing</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamBillingPortalButtonProps = {
|
|
||||||
buttonProps?: React.ComponentProps<typeof Button>;
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: createBillingPortal, isPending } =
|
|
||||||
trpc.team.createBillingPortal.useMutation();
|
|
||||||
|
|
||||||
const handleCreatePortal = async () => {
|
|
||||||
try {
|
|
||||||
const sessionUrl = await createBillingPortal({ teamId });
|
|
||||||
|
|
||||||
window.open(sessionUrl, '_blank');
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button {...buttonProps} onClick={async () => handleCreatePortal()} loading={isPending}>
|
|
||||||
<Trans>Manage subscription</Trans>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -25,7 +25,7 @@ export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
|
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
|
||||||
trpc.team.resendTeamEmailVerification.useMutation({
|
trpc.team.email.verification.resend.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
||||||
trpc.team.deleteTeamEmail.useMutation({
|
trpc.team.email.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import { useCurrentTeam } from '~/providers/team';
|
|||||||
export type TemplateEditFormProps = {
|
export type TemplateEditFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
initialTemplate: TTemplate;
|
initialTemplate: TTemplate;
|
||||||
isEnterprise: boolean;
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -41,7 +40,6 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
|||||||
export const TemplateEditForm = ({
|
export const TemplateEditForm = ({
|
||||||
initialTemplate,
|
initialTemplate,
|
||||||
className,
|
className,
|
||||||
isEnterprise,
|
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
}: TemplateEditFormProps) => {
|
}: TemplateEditFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -261,7 +259,6 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -274,7 +271,6 @@ export const TemplateEditForm = ({
|
|||||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
199
apps/remix/app/components/tables/admin-claims-table.tsx
Normal file
199
apps/remix/app/components/tables/admin-claims-table.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
|
||||||
|
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
|
||||||
|
|
||||||
|
export const AdminClaimsTable = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.admin.claims.find.useQuery({
|
||||||
|
query: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`ID`,
|
||||||
|
accessorKey: 'id',
|
||||||
|
maxSize: 50,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<CopyTextButton
|
||||||
|
value={row.original.id}
|
||||||
|
onCopySuccess={() => toast({ title: t`ID copied to clipboard` })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Name`,
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/admin/organisations?query=claim:${row.original.id}`}>
|
||||||
|
{row.original.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Allowed teams`,
|
||||||
|
accessorKey: 'teamCount',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (row.original.teamCount === 0) {
|
||||||
|
return <p className="text-muted-foreground">{t`Unlimited`}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p className="text-muted-foreground">{row.original.teamCount}</p>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Feature Flags`,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const flags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter(
|
||||||
|
({ key }) => row.original.flags[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (flags.length === 0) {
|
||||||
|
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
|
||||||
|
{flags.map(({ key, label }) => (
|
||||||
|
<li key={key}>{label}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<ClaimUpdateDialog
|
||||||
|
claim={row.original}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<EditIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Update</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClaimDeleteDialog
|
||||||
|
claimId={row.original.id}
|
||||||
|
claimName={row.original.name}
|
||||||
|
claimLocked={row.original.locked}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="py-4 pr-4">
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-2 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -35,7 +35,6 @@ type AdminDashboardUsersTableProps = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
individualPriceIds: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminDashboardUsersTable = ({
|
export const AdminDashboardUsersTable = ({
|
||||||
@ -43,7 +42,6 @@ export const AdminDashboardUsersTable = ({
|
|||||||
totalPages,
|
totalPages,
|
||||||
perPage,
|
perPage,
|
||||||
page,
|
page,
|
||||||
individualPriceIds,
|
|
||||||
}: AdminDashboardUsersTableProps) => {
|
}: AdminDashboardUsersTableProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@ -74,17 +72,6 @@ export const AdminDashboardUsersTable = ({
|
|||||||
accessorKey: 'roles',
|
accessorKey: 'roles',
|
||||||
cell: ({ row }) => row.original.roles.join(', '),
|
cell: ({ row }) => row.original.roles.join(', '),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: _(msg`Subscription`),
|
|
||||||
accessorKey: 'subscription',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const foundIndividualSubscription = (row.original.subscriptions ?? []).find((sub) =>
|
|
||||||
individualPriceIds.includes(sub.priceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
return foundIndividualSubscription?.status ?? 'NONE';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: _(msg`Documents`),
|
header: _(msg`Documents`),
|
||||||
accessorKey: 'documents',
|
accessorKey: 'documents',
|
||||||
@ -107,7 +94,7 @@ export const AdminDashboardUsersTable = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<(typeof users)[number]>[];
|
] satisfies DataTableColumnDef<(typeof users)[number]>[];
|
||||||
}, [individualPriceIds]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
|||||||
179
apps/remix/app/components/tables/admin-organisations-table.tsx
Normal file
179
apps/remix/app/components/tables/admin-organisations-table.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
CreditCardIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
|
||||||
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
export const AdminOrganisationsTable = () => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.admin.organisation.find.useQuery({
|
||||||
|
query: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`Organisation`,
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/admin/organisations/${row.original.id}`}>{row.original.name}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Created At`,
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Owner`,
|
||||||
|
accessorKey: 'owner',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/admin/users/${row.original.owner.id}`}>{row.original.owner.name}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Subscription`,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.subscription ? (
|
||||||
|
<Link
|
||||||
|
to={`https://dashboard.stripe.com/subscriptions/${row.original.subscription.planId}`}
|
||||||
|
target="_blank"
|
||||||
|
className="flex flex-row items-center gap-2"
|
||||||
|
>
|
||||||
|
{SUBSCRIPTION_STATUS_MAP[row.original.subscription.status]}
|
||||||
|
<ExternalLinkIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'None'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={`/admin/organisations/${row.original.id}`}>
|
||||||
|
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Manage</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={`/admin/users/${row.original.owner.id}`}>
|
||||||
|
<UserIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>View owner</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem disabled={!row.original.customerId} asChild>
|
||||||
|
<Link to={`https://dashboard.stripe.com/customers/${row.original.customerId}`}>
|
||||||
|
<CreditCardIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Stripe</Trans>
|
||||||
|
{!row.original.customerId && <span> (N/A)</span>}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="py-4 pr-4">
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-2 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { File } from 'lucide-react';
|
import { File } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@ -11,22 +11,23 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
export type TeamSettingsBillingInvoicesTableProps = {
|
export type OrganisationBillingInvoicesTableProps = {
|
||||||
teamId: number;
|
organisationId: string;
|
||||||
|
subscriptionExists: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamSettingsBillingInvoicesTable = ({
|
export const OrganisationBillingInvoicesTable = ({
|
||||||
teamId,
|
organisationId,
|
||||||
}: TeamSettingsBillingInvoicesTableProps) => {
|
subscriptionExists,
|
||||||
|
}: OrganisationBillingInvoicesTableProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery(
|
||||||
{
|
{
|
||||||
teamId,
|
organisationId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@ -43,7 +44,7 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
data: data?.data ?? [],
|
data: data || [],
|
||||||
perPage: 100,
|
perPage: 100,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
@ -58,13 +59,8 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
<div className="flex max-w-xs items-center gap-2">
|
<div className="flex max-w-xs items-center gap-2">
|
||||||
<File className="h-6 w-6" />
|
<File className="h-6 w-6" />
|
||||||
|
|
||||||
<div className="flex flex-col text-sm">
|
<div className="text-foreground/80 text-sm font-semibold">
|
||||||
<span className="text-foreground/80 font-semibold">
|
{DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
|
||||||
{DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<Plural value={row.original.quantity} one="# Seat" other="# Seats" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -73,10 +69,10 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
header: _(msg`Status`),
|
header: _(msg`Status`),
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { status, paid } = row.original;
|
const { status } = row.original;
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return paid ? <Trans>Paid</Trans> : <Trans>Unpaid</Trans>;
|
return 'N/A';
|
||||||
}
|
}
|
||||||
|
|
||||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
@ -94,9 +90,9 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
|
disabled={typeof row.original.hosted_invoice_url !== 'string'}
|
||||||
>
|
>
|
||||||
<Link to={row.original.hostedInvoicePdf ?? ''} target="_blank">
|
<Link to={row.original.hosted_invoice_url ?? ''} target="_blank">
|
||||||
<Trans>View</Trans>
|
<Trans>View</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -104,9 +100,9 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
asChild
|
||||||
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
|
disabled={typeof row.original.invoice_pdf !== 'string'}
|
||||||
>
|
>
|
||||||
<Link to={row.original.invoicePdf ?? ''} target="_blank">
|
<Link to={row.original.invoice_pdf ?? ''} target="_blank">
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -116,6 +112,10 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (results.data.length === 0 && !subscriptionExists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -157,7 +157,7 @@ export const TeamSettingsBillingInvoicesTable = ({
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{/* {(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />} */}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -7,6 +7,7 @@ import { OrganisationGroupType } from '@prisma/client';
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -17,8 +18,6 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat
|
|||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
import { OrganisationGroupDeleteDialog } from '../dialogs/organisation-group-delete-dialog';
|
import { OrganisationGroupDeleteDialog } from '../dialogs/organisation-group-delete-dialog';
|
||||||
|
|
||||||
export const OrganisationGroupsDataTable = () => {
|
export const OrganisationGroupsDataTable = () => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { History, MoreHorizontal, Trash2 } from 'lucide-react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -26,8 +27,6 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export const OrganisationMemberInvitesTable = () => {
|
export const OrganisationMemberInvitesTable = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||||
@ -29,7 +30,6 @@ import { TableCell } from '@documenso/ui/primitives/table';
|
|||||||
|
|
||||||
import { OrganisationMemberDeleteDialog } from '~/components/dialogs/organisation-member-delete-dialog';
|
import { OrganisationMemberDeleteDialog } from '~/components/dialogs/organisation-member-delete-dialog';
|
||||||
import { OrganisationMemberUpdateDialog } from '~/components/dialogs/organisation-member-update-dialog';
|
import { OrganisationMemberUpdateDialog } from '~/components/dialogs/organisation-member-update-dialog';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export const OrganisationMembersDataTable = () => {
|
export const OrganisationMembersDataTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { TeamCheckoutCreateDialog } from '~/components/dialogs/team-checkout-create-dialog';
|
|
||||||
|
|
||||||
import { UserSettingsPendingTeamsTableActions } from './user-settings-pending-teams-table-actions';
|
|
||||||
|
|
||||||
export const OrganisationPendingTeamsTable = () => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
|
||||||
|
|
||||||
const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
|
|
||||||
{
|
|
||||||
query: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Team`),
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
|
||||||
}
|
|
||||||
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Created on`),
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<UserSettingsPendingTeamsTableActions
|
|
||||||
className="justify-end"
|
|
||||||
pendingTeamId={row.original.id}
|
|
||||||
onPayClick={setCheckoutPendingTeamId}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const searchParamCheckout = searchParams?.get('checkout');
|
|
||||||
|
|
||||||
if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) {
|
|
||||||
setCheckoutPendingTeamId(parseInt(searchParamCheckout));
|
|
||||||
updateSearchParams({ checkout: null });
|
|
||||||
}
|
|
||||||
}, [searchParams, updateSearchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/3 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-10 w-16 rounded" />
|
|
||||||
<Skeleton className="h-10 w-20 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) =>
|
|
||||||
results.totalPages > 1 && (
|
|
||||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
<TeamCheckoutCreateDialog
|
|
||||||
pendingTeamId={checkoutPendingTeamId}
|
|
||||||
onClose={() => setCheckoutPendingTeamId(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -7,6 +7,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
@ -19,8 +20,6 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat
|
|||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
import { TeamDeleteDialog } from '../dialogs/team-delete-dialog';
|
import { TeamDeleteDialog } from '../dialogs/team-delete-dialog';
|
||||||
|
|
||||||
export const OrganisationTeamsTable = () => {
|
export const OrganisationTeamsTable = () => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { EditIcon, MoreHorizontal, Trash2Icon } from 'lucide-react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
@ -26,14 +27,13 @@ import {
|
|||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
|
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
|
||||||
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
|
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
|
||||||
|
|
||||||
export const TeamMembersDataTable = () => {
|
export const TeamMembersTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|||||||
@ -3,8 +3,7 @@ import { useMemo } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
@ -21,14 +20,10 @@ import { TableCell } from '@documenso/ui/primitives/table';
|
|||||||
|
|
||||||
import { OrganisationLeaveDialog } from '../dialogs/organisation-leave-dialog';
|
import { OrganisationLeaveDialog } from '../dialogs/organisation-leave-dialog';
|
||||||
|
|
||||||
export const UserSettingsOrganisationsTable = () => {
|
export const UserOrganisationsTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.organisation.getMany.useQuery();
|
const { data, isLoading, isLoadingError } = trpc.organisation.getMany.useQuery();
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
@ -38,14 +33,6 @@ export const UserSettingsOrganisationsTable = () => {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Todo: Orgs
|
|
||||||
// const results = data ?? {
|
|
||||||
// data: [],
|
|
||||||
// perPage: 10,
|
|
||||||
// currentPage: 1,
|
|
||||||
// totalPages: 1,
|
|
||||||
// };
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -97,7 +84,6 @@ export const UserSettingsOrganisationsTable = () => {
|
|||||||
organisationId={row.original.id}
|
organisationId={row.original.id}
|
||||||
organisationName={row.original.name}
|
organisationName={row.original.name}
|
||||||
organisationAvatarImageId={row.original.avatarImageId}
|
organisationAvatarImageId={row.original.avatarImageId}
|
||||||
organisationMemberId={row.original.currentMemberId}
|
|
||||||
role={row.original.currentOrganisationRole}
|
role={row.original.currentOrganisationRole}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@ -123,7 +109,6 @@ export const UserSettingsOrganisationsTable = () => {
|
|||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
// onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
error={{
|
||||||
enable: isLoadingError,
|
enable: isLoadingError,
|
||||||
}}
|
}}
|
||||||
@ -157,13 +142,7 @@ export const UserSettingsOrganisationsTable = () => {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{/* {(table) =>
|
|
||||||
results.totalPages > 1 && (
|
|
||||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
||||||
)
|
|
||||||
} */}
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type UserSettingsPendingTeamsTableActionsProps = {
|
|
||||||
className?: string;
|
|
||||||
pendingTeamId: number;
|
|
||||||
onPayClick: (pendingTeamId: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UserSettingsPendingTeamsTableActions = ({
|
|
||||||
className,
|
|
||||||
pendingTeamId,
|
|
||||||
onPayClick,
|
|
||||||
}: UserSettingsPendingTeamsTableActionsProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamPending, isPending: deletingTeam } =
|
|
||||||
trpc.team.deleteTeamPending.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Pending team deleted.`),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to delete the pending team. Please try again later.`,
|
|
||||||
),
|
|
||||||
duration: 10000,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset disabled={deletingTeam} className={cn('flex justify-end space-x-2', className)}>
|
|
||||||
<Button variant="outline" onClick={() => onPayClick(pendingTeamId)}>
|
|
||||||
<Trans>Pay</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
loading={deletingTeam}
|
|
||||||
onClick={async () => deleteTeamPending({ pendingTeamId: pendingTeamId })}
|
|
||||||
>
|
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</Button>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -3,18 +3,14 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { Link, Outlet, redirect } from 'react-router';
|
import { Link, Outlet, redirect } from 'react-router';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
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 { 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 { 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 { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { AppBanner } from '~/components/general/app-banner';
|
|
||||||
import { Header } from '~/components/general/app-header';
|
import { Header } from '~/components/general/app-header';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
|
||||||
import { OrganisationProvider } from '~/providers/organisation';
|
|
||||||
import { TeamProvider } from '~/providers/team';
|
import { TeamProvider } from '~/providers/team';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
@ -35,23 +31,23 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [limits, banner] = await Promise.all([
|
// const [limits, banner] = await Promise.all([
|
||||||
getLimits({ headers: requestHeaders }),
|
// getLimits({ headers: requestHeaders }),
|
||||||
getSiteSettings().then((settings) =>
|
// getSiteSettings().then((settings) =>
|
||||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
// settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
banner,
|
// banner,
|
||||||
limits,
|
// limits,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
|
|
||||||
const { banner, limits } = loaderData;
|
// const { banner, limits } = loaderData;
|
||||||
|
|
||||||
const teamUrl = params.teamUrl;
|
const teamUrl = params.teamUrl;
|
||||||
const orgUrl = params.orgUrl;
|
const orgUrl = params.orgUrl;
|
||||||
@ -140,12 +136,12 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
|||||||
return (
|
return (
|
||||||
<OrganisationProvider organisation={currentOrganisation}>
|
<OrganisationProvider organisation={currentOrganisation}>
|
||||||
<TeamProvider team={currentTeam || null}>
|
<TeamProvider team={currentTeam || null}>
|
||||||
<LimitsProvider initialValue={limits}>
|
<LimitsProvider>
|
||||||
<div id="portal-header"></div>
|
<div id="portal-header"></div>
|
||||||
|
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
{banner && <AppBanner banner={banner} />}
|
{/* {banner && <AppBanner banner={banner} />} */}
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
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 { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
@ -21,8 +29,12 @@ export default function AdminLayout() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<div className="grid grid-cols-12 md:mt-8 md:gap-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
|
<div
|
||||||
className={cn(
|
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',
|
'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>
|
</Link>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -70,20 +110,6 @@ export default function AdminLayout() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
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()}/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
import {
|
import {
|
||||||
getUserWithSignedDocumentMonthlyGrowth,
|
getOrganisationsWithSubscriptionsCount,
|
||||||
getUsersCount,
|
getUsersCount,
|
||||||
getUsersWithSubscriptionsCount,
|
|
||||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
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() {
|
export async function loader() {
|
||||||
const [
|
const [
|
||||||
usersCount,
|
usersCount,
|
||||||
usersWithSubscriptionsCount,
|
organisationsWithSubscriptionsCount,
|
||||||
docStats,
|
docStats,
|
||||||
recipientStats,
|
recipientStats,
|
||||||
signerConversionMonthly,
|
signerConversionMonthly,
|
||||||
// userWithAtLeastOneDocumentPerMonth,
|
// userWithAtLeastOneDocumentPerMonth,
|
||||||
// userWithAtLeastOneDocumentSignedPerMonth,
|
// userWithAtLeastOneDocumentSignedPerMonth,
|
||||||
MONTHLY_USERS_SIGNED,
|
// MONTHLY_USERS_SIGNED,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getUsersCount(),
|
getUsersCount(),
|
||||||
getUsersWithSubscriptionsCount(),
|
getOrganisationsWithSubscriptionsCount(),
|
||||||
getDocumentStats(),
|
getDocumentStats(),
|
||||||
getRecipientsStats(),
|
getRecipientsStats(),
|
||||||
getSignerConversionMonthly(),
|
getSignerConversionMonthly(),
|
||||||
// getUserWithAtLeastOneDocumentPerMonth(),
|
// getUserWithAtLeastOneDocumentPerMonth(),
|
||||||
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||||
getUserWithSignedDocumentMonthlyGrowth(),
|
// getUserWithSignedDocumentMonthlyGrowth(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
usersCount,
|
usersCount,
|
||||||
usersWithSubscriptionsCount,
|
organisationsWithSubscriptionsCount,
|
||||||
docStats,
|
docStats,
|
||||||
recipientStats,
|
recipientStats,
|
||||||
signerConversionMonthly,
|
signerConversionMonthly,
|
||||||
MONTHLY_USERS_SIGNED,
|
// MONTHLY_USERS_SIGNED,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,11 +66,11 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
usersCount,
|
usersCount,
|
||||||
usersWithSubscriptionsCount,
|
organisationsWithSubscriptionsCount,
|
||||||
docStats,
|
docStats,
|
||||||
recipientStats,
|
recipientStats,
|
||||||
signerConversionMonthly,
|
signerConversionMonthly,
|
||||||
MONTHLY_USERS_SIGNED,
|
// MONTHLY_USERS_SIGNED,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -86,7 +85,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
<CardMetric
|
<CardMetric
|
||||||
icon={UserPlus}
|
icon={UserPlus}
|
||||||
title={_(msg`Active Subscriptions`)}
|
title={_(msg`Active Subscriptions`)}
|
||||||
value={usersWithSubscriptionsCount}
|
value={organisationsWithSubscriptionsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
||||||
@ -149,12 +148,14 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||||
<AdminStatsUsersWithDocumentsChart
|
<AdminStatsUsersWithDocumentsChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={[]}
|
||||||
|
// data={MONTHLY_USERS_SIGNED}
|
||||||
title={_(msg`MAU (created document)`)}
|
title={_(msg`MAU (created document)`)}
|
||||||
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
||||||
/>
|
/>
|
||||||
<AdminStatsUsersWithDocumentsChart
|
<AdminStatsUsersWithDocumentsChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={[]}
|
||||||
|
// data={MONTHLY_USERS_SIGNED}
|
||||||
completed
|
completed
|
||||||
title={_(msg`MAU (had document completed)`)}
|
title={_(msg`MAU (had document completed)`)}
|
||||||
tooltip={_(
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
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 { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||||
|
|
||||||
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
|
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 perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||||
const search = url.searchParams.get('search') || '';
|
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 }),
|
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 {
|
return {
|
||||||
users,
|
users,
|
||||||
totalPages,
|
totalPages,
|
||||||
individualPriceIds,
|
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
|
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { users, totalPages, individualPriceIds, page, perPage } = loaderData;
|
const { users, totalPages, page, perPage } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -42,7 +36,6 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp
|
|||||||
|
|
||||||
<AdminDashboardUsersTable
|
<AdminDashboardUsersTable
|
||||||
users={users}
|
users={users}
|
||||||
individualPriceIds={individualPriceIds}
|
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
page={page}
|
page={page}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ScrollArea, ScrollBar } from '@documenso/ui/primitives/scroll-area';
|
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 { InboxTable } from '~/components/tables/inbox-table';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@ -51,6 +52,8 @@ export default function DashboardPage() {
|
|||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
<Trans>Welcome back! Here's an overview of your account.</Trans>
|
<Trans>Welcome back! Here's an overview of your account.</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<OrganisationInvitations className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Organisations Section */}
|
{/* Organisations Section */}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
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 { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
@ -29,7 +30,6 @@ import {
|
|||||||
|
|
||||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||||
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
export default function OrganisationSettingsTeamsPage() {
|
export default function OrganisationSettingsTeamsPage() {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
|||||||
@ -3,12 +3,13 @@ import { Outlet } from 'react-router';
|
|||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export default function Layout({ params }: Route.ComponentProps) {
|
export default function Layout({ params }: Route.ComponentProps) {
|
||||||
|
// Todo: orgs
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
{/* {currentOrganisation.subscription &&
|
{/* {currentOrganisation.subscription &&
|
||||||
currentOrganisation.subscription.status !== SubscriptionStatus.ACTIVE && (
|
currentOrganisation.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||||
<PortalComponent target="portal-header">
|
<PortalComponent target="portal-header">
|
||||||
<TeamLayoutBillingBanner
|
<OrganisationBillingBanner
|
||||||
subscriptionStatus={currentOrganisation.subscription.status}
|
subscriptionStatus={currentOrganisation.subscription.status}
|
||||||
teamId={currentOrganisation.id}
|
teamId={currentOrganisation.id}
|
||||||
userRole={currentOrganisation.currentTeamMember.role}
|
userRole={currentOrganisation.currentTeamMember.role}
|
||||||
|
|||||||
@ -4,13 +4,13 @@ import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } f
|
|||||||
import { FaUsers } from 'react-icons/fa6';
|
import { FaUsers } from 'react-icons/fa6';
|
||||||
import { Link, NavLink, Outlet } from 'react-router';
|
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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
@ -75,7 +75,7 @@ export default function SettingsLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
secondaryButton={null}
|
secondaryButton={null}
|
||||||
></GenericErrorLayout>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,16 @@
|
|||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
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';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
@ -7,6 +18,36 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamsSettingBillingPage() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-row items-end justify-between">
|
<div className="flex flex-row items-end justify-between">
|
||||||
@ -16,120 +57,91 @@ export default function TeamsSettingBillingPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OrganisationBillingPortalButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{!subscription && canManageBilling && <BillingPlans plans={plans} />}
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<OrganisationBillingInvoicesTable
|
||||||
|
organisationId={organisation.id}
|
||||||
|
subscriptionExists={Boolean(subscription)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</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>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
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 { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||||
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
|
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import {
|
import {
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
ORGANISATION_MEMBER_ROLE_MAP,
|
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 { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
import type { Route } from './+types/org.$orgUrl.settings.groups.$id';
|
import type { Route } from './+types/org.$orgUrl.settings.groups.$id';
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
@ -19,7 +20,6 @@ import {
|
|||||||
type TDocumentPreferencesFormSchema,
|
type TDocumentPreferencesFormSchema,
|
||||||
} from '~/components/forms/document-preferences-form';
|
} from '~/components/forms/document-preferences-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
|
|
||||||
import type { Route } from './+types/org.$orgUrl.settings.preferences';
|
import type { Route } from './+types/org.$orgUrl.settings.preferences';
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { OrganisationPendingTeamsTable } from '~/components/tables/organisation-pending-teams-table';
|
|
||||||
import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-table';
|
import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-table';
|
||||||
|
|
||||||
export default function OrganisationSettingsTeamsPage() {
|
export default function OrganisationSettingsTeamsPage() {
|
||||||
@ -25,15 +21,6 @@ export default function OrganisationSettingsTeamsPage() {
|
|||||||
|
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
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.
|
* Handle debouncing the search query.
|
||||||
*/
|
*/
|
||||||
@ -55,36 +42,14 @@ export default function OrganisationSettingsTeamsPage() {
|
|||||||
<TeamCreateDialog />
|
<TeamCreateDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<div>
|
<Input
|
||||||
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
defaultValue={searchQuery}
|
||||||
<Input
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
defaultValue={searchQuery}
|
placeholder={t`Search`}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
className="mb-4"
|
||||||
placeholder={t`Search`}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
<OrganisationTeamsTable />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,7 +4,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { OrganisationCreateDialog } from '~/components/dialogs/organisation-create-dialog';
|
import { OrganisationCreateDialog } from '~/components/dialogs/organisation-create-dialog';
|
||||||
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
|
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
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() {
|
export default function TeamsSettingsPage() {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -18,7 +18,7 @@ export default function TeamsSettingsPage() {
|
|||||||
<OrganisationCreateDialog />
|
<OrganisationCreateDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<UserSettingsOrganisationsTable />
|
<UserOrganisationsTable />
|
||||||
|
|
||||||
<div className="mt-8 space-y-8">
|
<div className="mt-8 space-y-8">
|
||||||
<OrganisationInvitations />
|
<OrganisationInvitations />
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { Link, redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
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 { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
@ -78,20 +77,14 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDocumentEnterprise = await isUserEnterprise({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
document,
|
document,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
isDocumentEnterprise,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentEditPage() {
|
export default function DocumentEditPage() {
|
||||||
const { document, documentRootPath, isDocumentEnterprise } = useSuperLoaderData<typeof loader>();
|
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
const { recipients } = document;
|
const { recipients } = document;
|
||||||
|
|
||||||
@ -133,7 +126,6 @@ export default function DocumentEditPage() {
|
|||||||
className="mt-6"
|
className="mt-6"
|
||||||
initialDocument={document}
|
initialDocument={document}
|
||||||
documentRootPath={documentRootPath}
|
documentRootPath={documentRootPath}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
import { useLocation, useSearchParams } from 'react-router';
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { TeamGroupsTable } from '~/components/tables/team-groups-table';
|
import { TeamGroupsTable } from '~/components/tables/team-groups-table';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export default function TeamsSettingsGroupsPage() {
|
export default function TeamsSettingsGroupsPage() {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { useLocation, useSearchParams } from 'react-router';
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
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 { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
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() {
|
export default function TeamsSettingsMembersPage() {
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@ -43,21 +42,18 @@ export default function TeamsSettingsMembersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
|
||||||
title={_(msg`Team Members`)}
|
|
||||||
subtitle={_(msg`Manage the members of your team.`)}
|
|
||||||
>
|
|
||||||
<TeamMemberCreateDialog />
|
<TeamMemberCreateDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
defaultValue={searchQuery}
|
defaultValue={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={_(msg`Search`)}
|
placeholder={t`Search`}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TeamMembersDataTable />
|
<TeamMembersTable />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -14,7 +15,6 @@ import {
|
|||||||
type TDocumentPreferencesFormSchema,
|
type TDocumentPreferencesFormSchema,
|
||||||
} from '~/components/forms/document-preferences-form';
|
} from '~/components/forms/document-preferences-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { useCurrentOrganisation } from '~/providers/organisation';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
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 { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
@ -43,20 +42,14 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(templateRootPath);
|
throw redirect(templateRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTemplateEnterprise = await isUserEnterprise({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
template,
|
template,
|
||||||
isTemplateEnterprise,
|
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TemplateEditPage() {
|
export default function TemplateEditPage() {
|
||||||
const { template, isTemplateEnterprise, templateRootPath } = useSuperLoaderData<typeof loader>();
|
const { template, templateRootPath } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
<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"
|
className="mt-6"
|
||||||
initialTemplate={template}
|
initialTemplate={template}
|
||||||
templateRootPath={templateRootPath}
|
templateRootPath={templateRootPath}
|
||||||
isEnterprise={isTemplateEnterprise}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
|
||||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
RECIPIENT_ROLES_DESCRIPTION,
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
@ -28,8 +27,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@documenso/ui/primitives/table';
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
|
||||||
|
|
||||||
import type { Route } from './+types/certificate';
|
import type { Route } from './+types/certificate';
|
||||||
|
|
||||||
const FRIENDLY_SIGNING_REASONS = {
|
const FRIENDLY_SIGNING_REASONS = {
|
||||||
@ -60,8 +57,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
throw redirect('/');
|
throw redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPlatformDocument = await isDocumentPlatform(document);
|
|
||||||
|
|
||||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||||
|
|
||||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||||
@ -73,7 +68,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
documentLanguage,
|
documentLanguage,
|
||||||
isPlatformDocument,
|
|
||||||
auditLogs,
|
auditLogs,
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
@ -89,7 +83,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
* Update: Maybe <Trans> tags work now after RR7 migration.
|
* Update: Maybe <Trans> tags work now after RR7 migration.
|
||||||
*/
|
*/
|
||||||
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
|
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
|
||||||
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
const { document, documentLanguage, auditLogs, messages } = loaderData;
|
||||||
|
|
||||||
const { i18n, _ } = useLingui();
|
const { i18n, _ } = useLingui();
|
||||||
|
|
||||||
@ -341,7 +335,8 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isPlatformDocument && (
|
{/* Todo: orgs - This shit does not make sense */}
|
||||||
|
{/* {isPlatformDocument && (
|
||||||
<div className="my-8 flex-row-reverse">
|
<div className="my-8 flex-row-reverse">
|
||||||
<div className="flex items-end justify-end gap-x-4">
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||||
@ -351,7 +346,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
<BrandingLogo className="max-h-6 print:max-h-4" />
|
<BrandingLogo className="max-h-6 print:max-h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
@ -53,19 +52,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
// For users who do not exist yet, set the team invite status to accepted, which is checked during
|
|
||||||
// user creation to determine if we should add the user to the team at that time.
|
|
||||||
if (!user && organisationMemberInvite.status !== OrganisationMemberInviteStatus.ACCEPTED) {
|
|
||||||
await prisma.organisationMemberInvite.update({
|
|
||||||
where: {
|
|
||||||
id: organisationMemberInvite.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
state: 'LoginRequired',
|
state: 'LoginRequired',
|
||||||
|
|||||||
@ -2,10 +2,8 @@ import { data } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
@ -36,10 +34,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw new Response('Not found', { status: 404 });
|
throw new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||||
|
|
||||||
|
const allowCustomBranding = organisationClaim.flags.branding;
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
|
||||||
// TODO: Make this more robust, we need to ensure the owner is either
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
// TODO: the member of a team that has an active subscription, is an early
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
// TODO: adopter or is an enterprise user.
|
// TODO: adopter or is an enterprise user.
|
||||||
if (IS_BILLING_ENABLED() && !template.teamId) {
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
throw data(
|
throw data(
|
||||||
{
|
{
|
||||||
type: 'embed-paywall',
|
type: 'embed-paywall',
|
||||||
@ -56,18 +59,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
documentAuth: template.authOptions,
|
documentAuth: template.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
|
||||||
isDocumentPlatform(template),
|
|
||||||
isUserEnterprise({
|
|
||||||
userId: template.userId,
|
|
||||||
teamId: template.teamId ?? undefined,
|
|
||||||
}),
|
|
||||||
isUserCommunityPlan({
|
|
||||||
userId: template.userId,
|
|
||||||
teamId: template.teamId ?? undefined,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||||
.with(null, () => true)
|
.with(null, () => true)
|
||||||
@ -102,7 +93,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
teamId: template.teamId,
|
teamId: template.teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hidePoweredBy = settings.brandingHidePoweredBy;
|
// Todo: orgs - figure out
|
||||||
|
// const hidePoweredBy = settings.brandingHidePoweredBy;
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
token,
|
token,
|
||||||
@ -110,10 +102,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
template,
|
template,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
hidePoweredBy,
|
allowCustomBranding,
|
||||||
isPlatformDocument,
|
allowEmbedSigningWhitelabel,
|
||||||
isEnterpriseDocument,
|
|
||||||
isCommunityPlan,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,10 +114,8 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
template,
|
template,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
hidePoweredBy,
|
allowCustomBranding,
|
||||||
isPlatformDocument,
|
allowEmbedSigningWhitelabel,
|
||||||
isEnterpriseDocument,
|
|
||||||
isCommunityPlan,
|
|
||||||
} = useSuperLoaderData<typeof loader>();
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -152,10 +140,8 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={template.templateMeta}
|
metadata={template.templateMeta}
|
||||||
hidePoweredBy={
|
hidePoweredBy={allowCustomBranding}
|
||||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||||
}
|
|
||||||
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
|
||||||
/>
|
/>
|
||||||
</DocumentSigningRecipientProvider>
|
</DocumentSigningRecipientProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -3,13 +3,11 @@ import { data } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
@ -51,10 +49,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw new Response('Not found', { status: 404 });
|
throw new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Todo: orgs - test
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const allowCustomBranding = organisationClaim.flags.branding;
|
||||||
|
|
||||||
// TODO: Make this more robust, we need to ensure the owner is either
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
// TODO: the member of a team that has an active subscription, is an early
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
// TODO: adopter or is an enterprise user.
|
// TODO: adopter or is an enterprise user.
|
||||||
if (IS_BILLING_ENABLED() && !document.teamId) {
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
throw data(
|
throw data(
|
||||||
{
|
{
|
||||||
type: 'embed-paywall',
|
type: 'embed-paywall',
|
||||||
@ -65,18 +69,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
|
||||||
isDocumentPlatform(document),
|
|
||||||
isUserEnterprise({
|
|
||||||
userId: document.userId,
|
|
||||||
teamId: document.teamId ?? undefined,
|
|
||||||
}),
|
|
||||||
isUserCommunityPlan({
|
|
||||||
userId: document.userId,
|
|
||||||
teamId: document.teamId ?? undefined,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
});
|
});
|
||||||
@ -123,7 +115,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hidePoweredBy = settings.brandingHidePoweredBy;
|
// Todo: orgs - figure out
|
||||||
|
// const hidePoweredBy = settings.brandingHidePoweredBy;
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
token,
|
token,
|
||||||
@ -133,10 +126,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
hidePoweredBy,
|
allowCustomBranding,
|
||||||
isPlatformDocument,
|
allowEmbedSigningWhitelabel,
|
||||||
isEnterpriseDocument,
|
|
||||||
isCommunityPlan,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,10 +140,8 @@ export default function EmbedSignDocumentPage() {
|
|||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
hidePoweredBy,
|
allowCustomBranding,
|
||||||
isPlatformDocument,
|
allowEmbedSigningWhitelabel,
|
||||||
isEnterpriseDocument,
|
|
||||||
isCommunityPlan,
|
|
||||||
} = useSuperLoaderData<typeof loader>();
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -178,10 +167,8 @@ export default function EmbedSignDocumentPage() {
|
|||||||
completedFields={completedFields}
|
completedFields={completedFields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={isDocumentCompleted(document.status)}
|
isCompleted={isDocumentCompleted(document.status)}
|
||||||
hidePoweredBy={
|
hidePoweredBy={allowCustomBranding}
|
||||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
}
|
|
||||||
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
/>
|
/>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -255,7 +255,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
const { remaining } = await getServerLimits({ teamId: team.id });
|
||||||
|
|
||||||
if (remaining.documents <= 0) {
|
if (remaining.documents <= 0) {
|
||||||
return {
|
return {
|
||||||
@ -464,7 +464,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { body, params } = args;
|
const { body, params } = args;
|
||||||
|
|
||||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
const { remaining } = await getServerLimits({ teamId: team?.id });
|
||||||
|
|
||||||
if (remaining.documents <= 0) {
|
if (remaining.documents <= 0) {
|
||||||
return {
|
return {
|
||||||
@ -562,7 +562,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||||
const { body, params } = args;
|
const { body, params } = args;
|
||||||
|
|
||||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
const { remaining } = await getServerLimits({ teamId: team?.id });
|
||||||
|
|
||||||
if (remaining.documents <= 0) {
|
if (remaining.documents <= 0) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -48,7 +48,7 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/organisation/invite/${teamInvite.token}`);
|
||||||
await expect(page.getByRole('heading')).toContainText('Team invitation');
|
await expect(page.getByRole('heading')).toContainText('Team invitation');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/organisation/invite/${teamInvite.token}`);
|
||||||
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
|
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ export type SessionUser = Pick<
|
|||||||
| 'twoFactorEnabled'
|
| 'twoFactorEnabled'
|
||||||
| 'roles'
|
| 'roles'
|
||||||
| 'signature'
|
| 'signature'
|
||||||
| 'customerId'
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type SessionValidationResult =
|
export type SessionValidationResult =
|
||||||
@ -98,7 +97,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
|||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
roles: true,
|
roles: true,
|
||||||
signature: true,
|
signature: true,
|
||||||
customerId: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { Hono } from 'hono';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||||
@ -21,7 +20,6 @@ import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-on
|
|||||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||||
import { alphaid } from '@documenso/lib/universal/id';
|
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@ -149,17 +147,9 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, email, password, signature, url } = c.req.valid('json');
|
const { name, email, password, signature } = c.req.valid('json');
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED() && url && url.length < 6) {
|
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||||
throw new AppError('PREMIUM_PROFILE_URL', {
|
|
||||||
message: 'Only subscribers can have a username shorter than 6 characters',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgUrl = url || alphaid(12);
|
|
||||||
|
|
||||||
const user = await createUser({ name, email, password, signature, orgUrl }).catch((err) => {
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -37,15 +37,6 @@ export const ZSignUpSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: ZPasswordSchema,
|
password: ZPasswordSchema,
|
||||||
signature: z.string().nullish(),
|
signature: z.string().nullish(),
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.min(1)
|
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
|
||||||
message: 'Username can only container alphanumeric characters and dashes.',
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
|
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
|
||||||
|
|||||||
@ -6,7 +6,13 @@ export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
|||||||
directTemplates: 3,
|
directTemplates: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
|
export const INACTIVE_PLAN_LIMITS: TLimitsSchema = {
|
||||||
|
documents: 0,
|
||||||
|
recipients: 0,
|
||||||
|
directTemplates: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAID_PLAN_LIMITS: TLimitsSchema = {
|
||||||
documents: Infinity,
|
documents: Infinity,
|
||||||
recipients: Infinity,
|
recipients: Infinity,
|
||||||
directTemplates: Infinity,
|
directTemplates: Infinity,
|
||||||
|
|||||||
@ -17,11 +17,11 @@ export const limitsHandler = async (req: Request) => {
|
|||||||
teamId = parseInt(rawTeamId, 10);
|
teamId = parseInt(rawTeamId, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId && rawTeamId) {
|
if (!teamId) {
|
||||||
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
|
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const limits = await getServerLimits({ email: user.email, teamId });
|
const limits = await getServerLimits({ userId: user.id, teamId });
|
||||||
|
|
||||||
return Response.json(limits, {
|
return Response.json(limits, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -2,21 +2,25 @@ import { DocumentSource, SubscriptionStatus } from '@prisma/client';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
|
import {
|
||||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
|
FREE_PLAN_LIMITS,
|
||||||
|
INACTIVE_PLAN_LIMITS,
|
||||||
|
PAID_PLAN_LIMITS,
|
||||||
|
SELFHOSTED_PLAN_LIMITS,
|
||||||
|
} from './constants';
|
||||||
import { ERROR_CODES } from './errors';
|
import { ERROR_CODES } from './errors';
|
||||||
import type { TLimitsResponseSchema } from './schema';
|
import type { TLimitsResponseSchema } from './schema';
|
||||||
import { ZLimitsSchema } from './schema';
|
|
||||||
|
|
||||||
export type GetServerLimitsOptions = {
|
export type GetServerLimitsOptions = {
|
||||||
email: string;
|
userId: number;
|
||||||
teamId: number | null;
|
teamId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerLimits = async ({
|
export const getServerLimits = async ({
|
||||||
email,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
|
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
|
||||||
if (!IS_BILLING_ENABLED()) {
|
if (!IS_BILLING_ENABLED()) {
|
||||||
@ -26,141 +30,89 @@ export const getServerLimits = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email) {
|
const organisation = await prisma.organisation.findFirst({
|
||||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
|
|
||||||
};
|
|
||||||
|
|
||||||
type HandleUserLimitsOptions = {
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
email,
|
teams: {
|
||||||
},
|
some: {
|
||||||
include: {
|
id: teamId,
|
||||||
subscriptions: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let quota = structuredClone(FREE_PLAN_LIMITS);
|
|
||||||
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
|
||||||
|
|
||||||
const activeSubscriptions = user.subscriptions.filter(
|
|
||||||
({ status }) => status === SubscriptionStatus.ACTIVE,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeSubscriptions.length > 0) {
|
|
||||||
const documentPlanPrices = await getDocumentRelatedPrices();
|
|
||||||
|
|
||||||
for (const subscription of activeSubscriptions) {
|
|
||||||
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
|
|
||||||
|
|
||||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentQuota = ZLimitsSchema.parse(
|
|
||||||
'metadata' in price.product ? price.product.metadata : {},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the subscription with the highest quota.
|
|
||||||
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
|
|
||||||
quota = currentQuota;
|
|
||||||
remaining = structuredClone(quota);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assume all active subscriptions provide unlimited direct templates.
|
|
||||||
remaining.directTemplates = Infinity;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [documents, directTemplates] = await Promise.all([
|
|
||||||
prisma.document.count({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
teamId: null,
|
|
||||||
createdAt: {
|
|
||||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
|
||||||
},
|
|
||||||
source: {
|
|
||||||
not: DocumentSource.TEMPLATE_DIRECT_LINK,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
prisma.template.count({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
teamId: null,
|
|
||||||
directLink: {
|
|
||||||
isNot: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
remaining.documents = Math.max(remaining.documents - documents, 0);
|
|
||||||
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
quota,
|
|
||||||
remaining,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type HandleTeamLimitsOptions = {
|
|
||||||
email: string;
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
|
|
||||||
const team = await prisma.team.findFirst({
|
|
||||||
where: {
|
|
||||||
id: teamId,
|
|
||||||
members: {
|
members: {
|
||||||
some: {
|
some: {
|
||||||
user: {
|
userId,
|
||||||
email,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscription: true,
|
subscription: true,
|
||||||
|
organisationClaim: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!team) {
|
if (!organisation) {
|
||||||
throw new Error('Team not found');
|
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subscription } = team;
|
const quota = structuredClone(FREE_PLAN_LIMITS);
|
||||||
|
const remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||||
|
|
||||||
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
const subscription = organisation.subscription;
|
||||||
|
|
||||||
|
// Bypass all limits even if plan expired for ENTERPRISE.
|
||||||
|
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
|
||||||
return {
|
return {
|
||||||
quota: {
|
quota: PAID_PLAN_LIMITS,
|
||||||
documents: 0,
|
remaining: PAID_PLAN_LIMITS,
|
||||||
recipients: 0,
|
};
|
||||||
directTemplates: 0,
|
}
|
||||||
},
|
|
||||||
remaining: {
|
// If free tier or plan does not have unlimited documents.
|
||||||
documents: 0,
|
if (!subscription || !organisation.organisationClaim.flags.unlimitedDocuments) {
|
||||||
recipients: 0,
|
const [documents, directTemplates] = await Promise.all([
|
||||||
directTemplates: 0,
|
prisma.document.count({
|
||||||
},
|
where: {
|
||||||
|
team: {
|
||||||
|
organisationId: organisation.id,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
not: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.template.count({
|
||||||
|
where: {
|
||||||
|
team: {
|
||||||
|
organisationId: organisation.id,
|
||||||
|
},
|
||||||
|
directLink: {
|
||||||
|
isNot: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||||
|
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quota,
|
||||||
|
remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If plan expired.
|
||||||
|
if (subscription.status !== SubscriptionStatus.ACTIVE) {
|
||||||
|
return {
|
||||||
|
quota: INACTIVE_PLAN_LIMITS,
|
||||||
|
remaining: INACTIVE_PLAN_LIMITS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quota: structuredClone(TEAM_PLAN_LIMITS),
|
quota: PAID_PLAN_LIMITS,
|
||||||
remaining: structuredClone(TEAM_PLAN_LIMITS),
|
remaining: PAID_PLAN_LIMITS,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
export type GetCheckoutSessionOptions = {
|
export type CreateCheckoutSessionOptions = {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
subscriptionMetadata?: Stripe.Metadata;
|
subscriptionMetadata?: Stripe.Metadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCheckoutSession = async ({
|
// Todo: orgs validate priceId to ensure it's only ones we allow
|
||||||
|
|
||||||
|
export const createCheckoutSession = async ({
|
||||||
customerId,
|
customerId,
|
||||||
priceId,
|
priceId,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
subscriptionMetadata,
|
subscriptionMetadata,
|
||||||
}: GetCheckoutSessionOptions) => {
|
}: CreateCheckoutSessionOptions) => {
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
@ -31,5 +34,11 @@ export const getCheckoutSession = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Failed to create checkout session',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return session.url;
|
return session.url;
|
||||||
};
|
};
|
||||||
13
packages/ee/server-only/stripe/create-customer.ts
Normal file
13
packages/ee/server-only/stripe/create-customer.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
type CreateCustomerOptions = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCustomer = async ({ name, email }: CreateCustomerOptions) => {
|
||||||
|
return await stripe.customers.create({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
|
|
||||||
type CreateOrganisationCustomerOptions = {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe customer for a given Organisation.
|
|
||||||
*/
|
|
||||||
export const createOrganisationCustomer = async ({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
}: CreateOrganisationCustomerOptions) => {
|
|
||||||
return await stripe.customers.create({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
metadata: {
|
|
||||||
type: STRIPE_CUSTOMER_TYPE.ORGANISATION,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
|
|
||||||
type DeleteCustomerPaymentMethodsOptions = {
|
|
||||||
customerId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all attached payment methods for a given customer.
|
|
||||||
*/
|
|
||||||
export const deleteCustomerPaymentMethods = async ({
|
|
||||||
customerId,
|
|
||||||
}: DeleteCustomerPaymentMethodsOptions) => {
|
|
||||||
const paymentMethods = await stripe.paymentMethods.list({
|
|
||||||
customer: customerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
paymentMethods.data.map(async (paymentMethod) =>
|
|
||||||
stripe.paymentMethods.detach(paymentMethod.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
export const getCommunityPlanPrices = async () => {
|
|
||||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCommunityPlanPriceIds = async () => {
|
|
||||||
const prices = await getCommunityPlanPrices();
|
|
||||||
|
|
||||||
return prices.map((price) => price.id);
|
|
||||||
};
|
|
||||||
@ -1,21 +1,4 @@
|
|||||||
import type { User } from '@prisma/client';
|
|
||||||
|
|
||||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a non team Stripe customer by email.
|
|
||||||
*/
|
|
||||||
export const getStripeCustomerByEmail = async (email: string) => {
|
|
||||||
const foundStripeCustomers = await stripe.customers.list({
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
|
|
||||||
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -26,86 +9,3 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Todo: (orgs)
|
|
||||||
/**
|
|
||||||
* Get a stripe customer by user.
|
|
||||||
*
|
|
||||||
* Will create a Stripe customer and update the relevant user if one does not exist.
|
|
||||||
*/
|
|
||||||
export const getStripeCustomerByUser = async (
|
|
||||||
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>,
|
|
||||||
) => {
|
|
||||||
if (user.customerId) {
|
|
||||||
const stripeCustomer = await getStripeCustomerById(user.customerId);
|
|
||||||
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
throw new Error('Missing Stripe customer');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
stripeCustomer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let stripeCustomer = await getStripeCustomerByEmail(user.email);
|
|
||||||
|
|
||||||
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
|
|
||||||
|
|
||||||
if (!stripeCustomer) {
|
|
||||||
stripeCustomer = await stripe.customers.create({
|
|
||||||
name: user.name ?? undefined,
|
|
||||||
email: user.email,
|
|
||||||
metadata: {
|
|
||||||
userId: user.id,
|
|
||||||
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
customerId: stripeCustomer.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync subscriptions if the customer already exists for back filling the DB
|
|
||||||
// and local development.
|
|
||||||
if (isSyncRequired) {
|
|
||||||
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: updatedUser,
|
|
||||||
stripeCustomer,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStripeCustomerIdByUser = async (user: User) => {
|
|
||||||
if (user.customerId !== null) {
|
|
||||||
return user.customerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
|
||||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
|
||||||
customer: stripeCustomerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
stripeSubscriptions.data.map(async (subscription) =>
|
|
||||||
onSubscriptionUpdated({
|
|
||||||
userId,
|
|
||||||
subscription,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the Stripe prices of items that affect the amount of documents a user can create.
|
|
||||||
*/
|
|
||||||
export const getDocumentRelatedPrices = async () => {
|
|
||||||
return await getPricesByPlan([
|
|
||||||
STRIPE_PLAN_TYPE.REGULAR,
|
|
||||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
|
||||||
STRIPE_PLAN_TYPE.PLATFORM,
|
|
||||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
export const getEnterprisePlanPrices = async () => {
|
|
||||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEnterprisePlanPriceIds = async () => {
|
|
||||||
const prices = await getEnterprisePlanPrices();
|
|
||||||
|
|
||||||
return prices.map((price) => price.id);
|
|
||||||
};
|
|
||||||
88
packages/ee/server-only/stripe/get-internal-claim-plans.ts
Normal file
88
packages/ee/server-only/stripe/get-internal-claim-plans.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { clone } from 'remeda';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import {
|
||||||
|
INTERNAL_CLAIM_ID,
|
||||||
|
type InternalClaim,
|
||||||
|
internalClaims,
|
||||||
|
} from '@documenso/lib/types/subscription';
|
||||||
|
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||||
|
|
||||||
|
export type InternalClaimPlans = {
|
||||||
|
[key in INTERNAL_CLAIM_ID]: InternalClaim & {
|
||||||
|
monthlyPrice?: Stripe.Price & {
|
||||||
|
product: Stripe.Product;
|
||||||
|
isVisibleInApp: boolean;
|
||||||
|
friendlyPrice: string;
|
||||||
|
};
|
||||||
|
yearlyPrice?: Stripe.Price & {
|
||||||
|
product: Stripe.Product;
|
||||||
|
isVisibleInApp: boolean;
|
||||||
|
friendlyPrice: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the main Documenso plans from Stripe.
|
||||||
|
*/
|
||||||
|
export const getInternalClaimPlans = async (): Promise<InternalClaimPlans> => {
|
||||||
|
const { data: prices } = await stripe.prices.search({
|
||||||
|
query: `active:'true' type:'recurring'`,
|
||||||
|
expand: ['data.product'],
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const plans: InternalClaimPlans = clone(internalClaims);
|
||||||
|
|
||||||
|
prices.forEach((price) => {
|
||||||
|
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const product = price.product as Stripe.Product;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const productClaimId = product.metadata.claimId as INTERNAL_CLAIM_ID | undefined;
|
||||||
|
const isVisibleInApp = price.metadata.visibleInApp === 'true';
|
||||||
|
|
||||||
|
if (!productClaimId || !Object.values(INTERNAL_CLAIM_ID).includes(productClaimId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.name.includes('Team')) {
|
||||||
|
console.log(JSON.stringify(price, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let usdPrice = toHumanPrice(price.unit_amount ?? 0);
|
||||||
|
|
||||||
|
if (price.recurring?.interval === 'month') {
|
||||||
|
if (product.metadata['isSeatBased'] === 'true') {
|
||||||
|
usdPrice = '50';
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
plans[productClaimId].monthlyPrice = {
|
||||||
|
...price,
|
||||||
|
isVisibleInApp,
|
||||||
|
product,
|
||||||
|
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price.recurring?.interval === 'year') {
|
||||||
|
if (product.metadata['isSeatBased'] === 'true') {
|
||||||
|
usdPrice = '480';
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
plans[productClaimId].yearlyPrice = {
|
||||||
|
...price,
|
||||||
|
isVisibleInApp,
|
||||||
|
product,
|
||||||
|
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
};
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
export const getPlatformPlanPrices = async () => {
|
|
||||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPlatformPlanPriceIds = async () => {
|
|
||||||
const prices = await getPlatformPlanPrices();
|
|
||||||
|
|
||||||
return prices.map((price) => price.id);
|
|
||||||
};
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import type Stripe from 'stripe';
|
|
||||||
|
|
||||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
|
|
||||||
// Utility type to handle usage of the `expand` option.
|
|
||||||
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
|
||||||
|
|
||||||
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
|
||||||
|
|
||||||
export type GetPricesByIntervalOptions = {
|
|
||||||
/**
|
|
||||||
* Filter products by their meta 'plan' attribute.
|
|
||||||
*/
|
|
||||||
plans?: STRIPE_PLAN_TYPE[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions = {}) => {
|
|
||||||
let { data: prices } = await stripe.prices.search({
|
|
||||||
query: `active:'true' type:'recurring'`,
|
|
||||||
expand: ['data.product'],
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
prices = prices.filter((price) => {
|
|
||||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const product = price.product as Stripe.Product;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const filter = !plans || plans.includes(product.metadata?.plan as STRIPE_PLAN_TYPE);
|
|
||||||
|
|
||||||
// Filter out prices for products that are not active.
|
|
||||||
return product.active && filter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const intervals: PriceIntervals = {
|
|
||||||
day: [],
|
|
||||||
week: [],
|
|
||||||
month: [],
|
|
||||||
year: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add each price to the correct interval.
|
|
||||||
for (const price of prices) {
|
|
||||||
if (price.recurring?.interval) {
|
|
||||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
intervals[price.recurring.interval].push(price as PriceWithProduct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order all prices by unit_amount.
|
|
||||||
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
|
||||||
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
|
||||||
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
|
||||||
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
|
||||||
|
|
||||||
return intervals;
|
|
||||||
};
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
|
|
||||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
|
||||||
|
|
||||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
|
||||||
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
|
||||||
|
|
||||||
const prices = await stripe.prices.list({
|
|
||||||
expand: ['data.product'],
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return prices.data.filter(
|
|
||||||
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the prices of items that count as the account's primary plan.
|
|
||||||
*/
|
|
||||||
export const getPrimaryAccountPlanPrices = async () => {
|
|
||||||
return await getPricesByPlan([
|
|
||||||
STRIPE_PLAN_TYPE.REGULAR,
|
|
||||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
|
||||||
STRIPE_PLAN_TYPE.PLATFORM,
|
|
||||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
|
|
||||||
export type GetProductByPriceIdOptions = {
|
|
||||||
priceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
|
|
||||||
const { product } = await stripe.prices.retrieve(priceId, {
|
|
||||||
expand: ['product'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof product === 'string' || 'deleted' in product) {
|
|
||||||
throw new Error('Product not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return product;
|
|
||||||
};
|
|
||||||
42
packages/ee/server-only/stripe/get-subscription.ts
Normal file
42
packages/ee/server-only/stripe/get-subscription.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetSubscriptionOptions = {
|
||||||
|
userId: number;
|
||||||
|
organisationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSubscription = async ({ organisationId, userId }: GetSubscriptionOptions) => {
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: buildOrganisationWhereQuery(
|
||||||
|
organisationId,
|
||||||
|
userId,
|
||||||
|
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
|
),
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Organisation not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organisation.subscription) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
|
||||||
|
expand: ['items.data.price.product'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
organisationSubscription: organisation.subscription,
|
||||||
|
stripeSubscription,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import type Stripe from 'stripe';
|
|
||||||
|
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
export const getTeamPrices = async () => {
|
|
||||||
const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
|
|
||||||
|
|
||||||
const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
|
|
||||||
const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
|
|
||||||
const priceIds = prices.map((price) => price.id);
|
|
||||||
|
|
||||||
if (!monthlyPrice || !yearlyPrice) {
|
|
||||||
throw new AppError('INVALID_CONFIG', {
|
|
||||||
message: 'Missing monthly or yearly price',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthly: {
|
|
||||||
friendlyInterval: 'Monthly',
|
|
||||||
interval: 'monthly',
|
|
||||||
...extractPriceData(monthlyPrice),
|
|
||||||
},
|
|
||||||
yearly: {
|
|
||||||
friendlyInterval: 'Yearly',
|
|
||||||
interval: 'yearly',
|
|
||||||
...extractPriceData(yearlyPrice),
|
|
||||||
},
|
|
||||||
priceIds,
|
|
||||||
} as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractPriceData = (price: Stripe.Price) => {
|
|
||||||
const product =
|
|
||||||
typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
priceId: price.id,
|
|
||||||
description: product?.description ?? '',
|
|
||||||
features: product?.features ?? [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
|
|
||||||
import { getPricesByPlan } from './get-prices-by-plan';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the Stripe prices of items that affect the amount of teams a user can create.
|
|
||||||
*/
|
|
||||||
export const getTeamRelatedPrices = async () => {
|
|
||||||
return await getPricesByPlan([
|
|
||||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
|
||||||
STRIPE_PLAN_TYPE.PLATFORM,
|
|
||||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
|
|
||||||
*/
|
|
||||||
export const getTeamRelatedPriceIds = async () => {
|
|
||||||
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user