mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 09:41:35 +10:00
fix: wip
This commit is contained in:
@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = {
|
|||||||
onDelete?: () => Promise<void> | void;
|
onDelete?: () => Promise<void> | void;
|
||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
teamId?: number;
|
|
||||||
canManageDocument: boolean;
|
canManageDocument: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type DocumentDuplicateDialogProps = {
|
type DocumentDuplicateDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
type DocumentMoveDialogProps = {
|
|
||||||
documentId: number;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Document moved`),
|
|
||||||
description: _(msg`The document has been successfully moved to the selected team.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: error.message || _(msg`An error occurred while moving the document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 7500,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onMove = async () => {
|
|
||||||
if (!selectedTeamId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await moveDocument({ documentId, teamId: selectedTeamId });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Move Document to Team</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={_(msg`Select a team`)} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{isLoadingTeams ? (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<Trans>Loading teams...</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
teams?.map((team) => (
|
|
||||||
<SelectItem key={team.id} value={team.id.toString()}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Avatar className="h-8 w-8">
|
|
||||||
{team.avatarImageId && (
|
|
||||||
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AvatarFallback className="text-sm text-gray-400">
|
|
||||||
{team.name.slice(0, 1).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<span>{team.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
|
|
||||||
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -36,7 +36,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { StackAvatar } from '../general/stack-avatar';
|
import { StackAvatar } from '../general/stack-avatar';
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
|||||||
|
|
||||||
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|||||||
221
apps/remix/app/components/dialogs/organisation-create-dialog.tsx
Normal file
221
apps/remix/app/components/dialogs/organisation-create-dialog.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type OrganisationCreateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
||||||
|
|
||||||
|
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, url }: TCreateOrganisationFormSchema) => {
|
||||||
|
try {
|
||||||
|
const response = await createOrganisation({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
// if (response.paymentRequired) {
|
||||||
|
// await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Your organisation has been created.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||||
|
form.setError('url', {
|
||||||
|
type: 'manual',
|
||||||
|
message: _(msg`This URL is already in use.`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapTextToUrl = (text: string) => {
|
||||||
|
return text.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset();
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
<Trans>Create organisation</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Create organisation</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Create an organisation to collaborate with teams</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
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
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<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 />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<Trans>Create Organisation</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
Normal file
164
apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
export type OrganisationDeleteDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const deleteMessage = _(msg`delete ${organisation.name}`);
|
||||||
|
|
||||||
|
const ZDeleteOrganisationFormSchema = z.object({
|
||||||
|
organisationName: z.literal(deleteMessage, {
|
||||||
|
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZDeleteOrganisationFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
organisationName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await deleteOrganisation({ organisationId: organisation.id });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Your organisation has been successfully deleted.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigate('/settings/organisations');
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure you wish to delete this organisation?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
You are about to delete <span className="font-semibold">{organisation.name}</span>.
|
||||||
|
All data related to this organisation such as teams, documents, and all other
|
||||||
|
resources will be deleted. This action is irreversible.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organisationName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>
|
||||||
|
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,254 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
|
ORGANISATION_MEMBER_ROLE_MAP,
|
||||||
|
} from '@documenso/lib/constants/organisations';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
export type OrganisationGroupCreateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({
|
||||||
|
name: true,
|
||||||
|
memberIds: true,
|
||||||
|
organisationRole: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateOrganisationGroupFormSchema = z.infer<typeof ZCreateOrganisationGroupFormSchema>;
|
||||||
|
|
||||||
|
export const OrganisationGroupCreateDialog = ({
|
||||||
|
trigger,
|
||||||
|
...props
|
||||||
|
}: OrganisationGroupCreateDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZCreateOrganisationGroupFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
organisationRole: OrganisationMemberRole.MEMBER,
|
||||||
|
memberIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
|
||||||
|
|
||||||
|
const { data: membersFindResult, isLoading: isLoadingMembers } =
|
||||||
|
trpc.organisation.member.find.useQuery({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = membersFindResult?.data ?? [];
|
||||||
|
|
||||||
|
const onFormSubmit = async ({
|
||||||
|
name,
|
||||||
|
organisationRole,
|
||||||
|
memberIds,
|
||||||
|
}: TCreateOrganisationGroupFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createOrganisationGroup({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
name,
|
||||||
|
organisationRole,
|
||||||
|
memberIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description: t`Group has been created.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`An unknown error occurred`,
|
||||||
|
description: t`We encountered an unknown error while attempting to create a group. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset();
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
<Trans>Create group</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Create group</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Organise your members into groups which can be assigned to teams</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Group Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organisationRole"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Organisation role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
|
||||||
|
organisation.currentOrganisationRole
|
||||||
|
].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{t(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="memberIds"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Members</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={members.map((member) => ({
|
||||||
|
label: member.name,
|
||||||
|
value: member.id,
|
||||||
|
}))}
|
||||||
|
loading={isLoadingMembers}
|
||||||
|
selectedValues={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="bg-background w-full"
|
||||||
|
emptySelectionPlaceholder={t`Select members`}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Select the members to add to this group</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<Trans>Create</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } 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';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
export type OrganisationGroupDeleteDialogProps = {
|
||||||
|
organisationGroupId: string;
|
||||||
|
organisationGroupName: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationGroupDeleteDialog = ({
|
||||||
|
trigger,
|
||||||
|
organisationGroupId,
|
||||||
|
organisationGroupName,
|
||||||
|
}: OrganisationGroupDeleteDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteGroup, isPending: isDeleting } =
|
||||||
|
trpc.organisation.group.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have successfully removed this group from the organisation.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Delete organisation group</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You are about to remove the following group from{' '}
|
||||||
|
<span className="font-semibold">{organisation.name}</span>.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription className="text-center font-semibold">
|
||||||
|
{organisationGroupName}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeleting}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeleting}
|
||||||
|
onClick={async () =>
|
||||||
|
deleteGroup({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
groupId: organisationGroupId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
120
apps/remix/app/components/dialogs/organisation-leave-dialog.tsx
Normal file
120
apps/remix/app/components/dialogs/organisation-leave-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { OrganisationMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
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 OrganisationLeaveDialogProps = {
|
||||||
|
organisationId: string;
|
||||||
|
organisationName: string;
|
||||||
|
organisationAvatarImageId?: string | null;
|
||||||
|
organisationMemberId: string;
|
||||||
|
role: OrganisationMemberRole;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationLeaveDialog = ({
|
||||||
|
trigger,
|
||||||
|
organisationId,
|
||||||
|
organisationName,
|
||||||
|
organisationAvatarImageId,
|
||||||
|
organisationMemberId,
|
||||||
|
role,
|
||||||
|
}: OrganisationLeaveDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
|
||||||
|
trpc.organisation.member.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have successfully left this organisation.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLeavingOrganisation && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trans>Leave organisation</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>You are about to leave the following organisation.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
|
||||||
|
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={organisationName}
|
||||||
|
secondaryText={_(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isLeavingOrganisation}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isLeavingOrganisation}
|
||||||
|
onClick={async () => leaveOrganisation({ organisationId, organisationMemberId })}
|
||||||
|
>
|
||||||
|
<Trans>Leave</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
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 { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
export type OrganisationMemberDeleteDialogProps = {
|
||||||
|
organisationMemberId: string;
|
||||||
|
organisationMemberName: string;
|
||||||
|
organisationMemberEmail: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationMemberDeleteDialog = ({
|
||||||
|
trigger,
|
||||||
|
organisationMemberId,
|
||||||
|
organisationMemberName,
|
||||||
|
organisationMemberEmail,
|
||||||
|
}: OrganisationMemberDeleteDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } =
|
||||||
|
trpc.organisation.member.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have successfully removed this user from the organisation.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Delete organisation member</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You are about to remove the following user from{' '}
|
||||||
|
<span className="font-semibold">{organisation.name}</span>.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
||||||
|
secondaryText={organisationMemberEmail}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeletingOrganisationMember}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeletingOrganisationMember}
|
||||||
|
onClick={async () =>
|
||||||
|
deleteOrganisationMembers({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
organisationMemberId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
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 { TeamMemberRole } from '@prisma/client';
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||||
import Papa, { type ParseResult } from 'papaparse';
|
import Papa, { type ParseResult } from 'papaparse';
|
||||||
@ -12,9 +12,12 @@ 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 { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import {
|
||||||
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
|
ORGANISATION_MEMBER_ROLE_MAP,
|
||||||
|
} from '@documenso/lib/constants/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -46,15 +49,15 @@ 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 { useCurrentTeam } from '~/providers/team';
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
export type TeamMemberInviteDialogProps = {
|
export type OrganisationMemberInviteDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZInviteTeamMembersFormSchema = z
|
const ZInviteOrganisationMembersFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations,
|
||||||
})
|
})
|
||||||
// Display exactly which rows are duplicates.
|
// Display exactly which rows are duplicates.
|
||||||
.superRefine((items, ctx) => {
|
.superRefine((items, ctx) => {
|
||||||
@ -84,18 +87,21 @@ const ZInviteTeamMembersFormSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
|
||||||
|
|
||||||
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||||
|
|
||||||
const ZImportTeamMemberSchema = z.array(
|
const ZImportOrganisationMemberSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
role: z.nativeEnum(TeamMemberRole),
|
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
|
export const OrganisationMemberInviteDialog = ({
|
||||||
|
trigger,
|
||||||
|
...props
|
||||||
|
}: OrganisationMemberInviteDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||||
@ -103,48 +109,49 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const form = useForm<TInviteTeamMembersFormSchema>({
|
const form = useForm<TInviteOrganisationMembersFormSchema>({
|
||||||
resolver: zodResolver(ZInviteTeamMembersFormSchema),
|
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
invitations: [
|
invitations: [
|
||||||
{
|
{
|
||||||
email: '',
|
email: '',
|
||||||
role: TeamMemberRole.MEMBER,
|
organisationRole: OrganisationMemberRole.MEMBER,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
append: appendTeamMemberInvite,
|
append: appendOrganisationMemberInvite,
|
||||||
fields: teamMemberInvites,
|
fields: organisationMemberInvites,
|
||||||
remove: removeTeamMemberInvite,
|
remove: removeOrganisationMemberInvite,
|
||||||
} = useFieldArray({
|
} = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: 'invitations',
|
name: 'invitations',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
|
const { mutateAsync: createOrganisationMemberInvites } =
|
||||||
|
trpc.organisation.member.invite.createMany.useMutation();
|
||||||
|
|
||||||
const onAddTeamMemberInvite = () => {
|
const onAddOrganisationMemberInvite = () => {
|
||||||
appendTeamMemberInvite({
|
appendOrganisationMemberInvite({
|
||||||
email: '',
|
email: '',
|
||||||
role: TeamMemberRole.MEMBER,
|
organisationRole: OrganisationMemberRole.MEMBER,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
|
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTeamMemberInvites({
|
await createOrganisationMemberInvites({
|
||||||
teamId: team.id,
|
organisationId: organisation.id,
|
||||||
invitations,
|
invitations,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Team invitations have been sent.`),
|
description: _(msg`Organisation invitations have been sent.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -153,7 +160,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
description: _(
|
description: _(
|
||||||
msg`We encountered an unknown error while attempting to invite team members. Please try again later.`,
|
msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`,
|
||||||
),
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -183,17 +190,17 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
role: role.trim().toUpperCase(),
|
organisationRole: role.trim().toUpperCase(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove the first row if it contains the headers.
|
// Remove the first row if it contains the headers.
|
||||||
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
|
if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') {
|
||||||
members.shift();
|
members.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const importedInvitations = ZImportTeamMemberSchema.parse(members);
|
const importedInvitations = ZImportOrganisationMemberSchema.parse(members);
|
||||||
|
|
||||||
form.setValue('invitations', importedInvitations);
|
form.setValue('invitations', importedInvitations);
|
||||||
form.clearErrors('invitations');
|
form.clearErrors('invitations');
|
||||||
@ -229,7 +236,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
});
|
});
|
||||||
|
|
||||||
downloadFile({
|
downloadFile({
|
||||||
filename: 'documenso-team-member-invites-template.csv',
|
filename: 'documenso-organisation-member-invites-template.csv',
|
||||||
data: blob,
|
data: blob,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -251,7 +258,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
<DialogContent position="center">
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Invite team members</Trans>
|
<Trans>Invite organisation members</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
@ -284,8 +291,11 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
||||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
<div
|
||||||
|
className="flex w-full flex-row space-x-4"
|
||||||
|
key={organisationMemberInvite.id}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`invitations.${index}.email`}
|
name={`invitations.${index}.email`}
|
||||||
@ -306,7 +316,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`invitations.${index}.role`}
|
name={`invitations.${index}.organisationRole`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
@ -321,13 +331,13 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
|
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
|
||||||
(role) => (
|
organisation.currentOrganisationRole
|
||||||
<SelectItem key={role} value={role}>
|
].map((role) => (
|
||||||
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
<SelectItem key={role} value={role}>
|
||||||
</SelectItem>
|
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
),
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -342,8 +352,8 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
index === 0 ? 'mt-8' : 'mt-0',
|
index === 0 ? 'mt-8' : 'mt-0',
|
||||||
)}
|
)}
|
||||||
disabled={teamMemberInvites.length === 1}
|
disabled={organisationMemberInvites.length === 1}
|
||||||
onClick={() => removeTeamMemberInvite(index)}
|
onClick={() => removeOrganisationMemberInvite(index)}
|
||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<Trash className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -356,7 +366,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
onClick={() => onAddTeamMemberInvite()}
|
onClick={() => onAddOrganisationMemberInvite()}
|
||||||
>
|
>
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
<Trans>Add more</Trans>
|
<Trans>Add more</Trans>
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
|
ORGANISATION_MEMBER_ROLE_MAP,
|
||||||
|
} from '@documenso/lib/constants/organisations';
|
||||||
|
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type OrganisationMemberUpdateDialogProps = {
|
||||||
|
currentUserOrganisationRole: OrganisationMemberRole;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
organisationId: string;
|
||||||
|
organisationMemberId: string;
|
||||||
|
organisationMemberName: string;
|
||||||
|
organisationMemberRole: OrganisationMemberRole;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateOrganisationMemberFormSchema = z.object({
|
||||||
|
role: z.nativeEnum(OrganisationMemberRole),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
||||||
|
|
||||||
|
export const OrganisationMemberUpdateDialog = ({
|
||||||
|
currentUserOrganisationRole,
|
||||||
|
trigger,
|
||||||
|
organisationId,
|
||||||
|
organisationMemberId,
|
||||||
|
organisationMemberName,
|
||||||
|
organisationMemberRole,
|
||||||
|
...props
|
||||||
|
}: OrganisationMemberUpdateDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
||||||
|
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
role: organisationMemberRole,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
||||||
|
try {
|
||||||
|
await updateOrganisationMember({
|
||||||
|
organisationId,
|
||||||
|
organisationMemberId,
|
||||||
|
data: {
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have updated ${organisationMemberName}.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Update organisation member</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Update organisation member</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You are currently updating{' '}
|
||||||
|
<span className="font-bold">{organisationMemberName}.</span>
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="w-full" position="popper">
|
||||||
|
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map(
|
||||||
|
(role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Update</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -49,7 +49,7 @@ import {
|
|||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type ManagePublicTemplateDialogProps = {
|
export type ManagePublicTemplateDialogProps = {
|
||||||
directTemplates: (Template & {
|
directTemplates: (Template & {
|
||||||
@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({
|
|||||||
|
|
||||||
const [open, onOpenChange] = useState(isOpen);
|
const [open, onOpenChange] = useState(isOpen);
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
|
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
|
||||||
|
|
||||||
|
|||||||
@ -14,8 +14,9 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
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 { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -36,24 +37,29 @@ 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 TeamCreateDialogProps = {
|
export type TeamCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
|
onCreated?: () => Promise<void>;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
|
const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({
|
||||||
teamName: true,
|
teamName: true,
|
||||||
teamUrl: true,
|
teamUrl: true,
|
||||||
|
inheritMembers: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
||||||
|
|
||||||
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
|
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -64,16 +70,19 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
teamName: '',
|
teamName: '',
|
||||||
teamUrl: '',
|
teamUrl: '',
|
||||||
|
inheritMembers: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
|
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
|
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const response = await createTeam({
|
const response = await createTeam({
|
||||||
|
organisationId: organisation.id,
|
||||||
teamName,
|
teamName,
|
||||||
teamUrl,
|
teamUrl,
|
||||||
|
inheritMembers,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@ -83,6 +92,8 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onCreated?.();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Your team has been created.`),
|
description: _(msg`Your team has been created.`),
|
||||||
@ -145,7 +156,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
|
|||||||
<Trans>Create team</Trans>
|
<Trans>Create team</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription>
|
||||||
<Trans>Create a team to collaborate with your team members.</Trans>
|
<Trans>Create a team to collaborate with your team members.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -212,6 +223,31 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
|
|||||||
@ -35,10 +35,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type TeamDeleteDialogProps = {
|
export type TeamDeleteDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
redirectTo?: string;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
|
export const TeamDeleteDialog = ({
|
||||||
|
trigger,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
redirectTo,
|
||||||
|
}: TeamDeleteDialogProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -60,7 +66,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
|
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
@ -72,7 +78,9 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate('/settings/teams');
|
if (redirectTo) {
|
||||||
|
await navigate(redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -113,7 +121,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
<Trans>Delete team</Trans>
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
304
apps/remix/app/components/dialogs/team-group-create-dialog.tsx
Normal file
304
apps/remix/app/components/dialogs/team-group-create-dialog.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZAddTeamMembersFormSchema = z.object({
|
||||||
|
groups: z.array(
|
||||||
|
z.object({
|
||||||
|
organisationGroupId: z.string(),
|
||||||
|
teamRole: z.nativeEnum(TeamMemberRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
||||||
|
|
||||||
|
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const form = useForm<TAddTeamMembersFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
|
||||||
|
|
||||||
|
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
|
||||||
|
organisationId: team.organisationId,
|
||||||
|
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
||||||
|
types: [OrganisationGroupType.CUSTOM],
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamGroupQuery = trpc.team.group.find.useQuery({
|
||||||
|
teamId: team.id,
|
||||||
|
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
||||||
|
});
|
||||||
|
|
||||||
|
const avaliableOrganisationGroups = useMemo(() => {
|
||||||
|
const organisationGroups = organisationGroupQuery.data?.data ?? [];
|
||||||
|
const teamGroups = teamGroupQuery.data?.data ?? [];
|
||||||
|
|
||||||
|
return organisationGroups.filter(
|
||||||
|
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
|
||||||
|
);
|
||||||
|
}, [organisationGroupQuery, teamGroupQuery]);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createTeamGroups({
|
||||||
|
teamId: team.id,
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description: t`Team members have been added.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: t`An unknown error occurred`,
|
||||||
|
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setStep('SELECT');
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||||
|
// Since it would be annoying to redo the whole process.
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||||
|
<Trans>Add groups</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent hideClose={true} position="center">
|
||||||
|
{match(step)
|
||||||
|
.with('SELECT', () => (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Add members</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select members or groups of members to add to the team.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
))
|
||||||
|
.with('ROLES', () => (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Add group roles</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Configure the team roles for each group</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={form.formState.isSubmitting}>
|
||||||
|
{step === 'SELECT' && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="groups"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Groups</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={avaliableOrganisationGroups.map((group) => ({
|
||||||
|
label: group.name ?? group.organisationRole,
|
||||||
|
value: group.id,
|
||||||
|
}))}
|
||||||
|
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
|
||||||
|
selectedValues={field.value.map(
|
||||||
|
({ organisationGroupId }) => organisationGroupId,
|
||||||
|
)}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(
|
||||||
|
value.map((organisationGroupId) => ({
|
||||||
|
organisationGroupId,
|
||||||
|
teamRole:
|
||||||
|
field.value.find(
|
||||||
|
(value) => value.organisationGroupId === organisationGroupId,
|
||||||
|
)?.teamRole || TeamMemberRole.MEMBER,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="bg-background w-full"
|
||||||
|
emptySelectionPlaceholder={t`Select groups`}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Select groups to add to this team</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={form.getValues('groups').length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setStep('ROLES');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'ROLES' && (
|
||||||
|
<>
|
||||||
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
|
{form.getValues('groups').map((group, index) => (
|
||||||
|
<div className="flex w-full flex-row space-x-4" key={index}>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Group</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
className="bg-background"
|
||||||
|
value={
|
||||||
|
avaliableOrganisationGroups.find(
|
||||||
|
({ id }) => id === group.organisationGroupId,
|
||||||
|
)?.name || t`Untitled Group`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`groups.${index}.teamRole`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Team Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
|
||||||
|
(role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Create Groups</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
139
apps/remix/app/components/dialogs/team-group-delete-dialog.tsx
Normal file
139
apps/remix/app/components/dialogs/team-group-delete-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
|
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';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TeamGroupDeleteDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
teamGroupId: string;
|
||||||
|
teamGroupName: string;
|
||||||
|
teamGroupRole: TeamMemberRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamGroupDeleteDialog = ({
|
||||||
|
trigger,
|
||||||
|
teamGroupId,
|
||||||
|
teamGroupName,
|
||||||
|
teamGroupRole,
|
||||||
|
}: TeamGroupDeleteDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have successfully removed this group from the team.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Delete team group</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You are about to remove the following group from{' '}
|
||||||
|
<span className="font-semibold">{team.name}</span>.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
|
||||||
|
<>
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription className="text-center font-semibold">
|
||||||
|
{teamGroupName}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeleting}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeleting}
|
||||||
|
onClick={async () =>
|
||||||
|
deleteGroup({
|
||||||
|
teamId: team.id,
|
||||||
|
teamGroupId: teamGroupId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription className="text-center font-semibold">
|
||||||
|
<Trans>You cannot delete a group which has a higher role than you.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
211
apps/remix/app/components/dialogs/team-group-update-dialog.tsx
Normal file
211
apps/remix/app/components/dialogs/team-group-update-dialog.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EXTENDED_TEAM_MEMBER_ROLE_MAP,
|
||||||
|
TEAM_MEMBER_ROLE_HIERARCHY,
|
||||||
|
} from '@documenso/lib/constants/teams';
|
||||||
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
|
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 {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TeamGroupUpdateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
teamGroupId: string;
|
||||||
|
teamGroupName: string;
|
||||||
|
teamGroupRole: TeamMemberRole;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateTeamGroupFormSchema = z.object({
|
||||||
|
role: z.nativeEnum(TeamMemberRole),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ZUpdateTeamGroupSchema = z.infer<typeof ZUpdateTeamGroupFormSchema>;
|
||||||
|
|
||||||
|
export const TeamGroupUpdateDialog = ({
|
||||||
|
trigger,
|
||||||
|
teamGroupId,
|
||||||
|
teamGroupName,
|
||||||
|
teamGroupRole,
|
||||||
|
...props
|
||||||
|
}: TeamGroupUpdateDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const form = useForm<ZUpdateTeamGroupSchema>({
|
||||||
|
resolver: zodResolver(ZUpdateTeamGroupFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
role: teamGroupRole,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => {
|
||||||
|
try {
|
||||||
|
await updateTeamGroup({
|
||||||
|
id: teamGroupId,
|
||||||
|
data: {
|
||||||
|
teamRole: role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have updated the team group.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to update this team member. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Update team group</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Update team group</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
You are currently updating the <span className="font-bold">{teamGroupName}</span> team
|
||||||
|
group.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="w-full" position="popper">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Update</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription className="text-center font-semibold">
|
||||||
|
<Trans>You cannot modify a group which has a higher role than you.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TeamMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
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 TeamLeaveDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
teamAvatarImageId?: string | null;
|
|
||||||
role: TeamMemberRole;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamLeaveDialog = ({
|
|
||||||
trigger,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
teamAvatarImageId,
|
|
||||||
role,
|
|
||||||
}: TeamLeaveDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully left this team.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to leave this team. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Leave team</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>You are about to leave the following team.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral" padding="tight">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
|
|
||||||
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={teamName}
|
|
||||||
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isLeavingTeam}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isLeavingTeam}
|
|
||||||
onClick={async () => leaveTeam({ teamId })}
|
|
||||||
>
|
|
||||||
<Trans>Leave</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
304
apps/remix/app/components/dialogs/team-member-create-dialog.tsx
Normal file
304
apps/remix/app/components/dialogs/team-member-create-dialog.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TeamMemberCreateDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZAddTeamMembersFormSchema = z.object({
|
||||||
|
members: z.array(
|
||||||
|
z.object({
|
||||||
|
organisationMemberId: z.string(),
|
||||||
|
teamRole: z.nativeEnum(TeamMemberRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
||||||
|
|
||||||
|
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const form = useForm<TAddTeamMembersFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
|
||||||
|
|
||||||
|
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
|
||||||
|
organisationId: team.organisationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMemberQuery = trpc.team.member.find.useQuery({
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const avaliableOrganisationMembers = useMemo(() => {
|
||||||
|
const organisationMembers = organisationMemberQuery.data?.data ?? [];
|
||||||
|
const teamMembers = teamMemberQuery.data?.data ?? [];
|
||||||
|
|
||||||
|
return organisationMembers.filter(
|
||||||
|
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
|
||||||
|
);
|
||||||
|
}, [organisationMemberQuery, teamMemberQuery]);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createTeamMembers({
|
||||||
|
teamId: team.id,
|
||||||
|
organisationMembers: members,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description: t`Team members have been added.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: t`An unknown error occurred`,
|
||||||
|
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setStep('SELECT');
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||||
|
// Since it would be annoying to redo the whole process.
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||||
|
<Trans>Add members</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent hideClose={true} position="center">
|
||||||
|
{match(step)
|
||||||
|
.with('SELECT', () => (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Add members</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select members or groups of members to add to the team.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
))
|
||||||
|
.with('MEMBERS', () => (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Add members roles</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Configure the team roles for each member</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={form.formState.isSubmitting}>
|
||||||
|
{step === 'SELECT' && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="members"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Members</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={avaliableOrganisationMembers.map((member) => ({
|
||||||
|
label: member.name,
|
||||||
|
value: member.id,
|
||||||
|
}))}
|
||||||
|
loading={organisationMemberQuery.isLoading}
|
||||||
|
selectedValues={field.value.map(
|
||||||
|
(member) => member.organisationMemberId,
|
||||||
|
)}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(
|
||||||
|
value.map((organisationMemberId) => ({
|
||||||
|
organisationMemberId,
|
||||||
|
teamRole:
|
||||||
|
field.value.find(
|
||||||
|
(member) =>
|
||||||
|
member.organisationMemberId === organisationMemberId,
|
||||||
|
)?.teamRole || TeamMemberRole.MEMBER,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="bg-background w-full"
|
||||||
|
emptySelectionPlaceholder={t`Select members`}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Select members to add to this team</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={form.getValues('members').length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setStep('MEMBERS');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'MEMBERS' && (
|
||||||
|
<>
|
||||||
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
|
{form.getValues('members').map((member, index) => (
|
||||||
|
<div className="flex w-full flex-row space-x-4" key={index}>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Member</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
className="bg-background"
|
||||||
|
value={
|
||||||
|
organisationMemberQuery.data?.data.find(
|
||||||
|
({ id }) => id === member.organisationMemberId,
|
||||||
|
)?.name || ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`members.${index}.teamRole`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Team Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
|
||||||
|
(role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Add Members</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -22,9 +22,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type TeamMemberDeleteDialogProps = {
|
export type TeamMemberDeleteDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamMemberId: number;
|
memberId: string;
|
||||||
teamMemberName: string;
|
memberName: string;
|
||||||
teamMemberEmail: string;
|
memberEmail: string;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -32,17 +32,17 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
teamName,
|
teamName,
|
||||||
teamMemberId,
|
memberId,
|
||||||
teamMemberName,
|
memberName,
|
||||||
teamMemberEmail,
|
memberEmail,
|
||||||
}: TeamMemberDeleteDialogProps) => {
|
}: TeamMemberDeleteDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } =
|
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } =
|
||||||
trpc.team.deleteTeamMembers.useMutation({
|
trpc.team.member.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -69,7 +69,7 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
<Trans>Delete team member</Trans>
|
<Trans>Remove team member</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@ -91,9 +91,9 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
<Alert variant="neutral" padding="tight">
|
<Alert variant="neutral" padding="tight">
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarClass="h-12 w-12"
|
avatarClass="h-12 w-12"
|
||||||
avatarFallback={teamMemberName.slice(0, 1).toUpperCase()}
|
avatarFallback={memberName.slice(0, 1).toUpperCase()}
|
||||||
primaryText={<span className="font-semibold">{teamMemberName}</span>}
|
primaryText={<span className="font-semibold">{memberName}</span>}
|
||||||
secondaryText={teamMemberEmail}
|
secondaryText={memberEmail}
|
||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@ -107,9 +107,9 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={isDeletingTeamMember}
|
loading={isDeletingTeamMember}
|
||||||
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
|
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
||||||
>
|
>
|
||||||
<Trans>Delete</Trans>
|
<Trans>Remove</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -43,9 +43,9 @@ export type TeamMemberUpdateDialogProps = {
|
|||||||
currentUserTeamRole: TeamMemberRole;
|
currentUserTeamRole: TeamMemberRole;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamMemberId: number;
|
memberId: string;
|
||||||
teamMemberName: string;
|
memberName: string;
|
||||||
teamMemberRole: TeamMemberRole;
|
memberTeamRole: TeamMemberRole;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZUpdateTeamMemberFormSchema = z.object({
|
const ZUpdateTeamMemberFormSchema = z.object({
|
||||||
@ -58,9 +58,9 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
currentUserTeamRole,
|
currentUserTeamRole,
|
||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
teamMemberId,
|
memberId,
|
||||||
teamMemberName,
|
memberName,
|
||||||
teamMemberRole,
|
memberTeamRole,
|
||||||
...props
|
...props
|
||||||
}: TeamMemberUpdateDialogProps) => {
|
}: TeamMemberUpdateDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -71,17 +71,17 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
const form = useForm<ZUpdateTeamMemberSchema>({
|
const form = useForm<ZUpdateTeamMemberSchema>({
|
||||||
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
role: teamMemberRole,
|
role: memberTeamRole,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
|
const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateTeamMember({
|
await updateTeamMember({
|
||||||
teamId,
|
teamId,
|
||||||
teamMemberId,
|
memberId,
|
||||||
data: {
|
data: {
|
||||||
role,
|
role,
|
||||||
},
|
},
|
||||||
@ -89,7 +89,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`You have updated ${teamMemberName}.`),
|
description: _(msg`You have updated ${memberName}.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -121,7 +121,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
|
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -143,9 +143,9 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
<Trans>Update team member</Trans>
|
<Trans>Update team member</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
You are currently updating <span className="font-bold">{teamMemberName}.</span>
|
You are currently updating <span className="font-bold">{memberName}.</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@ -1,272 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useRevalidator } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
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 {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamTransferDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
ownerUserId: number;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamTransferDialog = ({
|
|
||||||
trigger,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
ownerUserId,
|
|
||||||
}: TeamTransferDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
const { mutateAsync: requestTeamOwnershipTransfer } =
|
|
||||||
trpc.team.requestTeamOwnershipTransfer.useMutation();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
refetch: refetchTeamMembers,
|
|
||||||
isPending: loadingTeamMembers,
|
|
||||||
isLoadingError: loadingTeamMembersError,
|
|
||||||
} = trpc.team.getTeamMembers.useQuery({
|
|
||||||
teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmTransferMessage = _(msg`transfer ${teamName}`);
|
|
||||||
|
|
||||||
const ZTransferTeamFormSchema = z.object({
|
|
||||||
teamName: z.literal(confirmTransferMessage, {
|
|
||||||
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
|
|
||||||
}),
|
|
||||||
newOwnerUserId: z.string(),
|
|
||||||
clearPaymentMethods: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZTransferTeamFormSchema>>({
|
|
||||||
resolver: zodResolver(ZTransferTeamFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
teamName: '',
|
|
||||||
clearPaymentMethods: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({
|
|
||||||
newOwnerUserId,
|
|
||||||
clearPaymentMethods,
|
|
||||||
}: z.infer<typeof ZTransferTeamFormSchema>) => {
|
|
||||||
try {
|
|
||||||
await requestTeamOwnershipTransfer({
|
|
||||||
teamId,
|
|
||||||
newOwnerUserId: Number.parseInt(newOwnerUserId),
|
|
||||||
clearPaymentMethods,
|
|
||||||
});
|
|
||||||
|
|
||||||
await revalidate();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`An email requesting the transfer of this team has been sent.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && loadingTeamMembersError) {
|
|
||||||
void refetchTeamMembers();
|
|
||||||
}
|
|
||||||
}, [open, loadingTeamMembersError, refetchTeamMembers]);
|
|
||||||
|
|
||||||
const teamMembers = data
|
|
||||||
? data.filter((teamMember) => teamMember.userId !== ownerUserId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="outline" className="bg-background">
|
|
||||||
<Trans>Transfer team</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
{teamMembers && teamMembers.length > 0 ? (
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Transfer team</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>Transfer ownership of this team to a selected team member.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="newOwnerUserId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>New team owner</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{teamMembers.map((teamMember) => (
|
|
||||||
<SelectItem
|
|
||||||
key={teamMember.userId}
|
|
||||||
value={teamMember.userId.toString()}
|
|
||||||
>
|
|
||||||
{teamMember.user.name} ({teamMember.user.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="teamName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>
|
|
||||||
Confirm by typing{' '}
|
|
||||||
<span className="text-destructive">{confirmTransferMessage}</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription>
|
|
||||||
<ul className="list-outside list-disc space-y-2 pl-4">
|
|
||||||
{IS_BILLING_ENABLED() && (
|
|
||||||
<li>
|
|
||||||
<Trans>
|
|
||||||
Any payment methods attached to this team will remain attached to this
|
|
||||||
team. Please contact us if you need to update this information.
|
|
||||||
</Trans>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li>
|
|
||||||
<Trans>
|
|
||||||
The selected team member will receive an email which they must accept
|
|
||||||
before the team is transferred
|
|
||||||
</Trans>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Request transfer</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
) : (
|
|
||||||
<DialogContent
|
|
||||||
position="center"
|
|
||||||
className="text-muted-foreground flex items-center justify-center py-16 text-sm"
|
|
||||||
>
|
|
||||||
{loadingTeamMembers ? (
|
|
||||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-sm">
|
|
||||||
{loadingTeamMembersError ? (
|
|
||||||
<Trans>An error occurred while loading team members. Please try again later.</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>You must have at least one other team member to transfer ownership.</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -21,7 +21,7 @@ import {
|
|||||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const ZBulkSendFormSchema = z.object({
|
const ZBulkSendFormSchema = z.object({
|
||||||
file: z.instanceof(File),
|
file: z.instanceof(File),
|
||||||
@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const form = useForm<TBulkSendFormSchema>({
|
const form = useForm<TBulkSendFormSchema>({
|
||||||
resolver: zodResolver(ZBulkSendFormSchema),
|
resolver: zodResolver(ZBulkSendFormSchema),
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type TemplateCreateDialogProps = {
|
type TemplateCreateDialogProps = {
|
||||||
teamId?: number;
|
teamId: number;
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
type TemplateMoveDialogProps = {
|
|
||||||
templateId: number;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
onMove?: ({
|
|
||||||
templateId,
|
|
||||||
teamUrl,
|
|
||||||
}: {
|
|
||||||
templateId: number;
|
|
||||||
teamUrl: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TemplateMoveDialog = ({
|
|
||||||
templateId,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onMove,
|
|
||||||
}: TemplateMoveDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
const team = teams?.find((team) => team.id === selectedTeamId);
|
|
||||||
|
|
||||||
if (team) {
|
|
||||||
await onMove?.({ templateId, teamUrl: team.url });
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Template moved`),
|
|
||||||
description: _(msg`The template has been successfully moved to the selected team.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
|
||||||
.with(
|
|
||||||
AppErrorCode.NOT_FOUND,
|
|
||||||
() => msg`Template not found or already associated with a team.`,
|
|
||||||
)
|
|
||||||
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
|
|
||||||
.otherwise(() => msg`An error occurred while moving the template.`);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(errorMessage),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 7500,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOnMove = async () => {
|
|
||||||
if (!selectedTeamId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await moveTemplate({ templateId, teamId: selectedTeamId });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Move Template to Team</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={_(msg`Select a team`)} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{isLoadingTeams ? (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<Trans>Loading teams...</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
teams?.map((team) => (
|
|
||||||
<SelectItem key={team.id} value={team.id.toString()}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Avatar className="h-8 w-8">
|
|
||||||
{team.avatarImageId && (
|
|
||||||
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AvatarFallback className="text-sm text-gray-400">
|
|
||||||
{team.name.slice(0, 1).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<span>{team.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleOnMove}
|
|
||||||
loading={isPending}
|
|
||||||
disabled={!selectedTeamId || isPending}
|
|
||||||
>
|
|
||||||
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -30,7 +30,7 @@ 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 { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type TokenDeleteDialogProps = {
|
export type TokenDeleteDialogProps = {
|
||||||
token: Pick<ApiToken, 'id' | 'name'>;
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
eventTriggers,
|
eventTriggers,
|
||||||
secret,
|
secret,
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
teamId: team?.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@ -30,7 +30,7 @@ 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 { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type WebhookDeleteDialogProps = {
|
export type WebhookDeleteDialogProps = {
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||||
@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteWebhook({ id: webhook.id, teamId: team?.id });
|
await deleteWebhook({ id: webhook.id, teamId: team.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Webhook deleted`),
|
title: _(msg`Webhook deleted`),
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, 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 { Team, TeamGlobalSettings } from '@prisma/client';
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@ -25,12 +22,11 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
const ZTeamBrandingPreferencesFormSchema = z.object({
|
const ZBrandingPreferencesFormSchema = z.object({
|
||||||
brandingEnabled: z.boolean(),
|
brandingEnabled: z.boolean(),
|
||||||
brandingLogo: z
|
brandingLogo: z
|
||||||
.instanceof(File)
|
.instanceof(File)
|
||||||
@ -44,74 +40,36 @@ const ZTeamBrandingPreferencesFormSchema = z.object({
|
|||||||
brandingCompanyDetails: z.string().max(500).optional(),
|
brandingCompanyDetails: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
|
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
|
||||||
|
|
||||||
export type TeamBrandingPreferencesFormProps = {
|
type SettingsSubset = Pick<
|
||||||
team: Team;
|
TeamGlobalSettings,
|
||||||
settings?: TeamGlobalSettings | null;
|
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type BrandingPreferencesFormProps = {
|
||||||
|
settings: SettingsSubset;
|
||||||
|
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
|
export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPreferencesFormProps) {
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: updateTeamBrandingSettings } =
|
const form = useForm<TBrandingPreferencesFormSchema>({
|
||||||
trpc.team.updateTeamBrandingSettings.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TTeamBrandingPreferencesFormSchema>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
brandingEnabled: settings?.brandingEnabled ?? false,
|
brandingEnabled: settings?.brandingEnabled ?? false,
|
||||||
brandingUrl: settings?.brandingUrl ?? '',
|
brandingUrl: settings?.brandingUrl ?? '',
|
||||||
brandingLogo: undefined,
|
brandingLogo: undefined,
|
||||||
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
|
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
|
resolver: zodResolver(ZBrandingPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||||
|
|
||||||
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
|
|
||||||
try {
|
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
|
||||||
|
|
||||||
let uploadedBrandingLogo = settings?.brandingLogo;
|
|
||||||
|
|
||||||
if (brandingLogo) {
|
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (brandingLogo === null) {
|
|
||||||
uploadedBrandingLogo = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateTeamBrandingSettings({
|
|
||||||
teamId: team.id,
|
|
||||||
settings: {
|
|
||||||
brandingEnabled,
|
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl,
|
|
||||||
brandingCompanyDetails,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Branding preferences updated`),
|
|
||||||
description: _(msg`Your branding preferences have been updated`),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We were unable to update your branding preferences at this time, please try again later`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.brandingLogo) {
|
if (settings?.brandingLogo) {
|
||||||
const file = JSON.parse(settings.brandingLogo);
|
const file = JSON.parse(settings.brandingLogo);
|
||||||
@ -140,13 +98,17 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
};
|
};
|
||||||
}, [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(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset
|
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
|
||||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="brandingEnabled"
|
name="brandingEnabled"
|
||||||
@ -192,7 +154,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||||
Please upload a logo
|
<Trans>Please upload a logo</Trans>
|
||||||
|
|
||||||
{!hasLoadedPreview && (
|
{!hasLoadedPreview && (
|
||||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
@ -291,7 +254,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={_(msg`Enter your brand details`)}
|
placeholder={t`Enter your brand details`}
|
||||||
className="min-h-[100px] resize-y"
|
className="min-h-[100px] resize-y"
|
||||||
disabled={!isBrandingEnabled}
|
disabled={!isBrandingEnabled}
|
||||||
{...field}
|
{...field}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
import { DocumentVisibility } from '@prisma/client';
|
import { DocumentVisibility } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -15,7 +15,6 @@ import {
|
|||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -36,96 +35,74 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
const ZTeamDocumentPreferencesFormSchema = z.object({
|
/**
|
||||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
* it to be dynamic.
|
||||||
includeSenderDetails: z.boolean(),
|
*/
|
||||||
includeSigningCertificate: z.boolean(),
|
export type TDocumentPreferencesFormSchema = {
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
documentVisibility: DocumentVisibility | null;
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
||||||
}),
|
includeSenderDetails: boolean | null;
|
||||||
});
|
includeSigningCertificate: boolean | null;
|
||||||
|
signatureTypes: DocumentSignatureType[];
|
||||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
|
||||||
|
|
||||||
export type TeamDocumentPreferencesFormProps = {
|
|
||||||
team: Team;
|
|
||||||
settings?: TeamGlobalSettings | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamDocumentPreferencesForm = ({
|
type SettingsSubset = Pick<
|
||||||
team,
|
TeamGlobalSettings,
|
||||||
|
| 'documentVisibility'
|
||||||
|
| 'documentLanguage'
|
||||||
|
| 'includeSenderDetails'
|
||||||
|
| 'includeSigningCertificate'
|
||||||
|
| 'typedSignatureEnabled'
|
||||||
|
| 'uploadSignatureEnabled'
|
||||||
|
| 'drawSignatureEnabled'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DocumentPreferencesFormProps = {
|
||||||
|
settings: SettingsSubset;
|
||||||
|
canInherit: boolean;
|
||||||
|
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentPreferencesForm = ({
|
||||||
settings,
|
settings,
|
||||||
}: TeamDocumentPreferencesFormProps) => {
|
onFormSubmit,
|
||||||
const { _ } = useLingui();
|
canInherit,
|
||||||
const { toast } = useToast();
|
}: DocumentPreferencesFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const placeholderEmail = user.email ?? 'user@example.com';
|
const placeholderEmail = user.email ?? 'user@example.com';
|
||||||
|
|
||||||
const { mutateAsync: updateTeamDocumentPreferences } =
|
const ZDocumentPreferencesFormSchema = z.object({
|
||||||
trpc.team.updateTeamDocumentSettings.useMutation();
|
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||||
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||||
const form = useForm<TTeamDocumentPreferencesFormSchema>({
|
includeSenderDetails: z.boolean().nullable(),
|
||||||
defaultValues: {
|
includeSigningCertificate: z.boolean().nullable(),
|
||||||
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||||
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
? settings?.documentLanguage
|
}),
|
||||||
: 'en',
|
|
||||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
|
||||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
|
||||||
signatureTypes: extractTeamSignatureSettings(settings),
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const includeSenderDetails = form.watch('includeSenderDetails');
|
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
documentVisibility: settings.documentVisibility,
|
||||||
try {
|
documentLanguage: isValidLanguageCode(settings.documentLanguage)
|
||||||
const {
|
? settings.documentLanguage
|
||||||
documentVisibility,
|
: null,
|
||||||
documentLanguage,
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
includeSenderDetails,
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
includeSigningCertificate,
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
signatureTypes,
|
},
|
||||||
} = data;
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
|
});
|
||||||
await updateTeamDocumentPreferences({
|
|
||||||
teamId: team.id,
|
|
||||||
settings: {
|
|
||||||
documentVisibility,
|
|
||||||
documentLanguage,
|
|
||||||
includeSenderDetails,
|
|
||||||
includeSigningCertificate,
|
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Document preferences updated`),
|
|
||||||
description: _(msg`Your document preferences have been updated`),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong!`),
|
|
||||||
description: _(
|
|
||||||
msg`We were unable to update your document preferences at this time, please try again later`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset
|
<fieldset
|
||||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
className="flex h-full max-w-2xl flex-col gap-y-6"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
@ -138,7 +115,11 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value}
|
||||||
|
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||||
|
>
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -153,6 +134,12 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||||
<Trans>Only admins can access and view the document</Trans>
|
<Trans>Only admins can access and view the document</Trans>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -174,7 +161,11 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value}
|
||||||
|
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||||
|
>
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -185,6 +176,10 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
{language.full}
|
{language.full}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -212,14 +207,16 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||||
label: _(option.label),
|
label: t(option.label),
|
||||||
value: option.value,
|
value: option.value,
|
||||||
}))}
|
}))}
|
||||||
selectedValues={field.value}
|
selectedValues={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
className="bg-background w-full"
|
className="bg-background w-full"
|
||||||
enableSearch={false}
|
enableSearch={false}
|
||||||
emptySelectionPlaceholder="Select signature types"
|
emptySelectionPlaceholder={
|
||||||
|
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||||
|
}
|
||||||
testId="signature-types-combobox"
|
testId="signature-types-combobox"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -246,16 +243,35 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
<Trans>Send on Behalf of Team</Trans>
|
<Trans>Send on Behalf of Team</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<div>
|
<FormControl>
|
||||||
<FormControl className="block">
|
<Select
|
||||||
<Switch
|
{...field}
|
||||||
ref={field.ref}
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
name={field.name}
|
onValueChange={(value) =>
|
||||||
checked={field.value}
|
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||||
onCheckedChange={field.onChange}
|
}
|
||||||
/>
|
>
|
||||||
</FormControl>
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
</div>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">
|
||||||
|
<Trans>Yes</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value="false">
|
||||||
|
<Trans>No</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="text-muted-foreground text-xs font-medium">
|
<div className="text-muted-foreground text-xs font-medium">
|
||||||
@ -263,13 +279,13 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||||
{includeSenderDetails ? (
|
{field.value ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
"{placeholderEmail}" on behalf of "{team.name}" has invited you to sign
|
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign
|
||||||
"example document".
|
"example document".
|
||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>"{team.name}" has invited you to sign "example document".</Trans>
|
<Trans>"Team Name" has invited you to sign "example document".</Trans>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
@ -294,16 +310,35 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
<Trans>Include the Signing Certificate in the Document</Trans>
|
<Trans>Include the Signing Certificate in the Document</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<div>
|
<FormControl>
|
||||||
<FormControl className="block">
|
<Select
|
||||||
<Switch
|
{...field}
|
||||||
ref={field.ref}
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
name={field.name}
|
onValueChange={(value) =>
|
||||||
checked={field.value}
|
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||||
onCheckedChange={field.onChange}
|
}
|
||||||
/>
|
>
|
||||||
</FormControl>
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
</div>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">
|
||||||
|
<Trans>Yes</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value="false">
|
||||||
|
<Trans>No</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
178
apps/remix/app/components/forms/organisation-update-form.tsx
Normal file
178
apps/remix/app/components/forms/organisation-update-form.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TOrganisationUpdateFormSchema = z.infer<typeof ZOrganisationUpdateFormSchema>;
|
||||||
|
|
||||||
|
export const OrganisationUpdateForm = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZOrganisationUpdateFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: organisation.name,
|
||||||
|
url: organisation.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisation } = trpc.organisation.update.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, url }: TOrganisationUpdateFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateOrganisation({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Your organisation has been successfully updated.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url !== organisation.url) {
|
||||||
|
await navigate(`/org/${url}/settings`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||||
|
form.setError('url', {
|
||||||
|
type: 'manual',
|
||||||
|
message: _(msg`This URL is already in use.`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to update your organisation. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Organisation Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4">
|
||||||
|
<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 />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{form.formState.isDirty && (
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
||||||
|
<Trans>Reset</Trans>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="transition-opacity"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Update organisation</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -16,8 +16,8 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
|||||||
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
|
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
|
||||||
import {
|
import {
|
||||||
MAX_PROFILE_BIO_LENGTH,
|
MAX_PROFILE_BIO_LENGTH,
|
||||||
ZUpdatePublicProfileMutationSchema,
|
ZUpdateTeamPublicProfileMutationSchema,
|
||||||
} from '@documenso/trpc/server/profile-router/schema';
|
} from '@documenso/trpc/server/team-router/schema';
|
||||||
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 {
|
import {
|
||||||
@ -32,7 +32,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
|
export const ZPublicProfileFormSchema = ZUpdateTeamPublicProfileMutationSchema.pick({
|
||||||
bio: true,
|
bio: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: true,
|
url: true,
|
||||||
@ -43,7 +43,7 @@ export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
|
|||||||
export type PublicProfileFormProps = {
|
export type PublicProfileFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
profileUrl?: string | null;
|
profileUrl?: string | null;
|
||||||
teamUrl?: string;
|
teamUrl: string;
|
||||||
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
|
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
|
||||||
profile: UserProfile | TeamProfile;
|
profile: UserProfile | TeamProfile;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type { z } from 'zod';
|
|||||||
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 { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -29,7 +29,7 @@ export type UpdateTeamDialogProps = {
|
|||||||
teamUrl: string;
|
teamUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZTeamUpdateFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
const ZTeamUpdateFormSchema = ZUpdateTeamRequestSchema.shape.data.pick({
|
||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
});
|
});
|
||||||
@ -49,7 +49,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
const { mutateAsync: updateTeam } = trpc.team.update.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, url }: TTeamUpdateFormSchema) => {
|
const onFormSubmit = async ({ name, url }: TTeamUpdateFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
import { Link, useParams } from 'react-router';
|
||||||
|
|
||||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
|
||||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import { getRootHref } from '@documenso/lib/utils/params';
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@ -15,14 +13,10 @@ import { AppNavDesktop } from './app-nav-desktop';
|
|||||||
import { AppNavMobile } from './app-nav-mobile';
|
import { AppNavMobile } from './app-nav-mobile';
|
||||||
import { MenuSwitcher } from './menu-switcher';
|
import { MenuSwitcher } from './menu-switcher';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||||
user: SessionUser;
|
|
||||||
teams: TGetTeamsResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
@ -38,16 +32,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
return () => window.removeEventListener('scroll', onScroll);
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isPathTeamUrl = (teamUrl: string) => {
|
|
||||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname.split('/')[2] === teamUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -59,7 +43,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
>
|
>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||||
<Link
|
<Link
|
||||||
to={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
|
to={`${getRootHref(params, { returnEmptyRootString: true })}`}
|
||||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||||
>
|
>
|
||||||
<BrandingLogo className="h-6 w-auto" />
|
<BrandingLogo className="h-6 w-auto" />
|
||||||
@ -67,11 +51,8 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
|
|
||||||
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||||
|
|
||||||
<div
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
className="flex gap-x-4 md:ml-8"
|
<MenuSwitcher />
|
||||||
title={selectedTeam ? selectedTeam.name : (user.name ?? '')}
|
|
||||||
>
|
|
||||||
<MenuSwitcher user={user} teams={teams} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useEffect, 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 { motion } from 'framer-motion';
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
import { Link, useLocation, useParams } from 'react-router';
|
||||||
|
|
||||||
@ -55,23 +57,34 @@ export const AppNavDesktop = ({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline gap-x-6">
|
<div>
|
||||||
{navigationLinks.map(({ href, label }) => (
|
<AnimatePresence>
|
||||||
<Link
|
{params.teamUrl && (
|
||||||
key={href}
|
<motion.div
|
||||||
to={`${rootHref}${href}`}
|
initial={{ opacity: 0 }}
|
||||||
className={cn(
|
animate={{ opacity: 1 }}
|
||||||
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
exit={{ opacity: 0 }}
|
||||||
{
|
className="flex items-baseline gap-x-6"
|
||||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
>
|
||||||
`${rootHref}${href}`,
|
{navigationLinks.map(({ href, label }) => (
|
||||||
),
|
<Link
|
||||||
},
|
key={href}
|
||||||
)}
|
to={`${rootHref}${href}`}
|
||||||
>
|
className={cn(
|
||||||
{_(label)}
|
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||||
</Link>
|
{
|
||||||
))}
|
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||||
|
`${rootHref}${href}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{_(label)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export type DocumentSigningPageViewProps = {
|
|||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
|
includeSenderDetails: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
@ -56,18 +57,16 @@ export const DocumentSigningPageView = ({
|
|||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
|
includeSenderDetails,
|
||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
|
|
||||||
const shouldUseTeamDetails =
|
|
||||||
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
|
|
||||||
|
|
||||||
let senderName = document.user.name ?? '';
|
let senderName = document.user.name ?? '';
|
||||||
let senderEmail = `(${document.user.email})`;
|
let senderEmail = `(${document.user.email})`;
|
||||||
|
|
||||||
if (shouldUseTeamDetails) {
|
if (includeSenderDetails) {
|
||||||
senderName = document.team?.name ?? '';
|
senderName = document.team?.name ?? '';
|
||||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
||||||
}
|
}
|
||||||
@ -92,7 +91,7 @@ export const DocumentSigningPageView = ({
|
|||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () =>
|
.with(RecipientRole.VIEWER, () =>
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
on behalf of "{document.team?.name}" has invited you to view this document
|
on behalf of "{document.team?.name}" has invited you to view this document
|
||||||
</Trans>
|
</Trans>
|
||||||
@ -101,7 +100,7 @@ export const DocumentSigningPageView = ({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.with(RecipientRole.SIGNER, () =>
|
.with(RecipientRole.SIGNER, () =>
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||||
</Trans>
|
</Trans>
|
||||||
@ -110,7 +109,7 @@ export const DocumentSigningPageView = ({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.with(RecipientRole.APPROVER, () =>
|
.with(RecipientRole.APPROVER, () =>
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||||
</Trans>
|
</Trans>
|
||||||
@ -119,7 +118,7 @@ export const DocumentSigningPageView = ({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.with(RecipientRole.ASSISTANT, () =>
|
.with(RecipientRole.ASSISTANT, () =>
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
|||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentEditFormProps = {
|
export type DocumentEditFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -53,7 +53,7 @@ export const DocumentEditForm = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||||
|
|
||||||
@ -355,7 +355,7 @@ export const DocumentEditForm = ({
|
|||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.settings}
|
documentFlow={documentFlow.settings}
|
||||||
document={document}
|
document={document}
|
||||||
currentTeamMemberRole={team?.currentTeamMember?.role}
|
currentTeamMemberRole={team.currentTeamRole}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
@ -382,7 +382,7 @@ export const DocumentEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
teamId={team?.id}
|
teamId={team.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
|
|||||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentPageViewDropdownProps = {
|
export type DocumentPageViewDropdownProps = {
|
||||||
document: Document & {
|
document: Document & {
|
||||||
@ -53,7 +53,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentUploadDropzoneProps = {
|
export type DocumentUploadDropzoneProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -31,7 +31,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|||||||
@ -1,21 +1,28 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, 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 { motion } from 'framer-motion';
|
import {
|
||||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
Building2Icon,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Settings2Icon,
|
||||||
|
SettingsIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
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 type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
@ -24,71 +31,60 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
const MotionLink = motion(Link);
|
import { useOptionalCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type MenuSwitcherProps = {
|
export const MenuSwitcher = () => {
|
||||||
user: SessionUser;
|
|
||||||
teams: TGetTeamsResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { user, organisations } = useSession();
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
||||||
|
const [hoveredOrgId, setHoveredOrgId] = useState<string | null>(null);
|
||||||
|
|
||||||
const isUserAdmin = isAdmin(user);
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
const isPathOrgUrl = (orgUrl: string) => {
|
||||||
initialData: initialTeamsData,
|
if (!pathname || !pathname.startsWith(`/org/`)) {
|
||||||
});
|
|
||||||
|
|
||||||
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
|
|
||||||
|
|
||||||
const isPathTeamUrl = (teamUrl: string) => {
|
|
||||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pathname.split('/')[2] === teamUrl;
|
return pathname.split('/')[2] === orgUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
const selectedOrg = organisations.find((org) => isPathOrgUrl(org.url));
|
||||||
|
const hoveredOrg = organisations.find((org) => org.id === hoveredOrgId);
|
||||||
|
|
||||||
const formatAvatarFallback = (teamName?: string) => {
|
const currentOrganisation = useOptionalCurrentOrganisation();
|
||||||
if (teamName !== undefined) {
|
const currentTeam = useOptionalCurrentTeam();
|
||||||
return teamName.slice(0, 1).toUpperCase();
|
|
||||||
|
// Use hovered org for teams display if available,
|
||||||
|
// otherwise use current team's org if in a team,
|
||||||
|
// finally fallback to selected org
|
||||||
|
const displayedOrg = hoveredOrg || currentOrganisation || selectedOrg;
|
||||||
|
|
||||||
|
const formatAvatarFallback = (name?: string) => {
|
||||||
|
if (name !== undefined) {
|
||||||
|
return name.slice(0, 1).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
|
|
||||||
if (!team) {
|
|
||||||
return _(msg`Personal Account`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (team.ownerUserId === user.id) {
|
|
||||||
return _(msg`Owner`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _(TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats the redirect URL so we can switch between documents and templates page
|
* Formats the redirect URL so we can switch between documents and templates page
|
||||||
* seemlessly between teams and personal accounts.
|
* seemlessly between organisations and personal accounts.
|
||||||
*/
|
*/
|
||||||
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
const formatRedirectUrlOnSwitch = (orgUrl?: string) => {
|
||||||
const baseUrl = teamUrl ? `/t/${teamUrl}` : '';
|
const baseUrl = orgUrl ? `/org/${orgUrl}` : '';
|
||||||
|
|
||||||
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
const currentPathname = (pathname ?? '/').replace(/^\/org\/[^/]+/, '');
|
||||||
|
|
||||||
if (currentPathname === '/templates') {
|
if (currentPathname === '/templates') {
|
||||||
return `${baseUrl}/templates`;
|
return `${baseUrl}/templates`;
|
||||||
@ -97,8 +93,45 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
return baseUrl;
|
return baseUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dropdownMenuAvatarText = useMemo(() => {
|
||||||
|
if (currentTeam) {
|
||||||
|
return {
|
||||||
|
avatarSrc: formatAvatarUrl(currentTeam.avatarImageId),
|
||||||
|
avatarFallback: formatAvatarFallback(currentTeam.name),
|
||||||
|
primaryText: currentTeam.name,
|
||||||
|
secondaryText: _(EXTENDED_TEAM_MEMBER_ROLE_MAP[currentTeam.currentTeamRole]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentOrganisation) {
|
||||||
|
return {
|
||||||
|
avatarSrc: formatAvatarUrl(currentOrganisation.avatarImageId),
|
||||||
|
avatarFallback: formatAvatarFallback(currentOrganisation.name),
|
||||||
|
primaryText: currentOrganisation.name,
|
||||||
|
secondaryText: _(
|
||||||
|
EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[currentOrganisation.currentOrganisationRole],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarSrc: formatAvatarUrl(user.avatarImageId),
|
||||||
|
avatarFallback: formatAvatarFallback(user.name ?? user.email),
|
||||||
|
primaryText: user.name,
|
||||||
|
secondaryText: _(msg`Personal Account`),
|
||||||
|
};
|
||||||
|
}, [currentTeam, currentOrganisation, user]);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
setHoveredOrgId(currentOrganisation?.id || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
data-testid="menu-switcher"
|
data-testid="menu-switcher"
|
||||||
@ -106,12 +139,10 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||||
>
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarSrc={formatAvatarUrl(
|
avatarSrc={dropdownMenuAvatarText.avatarSrc}
|
||||||
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId,
|
avatarFallback={dropdownMenuAvatarText.avatarFallback}
|
||||||
)}
|
primaryText={dropdownMenuAvatarText.primaryText}
|
||||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
secondaryText={dropdownMenuAvatarText.secondaryText}
|
||||||
primaryText={selectedTeam ? selectedTeam.name : user.name}
|
|
||||||
secondaryText={formatSecondaryAvatarText(selectedTeam)}
|
|
||||||
rightSideComponent={
|
rightSideComponent={
|
||||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||||
}
|
}
|
||||||
@ -121,166 +152,199 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
className={cn(
|
||||||
|
'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
|
||||||
>
|
>
|
||||||
{teams ? (
|
<div className="flex h-[400px] w-full divide-x">
|
||||||
<>
|
{/* Organisations column */}
|
||||||
<DropdownMenuLabel>
|
<div className="flex w-1/3 flex-col">
|
||||||
<Trans>Personal</Trans>
|
<div className="flex h-12 items-center border-b p-2">
|
||||||
</DropdownMenuLabel>
|
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
||||||
|
<Building2Icon className="mr-2 h-3.5 w-3.5" />
|
||||||
<DropdownMenuItem asChild>
|
<Trans>Organisations</Trans>
|
||||||
<Link to={formatRedirectUrlOnSwitch()}>
|
</h3>
|
||||||
<AvatarWithText
|
|
||||||
avatarSrc={formatAvatarUrl(user.avatarImageId)}
|
|
||||||
avatarFallback={formatAvatarFallback()}
|
|
||||||
primaryText={user.name}
|
|
||||||
secondaryText={formatSecondaryAvatarText()}
|
|
||||||
rightSideComponent={
|
|
||||||
!pathname?.startsWith(`/t/`) && (
|
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator className="mt-2" />
|
|
||||||
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<p>
|
|
||||||
<Trans>Teams</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-row space-x-2">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Button
|
|
||||||
title={_(msg`Manage teams`)}
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to="/settings/teams">
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Button
|
|
||||||
title={_(msg`Create team`)}
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to="/settings/teams?action=add-team">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
|
||||||
{teams.map((team) => (
|
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
|
||||||
<MotionLink
|
|
||||||
initial="initial"
|
|
||||||
animate="initial"
|
|
||||||
whileHover="animate"
|
|
||||||
to={formatRedirectUrlOnSwitch(team.url)}
|
|
||||||
>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
|
||||||
primaryText={team.name}
|
|
||||||
textSectionClassName="w-[200px]"
|
|
||||||
secondaryText={
|
|
||||||
<div className="relative w-full">
|
|
||||||
<motion.span
|
|
||||||
className="overflow-hidden"
|
|
||||||
variants={{
|
|
||||||
initial: { opacity: 1, translateY: 0 },
|
|
||||||
animate: { opacity: 0, translateY: '100%' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatSecondaryAvatarText(team)}
|
|
||||||
</motion.span>
|
|
||||||
|
|
||||||
<motion.span
|
|
||||||
className="absolute inset-0"
|
|
||||||
variants={{
|
|
||||||
initial: { opacity: 0, translateY: '100%' },
|
|
||||||
animate: { opacity: 1, translateY: 0 },
|
|
||||||
}}
|
|
||||||
>{`/t/${team.url}`}</motion.span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
rightSideComponent={
|
|
||||||
isPathTeamUrl(team.url) && (
|
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</MotionLink>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
|
||||||
) : (
|
{organisations.map((org) => (
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<div
|
||||||
<Link
|
className="group relative"
|
||||||
to="/settings/teams?action=add-team"
|
key={org.id}
|
||||||
className="flex items-center justify-between"
|
onMouseEnter={() => setHoveredOrgId(org.id)}
|
||||||
>
|
>
|
||||||
<Trans>Create team</Trans>
|
<DropdownMenuItem
|
||||||
<Plus className="ml-2 h-4 w-4" />
|
className={cn(
|
||||||
</Link>
|
'text-muted-foreground w-full px-4 py-2',
|
||||||
</DropdownMenuItem>
|
org.id === currentOrganisation?.id && !hoveredOrgId && 'bg-accent',
|
||||||
)}
|
org.id === hoveredOrgId && 'bg-accent',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={`/org/${org.url}`} className="flex items-center space-x-2 pr-8">
|
||||||
|
<span
|
||||||
|
className={cn('min-w-0 flex-1 truncate', {
|
||||||
|
'font-semibold': org.id === selectedOrg?.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
{canExecuteOrganisationAction(
|
||||||
|
'MANAGE_ORGANISATION',
|
||||||
|
org.currentOrganisationRole,
|
||||||
|
) && (
|
||||||
|
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
||||||
|
<Link
|
||||||
|
to={`/org/${org.url}/settings`}
|
||||||
|
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Settings2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{isUserAdmin && (
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<Link to="/settings/organisations?action=add-organization">
|
||||||
<Link to="/admin">
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Trans>Admin panel</Trans>
|
<Trans>Create Organization</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
{/* Teams column */}
|
||||||
<Link to="/settings/profile">
|
<div className="flex w-1/3 flex-col">
|
||||||
<Trans>User settings</Trans>
|
<div className="flex h-12 items-center border-b p-2">
|
||||||
</Link>
|
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
||||||
</DropdownMenuItem>
|
<UsersIcon className="mr-2 h-3.5 w-3.5" />
|
||||||
|
<Trans>Teams</Trans>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
|
||||||
|
<AnimateGenericFadeInOut key={displayedOrg ? 'displayed-org' : 'no-org'}>
|
||||||
|
{hoveredOrg ? (
|
||||||
|
hoveredOrg.teams.map((team) => (
|
||||||
|
<div className="group relative" key={team.id}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground w-full px-4 py-2',
|
||||||
|
team.id === currentTeam?.id && 'bg-accent',
|
||||||
|
)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={`/t/${team.url}`} className="flex items-center space-x-2 pr-8">
|
||||||
|
<span
|
||||||
|
className={cn('min-w-0 flex-1 truncate', {
|
||||||
|
'font-semibold': team.id === currentTeam?.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{selectedTeam &&
|
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||||
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
|
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<Link
|
||||||
<Link to={`/t/${selectedTeam.url}/settings`}>
|
to={`/t/${team.url}/settings`}
|
||||||
<Trans>Team settings</Trans>
|
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||||
</Link>
|
>
|
||||||
</DropdownMenuItem>
|
<Settings2Icon className="h-3.5 w-3.5" />
|
||||||
)}
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground my-12 flex items-center justify-center px-2 text-center text-sm">
|
||||||
|
<Trans>Select an organisation to view teams</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem
|
{displayedOrg && (
|
||||||
className="text-muted-foreground px-4 py-2"
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
onClick={() => setLanguageSwitcherOpen(true)}
|
<Link to={`/org/${displayedOrg.url}/settings/teams?action=add-team`}>
|
||||||
>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Trans>Language</Trans>
|
<Trans>Create Team</Trans>
|
||||||
</DropdownMenuItem>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AnimateGenericFadeInOut>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DropdownMenuItem
|
{/* Settings column */}
|
||||||
className="text-destructive/90 hover:!text-destructive px-4 py-2"
|
<div className="flex w-1/3 flex-col">
|
||||||
onSelect={async () => authClient.signOut()}
|
<div className="flex h-12 items-center border-b p-2">
|
||||||
>
|
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
||||||
<Trans>Sign Out</Trans>
|
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
||||||
</DropdownMenuItem>
|
<Trans>Settings</Trans>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-1.5">
|
||||||
|
{isUserAdmin && (
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link to="/admin">
|
||||||
|
<Trans>Admin panel</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link to="/dashboard">
|
||||||
|
<Trans>Dashboard</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link to="/settings/profile">
|
||||||
|
<Trans>Account</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{currentOrganisation &&
|
||||||
|
canExecuteOrganisationAction(
|
||||||
|
'MANAGE_ORGANISATION',
|
||||||
|
currentOrganisation.currentOrganisationRole,
|
||||||
|
) && (
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link to={`/org/${currentOrganisation.url}/settings`}>
|
||||||
|
<Trans>Organisation settings</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||||
|
<Trans>Team settings</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-muted-foreground px-4 py-2"
|
||||||
|
onClick={() => setLanguageSwitcherOpen(true)}
|
||||||
|
>
|
||||||
|
<Trans>Language</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
|
||||||
|
onSelect={async () => authClient.signOut()}
|
||||||
|
>
|
||||||
|
<Trans>Sign Out</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
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 { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
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 { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
|
||||||
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';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
@ -21,8 +21,10 @@ 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 TeamInvitations = () => {
|
export const OrganisationInvitations = () => {
|
||||||
const { data, isLoading } = trpc.team.getTeamInvitations.useQuery();
|
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
|
||||||
|
status: OrganisationMemberInviteStatus.PENDING,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@ -37,12 +39,12 @@ export const TeamInvitations = () => {
|
|||||||
value={data.length}
|
value={data.length}
|
||||||
one={
|
one={
|
||||||
<span>
|
<span>
|
||||||
You have <strong>1</strong> pending team invitation
|
You have <strong>1</strong> pending invitation
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
other={
|
other={
|
||||||
<span>
|
<span>
|
||||||
You have <strong>#</strong> pending team invitations
|
You have <strong>#</strong> pending invitations
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -66,12 +68,12 @@ export const TeamInvitations = () => {
|
|||||||
value={data.length}
|
value={data.length}
|
||||||
one={
|
one={
|
||||||
<span>
|
<span>
|
||||||
You have <strong>1</strong> pending team invitation
|
You have <strong>1</strong> pending invitation
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
other={
|
other={
|
||||||
<span>
|
<span>
|
||||||
You have <strong>#</strong> pending team invitations
|
You have <strong>#</strong> pending invitations
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -80,21 +82,21 @@ export const TeamInvitations = () => {
|
|||||||
|
|
||||||
<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.teamId}>
|
<li key={invitation.id}>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarSrc={formatAvatarUrl(invitation.team.avatarImageId)}
|
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
|
||||||
className="w-full max-w-none py-4"
|
className="w-full max-w-none py-4"
|
||||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
avatarFallback={invitation.organisation.name.slice(0, 1)}
|
||||||
primaryText={
|
primaryText={
|
||||||
<span className="text-foreground/80 font-semibold">
|
<span className="text-foreground/80 font-semibold">
|
||||||
{invitation.team.name}
|
{invitation.organisation.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
secondaryText={formatTeamUrl(invitation.team.url)}
|
// secondaryText={formatOrganisationUrl(invitation.team.url)}
|
||||||
rightSideComponent={
|
rightSideComponent={
|
||||||
<div className="ml-auto space-x-2">
|
<div className="ml-auto space-x-2">
|
||||||
<DeclineTeamInvitationButton teamId={invitation.team.id} />
|
<DeclineOrganisationInvitationButton token={invitation.token} />
|
||||||
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
<AcceptOrganisationInvitationButton token={invitation.token} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -111,26 +113,26 @@ export const TeamInvitations = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: acceptTeamInvitation,
|
mutateAsync: acceptOrganisationInvitation,
|
||||||
isPending,
|
isPending,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
} = trpc.organisation.member.invite.accept.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Accepted team invitation`),
|
description: _(msg`Invitation accepted`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
description: _(msg`Unable to join this team at this time.`),
|
description: _(msg`Unable to join this organisation at this time.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
@ -139,7 +141,7 @@ const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => acceptTeamInvitation({ teamId })}
|
onClick={async () => acceptOrganisationInvitation({ token })}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
disabled={isPending || isSuccess}
|
disabled={isPending || isSuccess}
|
||||||
>
|
>
|
||||||
@ -148,26 +150,26 @@ const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: declineTeamInvitation,
|
mutateAsync: declineOrganisationInvitation,
|
||||||
isPending,
|
isPending,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
} = trpc.team.declineTeamInvitation.useMutation({
|
} = trpc.organisation.member.invite.decline.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Declined team invitation`),
|
description: _(msg`Invitation declined`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
description: _(msg`Unable to decline this team invitation at this time.`),
|
description: _(msg`Unable to decline this invitation at this time.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
@ -176,7 +178,7 @@ const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => declineTeamInvitation({ teamId })}
|
onClick={async () => declineOrganisationInvitation({ token })}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
disabled={isPending || isSuccess}
|
disabled={isPending || isSuccess}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
|
import { CreditCard, 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';
|
||||||
|
|
||||||
@ -31,29 +31,16 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/settings/public-profile">
|
<Link to="/settings/organisations">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Public Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/teams">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Users className="mr-2 h-5 w-5" />
|
<Users className="mr-2 h-5 w-5" />
|
||||||
<Trans>Teams</Trans>
|
<Trans>Organisations</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -70,32 +57,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/settings/tokens">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Braces className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Webhook className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Webhooks</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link to="/settings/billing">
|
<Link to="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
|
import { CreditCard, 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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
@ -33,29 +33,16 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/settings/public-profile">
|
<Link to="/settings/organisations">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Public Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/teams">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Users className="mr-2 h-5 w-5" />
|
<Users className="mr-2 h-5 w-5" />
|
||||||
<Trans>Teams</Trans>
|
<Trans>Organisations</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -72,32 +59,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/settings/tokens">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Braces className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Webhook className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Webhooks</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link to="/settings/billing">
|
<Link to="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|||||||
export default function DocumentEditSkeleton() {
|
export default function DocumentEditSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<Link to="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
<Link to="/" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
import {
|
||||||
|
Braces,
|
||||||
|
CreditCard,
|
||||||
|
Globe2Icon,
|
||||||
|
GroupIcon,
|
||||||
|
Settings,
|
||||||
|
Settings2,
|
||||||
|
Users,
|
||||||
|
Webhook,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
import { Link, useLocation, useParams } from 'react-router';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
@ -20,6 +29,7 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
|
|||||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
@ -76,6 +86,16 @@ export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavD
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link to={groupsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<GroupIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Groups</Trans>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link to={tokensPath}>
|
<Link to={tokensPath}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
import {
|
||||||
|
Braces,
|
||||||
|
CreditCard,
|
||||||
|
Globe2Icon,
|
||||||
|
GroupIcon,
|
||||||
|
Key,
|
||||||
|
Settings2,
|
||||||
|
User,
|
||||||
|
Webhook,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
import { Link, useLocation, useParams } from 'react-router';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
@ -20,6 +29,7 @@ export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMo
|
|||||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
@ -85,6 +95,16 @@ export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMo
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link to={groupsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<GroupIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Groups</Trans>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link to={tokensPath}>
|
<Link to={tokensPath}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -1,126 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TeamMemberRole, TeamTransferVerification } from '@prisma/client';
|
|
||||||
import { AnimatePresence } from 'framer-motion';
|
|
||||||
import { useRevalidator } from 'react-router';
|
|
||||||
|
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
|
||||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamTransferStatusProps = {
|
|
||||||
className?: string;
|
|
||||||
currentUserTeamRole: TeamMemberRole;
|
|
||||||
teamId: number;
|
|
||||||
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamTransferStatus = ({
|
|
||||||
className,
|
|
||||||
currentUserTeamRole,
|
|
||||||
teamId,
|
|
||||||
transferVerification,
|
|
||||||
}: TeamTransferStatusProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamTransferRequest, isPending } =
|
|
||||||
trpc.team.deleteTeamTransferRequest.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
if (!isExpired) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`The team transfer invitation has been successfully deleted.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await revalidate();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{transferVerification && (
|
|
||||||
<AnimateGenericFadeInOut>
|
|
||||||
<Alert
|
|
||||||
variant={isExpired ? 'destructive' : 'warning'}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<AlertTitle>
|
|
||||||
{isExpired ? (
|
|
||||||
<Trans>Team transfer request expired</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Team transfer in progress</Trans>
|
|
||||||
)}
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription>
|
|
||||||
{isExpired ? (
|
|
||||||
<p className="text-sm">
|
|
||||||
<Trans>
|
|
||||||
The team transfer request to <strong>{transferVerification.name}</strong> has
|
|
||||||
expired.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<section className="text-sm">
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
A request to transfer the ownership of this team has been sent to{' '}
|
|
||||||
<strong>
|
|
||||||
{transferVerification.name} ({transferVerification.email})
|
|
||||||
</strong>
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
If they accept this request, the team will be transferred to their account.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
|
|
||||||
<Button
|
|
||||||
onClick={async () => deleteTeamTransferRequest({ teamId })}
|
|
||||||
loading={isPending}
|
|
||||||
variant={isExpired ? 'destructive' : 'ghost'}
|
|
||||||
className={cn('ml-auto', {
|
|
||||||
'hover:bg-transparent hover:text-blue-800': !isExpired,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isExpired ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -26,7 +26,7 @@ import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/templat
|
|||||||
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type TemplateEditFormProps = {
|
export type TemplateEditFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -48,7 +48,7 @@ export const TemplateEditForm = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditTemplateStep>('settings');
|
const [step, setStep] = useState<EditTemplateStep>('settings');
|
||||||
|
|
||||||
@ -256,7 +256,7 @@ export const TemplateEditForm = ({
|
|||||||
<AddTemplateSettingsFormPartial
|
<AddTemplateSettingsFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
template={template}
|
template={template}
|
||||||
currentTeamMemberRole={team?.currentTeamMember?.role}
|
currentTeamMemberRole={team.currentTeamRole}
|
||||||
documentFlow={documentFlow.settings}
|
documentFlow={documentFlow.settings}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with
|
|||||||
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
||||||
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
||||||
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { PeriodSelector } from '../period-selector';
|
import { PeriodSelector } from '../period-selector';
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ export const TemplatePageViewDocumentsTable = ({
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
|
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
|
||||||
Object.fromEntries(searchParams ?? []),
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { User } from '@prisma/client';
|
|
||||||
import { File, User2 } from 'lucide-react';
|
import { File, User2 } from 'lucide-react';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
@ -9,7 +8,10 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
export type UserProfileSkeletonProps = {
|
export type UserProfileSkeletonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: Pick<User, 'name' | 'url'>;
|
user: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
rows?: number;
|
rows?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { trpc as trpcClient } from '@documenso/trpc/client';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionButtonProps = {
|
export type DocumentsTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@ -30,7 +30,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
EyeIcon,
|
EyeIcon,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoveRight,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Share,
|
Share,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -37,10 +36,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||||
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
|
|
||||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionDropdownProps = {
|
export type DocumentsTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@ -52,14 +50,13 @@ export type DocumentsTableActionDropdownProps = {
|
|||||||
|
|
||||||
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
@ -157,14 +154,6 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
|
||||||
{!team && !row.teamId && (
|
|
||||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Move to Team</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No point displaying this if there's no functionality. */}
|
{/* No point displaying this if there's no functionality. */}
|
||||||
{/* <DropdownMenuItem disabled>
|
{/* <DropdownMenuItem disabled>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
@ -216,16 +205,9 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
documentTitle={row.title}
|
documentTitle={row.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
teamId={team?.id}
|
|
||||||
canManageDocument={canManageDocument}
|
canManageDocument={canManageDocument}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentMoveDialog
|
|
||||||
documentId={row.id}
|
|
||||||
open={isMoveDialogOpen}
|
|
||||||
onOpenChange={setMoveDialogOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
|
||||||
@ -18,18 +17,20 @@ export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilte
|
|||||||
|
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
|
const senderIds = (searchParams?.get('senderIds') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.filter((value) => value !== '');
|
||||||
|
|
||||||
const { data, isLoading } = trpc.team.getTeamMembers.useQuery({
|
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||||
label: member.user.name ?? member.user.email,
|
label: member.name ?? member.email,
|
||||||
value: member.user.id,
|
value: member.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onChange = (newSenderIds: number[]) => {
|
const onChange = (newSenderIds: string[]) => {
|
||||||
if (!pathname) {
|
if (!pathname) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||||
import { DocumentsTableActionButton } from './documents-table-action-button';
|
import { DocumentsTableActionButton } from './documents-table-action-button';
|
||||||
@ -36,7 +36,7 @@ type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
|||||||
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
|
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|||||||
199
apps/remix/app/components/tables/inbox-table.tsx
Normal file
199
apps/remix/app/components/tables/inbox-table.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { useMemo, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { CheckCircleIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/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 { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||||
|
|
||||||
|
export type DocumentsTableProps = {
|
||||||
|
data?: TFindDocumentsResponse;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isLoadingError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||||
|
|
||||||
|
export const InboxTable = () => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||||
|
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
|
||||||
|
status: ExtendedDocumentStatus.INBOX,
|
||||||
|
page: page || 1,
|
||||||
|
perPage: perPage || 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: _(msg`Created`),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Title`),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/sign/${row.original.recipients[0]?.token}`}
|
||||||
|
title={row.original.title}
|
||||||
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
|
>
|
||||||
|
{row.original.title}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: _(msg`Sender`),
|
||||||
|
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Recipient`),
|
||||||
|
accessorKey: 'recipient',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.recipients}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Actions`),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link to={`/sign/${row.original.recipients[0]?.token}`}>
|
||||||
|
{match(row.original.recipients[0]?.role)
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<>
|
||||||
|
<PencilIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<>
|
||||||
|
<CheckCircleIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Approve</Trans>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>View</Trans>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||||
|
}, [team]);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
columnVisibility={{
|
||||||
|
sender: team !== undefined,
|
||||||
|
}}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError || false,
|
||||||
|
}}
|
||||||
|
emptyState={
|
||||||
|
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||||
|
<p>
|
||||||
|
<Trans>Documents that require your attention will appear here</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading || false,
|
||||||
|
rows: 5,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-40 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-10 w-24 rounded" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
150
apps/remix/app/components/tables/organisation-groups-table.tsx
Normal file
150
apps/remix/app/components/tables/organisation-groups-table.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationGroupType } from '@prisma/client';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/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 { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
import { OrganisationGroupDeleteDialog } from '../dialogs/organisation-group-delete-dialog';
|
||||||
|
|
||||||
|
export const OrganisationGroupsDataTable = () => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.organisation.group.find.useQuery(
|
||||||
|
{
|
||||||
|
organisationId: organisation.id,
|
||||||
|
query: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
types: [OrganisationGroupType.CUSTOM],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`Group`),
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Role`),
|
||||||
|
accessorKey: 'organisationRole',
|
||||||
|
cell: ({ row }) => _(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Members`),
|
||||||
|
accessorKey: 'members',
|
||||||
|
cell: ({ row }) => row.original.members.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Assigned Teams`),
|
||||||
|
accessorKey: 'teams',
|
||||||
|
cell: ({ row }) => row.original.teams.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Actions`),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={`/org/${organisation.url}/settings/groups/${row.original.id}`}>Manage</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<OrganisationGroupDeleteDialog
|
||||||
|
organisationGroupId={row.original.id}
|
||||||
|
organisationGroupName={row.original.name ?? ''}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive" title={_(msg`Remove organisation group`)}>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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/2 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/3 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,11 +3,12 @@ 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 { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||||
import { History, MoreHorizontal, Trash2 } from 'lucide-react';
|
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 { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
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';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
@ -25,32 +26,33 @@ 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 { useCurrentTeam } from '~/providers/team';
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
export const TeamSettingsMemberInvitesTable = () => {
|
export const OrganisationMemberInvitesTable = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const team = useCurrentTeam();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.organisation.member.invite.find.useQuery(
|
||||||
{
|
{
|
||||||
teamId: team.id,
|
organisationId: organisation.id,
|
||||||
query: parsedSearchParams.query,
|
query: parsedSearchParams.query,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
perPage: parsedSearchParams.perPage,
|
perPage: parsedSearchParams.perPage,
|
||||||
|
status: OrganisationMemberInviteStatus.PENDING,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: resendTeamMemberInvitation } =
|
const { mutateAsync: resendOrganisationMemberInvitation } =
|
||||||
trpc.team.resendTeamMemberInvitation.useMutation({
|
trpc.organisation.member.invite.resend.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -66,8 +68,8 @@ export const TeamSettingsMemberInvitesTable = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamMemberInvitations } =
|
const { mutateAsync: deleteOrganisationMemberInvitations } =
|
||||||
trpc.team.deleteTeamMemberInvitations.useMutation({
|
trpc.organisation.member.invite.deleteMany.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -100,7 +102,7 @@ export const TeamSettingsMemberInvitesTable = () => {
|
|||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
header: _(msg`Team Member`),
|
header: _(msg`Organisation Member`),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
@ -116,7 +118,7 @@ export const TeamSettingsMemberInvitesTable = () => {
|
|||||||
{
|
{
|
||||||
header: _(msg`Role`),
|
header: _(msg`Role`),
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
cell: ({ row }) => _(TEAM_MEMBER_ROLE_MAP[row.original.role]) ?? row.original.role,
|
cell: ({ row }) => _(ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Invited At`),
|
header: _(msg`Invited At`),
|
||||||
@ -138,8 +140,8 @@ export const TeamSettingsMemberInvitesTable = () => {
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
resendTeamMemberInvitation({
|
resendOrganisationMemberInvitation({
|
||||||
teamId: team.id,
|
organisationId: organisation.id,
|
||||||
invitationId: row.original.id,
|
invitationId: row.original.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -150,8 +152,8 @@ export const TeamSettingsMemberInvitesTable = () => {
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
deleteTeamMemberInvitations({
|
deleteOrganisationMemberInvitations({
|
||||||
teamId: team.id,
|
organisationId: organisation.id,
|
||||||
invitationIds: [row.original.id],
|
invitationIds: [row.original.id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -201,7 +203,11 @@ export const TeamSettingsMemberInvitesTable = () => {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
219
apps/remix/app/components/tables/organisation-members-table.tsx
Normal file
219
apps/remix/app/components/tables/organisation-members-table.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationGroupType } from '@prisma/client';
|
||||||
|
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
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 {
|
||||||
|
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 { OrganisationMemberDeleteDialog } from '~/components/dialogs/organisation-member-delete-dialog';
|
||||||
|
import { OrganisationMemberUpdateDialog } from '~/components/dialogs/organisation-member-update-dialog';
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
export const OrganisationMembersDataTable = () => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.organisation.member.find.useQuery(
|
||||||
|
{
|
||||||
|
organisationId: organisation.id,
|
||||||
|
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`Organisation Member`),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const avatarFallbackText = row.original.name
|
||||||
|
? extractInitials(row.original.name)
|
||||||
|
: row.original.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={avatarFallbackText}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||||
|
}
|
||||||
|
secondaryText={row.original.email}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Role`),
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
organisation.ownerUserId === row.original.userId
|
||||||
|
? _(msg`Owner`)
|
||||||
|
: _(ORGANISATION_MEMBER_ROLE_MAP[row.original.currentOrganisationRole]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Member Since`),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Groups`),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Actions`),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<OrganisationMemberUpdateDialog
|
||||||
|
currentUserOrganisationRole={organisation.currentOrganisationRole}
|
||||||
|
organisationId={organisation.id}
|
||||||
|
organisationMemberId={row.original.id}
|
||||||
|
organisationMemberName={row.original.name ?? ''}
|
||||||
|
organisationMemberRole={row.original.currentOrganisationRole}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={
|
||||||
|
organisation.ownerUserId === row.original.userId ||
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(
|
||||||
|
organisation.currentOrganisationRole,
|
||||||
|
row.original.currentOrganisationRole,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
title="Update organisation member role"
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Update role</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrganisationMemberDeleteDialog
|
||||||
|
organisationMemberId={row.original.id}
|
||||||
|
organisationMemberName={row.original.name ?? ''}
|
||||||
|
organisationMemberEmail={row.original.email}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
disabled={
|
||||||
|
organisation.ownerUserId === row.original.userId ||
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(
|
||||||
|
organisation.currentOrganisationRole,
|
||||||
|
row.original.currentOrganisationRole,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={_(msg`Remove organisation member`)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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/2 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/3 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -19,7 +19,7 @@ import { TeamCheckoutCreateDialog } from '~/components/dialogs/team-checkout-cre
|
|||||||
|
|
||||||
import { UserSettingsPendingTeamsTableActions } from './user-settings-pending-teams-table-actions';
|
import { UserSettingsPendingTeamsTableActions } from './user-settings-pending-teams-table-actions';
|
||||||
|
|
||||||
export const UserSettingsPendingTeamsDataTable = () => {
|
export const OrganisationPendingTeamsTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@ -137,7 +137,11 @@ export const UserSettingsPendingTeamsDataTable = () => {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<TeamCheckoutCreateDialog
|
<TeamCheckoutCreateDialog
|
||||||
@ -8,10 +8,8 @@ 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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { 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 { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -21,26 +19,25 @@ 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 { TeamLeaveDialog } from '~/components/dialogs/team-leave-dialog';
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
export const UserSettingsCurrentTeamsDataTable = () => {
|
import { TeamDeleteDialog } from '../dialogs/team-delete-dialog';
|
||||||
|
|
||||||
|
export const OrganisationTeamsTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.findTeams.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.team.find.useQuery({
|
||||||
{
|
organisationId: organisation.id,
|
||||||
query: parsedSearchParams.query,
|
query: parsedSearchParams.query,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
perPage: parsedSearchParams.perPage,
|
perPage: parsedSearchParams.perPage,
|
||||||
},
|
});
|
||||||
{
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -76,15 +73,7 @@ export const UserSettingsCurrentTeamsDataTable = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Role`),
|
header: _(msg`Created`),
|
||||||
accessorKey: 'role',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.ownerUserId === row.original.currentTeamMember.userId
|
|
||||||
? _(msg`Owner`)
|
|
||||||
: _(TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Member Since`),
|
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
},
|
},
|
||||||
@ -92,26 +81,18 @@ export const UserSettingsCurrentTeamsDataTable = () => {
|
|||||||
id: 'actions',
|
id: 'actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
{canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && (
|
<Button variant="outline" asChild>
|
||||||
<Button variant="outline" asChild>
|
<Link to={`/t/${row.original.url}/settings`}>
|
||||||
<Link to={`/t/${row.original.url}/settings`}>
|
<Trans>Manage</Trans>
|
||||||
<Trans>Manage</Trans>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TeamLeaveDialog
|
<TeamDeleteDialog
|
||||||
teamId={row.original.id}
|
teamId={row.original.id}
|
||||||
teamName={row.original.name}
|
teamName={row.original.name}
|
||||||
teamAvatarImageId={row.original.avatarImageId}
|
|
||||||
role={row.original.currentTeamMember.role}
|
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button variant="destructive" onSelect={(e) => e.preventDefault()}>
|
||||||
variant="destructive"
|
<Trans>Delete</Trans>
|
||||||
disabled={row.original.ownerUserId === row.original.currentTeamMember.userId}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Trans>Leave</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -163,7 +144,11 @@ export const UserSettingsCurrentTeamsDataTable = () => {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
183
apps/remix/app/components/tables/team-groups-table.tsx
Normal file
183
apps/remix/app/components/tables/team-groups-table.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationGroupType } from '@prisma/client';
|
||||||
|
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
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';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { TeamGroupDeleteDialog } from '../dialogs/team-group-delete-dialog';
|
||||||
|
import { TeamGroupUpdateDialog } from '../dialogs/team-group-update-dialog';
|
||||||
|
|
||||||
|
export const TeamGroupsTable = () => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.team.group.find.useQuery(
|
||||||
|
{
|
||||||
|
teamId: team.id,
|
||||||
|
query: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
types: [OrganisationGroupType.CUSTOM],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`Group`),
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Role`),
|
||||||
|
accessorKey: 'teamRole',
|
||||||
|
cell: ({ row }) => _(EXTENDED_TEAM_MEMBER_ROLE_MAP[row.original.teamRole]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Members`),
|
||||||
|
accessorKey: 'members',
|
||||||
|
cell: ({ row }) => row.original.members.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`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>
|
||||||
|
|
||||||
|
<TeamGroupUpdateDialog
|
||||||
|
teamGroupId={row.original.id}
|
||||||
|
teamGroupName={row.original.name ?? ''}
|
||||||
|
teamGroupRole={row.original.teamRole}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
title="Update team group role"
|
||||||
|
>
|
||||||
|
<EditIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Update role</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TeamGroupDeleteDialog
|
||||||
|
teamGroupId={row.original.id}
|
||||||
|
teamGroupName={row.original.name ?? ''}
|
||||||
|
teamGroupRole={row.original.teamRole}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
emptyState={
|
||||||
|
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||||
|
<p>
|
||||||
|
<Trans>No team groups found</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/2 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/3 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,11 +3,11 @@ 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 { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
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 { 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';
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
@ -26,20 +26,24 @@ 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 { TeamMemberDeleteDialog } from '~/components/dialogs/team-member-delete-dialog';
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
import { TeamMemberUpdateDialog } from '~/components/dialogs/team-member-update-dialog';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export const TeamSettingsMembersDataTable = () => {
|
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
|
||||||
|
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
|
||||||
|
|
||||||
|
export const TeamMembersDataTable = () => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.team.member.find.useQuery(
|
||||||
{
|
{
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
query: parsedSearchParams.query,
|
query: parsedSearchParams.query,
|
||||||
@ -70,18 +74,18 @@ export const TeamSettingsMembersDataTable = () => {
|
|||||||
{
|
{
|
||||||
header: _(msg`Team Member`),
|
header: _(msg`Team Member`),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.user.name
|
const avatarFallbackText = row.original.name
|
||||||
? extractInitials(row.original.user.name)
|
? extractInitials(row.original.name)
|
||||||
: row.original.user.email.slice(0, 1).toUpperCase();
|
: row.original.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarClass="h-12 w-12"
|
avatarClass="h-12 w-12"
|
||||||
avatarFallback={avatarFallbackText}
|
avatarFallback={avatarFallbackText}
|
||||||
primaryText={
|
primaryText={
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.user.name}</span>
|
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||||
}
|
}
|
||||||
secondaryText={row.original.user.email}
|
secondaryText={row.original.email}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -89,15 +93,12 @@ export const TeamSettingsMembersDataTable = () => {
|
|||||||
{
|
{
|
||||||
header: _(msg`Role`),
|
header: _(msg`Role`),
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => _(EXTENDED_TEAM_MEMBER_ROLE_MAP[row.original.teamRole]),
|
||||||
team.ownerUserId === row.original.userId
|
|
||||||
? _(msg`Owner`)
|
|
||||||
: _(TEAM_MEMBER_ROLE_MAP[row.original.role]),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Member Since`),
|
header: _(msg`Source`),
|
||||||
accessorKey: 'createdAt',
|
cell: ({ row }) => _(msg`Group`),
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
// cell: ({ row }) => (row.original.type === 'member' ? _(msg`Member`) : _(msg`Group`)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Actions`),
|
header: _(msg`Actions`),
|
||||||
@ -113,21 +114,21 @@ export const TeamSettingsMembersDataTable = () => {
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<TeamMemberUpdateDialog
|
<TeamMemberUpdateDialog
|
||||||
currentUserTeamRole={team.currentTeamMember.role}
|
currentUserTeamRole={team.currentTeamRole}
|
||||||
teamId={row.original.teamId}
|
teamId={team.id}
|
||||||
teamMemberId={row.original.id}
|
memberId={row.original.id}
|
||||||
teamMemberName={row.original.user.name ?? ''}
|
memberName={row.original.name ?? ''}
|
||||||
teamMemberRole={row.original.role}
|
memberTeamRole={row.original.teamRole}
|
||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={
|
disabled={
|
||||||
team.ownerUserId === row.original.userId ||
|
organisation.ownerUserId === row.original.userId ||
|
||||||
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
|
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
|
||||||
}
|
}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
title="Update team member role"
|
title="Update team member role"
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<EditIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Update role</Trans>
|
<Trans>Update role</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
}
|
}
|
||||||
@ -136,19 +137,19 @@ export const TeamSettingsMembersDataTable = () => {
|
|||||||
<TeamMemberDeleteDialog
|
<TeamMemberDeleteDialog
|
||||||
teamId={team.id}
|
teamId={team.id}
|
||||||
teamName={team.name}
|
teamName={team.name}
|
||||||
teamMemberId={row.original.id}
|
memberId={row.original.id}
|
||||||
teamMemberName={row.original.user.name ?? ''}
|
memberName={row.original.name ?? ''}
|
||||||
teamMemberEmail={row.original.user.email}
|
memberEmail={row.original.email}
|
||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
disabled={
|
disabled={
|
||||||
team.ownerUserId === row.original.userId ||
|
organisation.ownerUserId === row.original.userId ||
|
||||||
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
|
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
|
||||||
}
|
}
|
||||||
title={_(msg`Remove team member`)}
|
title={_(msg`Remove team member`)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Remove</Trans>
|
<Trans>Remove</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
||||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
|
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@ -18,7 +18,6 @@ import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
|
|||||||
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
|
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
|
||||||
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
||||||
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
||||||
import { TemplateMoveDialog } from '../dialogs/template-move-dialog';
|
|
||||||
|
|
||||||
export type TemplatesTableActionDropdownProps = {
|
export type TemplatesTableActionDropdownProps = {
|
||||||
row: Template & {
|
row: Template & {
|
||||||
@ -26,15 +25,8 @@ export type TemplatesTableActionDropdownProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
teamId?: number;
|
teamId: number;
|
||||||
onDelete?: () => Promise<void> | void;
|
onDelete?: () => Promise<void> | void;
|
||||||
onMove?: ({
|
|
||||||
templateId,
|
|
||||||
teamUrl,
|
|
||||||
}: {
|
|
||||||
templateId: number;
|
|
||||||
teamUrl: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplatesTableActionDropdown = ({
|
export const TemplatesTableActionDropdown = ({
|
||||||
@ -42,14 +34,12 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
templateRootPath,
|
templateRootPath,
|
||||||
teamId,
|
teamId,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMove,
|
|
||||||
}: TemplatesTableActionDropdownProps) => {
|
}: TemplatesTableActionDropdownProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const isOwner = row.userId === user.id;
|
const isOwner = row.userId === user.id;
|
||||||
const isTeamTemplate = row.teamId === teamId;
|
const isTeamTemplate = row.teamId === teamId;
|
||||||
@ -83,13 +73,6 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
<Trans>Direct link</Trans>
|
<Trans>Direct link</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{!teamId && !row.teamId && (
|
|
||||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Move to Team</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TemplateBulkSendDialog
|
<TemplateBulkSendDialog
|
||||||
templateId={row.id}
|
templateId={row.id}
|
||||||
recipients={row.recipients}
|
recipients={row.recipients}
|
||||||
@ -122,13 +105,6 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateMoveDialog
|
|
||||||
templateId={row.id}
|
|
||||||
open={isMoveDialogOpen}
|
|
||||||
onOpenChange={setMoveDialogOpen}
|
|
||||||
onMove={onMove}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TemplateDeleteDialog
|
<TemplateDeleteDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { TableCell } from '@documenso/ui/primitives/table';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { TemplateType } from '~/components/general/template/template-type';
|
import { TemplateType } from '~/components/general/template/template-type';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { TemplateUseDialog } from '../dialogs/template-use-dialog';
|
import { TemplateUseDialog } from '../dialogs/template-use-dialog';
|
||||||
import { TemplateDirectLinkBadge } from '../general/template/template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '../general/template/template-direct-link-badge';
|
||||||
@ -45,7 +45,7 @@ export const TemplatesTable = ({
|
|||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,169 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { OrganisationLeaveDialog } from '../dialogs/organisation-leave-dialog';
|
||||||
|
|
||||||
|
export const UserSettingsOrganisationsTable = () => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError } = trpc.organisation.getMany.useQuery();
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
data: data || [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Todo: Orgs
|
||||||
|
// const results = data ?? {
|
||||||
|
// data: [],
|
||||||
|
// perPage: 10,
|
||||||
|
// currentPage: 1,
|
||||||
|
// totalPages: 1,
|
||||||
|
// };
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: _(msg`Organisation`),
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/org/${row.original.url}`} preventScrollReset={true}>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
|
||||||
|
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()}/org/${row.original.url}`}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Role`),
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.ownerUserId === user.id
|
||||||
|
? _(msg`Owner`)
|
||||||
|
: _(ORGANISATION_MEMBER_ROLE_MAP[row.original.currentOrganisationRole]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Member Since`),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
{canExecuteOrganisationAction(
|
||||||
|
'MANAGE_ORGANISATION',
|
||||||
|
row.original.currentOrganisationRole,
|
||||||
|
) && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to={`/org/${row.original.url}/settings`}>
|
||||||
|
<Trans>Manage</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OrganisationLeaveDialog
|
||||||
|
organisationId={row.original.id}
|
||||||
|
organisationName={row.original.name}
|
||||||
|
organisationAvatarImageId={row.original.avatarImageId}
|
||||||
|
organisationMemberId={row.original.currentMemberId}
|
||||||
|
role={row.original.currentOrganisationRole}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={row.original.ownerUserId === user.id}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Trans>Leave</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] 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="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>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
|
<Skeleton className="h-10 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* {(table) =>
|
||||||
|
results.totalPages > 1 && (
|
||||||
|
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||||
|
)
|
||||||
|
} */}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { UserSettingsCurrentTeamsDataTable } from './user-settings-current-teams-table';
|
|
||||||
import { UserSettingsPendingTeamsDataTable } from './user-settings-pending-teams-table';
|
|
||||||
|
|
||||||
export const UserSettingsTeamsPageDataTable = () => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
|
||||||
|
|
||||||
params.set('query', debouncedSearchQuery);
|
|
||||||
|
|
||||||
if (debouncedSearchQuery === '') {
|
|
||||||
params.delete('query');
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchParams(params);
|
|
||||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
|
||||||
<Input
|
|
||||||
defaultValue={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={_(msg`Search`)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="active" asChild>
|
|
||||||
<Link to={pathname ?? '/'}>
|
|
||||||
<Trans>Active</Trans>
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="pending" asChild>
|
|
||||||
<Link to={`${pathname}?tab=pending`}>
|
|
||||||
<Trans>Pending</Trans>
|
|
||||||
{data && data.count > 0 && (
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">{data.count}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentTab === 'pending' ? (
|
|
||||||
<UserSettingsPendingTeamsDataTable />
|
|
||||||
) : (
|
|
||||||
<UserSettingsCurrentTeamsDataTable />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
33
apps/remix/app/providers/organisation.tsx
Normal file
33
apps/remix/app/providers/organisation.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||||
|
|
||||||
|
type OrganisationProviderValue = OrganisationSession;
|
||||||
|
|
||||||
|
interface OrganisationProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
organisation: OrganisationProviderValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrganisationContext = createContext<OrganisationProviderValue | null>(null);
|
||||||
|
|
||||||
|
export const useCurrentOrganisation = () => {
|
||||||
|
const context = useContext(OrganisationContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCurrentOrganisation must be used within a OrganisationProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOptionalCurrentOrganisation = () => {
|
||||||
|
return useContext(OrganisationContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
|
||||||
|
return (
|
||||||
|
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
import type { TeamSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||||
|
|
||||||
type TeamProviderValue = TGetTeamsResponse[0];
|
type TeamProviderValue = TeamSession;
|
||||||
|
|
||||||
interface TeamProviderProps {
|
interface TeamProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
team: TeamProviderValue;
|
team: TeamProviderValue | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamContext = createContext<TeamProviderValue | null>(null);
|
const TeamContext = createContext<TeamProviderValue | null>(null);
|
||||||
|
|||||||
@ -17,10 +17,10 @@ import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
||||||
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import { createPublicEnv, env } from '@documenso/lib/utils/env';
|
import { createPublicEnv, env } from '@documenso/lib/utils/env';
|
||||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
|
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
@ -68,12 +68,6 @@ export const shouldRevalidate = () => false;
|
|||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const session = await getOptionalSession(request);
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
let teams: TGetTeamsResponse = [];
|
|
||||||
|
|
||||||
if (session.isAuthenticated) {
|
|
||||||
teams = await getTeams({ userId: session.user.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { getTheme } = await themeSessionResolver(request);
|
const { getTheme } = await themeSessionResolver(request);
|
||||||
|
|
||||||
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
|
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||||
@ -82,6 +76,12 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
lang = extractLocaleData({ headers: request.headers }).lang;
|
lang = extractLocaleData({ headers: request.headers }).lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let organisations = null;
|
||||||
|
|
||||||
|
if (session.isAuthenticated) {
|
||||||
|
organisations = await getOrganisationSession({ userId: session.user.id });
|
||||||
|
}
|
||||||
|
|
||||||
return data(
|
return data(
|
||||||
{
|
{
|
||||||
lang,
|
lang,
|
||||||
@ -90,7 +90,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
? {
|
? {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
session: session.session,
|
session: session.session,
|
||||||
teams,
|
organisations: organisations || [],
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
publicEnv: createPublicEnv(),
|
publicEnv: createPublicEnv(),
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { Outlet, redirect } from 'react-router';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
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 { getLimits } from '@documenso/ee/server-only/limits/client';
|
||||||
@ -6,10 +8,14 @@ import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client
|
|||||||
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 { 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 { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { AppBanner } from '~/components/general/app-banner';
|
import { 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 { 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 type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
@ -42,24 +48,112 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
||||||
const { user, teams } = useSession();
|
const { user, organisations } = useSession();
|
||||||
|
|
||||||
const { banner, limits } = loaderData;
|
const { banner, limits } = loaderData;
|
||||||
|
|
||||||
|
const teamUrl = params.teamUrl;
|
||||||
|
const orgUrl = params.orgUrl;
|
||||||
|
|
||||||
|
const teams = organisations.flatMap((org) => org.teams);
|
||||||
|
|
||||||
|
// Todo: orgs limits
|
||||||
|
// const limits = useMemo(() => {
|
||||||
|
// if (!currentTeam) {
|
||||||
|
// return undefined;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (
|
||||||
|
// currentTeam?.subscription &&
|
||||||
|
// currentTeam.subscription.status === SubscriptionStatus.INACTIVE
|
||||||
|
// ) {
|
||||||
|
// return {
|
||||||
|
// quota: {
|
||||||
|
// documents: 0,
|
||||||
|
// recipients: 0,
|
||||||
|
// directTemplates: 0,
|
||||||
|
// },
|
||||||
|
// remaining: {
|
||||||
|
// documents: 0,
|
||||||
|
// recipients: 0,
|
||||||
|
// directTemplates: 0,
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// quota: TEAM_PLAN_LIMITS,
|
||||||
|
// remaining: TEAM_PLAN_LIMITS,
|
||||||
|
// };
|
||||||
|
// }, [currentTeam?.subscription, currentTeam?.id]);
|
||||||
|
|
||||||
|
const extractCurrentOrganisation = () => {
|
||||||
|
if (orgUrl) {
|
||||||
|
return organisations.find((org) => org.url === orgUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search organisations to find the team since we don't have access to the orgUrl in the URL.
|
||||||
|
if (teamUrl) {
|
||||||
|
return organisations.find((org) => org.teams.some((team) => team.url === teamUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTeam = teams.find((team) => team.url === teamUrl);
|
||||||
|
const currentOrganisation = extractCurrentOrganisation() || null;
|
||||||
|
|
||||||
|
const orgNotFound = params.orgUrl && !currentOrganisation;
|
||||||
|
const teamNotFound = params.teamUrl && !currentTeam;
|
||||||
|
|
||||||
|
if (orgNotFound || teamNotFound) {
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={404}
|
||||||
|
errorCodeMap={{
|
||||||
|
404: orgNotFound
|
||||||
|
? {
|
||||||
|
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.`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
heading: msg`Team not found`,
|
||||||
|
subHeading: msg`404 Team not found`,
|
||||||
|
message: msg`The team you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<Trans>Go home</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LimitsProvider initialValue={limits}>
|
<OrganisationProvider organisation={currentOrganisation}>
|
||||||
<div id="portal-header"></div>
|
<TeamProvider team={currentTeam || null}>
|
||||||
|
<LimitsProvider initialValue={limits}>
|
||||||
|
<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 user={user} teams={teams} />
|
<Header />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</LimitsProvider>
|
</LimitsProvider>
|
||||||
|
</TeamProvider>
|
||||||
|
</OrganisationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
232
apps/remix/app/routes/_authenticated+/dashboard.tsx
Normal file
232
apps/remix/app/routes/_authenticated+/dashboard.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Building2Icon, InboxIcon, SettingsIcon, UsersIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { ScrollArea, ScrollBar } from '@documenso/ui/primitives/scroll-area';
|
||||||
|
|
||||||
|
import { InboxTable } from '~/components/tables/inbox-table';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const { user, organisations } = useSession();
|
||||||
|
|
||||||
|
// Todo: Sort by recent access (TBD by cookies)
|
||||||
|
// Teams, flattened with the organisation data still attached.
|
||||||
|
const teams = useMemo(() => {
|
||||||
|
return organisations.flatMap((org) =>
|
||||||
|
org.teams.map((team) => ({
|
||||||
|
...team,
|
||||||
|
organisation: {
|
||||||
|
...org,
|
||||||
|
teams: undefined,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, [organisations]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="container">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<Trans>Dashboard</Trans>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
<Trans>Welcome back! Here's an overview of your account.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisations Section */}
|
||||||
|
{organisations.length > 1 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2Icon className="text-muted-foreground h-5 w-5" />
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
<Trans>Organisations</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right hand side action if required. */}
|
||||||
|
{/* <Button variant="outline" size="sm" className="gap-1">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
<Trans>New</Trans>
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{organisations.map((org) => (
|
||||||
|
<div key={org.id} className="group relative">
|
||||||
|
<Link to={`/org/${org.url}`}>
|
||||||
|
<Card className="hover:bg-muted/50 h-full border pr-6 transition-all">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10 border border-solid">
|
||||||
|
{org.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(org.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
|
{org.name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">{org.name}</h3>
|
||||||
|
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<UsersIcon className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{org.ownerUserId === user.id
|
||||||
|
? t`Owner`
|
||||||
|
: t(ORGANISATION_MEMBER_ROLE_MAP[org.currentOrganisationRole])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Building2Icon className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
<Plural
|
||||||
|
value={org.teams.length}
|
||||||
|
one={<Trans># team</Trans>}
|
||||||
|
other={<Trans># teams</Trans>}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{canExecuteOrganisationAction(
|
||||||
|
'MANAGE_ORGANISATION',
|
||||||
|
org.currentOrganisationRole,
|
||||||
|
) && (
|
||||||
|
<div className="text-muted-foreground absolute right-4 top-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<Link to={`/org/${org.url}/settings`}>
|
||||||
|
<SettingsIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Teams Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UsersIcon className="text-muted-foreground h-5 w-5" />
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
<Trans>Teams</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/* <Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/" className="gap-1">
|
||||||
|
<Trans>View all</Trans>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="w-full whitespace-nowrap pb-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{teams.map((team) => (
|
||||||
|
<div key={team.id} className="group relative">
|
||||||
|
<Link to={`/t/${team.url}`}>
|
||||||
|
<Card className="hover:bg-muted/50 w-[350px] shrink-0 border transition-all">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10 border border-solid">
|
||||||
|
{team.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
|
{team.name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">{team.name}</h3>
|
||||||
|
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<UsersIcon className="h-3 w-3" />
|
||||||
|
{team.organisation.ownerUserId === user.id
|
||||||
|
? t`Owner`
|
||||||
|
: t(TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Building2Icon className="h-3 w-3" />
|
||||||
|
<span className="truncate">{team.organisation.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-3 text-xs">
|
||||||
|
<Trans>
|
||||||
|
Joined{' '}
|
||||||
|
{DateTime.fromJSDate(team.createdAt).toRelative({ style: 'short' })}
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||||
|
<div className="text-muted-foreground absolute right-4 top-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<Link to={`/t/${team.url}/settings`}>
|
||||||
|
<SettingsIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inbox Section */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InboxIcon className="text-muted-foreground h-5 w-5" />
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
<Trans>Personal Inbox</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/* <Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/inbox" className="gap-1">
|
||||||
|
<span>
|
||||||
|
<Trans>View all</Trans>
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InboxTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,263 +0,0 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
|
||||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
|
||||||
import { Link, redirect } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
|
||||||
|
|
||||||
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
|
||||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
|
||||||
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
|
|
||||||
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
|
||||||
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
|
||||||
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
|
||||||
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
|
||||||
import {
|
|
||||||
DocumentStatus as DocumentStatusComponent,
|
|
||||||
FRIENDLY_STATUS_MAP,
|
|
||||||
} from '~/components/general/document/document-status';
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|
||||||
|
|
||||||
import type { Route } from './+types/documents.$id._index';
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { user } = await getSession(request);
|
|
||||||
|
|
||||||
let team: TGetTeamByUrlResponse | null = null;
|
|
||||||
|
|
||||||
if (params.teamUrl) {
|
|
||||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = await getDocumentById({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (document?.teamId && !team?.url) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentVisibility = document?.visibility;
|
|
||||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
|
||||||
let canAccessDocument = true;
|
|
||||||
|
|
||||||
if (team && !isRecipient && document?.userId !== user.id) {
|
|
||||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.otherwise(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document || !document.documentData || (team && !canAccessDocument)) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (team && !canAccessDocument) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo: Get full document instead?
|
|
||||||
const [recipients, fields] = await Promise.all([
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
teamId: team?.id,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const documentWithRecipients = {
|
|
||||||
...document,
|
|
||||||
recipients,
|
|
||||||
};
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
document: documentWithRecipients,
|
|
||||||
documentRootPath,
|
|
||||||
fields,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentPage() {
|
|
||||||
const loaderData = useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const { document, documentRootPath, fields } = loaderData;
|
|
||||||
|
|
||||||
const { recipients, documentData, documentMeta } = document;
|
|
||||||
|
|
||||||
// This was a feature flag. Leave to false since it's not ready.
|
|
||||||
const isDocumentHistoryEnabled = false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
{document.status === DocumentStatus.PENDING && (
|
|
||||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
<Trans>Documents</Trans>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-row justify-between truncate">
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatusComponent
|
|
||||||
inheritColor
|
|
||||||
status={document.status}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={recipients}
|
|
||||||
documentStatus={document.status}
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Trans>{recipients.length} Recipient(s)</Trans>
|
|
||||||
</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.deletedAt && (
|
|
||||||
<Badge variant="destructive">
|
|
||||||
<Trans>Document deleted</Trans>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDocumentHistoryEnabled && (
|
|
||||||
<div className="self-end">
|
|
||||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
|
||||||
<Trans>Document history</Trans>
|
|
||||||
</Button>
|
|
||||||
</DocumentHistorySheet>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
|
||||||
<Card
|
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{document.status === DocumentStatus.PENDING && (
|
|
||||||
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
|
||||||
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<DocumentPageViewDropdown document={document} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
|
||||||
{match(document.status)
|
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
|
||||||
<Trans>This document has been signed by all recipients</Trans>
|
|
||||||
))
|
|
||||||
.with(DocumentStatus.REJECTED, () => (
|
|
||||||
<Trans>This document has been rejected by a recipient</Trans>
|
|
||||||
))
|
|
||||||
.with(DocumentStatus.DRAFT, () => (
|
|
||||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
|
||||||
))
|
|
||||||
.with(DocumentStatus.PENDING, () => {
|
|
||||||
const pendingRecipients = recipients.filter(
|
|
||||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Plural
|
|
||||||
value={pendingRecipients.length}
|
|
||||||
one="Waiting on 1 recipient"
|
|
||||||
other="Waiting on # recipients"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.exhaustive()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t px-4 pt-4">
|
|
||||||
<DocumentPageViewButton document={document} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Document information section. */}
|
|
||||||
<DocumentPageViewInformation document={document} userId={user.id} />
|
|
||||||
|
|
||||||
{/* Recipients section. */}
|
|
||||||
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
|
|
||||||
|
|
||||||
{/* Recent activity section. */}
|
|
||||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
import { Plural, Trans } from '@lingui/react/macro';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
|
||||||
import { Link, redirect } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
|
||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
|
||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
|
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|
||||||
|
|
||||||
import type { Route } from './+types/documents.$id.edit';
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { user } = await getSession(request);
|
|
||||||
|
|
||||||
let team: TGetTeamByUrlResponse | null = null;
|
|
||||||
|
|
||||||
if (params.teamUrl) {
|
|
||||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = await getDocumentWithDetailsById({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (document?.teamId && !team?.url) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentVisibility = document?.visibility;
|
|
||||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
|
||||||
let canAccessDocument = true;
|
|
||||||
|
|
||||||
if (!isRecipient && document?.userId !== user.id) {
|
|
||||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.otherwise(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (team && !canAccessDocument) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
|
||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDocumentEnterprise = await isUserEnterprise({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
document,
|
|
||||||
documentRootPath,
|
|
||||||
isDocumentEnterprise,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentEditPage() {
|
|
||||||
const { document, documentRootPath, isDocumentEnterprise } = useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
const { recipients } = document;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
<Trans>Documents</Trans>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1
|
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={recipients}
|
|
||||||
documentStatus={document.status}
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
|
||||||
</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentEditForm
|
|
||||||
className="mt-6"
|
|
||||||
initialDocument={document}
|
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { Recipient } from '@prisma/client';
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { Link, redirect } from 'react-router';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { Card } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import { DocumentAuditLogDownloadButton } from '~/components/general/document/document-audit-log-download-button';
|
|
||||||
import { DocumentCertificateDownloadButton } from '~/components/general/document/document-certificate-download-button';
|
|
||||||
import {
|
|
||||||
DocumentStatus as DocumentStatusComponent,
|
|
||||||
FRIENDLY_STATUS_MAP,
|
|
||||||
} from '~/components/general/document/document-status';
|
|
||||||
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
|
||||||
|
|
||||||
import type { Route } from './+types/documents.$id.logs';
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { user } = await getSession(request);
|
|
||||||
|
|
||||||
let team: TGetTeamByUrlResponse | null = null;
|
|
||||||
|
|
||||||
if (params.teamUrl) {
|
|
||||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo: Get full document instead?
|
|
||||||
const [document, recipients] = await Promise.all([
|
|
||||||
getDocumentById({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}).catch(() => null),
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
document,
|
|
||||||
documentRootPath,
|
|
||||||
recipients,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { document, documentRootPath, recipients } = loaderData;
|
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
|
||||||
{
|
|
||||||
description: msg`Document title`,
|
|
||||||
value: document.title,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: msg`Document ID`,
|
|
||||||
value: document.id.toString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: msg`Document status`,
|
|
||||||
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: msg`Created by`,
|
|
||||||
value: document.user.name
|
|
||||||
? `${document.user.name} (${document.user.email})`
|
|
||||||
: document.user.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: msg`Date created`,
|
|
||||||
value: DateTime.fromJSDate(document.createdAt)
|
|
||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
|
||||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: msg`Last updated`,
|
|
||||||
value: DateTime.fromJSDate(document.updatedAt)
|
|
||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
|
||||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: msg`Time zone`,
|
|
||||||
value: document.documentMeta?.timezone ?? 'N/A',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatRecipientText = (recipient: Recipient) => {
|
|
||||||
let text = recipient.email;
|
|
||||||
|
|
||||||
if (recipient.name) {
|
|
||||||
text = `${recipient.name} (${recipient.email})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[${recipient.role}] ${text}`;
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link
|
|
||||||
to={`${documentRootPath}/${document.id}`}
|
|
||||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
<Trans>Document</Trans>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-col justify-between sm:flex-row">
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatusComponent
|
|
||||||
inheritColor
|
|
||||||
status={document.status}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
|
||||||
<DocumentCertificateDownloadButton
|
|
||||||
className="mr-2"
|
|
||||||
documentId={document.id}
|
|
||||||
documentStatus={document.status}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocumentAuditLogDownloadButton documentId={document.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
|
||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
|
||||||
{documentInformation.map((info, i) => (
|
|
||||||
<div className="text-foreground text-sm" key={i}>
|
|
||||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
|
||||||
<p className="text-muted-foreground truncate">{info.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="text-foreground text-sm">
|
|
||||||
<h3 className="font-semibold">Recipients</h3>
|
|
||||||
<ul className="text-muted-foreground list-inside list-disc">
|
|
||||||
{recipients.map((recipient) => (
|
|
||||||
<li key={`recipient-${recipient.id}`}>
|
|
||||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
|
||||||
<DocumentLogsTable documentId={document.id} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { useSearchParams } from 'react-router';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import {
|
|
||||||
type TFindDocumentsInternalResponse,
|
|
||||||
ZFindDocumentsInternalRequestSchema,
|
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
||||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
|
||||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
|
||||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags('Documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
|
||||||
status: true,
|
|
||||||
period: true,
|
|
||||||
page: true,
|
|
||||||
perPage: true,
|
|
||||||
query: true,
|
|
||||||
}).extend({
|
|
||||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
|
||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
|
||||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const findDocumentSearchParams = useMemo(
|
|
||||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
|
||||||
[searchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
|
|
||||||
{
|
|
||||||
...findDocumentSearchParams,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refetch the documents when the team URL changes.
|
|
||||||
useEffect(() => {
|
|
||||||
void refetch();
|
|
||||||
}, [team?.url]);
|
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
|
|
||||||
params.set('status', value);
|
|
||||||
|
|
||||||
if (value === ExtendedDocumentStatus.ALL) {
|
|
||||||
params.delete('status');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.has('page')) {
|
|
||||||
params.delete('page');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.stats) {
|
|
||||||
setStats(data.stats);
|
|
||||||
}
|
|
||||||
}, [data?.stats]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<DocumentUploadDropzone />
|
|
||||||
|
|
||||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
{team && (
|
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
|
||||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
|
||||||
{team.name.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-semibold">
|
|
||||||
<Trans>Documents</Trans>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
|
||||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
{[
|
|
||||||
ExtendedDocumentStatus.INBOX,
|
|
||||||
ExtendedDocumentStatus.PENDING,
|
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
|
||||||
ExtendedDocumentStatus.DRAFT,
|
|
||||||
ExtendedDocumentStatus.ALL,
|
|
||||||
].map((value) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={value}
|
|
||||||
className="hover:text-foreground min-w-[60px]"
|
|
||||||
value={value}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={getTabHref(value)} preventScrollReset>
|
|
||||||
<DocumentStatus status={value} />
|
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
|
||||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
|
||||||
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<PeriodSelector />
|
|
||||||
</div>
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<div>
|
|
||||||
{data && data.count === 0 ? (
|
|
||||||
<DocumentsTableEmptyState
|
|
||||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
223
apps/remix/app/routes/_authenticated+/org.$orgUrl._index.tsx
Normal file
223
apps/remix/app/routes/_authenticated+/org.$orgUrl._index.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CalendarIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { canExecuteTeamAction, formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||||
|
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||||
|
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
export default function OrganisationSettingsTeamsPage() {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
// No teams view.
|
||||||
|
if (organisation.teams.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center px-4 py-16">
|
||||||
|
<div className="bg-muted mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||||
|
<UsersIcon className="text-muted-foreground h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">
|
||||||
|
<Trans>No teams yet</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{canExecuteOrganisationAction(
|
||||||
|
'MANAGE_ORGANISATION',
|
||||||
|
organisation.currentOrganisationRole,
|
||||||
|
) ? (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-md text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
Teams help you organize your work and collaborate with others. Create your first
|
||||||
|
team to get started.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<TeamCreateDialog
|
||||||
|
trigger={
|
||||||
|
<Button className="flex items-center gap-2">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
<Trans>Create team</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-12 max-w-md rounded-lg border px-8 py-6">
|
||||||
|
<h3 className="mb-2 font-medium">
|
||||||
|
<Trans>What you can do with teams:</Trans>
|
||||||
|
</h3>
|
||||||
|
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||||
|
<li className="flex flex-row items-center gap-2">
|
||||||
|
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
|
||||||
|
<span className="text-xs">1</span>
|
||||||
|
</div>
|
||||||
|
<Trans>Organize your documents and templates</Trans>
|
||||||
|
</li>
|
||||||
|
<li className="flex flex-row items-center gap-2">
|
||||||
|
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
|
||||||
|
<span className="text-xs">2</span>
|
||||||
|
</div>
|
||||||
|
<Trans>Invite team members to collaborate</Trans>
|
||||||
|
</li>
|
||||||
|
<li className="flex flex-row items-center gap-2">
|
||||||
|
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
|
||||||
|
<span className="text-xs">3</span>
|
||||||
|
</div>
|
||||||
|
<Trans>Manage permissions and access controls</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-md text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
You currently have no access to any teams within this organisation. Please contact
|
||||||
|
your organisation to request access.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex flex-row justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
<Trans>{organisation.name} Teams</Trans>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Select a team to view its dashboard</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/org/${organisation.url}/settings`}>Manage Organisation</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{organisation.teams.map((team) => (
|
||||||
|
<Link to={`/t/${team.url}`} key={team.id}>
|
||||||
|
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10 border-2 border-solid">
|
||||||
|
{team.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
|
{team.name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{team.name}</h3>
|
||||||
|
<div className="text-muted-foreground truncate text-xs">
|
||||||
|
{formatTeamUrl(team.url)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamDropdownMenu team={team} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-4">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<CalendarIcon className="h-3 w-3" />
|
||||||
|
{i18n.date(team.createdAt, { dateStyle: 'short' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<UserIcon className="h-3 w-3" />
|
||||||
|
<span>{t(TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['teams'][0] }) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<MoreVerticalIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={`/t/${team.url}`}>
|
||||||
|
<ArrowRight className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Go to team</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={`/t/${team.url}/settings`}>
|
||||||
|
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={`/t/${team.url}/settings/members`}>
|
||||||
|
<UsersIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Members</Trans>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{canExecuteTeamAction('DELETE_TEAM', team.currentTeamRole) && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<TeamDeleteDialog
|
||||||
|
teamId={team.id}
|
||||||
|
teamName={team.name}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<TrashIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
|
export default function Layout({ params }: Route.ComponentProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
{/* {currentOrganisation.subscription &&
|
||||||
|
currentOrganisation.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||||
|
<PortalComponent target="portal-header">
|
||||||
|
<TeamLayoutBillingBanner
|
||||||
|
subscriptionStatus={currentOrganisation.subscription.status}
|
||||||
|
teamId={currentOrganisation.id}
|
||||||
|
userRole={currentOrganisation.currentTeamMember.role}
|
||||||
|
/>
|
||||||
|
</PortalComponent>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from 'react-router';
|
||||||
|
|
||||||
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
|
export function loader({ params }: Route.LoaderArgs) {
|
||||||
|
if (params.orgUrl) {
|
||||||
|
throw redirect(`/org/${params.orgUrl}/settings/general`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect('/');
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } from 'lucide-react';
|
||||||
|
import { FaUsers } from 'react-icons/fa6';
|
||||||
|
import { Link, NavLink, Outlet } from 'react-router';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Organisation Settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const organisationSettingRoutes = [
|
||||||
|
{
|
||||||
|
path: `/org/${organisation.url}/settings/general`,
|
||||||
|
label: t`General`,
|
||||||
|
icon: Building2Icon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/org/${organisation.url}/settings/preferences`,
|
||||||
|
label: t`Preferences`,
|
||||||
|
icon: Settings2Icon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/org/${organisation.url}/settings/teams`,
|
||||||
|
label: t`Teams`,
|
||||||
|
icon: FaUsers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/org/${organisation.url}/settings/members`,
|
||||||
|
label: t`Members`,
|
||||||
|
icon: Users2Icon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/org/${organisation.url}/settings/groups`,
|
||||||
|
label: t`Groups`,
|
||||||
|
icon: GroupIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/org/${organisation.url}/settings/billing`,
|
||||||
|
label: t`Billing`,
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
},
|
||||||
|
].filter((route) => (isBillingEnabled ? route : !route.path.includes('/billing')));
|
||||||
|
|
||||||
|
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={401}
|
||||||
|
errorCodeMap={{
|
||||||
|
401: {
|
||||||
|
heading: msg`Unauthorized`,
|
||||||
|
subHeading: msg`401 Unauthorized`,
|
||||||
|
message: msg`You are not authorized to access this page.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/org/${organisation.url}`}>
|
||||||
|
<Trans>Go Back</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
secondaryButton={null}
|
||||||
|
></GenericErrorLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Organisation Settings</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
|
{/* Navigation */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{organisationSettingRoutes.map((route) => (
|
||||||
|
<NavLink to={route.path} className="group w-full justify-start" key={route.path}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="group-aria-[current]:bg-secondary w-full justify-start"
|
||||||
|
>
|
||||||
|
<route.icon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>{route.label}</Trans>
|
||||||
|
</Button>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 md:col-span-9">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
|
||||||
|
import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-delete-dialog';
|
||||||
|
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||||
|
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrganisationSettingsGeneral() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<SettingsHeader
|
||||||
|
title={_(msg`General`)}
|
||||||
|
subtitle={_(msg`Here you can edit your organisation details.`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<AvatarImageForm />
|
||||||
|
<OrganisationUpdateForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canExecuteOrganisationAction(
|
||||||
|
'DELETE_ORGANISATION',
|
||||||
|
organisation.currentOrganisationRole,
|
||||||
|
) && (
|
||||||
|
<>
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Delete organisation</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>
|
||||||
|
This organisation, and any associated data will be permanently deleted.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrganisationDeleteDialog />
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,304 @@
|
|||||||
|
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 { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
|
ORGANISATION_MEMBER_ROLE_MAP,
|
||||||
|
} from '@documenso/lib/constants/organisations';
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
||||||
|
import type { TFindOrganisationMembersResponse } from '@documenso/trpc/server/organisation-router/find-organisation-members.types';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
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 { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
import type { Route } from './+types/org.$orgUrl.settings.groups.$id';
|
||||||
|
|
||||||
|
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const groupId = params.id;
|
||||||
|
|
||||||
|
const { data: members, isLoading: isLoadingMembers } = trpc.organisation.member.find.useQuery({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: groupData, isLoading: isLoadingGroup } = trpc.organisation.group.find.useQuery(
|
||||||
|
{
|
||||||
|
organisationId: organisation.id,
|
||||||
|
organisationGroupId: groupId,
|
||||||
|
page: 1,
|
||||||
|
perPage: 1,
|
||||||
|
types: [OrganisationGroupType.CUSTOM],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!organisation.id && !!groupId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const group = groupData?.data.find((g) => g.id === groupId);
|
||||||
|
|
||||||
|
if (isLoadingGroup || isLoadingMembers) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center rounded-lg py-32">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Update UI, currently out of place.
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={404}
|
||||||
|
errorCodeMap={{
|
||||||
|
404: {
|
||||||
|
heading: msg`Organisation group not found`,
|
||||||
|
subHeading: msg`404 Organisation group not found`,
|
||||||
|
message: msg`The organisation group you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/org/${organisation.url}/settings/groups`}>
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
secondaryButton={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={t`Organisation Group Settings`}
|
||||||
|
subtitle={t`Manage your organisation group settings.`}
|
||||||
|
>
|
||||||
|
<OrganisationGroupDeleteDialog
|
||||||
|
organisationGroupId={groupId}
|
||||||
|
organisationGroupName={group.name || ''}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive" title={t`Remove organisation group`}>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<OrganisationGroupForm group={group} organisationMembers={members?.data || []} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZUpdateOrganisationGroupFormSchema = z.object({
|
||||||
|
name: z.string().min(1, msg`Name is required`.id),
|
||||||
|
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||||
|
memberIds: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdateOrganisationGroupFormSchema = z.infer<typeof ZUpdateOrganisationGroupFormSchema>;
|
||||||
|
|
||||||
|
type OrganisationGroupFormOptions = {
|
||||||
|
group: TFindOrganisationGroupsResponse['data'][number];
|
||||||
|
organisationMembers: TFindOrganisationMembersResponse['data'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroupFormOptions) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationGroup } = trpc.organisation.group.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TUpdateOrganisationGroupFormSchema>({
|
||||||
|
resolver: zodResolver(ZUpdateOrganisationGroupFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: group.name || '',
|
||||||
|
organisationRole: group.organisationRole,
|
||||||
|
memberIds: group.members.map((member) => member.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: TUpdateOrganisationGroupFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateOrganisationGroup({
|
||||||
|
id: group.id,
|
||||||
|
name: values.name,
|
||||||
|
organisationRole: values.organisationRole,
|
||||||
|
memberIds: values.memberIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description: t`Group 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 group. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const teamGroupsColumns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t`Team`,
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t`Team Role`,
|
||||||
|
cell: ({ row }) => t(TEAM_MEMBER_ROLE_MAP[row.original.teamRole]),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<OrganisationGroupFormOptions['group']['teams'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Group Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organisationRole"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Organisation Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{ORGANISATION_MEMBER_ROLE_HIERARCHY[organisation.currentOrganisationRole].map(
|
||||||
|
(role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
The organisation role that will be applied to all members in this group.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="memberIds"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Members</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={organisationMembers.map((member) => ({
|
||||||
|
label: member.name || member.email,
|
||||||
|
value: member.id,
|
||||||
|
}))}
|
||||||
|
selectedValues={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="w-full"
|
||||||
|
emptySelectionPlaceholder={t`Select members`}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Select the members to include in this group</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Team Assignments</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="my-2">
|
||||||
|
<DataTable columns={teamGroupsColumns} data={group.teams} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Teams that this organisation group is currently assigned to</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Update</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { OrganisationGroupCreateDialog } from '~/components/dialogs/organisation-group-create-dialog';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { OrganisationGroupsDataTable } from '~/components/tables/organisation-groups-table';
|
||||||
|
|
||||||
|
export default function TeamsSettingsMembersPage() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={t`Custom Organisation Groups`}
|
||||||
|
subtitle={t`Manage the custom groups of members for your organisation.`}
|
||||||
|
>
|
||||||
|
<OrganisationGroupCreateDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<OrganisationGroupsDataTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { OrganisationMemberInvitesTable } from '~/components/tables/organisation-member-invites-table';
|
||||||
|
import { OrganisationMembersDataTable } from '~/components/tables/organisation-members-table';
|
||||||
|
|
||||||
|
export default function TeamsSettingsMembersPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||||
|
|
||||||
|
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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={_(msg`Organisation Members`)}
|
||||||
|
subtitle={_(msg`Manage the members or invite new members.`)}
|
||||||
|
>
|
||||||
|
<OrganisationMemberInviteDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={_(msg`Search`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
||||||
|
<Link to={pathname ?? '/'}>
|
||||||
|
<Trans>Active</Trans>
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
||||||
|
<Link to={`${pathname}?tab=invites`}>
|
||||||
|
<Trans>Pending</Trans>
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTab === 'invites' ? (
|
||||||
|
<OrganisationMemberInvitesTable key="invites" />
|
||||||
|
) : (
|
||||||
|
<OrganisationMembersDataTable key="members" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,183 @@
|
|||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrandingPreferencesForm,
|
||||||
|
type TBrandingPreferencesFormSchema,
|
||||||
|
} from '~/components/forms/branding-preferences-form';
|
||||||
|
import {
|
||||||
|
DocumentPreferencesForm,
|
||||||
|
type TDocumentPreferencesFormSchema,
|
||||||
|
} from '~/components/forms/document-preferences-form';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { useCurrentOrganisation } from '~/providers/organisation';
|
||||||
|
|
||||||
|
import type { Route } from './+types/org.$orgUrl.settings.preferences';
|
||||||
|
|
||||||
|
export default function OrganisationSettingsPreferencesPage({ params }: Route.ComponentProps) {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } =
|
||||||
|
trpc.organisation.get.useQuery({
|
||||||
|
organisationReference: params.orgUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationSettings } =
|
||||||
|
trpc.organisation.settings.update.useMutation();
|
||||||
|
|
||||||
|
// Todo: orgs
|
||||||
|
const canOrganisationHaveBranding = true;
|
||||||
|
|
||||||
|
const onDocumentPreferencesFormSubmit = async (data: TDocumentPreferencesFormSchema) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
documentVisibility,
|
||||||
|
documentLanguage,
|
||||||
|
includeSenderDetails,
|
||||||
|
includeSigningCertificate,
|
||||||
|
signatureTypes,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
documentVisibility === null ||
|
||||||
|
documentLanguage === null ||
|
||||||
|
includeSenderDetails === null ||
|
||||||
|
includeSigningCertificate === null
|
||||||
|
) {
|
||||||
|
throw new Error('Should not be possible.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrganisationSettings({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
data: {
|
||||||
|
documentVisibility,
|
||||||
|
documentLanguage,
|
||||||
|
includeSenderDetails,
|
||||||
|
includeSigningCertificate,
|
||||||
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Document preferences updated`,
|
||||||
|
description: t`Your document preferences have been updated`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong!`,
|
||||||
|
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||||
|
try {
|
||||||
|
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||||
|
|
||||||
|
let uploadedBrandingLogo: string | undefined = '';
|
||||||
|
|
||||||
|
if (brandingLogo) {
|
||||||
|
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOrganisationSettings({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
data: {
|
||||||
|
brandingEnabled,
|
||||||
|
brandingLogo: uploadedBrandingLogo,
|
||||||
|
brandingUrl,
|
||||||
|
brandingCompanyDetails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Branding preferences updated`,
|
||||||
|
description: t`Your branding preferences have been updated`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center rounded-lg py-32">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<SettingsHeader
|
||||||
|
title={t`Organisation Preferences`}
|
||||||
|
subtitle={t`Here you can set preferences and defaults for your organisation. Teams will inherit these settings by default.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<DocumentPreferencesForm
|
||||||
|
canInherit={false}
|
||||||
|
settings={organisationWithSettings.organisationGlobalSettings}
|
||||||
|
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canOrganisationHaveBranding ? (
|
||||||
|
<section>
|
||||||
|
<SettingsHeader
|
||||||
|
title={t`Branding Preferences`}
|
||||||
|
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||||
|
className="mt-8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BrandingPreferencesForm
|
||||||
|
settings={organisationWithSettings.organisationGlobalSettings}
|
||||||
|
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Branding Preferences</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={`/org/${organisation.url}/settings/billing`}>
|
||||||
|
<Trans>Update Billing</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { OrganisationPendingTeamsTable } from '~/components/tables/organisation-pending-teams-table';
|
||||||
|
import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-table';
|
||||||
|
|
||||||
|
export default function OrganisationSettingsTeamsPage() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('query', debouncedSearchQuery);
|
||||||
|
|
||||||
|
if (debouncedSearchQuery === '') {
|
||||||
|
params.delete('query');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
|
||||||
|
<TeamCreateDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t`Search`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="active" asChild>
|
||||||
|
<Link to={pathname ?? '/'}>
|
||||||
|
<Trans>Active</Trans>
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="pending" asChild>
|
||||||
|
<Link to={`${pathname}?tab=pending`}>
|
||||||
|
<Trans>Pending</Trans>
|
||||||
|
{data && data.count > 0 && (
|
||||||
|
<span className="ml-1 hidden opacity-50 md:inline-block">{data.count}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTab === 'pending' ? <OrganisationPendingTeamsTable /> : <OrganisationTeamsTable />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,82 +1,12 @@
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
|
||||||
import { redirect } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
|
||||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
|
||||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
|
||||||
|
|
||||||
import { BillingPlans } from '~/components/general/billing-plans';
|
|
||||||
import { BillingPortalButton } from '~/components/general/billing-portal-button';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|
||||||
|
|
||||||
import type { Route } from './+types/billing';
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags('Billing');
|
return appMetaTags('Billing');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const { user } = await getSession(request);
|
|
||||||
|
|
||||||
// Redirect if subscriptions are not enabled.
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
|
||||||
throw redirect('/settings/profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.customerId) {
|
|
||||||
await getStripeCustomerByUser(user).then((result) => result.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
|
||||||
getSubscriptionsByUserId({ userId: user.id }),
|
|
||||||
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
|
|
||||||
getPrimaryAccountPlanPrices(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
|
|
||||||
|
|
||||||
let subscriptionProduct: Stripe.Product | null = null;
|
|
||||||
|
|
||||||
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
|
||||||
primaryAccountPlanPriceIds.includes(priceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscription =
|
|
||||||
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
|
||||||
primaryAccountPlanSubscriptions[0];
|
|
||||||
|
|
||||||
if (subscription?.priceId) {
|
|
||||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMissingOrInactiveOrFreePlan =
|
|
||||||
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
prices,
|
|
||||||
subscription,
|
|
||||||
subscriptionProductName: subscriptionProduct?.name,
|
|
||||||
isMissingOrInactiveOrFreePlan,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TeamsSettingBillingPage() {
|
export default function TeamsSettingBillingPage() {
|
||||||
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
|
|
||||||
useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-row items-end justify-between">
|
<div className="flex flex-row items-end justify-between">
|
||||||
@ -86,72 +16,10 @@ export default function TeamsSettingBillingPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
<Trans>Billing has been moved to organisations</Trans>
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Todo: Translation */}
|
|
||||||
{!isMissingOrInactiveOrFreePlan &&
|
|
||||||
match(subscription.status)
|
|
||||||
.with('ACTIVE', () => (
|
|
||||||
<p>
|
|
||||||
{subscriptionProductName ? (
|
|
||||||
<span>
|
|
||||||
You are currently subscribed to{' '}
|
|
||||||
<span className="font-semibold">{subscriptionProductName}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>You currently have an active plan</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{subscription.periodEnd && (
|
|
||||||
<span>
|
|
||||||
{' '}
|
|
||||||
which is set to{' '}
|
|
||||||
{subscription.cancelAtPeriodEnd ? (
|
|
||||||
<span>
|
|
||||||
end on{' '}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{i18n.date(subscription.periodEnd)}.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
automatically renew on{' '}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{i18n.date(subscription.periodEnd)}.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
.with('PAST_DUE', () => (
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
Your current plan is past due. Please update your payment information.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
.otherwise(() => null)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
|
||||||
<BillingPortalButton>
|
|
||||||
<Trans>Manage billing</Trans>
|
|
||||||
</BillingPortalButton>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { OrganisationCreateDialog } from '~/components/dialogs/organisation-create-dialog';
|
||||||
|
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
|
||||||
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { UserSettingsOrganisationsTable } from '~/components/tables/user-settings-organisations-table';
|
||||||
|
|
||||||
|
export default function TeamsSettingsPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={_(msg`Organisations`)}
|
||||||
|
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
|
||||||
|
>
|
||||||
|
<OrganisationCreateDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<UserSettingsOrganisationsTable />
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-8">
|
||||||
|
<OrganisationInvitations />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,237 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TemplateDirectLink } from '@prisma/client';
|
|
||||||
import { TemplateType } from '@prisma/client';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { ManagePublicTemplateDialog } from '~/components/dialogs/public-profile-template-manage-dialog';
|
|
||||||
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
|
||||||
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { SettingsPublicProfileTemplatesTable } from '~/components/tables/settings-public-profile-templates-table';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
import type { Route } from './+types/public-profile';
|
|
||||||
|
|
||||||
type DirectTemplate = FindTemplateRow & {
|
|
||||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const userProfileText = {
|
|
||||||
settingsTitle: msg`Public Profile`,
|
|
||||||
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
|
|
||||||
templatesTitle: msg`My templates`,
|
|
||||||
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const teamProfileText = {
|
|
||||||
settingsTitle: msg`Team Public Profile`,
|
|
||||||
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
|
|
||||||
templatesTitle: msg`Team templates`,
|
|
||||||
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const { user } = await getSession(request);
|
|
||||||
|
|
||||||
const { profile } = await getUserPublicProfile({
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { profile };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicProfilePage({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { profile } = loaderData;
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { user, refreshSession } = useSession();
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
|
||||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data } = trpc.template.findTemplates.useQuery({
|
|
||||||
perPage: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateUserProfile, isPending: isUpdatingUserProfile } =
|
|
||||||
trpc.profile.updatePublicProfile.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: updateTeamProfile, isPending: isUpdatingTeamProfile } =
|
|
||||||
trpc.team.updateTeamPublicProfile.useMutation();
|
|
||||||
|
|
||||||
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
|
|
||||||
const profileText = team ? teamProfileText : userProfileText;
|
|
||||||
|
|
||||||
const enabledPrivateDirectTemplates = useMemo(
|
|
||||||
() =>
|
|
||||||
(data?.data ?? []).filter(
|
|
||||||
(template): template is DirectTemplate =>
|
|
||||||
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
|
|
||||||
),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
|
|
||||||
if (team) {
|
|
||||||
await updateTeamProfile({
|
|
||||||
teamId: team.id,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateUserProfile(data);
|
|
||||||
|
|
||||||
// Need to refresh session because we're editing the user's profile.
|
|
||||||
await refreshSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.enabled === undefined && !isPublicProfileVisible) {
|
|
||||||
setIsTooltipOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePublicProfileVisibility = async (isVisible: boolean) => {
|
|
||||||
setIsTooltipOpen(false);
|
|
||||||
|
|
||||||
if (isUpdating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVisible && !user.url) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`You must set a profile URL before enabling your public profile.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPublicProfileVisible(isVisible);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onProfileUpdate({
|
|
||||||
enabled: isVisible,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`We were unable to set your public profile to public. Please try again.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsPublicProfileVisible(!isVisible);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsPublicProfileVisible(profile.enabled);
|
|
||||||
}, [profile.enabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<SettingsHeader
|
|
||||||
title={_(profileText.settingsTitle)}
|
|
||||||
subtitle={_(profileText.settingsSubtitle)}
|
|
||||||
>
|
|
||||||
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
|
|
||||||
{
|
|
||||||
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
|
||||||
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Trans>Hide</Trans>
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
disabled={isUpdating}
|
|
||||||
checked={isPublicProfileVisible}
|
|
||||||
onCheckedChange={togglePublicProfileVisibility}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<Trans>Show</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
|
|
||||||
{isPublicProfileVisible ? (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
Profile is currently <strong>visible</strong>.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<Trans>Toggle the switch to hide your profile from the public.</Trans>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
Profile is currently <strong>hidden</strong>.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<Trans>Toggle the switch to show your profile to the public.</Trans>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
<PublicProfileForm
|
|
||||||
profileUrl={team ? team.url : user.url}
|
|
||||||
teamUrl={team?.url}
|
|
||||||
profile={profile}
|
|
||||||
onProfileUpdate={onProfileUpdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<SettingsHeader
|
|
||||||
title={_(profileText.templatesTitle)}
|
|
||||||
subtitle={_(profileText.templatesSubtitle)}
|
|
||||||
hideDivider={true}
|
|
||||||
className="mt-8 [&>*>h3]:text-base"
|
|
||||||
>
|
|
||||||
<ManagePublicTemplateDialog
|
|
||||||
directTemplates={enabledPrivateDirectTemplates}
|
|
||||||
trigger={
|
|
||||||
<Button variant="outline">
|
|
||||||
<Trans>Link template</Trans>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<SettingsPublicProfileTemplatesTable />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { AnimatePresence } from 'framer-motion';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
||||||
|
|
||||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
|
|
||||||
import { TeamInvitations } from '~/components/general/teams/team-invitations';
|
|
||||||
import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
|
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title={_(msg`Teams`)}
|
|
||||||
subtitle={_(msg`Manage all teams you are currently associated with.`)}
|
|
||||||
>
|
|
||||||
<TeamCreateDialog />
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
<UserSettingsTeamsPageDataTable />
|
|
||||||
|
|
||||||
<div className="mt-8 space-y-8">
|
|
||||||
<AnimatePresence>
|
|
||||||
{teamEmail && (
|
|
||||||
<AnimateGenericFadeInOut>
|
|
||||||
<TeamEmailUsage teamEmail={teamEmail} />
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<TeamInvitations />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
|
|
||||||
import { ApiTokenForm } from '~/components/forms/token';
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export default function ApiTokensPage() {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title={<Trans>API Tokens</Trans>}
|
|
||||||
subtitle={
|
|
||||||
<Trans>
|
|
||||||
On this page, you can create and manage API tokens. See our{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary underline"
|
|
||||||
href={'https://docs.documenso.com/developers/public-api'}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>{' '}
|
|
||||||
for more information.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{team && team?.currentTeamMember.role !== TeamMemberRole.ADMIN ? (
|
|
||||||
<Alert
|
|
||||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
|
||||||
variant="warning"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Unauthorized</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
<Trans>You need to be an admin to manage API tokens.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ApiTokenForm className="max-w-xl" tokens={tokens} />
|
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
|
||||||
|
|
||||||
<h4 className="text-xl font-medium">
|
|
||||||
<Trans>Your existing tokens</Trans>
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{tokens && tokens.length === 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
|
||||||
<Trans>Your tokens will be shown here once you create them.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tokens && tokens.length > 0 && (
|
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
|
||||||
{tokens.map((token) => (
|
|
||||||
<div key={token.id} className="border-border rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between gap-x-4">
|
|
||||||
<div>
|
|
||||||
<h5 className="text-base">{token.name}</h5>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
<Trans>
|
|
||||||
Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
{token.expires ? (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
<Trans>
|
|
||||||
Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
<Trans>Token doesn't have an expiration date</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<TokenDeleteDialog token={token}>
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</TokenDeleteDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useParams, useRevalidator } from 'react-router';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
|
||||||
|
|
||||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
|
||||||
|
|
||||||
export default function WebhookPage() {
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
const webhookId = params.id || '';
|
|
||||||
|
|
||||||
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
|
||||||
{
|
|
||||||
id: webhookId,
|
|
||||||
},
|
|
||||||
{ enabled: !!webhookId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TEditWebhookFormSchema>({
|
|
||||||
resolver: zodResolver(ZEditWebhookFormSchema),
|
|
||||||
values: {
|
|
||||||
webhookUrl: webhook?.webhookUrl ?? '',
|
|
||||||
eventTriggers: webhook?.eventTriggers ?? [],
|
|
||||||
secret: webhook?.secret ?? '',
|
|
||||||
enabled: webhook?.enabled ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateWebhook({
|
|
||||||
id: webhookId,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Webhook updated`),
|
|
||||||
description: _(msg`The webhook has been updated successfully.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await revalidate();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Failed to update webhook`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an error while updating the webhook. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title={_(msg`Edit webhook`)}
|
|
||||||
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Webhook URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Enabled</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="eventTriggers"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<FormItem className="flex flex-col gap-2">
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Triggers</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<WebhookMultiSelectCombobox
|
|
||||||
listValues={value}
|
|
||||||
onChange={(values: string[]) => {
|
|
||||||
onChange(values);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="secret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Secret</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
A secret that will be sent to your URL so you can verify that the request has
|
|
||||||
been sent by Documenso.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Update webhook</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
|
|
||||||
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
|
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
|
||||||
|
|
||||||
export default function WebhookPage() {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title={_(msg`Webhooks`)}
|
|
||||||
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
|
||||||
>
|
|
||||||
<WebhookCreateDialog />
|
|
||||||
</SettingsHeader>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{webhooks && webhooks.length === 0 && (
|
|
||||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
|
||||||
<Trans>
|
|
||||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{webhooks && webhooks.length > 0 && (
|
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
|
||||||
{webhooks?.map((webhook) => (
|
|
||||||
<div
|
|
||||||
key={webhook.id}
|
|
||||||
className={cn(
|
|
||||||
'border-border rounded-lg border p-4',
|
|
||||||
!webhook.enabled && 'bg-muted/40',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
|
||||||
|
|
||||||
<div className="mt-1.5 flex items-center gap-4">
|
|
||||||
<h5
|
|
||||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
|
||||||
title={webhook.webhookUrl}
|
|
||||||
>
|
|
||||||
{webhook.webhookUrl}
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
|
||||||
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
<Trans>
|
|
||||||
Listening to{' '}
|
|
||||||
{webhook.eventTriggers
|
|
||||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
|
||||||
.join(', ')}
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link to={`/settings/webhooks/${webhook.id}`}>
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<WebhookDeleteDialog webhook={webhook}>
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</WebhookDeleteDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,58 +1,17 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
|
||||||
import { Link, Outlet } from 'react-router';
|
import { Link, Outlet } from 'react-router';
|
||||||
|
|
||||||
import { TEAM_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
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 { PortalComponent } from '~/components/general/portal';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { TeamLayoutBillingBanner } from '~/components/general/teams/team-layout-billing-banner';
|
|
||||||
import { TeamProvider } from '~/providers/team';
|
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
export default function Layout() {
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
export default function Layout({ params }: Route.ComponentProps) {
|
if (!team) {
|
||||||
const { teams } = useSession();
|
|
||||||
|
|
||||||
const currentTeam = teams.find((team) => team.url === params.teamUrl);
|
|
||||||
|
|
||||||
const limits = useMemo(() => {
|
|
||||||
if (!currentTeam) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentTeam?.subscription &&
|
|
||||||
currentTeam.subscription.status === SubscriptionStatus.INACTIVE
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
quota: {
|
|
||||||
documents: 0,
|
|
||||||
recipients: 0,
|
|
||||||
directTemplates: 0,
|
|
||||||
},
|
|
||||||
remaining: {
|
|
||||||
documents: 0,
|
|
||||||
recipients: 0,
|
|
||||||
directTemplates: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
quota: TEAM_PLAN_LIMITS,
|
|
||||||
remaining: TEAM_PLAN_LIMITS,
|
|
||||||
};
|
|
||||||
}, [currentTeam?.subscription, currentTeam?.id]);
|
|
||||||
|
|
||||||
if (!currentTeam) {
|
|
||||||
return (
|
return (
|
||||||
<GenericErrorLayout
|
<GenericErrorLayout
|
||||||
errorCode={404}
|
errorCode={404}
|
||||||
@ -76,29 +35,12 @@ export default function Layout({ params }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trpcHeaders = {
|
const trpcHeaders = {
|
||||||
'x-team-Id': currentTeam.id.toString(),
|
'x-team-Id': team.id.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TeamProvider team={currentTeam}>
|
<TrpcProvider headers={trpcHeaders}>
|
||||||
<LimitsProvider initialValue={limits} teamId={currentTeam.id}>
|
<Outlet />
|
||||||
<TrpcProvider headers={trpcHeaders}>
|
</TrpcProvider>
|
||||||
{currentTeam?.subscription &&
|
|
||||||
currentTeam.subscription.status !== SubscriptionStatus.ACTIVE && (
|
|
||||||
<PortalComponent target="portal-header">
|
|
||||||
<TeamLayoutBillingBanner
|
|
||||||
subscriptionStatus={currentTeam.subscription.status}
|
|
||||||
teamId={currentTeam.id}
|
|
||||||
userRole={currentTeam.currentTeamMember.role}
|
|
||||||
/>
|
|
||||||
</PortalComponent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</TrpcProvider>
|
|
||||||
</LimitsProvider>
|
|
||||||
</TeamProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,243 @@
|
|||||||
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.$id._index';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
export { loader };
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
export default DocumentPage;
|
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
||||||
|
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||||
|
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
|
||||||
|
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
||||||
|
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
||||||
|
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
||||||
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
|
import {
|
||||||
|
DocumentStatus as DocumentStatusComponent,
|
||||||
|
FRIENDLY_STATUS_MAP,
|
||||||
|
} from '~/components/general/document/document-status';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.$id._index';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
const teamUrl = params.teamUrl;
|
||||||
|
|
||||||
|
if (!teamUrl) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentWithDetailsById({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
// Todo: 401 or 404 page.
|
||||||
|
if (!document) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentVisibility = document?.visibility;
|
||||||
|
const currentTeamMemberRole = team.currentTeamRole;
|
||||||
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
let canAccessDocument = true;
|
||||||
|
|
||||||
|
if (!isRecipient && document?.userId !== user.id) {
|
||||||
|
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.otherwise(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document || !document.documentData || !canAccessDocument) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
document,
|
||||||
|
documentRootPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentPage() {
|
||||||
|
const loaderData = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const { document, documentRootPath } = loaderData;
|
||||||
|
|
||||||
|
const { recipients, documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
// This was a feature flag. Leave to false since it's not ready.
|
||||||
|
const isDocumentHistoryEnabled = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-between truncate">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||||
|
</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.deletedAt && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<Trans>Document deleted</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDocumentHistoryEnabled && (
|
||||||
|
<div className="self-end">
|
||||||
|
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||||
|
<Trans>Document history</Trans>
|
||||||
|
</Button>
|
||||||
|
</DocumentHistorySheet>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
|
<Card
|
||||||
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={document.fields}
|
||||||
|
documentMeta={documentMeta || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<DocumentPageViewDropdown document={document} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||||
|
{match(document.status)
|
||||||
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.REJECTED, () => (
|
||||||
|
<Trans>This document has been rejected by a recipient</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.PENDING, () => {
|
||||||
|
const pendingRecipients = recipients.filter(
|
||||||
|
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Plural
|
||||||
|
value={pendingRecipients.length}
|
||||||
|
one="Waiting on 1 recipient"
|
||||||
|
other="Waiting on # recipients"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.exhaustive()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t px-4 pt-4">
|
||||||
|
<DocumentPageViewButton document={document} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Document information section. */}
|
||||||
|
<DocumentPageViewInformation document={document} userId={user.id} />
|
||||||
|
|
||||||
|
{/* Recipients section. */}
|
||||||
|
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
|
||||||
|
|
||||||
|
{/* Recent activity section. */}
|
||||||
|
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,140 @@
|
|||||||
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.$id.edit';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
export { loader };
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
export default DocumentEditPage;
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.$id.edit';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
const teamUrl = params.teamUrl;
|
||||||
|
|
||||||
|
if (!teamUrl) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentWithDetailsById({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (document?.teamId && !team?.url) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentVisibility = document?.visibility;
|
||||||
|
const currentTeamMemberRole = team.currentTeamRole;
|
||||||
|
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
let canAccessDocument = true;
|
||||||
|
|
||||||
|
if (!isRecipient && document?.userId !== user.id) {
|
||||||
|
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||||
|
.otherwise(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team && !canAccessDocument) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDocumentCompleted(document.status)) {
|
||||||
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
document,
|
||||||
|
documentRootPath,
|
||||||
|
isDocumentEnterprise,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentEditPage() {
|
||||||
|
const { document, documentRootPath, isDocumentEnterprise } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { recipients } = document;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
|
</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentEditForm
|
||||||
|
className="mt-6"
|
||||||
|
initialDocument={document}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,184 @@
|
|||||||
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.$id.logs';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
export { loader };
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
export default DocumentLogsPage;
|
import { DocumentAuditLogDownloadButton } from '~/components/general/document/document-audit-log-download-button';
|
||||||
|
import { DocumentCertificateDownloadButton } from '~/components/general/document/document-certificate-download-button';
|
||||||
|
import {
|
||||||
|
DocumentStatus as DocumentStatusComponent,
|
||||||
|
FRIENDLY_STATUS_MAP,
|
||||||
|
} from '~/components/general/document/document-status';
|
||||||
|
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.$id.logs';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Get full document instead?
|
||||||
|
const [document, recipients] = await Promise.all([
|
||||||
|
getDocumentById({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
}).catch(() => null),
|
||||||
|
getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!document || !document.documentData) {
|
||||||
|
throw redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
documentRootPath,
|
||||||
|
recipients,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { document, documentRootPath, recipients } = loaderData;
|
||||||
|
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
||||||
|
{
|
||||||
|
description: msg`Document title`,
|
||||||
|
value: document.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document ID`,
|
||||||
|
value: document.id.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document status`,
|
||||||
|
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Created by`,
|
||||||
|
value: document.user.name
|
||||||
|
? `${document.user.name} (${document.user.email})`
|
||||||
|
: document.user.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Date created`,
|
||||||
|
value: DateTime.fromJSDate(document.createdAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Last updated`,
|
||||||
|
value: DateTime.fromJSDate(document.updatedAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: msg`Time zone`,
|
||||||
|
value: document.documentMeta?.timezone ?? 'N/A',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatRecipientText = (recipient: Recipient) => {
|
||||||
|
let text = recipient.email;
|
||||||
|
|
||||||
|
if (recipient.name) {
|
||||||
|
text = `${recipient.name} (${recipient.email})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${recipient.role}] ${text}`;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link
|
||||||
|
to={`${documentRootPath}/${document.id}`}
|
||||||
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Document</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-col justify-between sm:flex-row">
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
|
<DocumentCertificateDownloadButton
|
||||||
|
className="mr-2"
|
||||||
|
documentId={document.id}
|
||||||
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
|
{documentInformation.map((info, i) => (
|
||||||
|
<div className="text-foreground text-sm" key={i}>
|
||||||
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
|
<p className="text-muted-foreground truncate">{info.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="text-foreground text-sm">
|
||||||
|
<h3 className="font-semibold">Recipients</h3>
|
||||||
|
<ul className="text-muted-foreground list-inside list-disc">
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<li key={`recipient-${recipient.id}`}>
|
||||||
|
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<DocumentLogsTable documentId={document.id} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,168 @@
|
|||||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents._index';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export { meta };
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default DocumentsPage;
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import {
|
||||||
|
type TFindDocumentsInternalResponse,
|
||||||
|
ZFindDocumentsInternalRequestSchema,
|
||||||
|
} from '@documenso/trpc/server/document-router/schema';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||||
|
import { PeriodSelector } from '~/components/general/period-selector';
|
||||||
|
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
|
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||||
|
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||||
|
status: true,
|
||||||
|
period: true,
|
||||||
|
page: true,
|
||||||
|
perPage: true,
|
||||||
|
query: true,
|
||||||
|
}).extend({
|
||||||
|
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||||
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||||
|
[ExtendedDocumentStatus.INBOX]: 0,
|
||||||
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findDocumentSearchParams = useMemo(
|
||||||
|
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
|
||||||
|
{
|
||||||
|
...findDocumentSearchParams,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refetch the documents when the team URL changes.
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
}, [team?.url]);
|
||||||
|
|
||||||
|
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
params.set('status', value);
|
||||||
|
|
||||||
|
if (value === ExtendedDocumentStatus.ALL) {
|
||||||
|
params.delete('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.has('page')) {
|
||||||
|
params.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.stats) {
|
||||||
|
setStats(data.stats);
|
||||||
|
}
|
||||||
|
}, [data?.stats]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<DocumentUploadDropzone />
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{team && (
|
||||||
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
|
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||||
|
<AvatarFallback className="text-xs text-gray-400">
|
||||||
|
{team.name.slice(0, 1)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
|
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
{[
|
||||||
|
ExtendedDocumentStatus.INBOX,
|
||||||
|
ExtendedDocumentStatus.PENDING,
|
||||||
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
|
ExtendedDocumentStatus.DRAFT,
|
||||||
|
ExtendedDocumentStatus.ALL,
|
||||||
|
].map((value) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={value}
|
||||||
|
className="hover:text-foreground min-w-[60px]"
|
||||||
|
value={value}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={getTabHref(value)} preventScrollReset>
|
||||||
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
|
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||||
|
|
||||||
|
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
|
<PeriodSelector />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
|
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<div>
|
||||||
|
{data && data.count === 0 ? (
|
||||||
|
<DocumentsTableEmptyState
|
||||||
|
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -3,22 +3,19 @@ import { CheckCircle2, Clock } from 'lucide-react';
|
|||||||
import { P, match } from 'ts-pattern';
|
import { P, 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 { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
||||||
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
|
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
|
||||||
import { TeamTransferDialog } from '~/components/dialogs/team-transfer-dialog';
|
|
||||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||||
import { TeamUpdateForm } from '~/components/forms/team-update-form';
|
import { TeamUpdateForm } from '~/components/forms/team-update-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
|
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
|
||||||
import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
|
|
||||||
|
|
||||||
import type { Route } from './+types/settings._index';
|
import type { Route } from './+types/settings._index';
|
||||||
|
|
||||||
@ -38,22 +35,10 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||||||
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
|
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { team } = loaderData;
|
const { team } = loaderData;
|
||||||
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const isTransferVerificationExpired =
|
|
||||||
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="max-w-2xl">
|
||||||
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
|
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
|
||||||
|
|
||||||
<TeamTransferStatus
|
|
||||||
className="mb-4"
|
|
||||||
currentUserTeamRole={team.currentTeamMember.role}
|
|
||||||
teamId={team.id}
|
|
||||||
transferVerification={team.transferVerification}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AvatarImageForm className="mb-8" />
|
<AvatarImageForm className="mb-8" />
|
||||||
|
|
||||||
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||||
@ -161,51 +146,26 @@ export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps)
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{team.ownerUserId === user.id && (
|
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||||
<>
|
<Alert
|
||||||
{isTransferVerificationExpired && (
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
<Alert
|
variant="neutral"
|
||||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
>
|
||||||
variant="neutral"
|
<div className="mb-4 sm:mb-0">
|
||||||
>
|
<AlertTitle>
|
||||||
<div className="mb-4 sm:mb-0">
|
<Trans>Delete team</Trans>
|
||||||
<AlertTitle>
|
</AlertTitle>
|
||||||
<Trans>Transfer team</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
<Trans>Transfer the ownership of the team to another team member.</Trans>
|
<Trans>
|
||||||
</AlertDescription>
|
This team, and any associated data excluding billing invoices will be permanently
|
||||||
</div>
|
deleted.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TeamTransferDialog
|
<TeamDeleteDialog teamId={team.id} teamName={team.name} />
|
||||||
ownerUserId={team.ownerUserId}
|
</Alert>
|
||||||
teamId={team.id}
|
|
||||||
teamName={team.name}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<div className="mb-4 sm:mb-0">
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Delete team</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
<Trans>
|
|
||||||
This team, and any associated data excluding billing invoices will be
|
|
||||||
permanently deleted.
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TeamDeleteDialog teamId={team.id} teamName={team.name} />
|
|
||||||
</Alert>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user