This commit is contained in:
David Nguyen
2025-01-31 18:57:45 +11:00
parent d7d0fca501
commit aec44b78d0
34 changed files with 2252 additions and 422 deletions

View File

@ -3,6 +3,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';

View File

@ -1,98 +0,0 @@
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TeamMemberRole } from '@prisma/client';
import { Link, useLocation, useNavigate, 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 { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table';
import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table';
export type TeamsMemberPageDataTableProps = {
currentUserTeamRole: TeamMemberRole;
teamId: number;
teamName: string;
teamOwnerUserId: number;
};
export const TeamsMemberPageDataTable = ({
currentUserTeamRole,
teamId,
teamName,
teamOwnerUserId,
}: TeamsMemberPageDataTableProps) => {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
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(() => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
void navigate(`${pathname}?${params.toString()}`);
}, [debouncedSearchQuery, pathname, navigate, 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="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' ? (
<TeamMemberInvitesDataTable key="invites" teamId={teamId} />
) : (
<TeamMembersDataTable
key="members"
currentUserTeamRole={currentUserTeamRole}
teamId={teamId}
teamName={teamName}
teamOwnerUserId={teamOwnerUserId}
/>
)}
</div>
);
};

View File

@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';

View File

@ -35,17 +35,17 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type CreateWebhookDialogProps = {
export type WebhookCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -185,7 +185,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);

View File

@ -33,13 +33,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DeleteWebhookDialogProps = {
export type WebhookDeleteDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
children: React.ReactNode;
};
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -0,0 +1,319 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZTeamBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine(
(file) => ACCEPTED_FILE_TYPES.includes(file.type),
'Only .jpg, .png, and .webp files are accepted',
)
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
});
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
export type TeamBrandingPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
};
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
const { _ } = useLingui();
const { toast } = useToast();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const { mutateAsync: updateTeamBrandingSettings } =
trpc.team.updateTeamBrandingSettings.useMutation();
const form = useForm<TTeamBrandingPreferencesFormSchema>({
defaultValues: {
brandingEnabled: settings?.brandingEnabled ?? false,
brandingUrl: settings?.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
},
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
});
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(() => {
if (settings?.brandingLogo) {
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
void getFile(file).then((binaryData) => {
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
return;
}
}
setHasLoadedPreview(true);
}, [settings?.brandingLogo]);
// Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => {
return () => {
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="brandingEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enable Custom Branding</FormLabel>
<div>
<FormControl>
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>Enable custom branding for all documents in this team.</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
<FormField
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
<FormItem className="flex-1">
<FormLabel>Branding Logo</FormLabel>
<div className="flex flex-col gap-4">
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
{previewUrl ? (
<img
src={previewUrl}
alt="Logo preview"
className="h-full w-full object-contain p-4"
/>
) : (
<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
{!hasLoadedPreview && (
<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" />
</div>
)}
</div>
)}
</div>
<div className="relative">
<FormControl className="relative">
<Input
type="file"
accept={ACCEPTED_FILE_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onChange(file);
}
}}
className={cn(
'h-auto p-2',
'file:text-primary hover:file:bg-primary/90',
'file:mr-4 file:cursor-pointer file:rounded-md file:border-0',
'file:p-2 file:py-2 file:font-medium',
'file:bg-primary file:text-primary-foreground',
!isBrandingEnabled && 'cursor-not-allowed',
)}
{...field}
/>
</FormControl>
<div className="absolute right-2 top-0 inline-flex h-full items-center justify-center">
<Button
type="button"
variant="link"
size="sm"
className="text-destructive text-xs"
onClick={() => {
setPreviewUrl('');
onChange(null);
}}
>
<Trans>Remove</Trans>
</Button>
</div>
</div>
<FormDescription>
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Website</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com"
disabled={!isBrandingEnabled}
{...field}
/>
</FormControl>
<FormDescription>
<Trans>Your brand website URL</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingCompanyDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Details</FormLabel>
<FormControl>
<Textarea
placeholder={_(msg`Enter your brand details`)}
className="min-h-[100px] resize-y"
disabled={!isBrandingEnabled}
{...field}
/>
</FormControl>
<FormDescription>
<Trans>Additional brand information to display at the bottom of emails</Trans>
</FormDescription>
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
}

View File

@ -0,0 +1,312 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
typedSignatureEnabled: z.boolean(),
includeSigningCertificate: z.boolean(),
});
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
export type TeamDocumentPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
};
export const TeamDocumentPreferencesForm = ({
team,
settings,
}: TeamDocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { data } = useSession();
const placeholderEmail = data?.user.email ?? 'user@example.com';
const { mutateAsync: updateTeamDocumentPreferences } =
trpc.team.updateTeamDocumentSettings.useMutation();
const form = useForm<TTeamDocumentPreferencesFormSchema>({
defaultValues: {
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
? settings?.documentLanguage
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
});
const includeSenderDetails = form.watch('includeSenderDetails');
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
try {
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
} = data;
await updateTeamDocumentPreferences({
teamId: team.id,
settings: {
documentVisibility,
documentLanguage,
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
},
});
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Only managers and above can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Only admins can access and view the document</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls the default language of an uploaded document. This will be used as the
language in email communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<div className="pt-2">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{includeSenderDetails ? (
<Trans>
"{placeholderEmail}" on behalf of "{team.name}" has invited you to sign
"example document".
</Trans>
) : (
<Trans>"{team.name}" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a
recipient to sign a document. If a custom message has been provided while
configuring the document, it will be used instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enable Typed Signature</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the recipients can sign the documents using a typed signature.
Enable or disable the typed signature globally.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -18,15 +18,15 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
import { truncateTitle } from '~/helpers/truncate-title';
type TriggerMultiSelectComboboxProps = {
type WebhookMultiSelectComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
export const TriggerMultiSelectCombobox = ({
export const WebhookMultiSelectCombobox = ({
listValues,
onChange,
}: TriggerMultiSelectComboboxProps) => {
}: WebhookMultiSelectComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>([]);

View File

@ -0,0 +1,96 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
export type TeamEmailDropdownProps = {
team: Awaited<ReturnType<typeof getTeamByUrl>>;
};
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.resendTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Email verification has been resent`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to resend verification at this time. Please try again.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
<Trans>Resend verification</Trans>
</DropdownMenuItem>
)}
{team.teamEmail && (
<TeamEmailUpdateDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
}
/>
)}
<TeamEmailDeleteDialog
team={team}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<X className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -4,21 +4,16 @@ import { Trans } from '@lingui/macro';
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export type TeamSettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
export const TeamSettingsDesktopNav = ({ className, ...props }: TeamSettingsDesktopNavProps) => {
const { pathname } = useLocation();
const params = useParams();
const { getFlag } = useFeatureFlags();
const isPublicProfileEnabled = getFlag('app_public_profile');
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
@ -55,20 +50,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link to={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
)}
<Link to={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to={membersPath}>
<Button

View File

@ -4,21 +4,16 @@ import { Trans } from '@lingui/macro';
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
export type TeamSettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
export const TeamSettingsMobileNav = ({ className, ...props }: TeamSettingsMobileNavProps) => {
const { pathname } = useLocation();
const params = useParams();
const { getFlag } = useFeatureFlags();
const isPublicProfileEnabled = getFlag('app_public_profile');
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
@ -64,20 +59,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{isPublicProfileEnabled && (
<Link to={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
)}
<Link to={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to={membersPath}>
<Button

View File

@ -0,0 +1,124 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence } from 'framer-motion';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
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 isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
const { mutateAsync: deleteTeamTransferRequest, isPending } =
trpc.team.deleteTeamTransferRequest.useMutation({
onSuccess: () => {
if (!isExpired) {
toast({
title: _(msg`Success`),
description: _(msg`The team transfer invitation has been successfully deleted.`),
duration: 5000,
});
}
// todo?
// router.refresh();
},
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>
);
};

View File

@ -24,13 +24,12 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamMemberInvitesDataTableProps = {
teamId: number;
};
import { useCurrentTeam } from '~/providers/team';
export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => {
export const TeamSettingsMemberInvitesTable = () => {
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const { _, i18n } = useLingui();
const { toast } = useToast();
@ -39,7 +38,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery(
{
teamId,
teamId: team.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
@ -139,7 +138,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
<DropdownMenuItem
onClick={async () =>
resendTeamMemberInvitation({
teamId,
teamId: team.id,
invitationId: row.original.id,
})
}
@ -151,7 +150,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
<DropdownMenuItem
onClick={async () =>
deleteTeamMemberInvitations({
teamId,
teamId: team.id,
invitationIds: [row.original.id],
})
}

View File

@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TeamMemberRole } from '@prisma/client';
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSearchParams } from 'react-router';
@ -26,32 +25,22 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { DeleteTeamMemberDialog } from '../../dialogs/team-member-delete-dialog';
import { UpdateTeamMemberDialog } from '../../dialogs/team-member-update-dialog';
import { TeamMemberDeleteDialog } from '~/components/dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '~/components/dialogs/team-member-update-dialog';
import { useCurrentTeam } from '~/providers/team';
export type TeamMembersDataTableProps = {
currentUserTeamRole: TeamMemberRole;
teamOwnerUserId: number;
teamId: number;
teamName: string;
};
export const TeamMembersDataTable = ({
currentUserTeamRole,
teamOwnerUserId,
teamId,
teamName,
}: TeamMembersDataTableProps) => {
export const TeamSettingsMembersDataTable = () => {
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.findTeamMembers.useQuery(
{
teamId,
teamId: team.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
@ -100,7 +89,7 @@ export const TeamMembersDataTable = ({
header: _(msg`Role`),
accessorKey: 'role',
cell: ({ row }) =>
teamOwnerUserId === row.original.userId
team.ownerUserId === row.original.userId
? _(msg`Owner`)
: _(TEAM_MEMBER_ROLE_MAP[row.original.role]),
},
@ -122,8 +111,8 @@ export const TeamMembersDataTable = ({
<Trans>Actions</Trans>
</DropdownMenuLabel>
<UpdateTeamMemberDialog
currentUserTeamRole={currentUserTeamRole}
<TeamMemberUpdateDialog
currentUserTeamRole={team.currentTeamMember.role}
teamId={row.original.teamId}
teamMemberId={row.original.id}
teamMemberName={row.original.user.name ?? ''}
@ -131,8 +120,8 @@ export const TeamMembersDataTable = ({
trigger={
<DropdownMenuItem
disabled={
teamOwnerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
team.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
}
onSelect={(e) => e.preventDefault()}
title="Update team member role"
@ -143,9 +132,9 @@ export const TeamMembersDataTable = ({
}
/>
<DeleteTeamMemberDialog
teamId={teamId}
teamName={teamName}
<TeamMemberDeleteDialog
teamId={team.id}
teamName={team.name}
teamMemberId={row.original.id}
teamMemberName={row.original.user.name ?? ''}
teamMemberEmail={row.original.user.email}
@ -153,8 +142,8 @@ export const TeamMembersDataTable = ({
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
teamOwnerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
team.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
}
title={_(msg`Remove team member`)}
>