mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
fix: wip
This commit is contained in:
@ -3,6 +3,7 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
|||||||
@ -35,17 +35,17 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
|
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
export type CreateWebhookDialogProps = {
|
export type WebhookCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
<Trans>Triggers</Trans>
|
<Trans>Triggers</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TriggerMultiSelectCombobox
|
<WebhookMultiSelectCombobox
|
||||||
listValues={value}
|
listValues={value}
|
||||||
onChange={(values: string[]) => {
|
onChange={(values: string[]) => {
|
||||||
onChange(values);
|
onChange(values);
|
||||||
@ -33,13 +33,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DeleteWebhookDialogProps = {
|
export type WebhookDeleteDialogProps = {
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -18,15 +18,15 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
type TriggerMultiSelectComboboxProps = {
|
type WebhookMultiSelectComboboxProps = {
|
||||||
listValues: string[];
|
listValues: string[];
|
||||||
onChange: (_values: string[]) => void;
|
onChange: (_values: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TriggerMultiSelectCombobox = ({
|
export const WebhookMultiSelectCombobox = ({
|
||||||
listValues,
|
listValues,
|
||||||
onChange,
|
onChange,
|
||||||
}: TriggerMultiSelectComboboxProps) => {
|
}: WebhookMultiSelectComboboxProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,21 +4,16 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
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 { pathname } = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
|
||||||
|
|
||||||
const isPublicProfileEnabled = getFlag('app_public_profile');
|
|
||||||
|
|
||||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||||
|
|
||||||
const settingsPath = `/t/${teamUrl}/settings`;
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
@ -55,20 +50,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isPublicProfileEnabled && (
|
<Link to={publicProfilePath}>
|
||||||
<Link to={publicProfilePath}>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
className={cn(
|
||||||
className={cn(
|
'w-full justify-start',
|
||||||
'w-full justify-start',
|
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
||||||
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
<Trans>Public Profile</Trans>
|
||||||
<Trans>Public Profile</Trans>
|
</Button>
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to={membersPath}>
|
<Link to={membersPath}>
|
||||||
<Button
|
<Button
|
||||||
@ -4,21 +4,16 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
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 { pathname } = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
|
||||||
|
|
||||||
const isPublicProfileEnabled = getFlag('app_public_profile');
|
|
||||||
|
|
||||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||||
|
|
||||||
const settingsPath = `/t/${teamUrl}/settings`;
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
@ -64,20 +59,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isPublicProfileEnabled && (
|
<Link to={publicProfilePath}>
|
||||||
<Link to={publicProfilePath}>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
className={cn(
|
||||||
className={cn(
|
'w-full justify-start',
|
||||||
'w-full justify-start',
|
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
||||||
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
<Trans>Public Profile</Trans>
|
||||||
<Trans>Public Profile</Trans>
|
</Button>
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to={membersPath}>
|
<Link to={membersPath}>
|
||||||
<Button
|
<Button
|
||||||
124
apps/remix/app/components/pages/teams/team-transfer-status.tsx
Normal file
124
apps/remix/app/components/pages/teams/team-transfer-status.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -24,13 +24,12 @@ 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';
|
||||||
|
|
||||||
export type TeamMemberInvitesDataTableProps = {
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => {
|
export const TeamSettingsMemberInvitesTable = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -39,7 +38,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
|
|||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.team.findTeamMemberInvites.useQuery(
|
||||||
{
|
{
|
||||||
teamId,
|
teamId: team.id,
|
||||||
query: parsedSearchParams.query,
|
query: parsedSearchParams.query,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
perPage: parsedSearchParams.perPage,
|
perPage: parsedSearchParams.perPage,
|
||||||
@ -139,7 +138,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
resendTeamMemberInvitation({
|
resendTeamMemberInvitation({
|
||||||
teamId,
|
teamId: team.id,
|
||||||
invitationId: row.original.id,
|
invitationId: row.original.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -151,7 +150,7 @@ export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTabl
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
deleteTeamMemberInvitations({
|
deleteTeamMemberInvitations({
|
||||||
teamId,
|
teamId: team.id,
|
||||||
invitationIds: [row.original.id],
|
invitationIds: [row.original.id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import type { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
@ -26,32 +25,22 @@ 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 { DeleteTeamMemberDialog } from '../../dialogs/team-member-delete-dialog';
|
import { TeamMemberDeleteDialog } from '~/components/dialogs/team-member-delete-dialog';
|
||||||
import { UpdateTeamMemberDialog } from '../../dialogs/team-member-update-dialog';
|
import { TeamMemberUpdateDialog } from '~/components/dialogs/team-member-update-dialog';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type TeamMembersDataTableProps = {
|
export const TeamSettingsMembersDataTable = () => {
|
||||||
currentUserTeamRole: TeamMemberRole;
|
|
||||||
teamOwnerUserId: number;
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamMembersDataTable = ({
|
|
||||||
currentUserTeamRole,
|
|
||||||
teamOwnerUserId,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
}: TeamMembersDataTableProps) => {
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
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.findTeamMembers.useQuery(
|
||||||
{
|
{
|
||||||
teamId,
|
teamId: team.id,
|
||||||
query: parsedSearchParams.query,
|
query: parsedSearchParams.query,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
perPage: parsedSearchParams.perPage,
|
perPage: parsedSearchParams.perPage,
|
||||||
@ -100,7 +89,7 @@ export const TeamMembersDataTable = ({
|
|||||||
header: _(msg`Role`),
|
header: _(msg`Role`),
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
teamOwnerUserId === row.original.userId
|
team.ownerUserId === row.original.userId
|
||||||
? _(msg`Owner`)
|
? _(msg`Owner`)
|
||||||
: _(TEAM_MEMBER_ROLE_MAP[row.original.role]),
|
: _(TEAM_MEMBER_ROLE_MAP[row.original.role]),
|
||||||
},
|
},
|
||||||
@ -122,8 +111,8 @@ export const TeamMembersDataTable = ({
|
|||||||
<Trans>Actions</Trans>
|
<Trans>Actions</Trans>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<UpdateTeamMemberDialog
|
<TeamMemberUpdateDialog
|
||||||
currentUserTeamRole={currentUserTeamRole}
|
currentUserTeamRole={team.currentTeamMember.role}
|
||||||
teamId={row.original.teamId}
|
teamId={row.original.teamId}
|
||||||
teamMemberId={row.original.id}
|
teamMemberId={row.original.id}
|
||||||
teamMemberName={row.original.user.name ?? ''}
|
teamMemberName={row.original.user.name ?? ''}
|
||||||
@ -131,8 +120,8 @@ export const TeamMembersDataTable = ({
|
|||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={
|
disabled={
|
||||||
teamOwnerUserId === row.original.userId ||
|
team.ownerUserId === row.original.userId ||
|
||||||
!isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
|
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
|
||||||
}
|
}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
title="Update team member role"
|
title="Update team member role"
|
||||||
@ -143,9 +132,9 @@ export const TeamMembersDataTable = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteTeamMemberDialog
|
<TeamMemberDeleteDialog
|
||||||
teamId={teamId}
|
teamId={team.id}
|
||||||
teamName={teamName}
|
teamName={team.name}
|
||||||
teamMemberId={row.original.id}
|
teamMemberId={row.original.id}
|
||||||
teamMemberName={row.original.user.name ?? ''}
|
teamMemberName={row.original.user.name ?? ''}
|
||||||
teamMemberEmail={row.original.user.email}
|
teamMemberEmail={row.original.user.email}
|
||||||
@ -153,8 +142,8 @@ export const TeamMembersDataTable = ({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
disabled={
|
disabled={
|
||||||
teamOwnerUserId === row.original.userId ||
|
team.ownerUserId === row.original.userId ||
|
||||||
!isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
|
!isTeamRoleWithinUserHierarchy(team.currentTeamMember.role, row.original.role)
|
||||||
}
|
}
|
||||||
title={_(msg`Remove team member`)}
|
title={_(msg`Remove team member`)}
|
||||||
>
|
>
|
||||||
@ -1,14 +1,14 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { TGetTeamByIdResponse } from '@documenso/lib/server-only/team/get-team';
|
import type { TGetTeamByUrlResponse } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
interface TeamProviderProps {
|
interface TeamProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
team: TGetTeamByIdResponse;
|
team: TGetTeamByUrlResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamContext = createContext<TGetTeamByIdResponse | null>(null);
|
const TeamContext = createContext<TGetTeamByUrlResponse | null>(null);
|
||||||
|
|
||||||
export const useCurrentTeam = () => {
|
export const useCurrentTeam = () => {
|
||||||
const context = useContext(TeamContext);
|
const context = useContext(TeamContext);
|
||||||
|
|||||||
@ -1,13 +1,50 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type { TemplateDirectLink } from '@prisma/client';
|
||||||
|
import { TemplateType } from '@prisma/client';
|
||||||
|
|
||||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
||||||
|
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
||||||
|
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
|
||||||
import { useAuth } from '~/providers/auth';
|
import { useAuth } from '~/providers/auth';
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
|
||||||
import type { Route } from './+types/index';
|
import type { Route } from './+types/index';
|
||||||
import { PublicProfilePageView } from './public-profile-page-view';
|
|
||||||
|
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) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const { user } = await getRequiredSession(request);
|
const { user } = await getRequiredSession(request); // Todo: Pull from...
|
||||||
|
|
||||||
const { profile } = await getUserPublicProfile({
|
const { profile } = await getUserPublicProfile({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -17,9 +54,180 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PublicProfilePage({ loaderData }: Route.ComponentProps) {
|
export default function PublicProfilePage({ loaderData }: Route.ComponentProps) {
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const { profile } = loaderData;
|
const { profile } = loaderData;
|
||||||
|
|
||||||
return <PublicProfilePageView user={user} profile={profile} />;
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const user = useAuth();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,221 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type { Team, TeamProfile, TemplateDirectLink, User, UserProfile } from '@prisma/client';
|
|
||||||
import { TemplateType } from '@prisma/client';
|
|
||||||
|
|
||||||
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
|
||||||
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
|
||||||
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
|
|
||||||
|
|
||||||
import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
|
|
||||||
|
|
||||||
export type PublicProfilePageViewOptions = {
|
|
||||||
user: User;
|
|
||||||
team?: Team;
|
|
||||||
profile: UserProfile | TeamProfile;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -24,7 +24,7 @@ import { Switch } from '@documenso/ui/primitives/switch';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
|
import { TriggerMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import { Badge } from '@documenso/ui/primitives/badge';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
import { CreateWebhookDialog } from '~/components/dialogs/webhook-create-dialog';
|
||||||
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
import { DeleteWebhookDialog } from '~/components/dialogs/webhook-delete-dialog';
|
||||||
|
|
||||||
export default function WebhookPage() {
|
export default function WebhookPage() {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
import { Outlet, replace } from 'react-router';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { Link, Outlet, isRouteErrorResponse, replace, useNavigate } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { TeamProvider } from '~/providers/team';
|
import { TeamProvider } from '~/providers/team';
|
||||||
|
|
||||||
@ -63,3 +70,104 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
</TeamProvider>
|
</TeamProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Todo: Handle this.
|
||||||
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
let errorMessage = msg`Unknown error`;
|
||||||
|
let errorDetails: MessageDescriptor | null = null;
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
errorMessage = msg`Unauthorized`;
|
||||||
|
errorDetails = msg`You are not authorized to view this page.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
return match(error.status)
|
||||||
|
.with(404, () => (
|
||||||
|
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">
|
||||||
|
<Trans>404 Team not found</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
|
||||||
|
<Trans>Oops! Something went wrong.</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
<Trans>
|
||||||
|
The team you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button asChild className="w-32">
|
||||||
|
<Link to="/settings/teams">
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Go Back</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.with(500, () => (
|
||||||
|
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">{_(errorMessage)}</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
|
||||||
|
<Trans>Oops! Something went wrong.</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
{errorDetails ? _(errorDetails) : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-32"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Go Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/settings/teams">
|
||||||
|
<Trans>View teams</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<>
|
||||||
|
<h1>
|
||||||
|
{error.status} {error.statusText}
|
||||||
|
</h1>
|
||||||
|
<p>{error.data}</p>
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
<p>The stack trace is:</p>
|
||||||
|
<pre>{error.stack}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <h1>Unknown Error</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,197 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { CheckCircle2, Clock } from 'lucide-react';
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
|
||||||
|
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-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 { TeamEmailDropdown } from '~/components/pages/teams/team-email-dropdown';
|
||||||
|
import { TeamTransferStatus } from '~/components/pages/teams/team-transfer-status';
|
||||||
|
import { useAuth } from '~/providers/auth';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export default function TeamsSettingsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const isTransferVerificationExpired =
|
||||||
|
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||||
|
|
||||||
|
<section className="mt-6 space-y-6">
|
||||||
|
{(team.teamEmail || team.emailVerification) && (
|
||||||
|
<Alert className="p-6" variant="neutral">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Team email</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>
|
||||||
|
You can view documents associated with this email and use this identity when sending
|
||||||
|
documents.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<hr className="border-border/50 mt-2" />
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between pt-4">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
||||||
|
avatarFallback={extractInitials(
|
||||||
|
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
||||||
|
)}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 text-sm font-semibold">
|
||||||
|
{team.teamEmail?.name || team.emailVerification?.name}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
secondaryText={
|
||||||
|
<span className="text-sm">
|
||||||
|
{team.teamEmail?.email || team.emailVerification?.email}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center pr-2">
|
||||||
|
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
|
||||||
|
{match({
|
||||||
|
teamEmail: team.teamEmail,
|
||||||
|
emailVerification: team.emailVerification,
|
||||||
|
})
|
||||||
|
.with({ teamEmail: P.not(null) }, () => (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
||||||
|
<Trans>Active</Trans>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(
|
||||||
|
{
|
||||||
|
emailVerification: P.when(
|
||||||
|
(emailVerification) =>
|
||||||
|
emailVerification && emailVerification?.expiresAt < new Date(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ emailVerification: P.not(null) }, () => (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
||||||
|
<Trans>Awaiting email confirmation</Trans>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamEmailDropdown team={team} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!team.teamEmail && !team.emailVerification && (
|
||||||
|
<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>Team email</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
|
||||||
|
{/* Feature not available yet. */}
|
||||||
|
{/* <li>Display this name and email when sending documents</li> */}
|
||||||
|
{/* <li>View documents associated with this email</li> */}
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans>View documents associated with this email</Trans>
|
||||||
|
</span>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamEmailAddDialog teamId={team.id} />
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{team.ownerUserId === user.id && (
|
||||||
|
<>
|
||||||
|
{isTransferVerificationExpired && (
|
||||||
|
<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>Transfer team</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>Transfer the ownership of the team to another team member.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamTransferDialog
|
||||||
|
ownerUserId={team.ownerUserId}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
|
import { TeamSettingsDesktopNav } from '~/components/pages/teams/team-settings-desktop-nav';
|
||||||
|
import { TeamSettingsMobileNav } from '~/components/pages/teams/team-settings-mobile-nav';
|
||||||
|
|
||||||
|
import type { Route } from '../+types/_layout';
|
||||||
|
|
||||||
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
|
// Todo: Get from parent loaders...
|
||||||
|
const { user } = await getRequiredSession(request);
|
||||||
|
const teamUrl = params.teamUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
||||||
|
// Unauthorized.
|
||||||
|
throw new Response(null, { status: 401 }); // Todo: Test
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const error = AppError.parseError(e);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
throw new Response(null, { status: 404 }); // Todo: Test
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamsSettingsLayout() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Team Settings</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
|
<TeamSettingsDesktopNav className="hidden md:col-span-3 md:flex" />
|
||||||
|
<TeamSettingsMobileNav className="col-span-12 mb-8 md:hidden" />
|
||||||
|
|
||||||
|
<div className="col-span-12 md:col-span-9">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
import { Plural, Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getRequiredSession } 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/(dashboard)/settings/layout/header';
|
||||||
|
import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
|
||||||
|
import { TeamBillingPortalButton } from '~/components/pages/teams/team-billing-portal-button';
|
||||||
|
|
||||||
|
import type { Route } from './+types/billing';
|
||||||
|
|
||||||
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getRequiredSession(request);
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||||
|
|
||||||
|
let teamSubscription: Stripe.Subscription | null = null;
|
||||||
|
|
||||||
|
if (team.subscription) {
|
||||||
|
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
team,
|
||||||
|
teamSubscription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { team, teamSubscription } = loaderData;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<TeamBillingInvoicesDataTable teamId={team.id} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { TeamMemberInviteDialog } from '~/components/dialogs/team-member-invite-dialog';
|
||||||
|
import { TeamSettingsMemberInvitesTable } from '~/components/tables/team-settings-member-invites-table';
|
||||||
|
import { TeamSettingsMembersDataTable } from '~/components/tables/team-settings-members-table';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export default function TeamsSettingsMembersPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
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>
|
||||||
|
<SettingsHeader
|
||||||
|
title={_(msg`Members`)}
|
||||||
|
subtitle={_(msg`Manage the members or invite new members.`)}
|
||||||
|
>
|
||||||
|
<TeamMemberInviteDialog
|
||||||
|
teamId={team.id}
|
||||||
|
currentUserTeamRole={team.currentTeamMember.role}
|
||||||
|
/>
|
||||||
|
</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' ? (
|
||||||
|
<TeamSettingsMemberInvitesTable key="invites" />
|
||||||
|
) : (
|
||||||
|
<TeamSettingsMembersDataTable key="members" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-preferences-form';
|
||||||
|
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export default function TeamsSettingsPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={_(msg`Team Preferences`)}
|
||||||
|
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SettingsHeader
|
||||||
|
title={_(msg`Branding Preferences`)}
|
||||||
|
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
|
||||||
|
className="mt-8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
|
||||||
|
|
||||||
|
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
|
||||||
|
|
||||||
|
import type { Route } from './+types/public-profile';
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
// Todo: Pull from...
|
||||||
|
const team = { id: 1 };
|
||||||
|
const { user } = await getRequiredSession(request);
|
||||||
|
|
||||||
|
const { profile } = await getTeamPublicProfile({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PublicProfilePage;
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
||||||
|
import { ApiTokenForm } from '~/components/forms/token';
|
||||||
|
|
||||||
|
import type { Route } from './+types/tokens';
|
||||||
|
|
||||||
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getRequiredSession(request); // Todo
|
||||||
|
|
||||||
|
// Todo
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||||
|
|
||||||
|
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
team,
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiTokensPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
const { team, tokens } = loaderData;
|
||||||
|
|
||||||
|
if (!tokens) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">
|
||||||
|
<Trans>API Tokens</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
<Trans>Something went wrong.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">
|
||||||
|
<Trans>API Tokens</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
On this page, you can create new API tokens and manage the existing ones. <br />
|
||||||
|
You can view our swagger docs{' '}
|
||||||
|
<a
|
||||||
|
className="text-primary underline"
|
||||||
|
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<ApiTokenForm className="max-w-xl" teamId={team.id} tokens={tokens} />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-xl font-medium">
|
||||||
|
<Trans>Your existing tokens</Trans>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{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.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>
|
||||||
|
<DeleteTokenDialog token={token} teamId={team.id}>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DeleteTokenDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
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 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/(dashboard)/settings/layout/header';
|
||||||
|
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import type { Route } from './+types/webhooks.$id';
|
||||||
|
|
||||||
|
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||||
|
|
||||||
|
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
||||||
|
|
||||||
|
export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
||||||
|
{
|
||||||
|
id: params.id,
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
{ enabled: !!params.id && !!team.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
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: params.id,
|
||||||
|
teamId: team.id,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Webhook updated`),
|
||||||
|
description: _(msg`The webhook has been updated successfully.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todo
|
||||||
|
// router.refresh();
|
||||||
|
} 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>Webhook URL</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>Secret</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
|
||||||
|
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export default function WebhookPage() {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
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-2">
|
||||||
|
<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={`/t/${team.url}/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,5 +0,0 @@
|
|||||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
|
|
||||||
|
|
||||||
export { meta };
|
|
||||||
|
|
||||||
export default DocumentsPage;
|
|
||||||
45
apps/remix/server/load-context.ts
Normal file
45
apps/remix/server/load-context.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
|
||||||
|
type GetLoadContextArgs = {
|
||||||
|
request: Request;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module 'react-router' {
|
||||||
|
interface AppLoadContext extends Awaited<ReturnType<typeof getLoadContext>> {
|
||||||
|
session: any;
|
||||||
|
url: string;
|
||||||
|
extra: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoadContext(args: GetLoadContextArgs) {
|
||||||
|
console.log('-----------------');
|
||||||
|
console.log(args.request.url);
|
||||||
|
|
||||||
|
const url = new URL(args.request.url);
|
||||||
|
console.log(url.pathname);
|
||||||
|
console.log(args.request.headers);
|
||||||
|
|
||||||
|
const splitUrl = url.pathname.split('/');
|
||||||
|
|
||||||
|
// let team: TGetTeamByUrlResponse | null = null;
|
||||||
|
|
||||||
|
const session = await getSession(args.request);
|
||||||
|
|
||||||
|
// if (session.isAuthenticated && splitUrl[1] === 't' && splitUrl[2]) {
|
||||||
|
// const teamUrl = splitUrl[2];
|
||||||
|
|
||||||
|
// team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||||
|
// }
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
...session,
|
||||||
|
// currentUser:
|
||||||
|
// currentTeam: team,
|
||||||
|
},
|
||||||
|
url: args.request.url,
|
||||||
|
extra: 'stuff',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import { defineConfig, loadEnv } from 'vite';
|
|||||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
import { getLoadContext } from './server/load-context';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envDir: path.join(__dirname, '../../'),
|
envDir: path.join(__dirname, '../../'),
|
||||||
envPrefix: '__DO_NOT_USE_OR_YOU_WILL_BE_FIRED__',
|
envPrefix: '__DO_NOT_USE_OR_YOU_WILL_BE_FIRED__',
|
||||||
@ -35,6 +37,7 @@ export default defineConfig({
|
|||||||
lingui(),
|
lingui(),
|
||||||
macrosPlugin(),
|
macrosPlugin(),
|
||||||
serverAdapter({
|
serverAdapter({
|
||||||
|
getLoadContext,
|
||||||
entry: 'server/index.ts',
|
entry: 'server/index.ts',
|
||||||
}),
|
}),
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
} from '@documenso/prisma/generated/zod';
|
} from '@documenso/prisma/generated/zod';
|
||||||
import { TeamMemberSchema } from '@documenso/prisma/generated/zod';
|
import { TeamMemberSchema } from '@documenso/prisma/generated/zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
|
||||||
export type GetTeamByIdOptions = {
|
export type GetTeamByIdOptions = {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
@ -74,6 +76,8 @@ export type GetTeamByUrlOptions = {
|
|||||||
teamUrl: string;
|
teamUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TGetTeamByUrlResponse = Awaited<ReturnType<typeof getTeamByUrl>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a team given a team URL.
|
* Get a team given a team URL.
|
||||||
*/
|
*/
|
||||||
@ -90,7 +94,7 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.team.findUniqueOrThrow({
|
const result = await prisma.team.findFirst({
|
||||||
where: whereFilter,
|
where: whereFilter,
|
||||||
include: {
|
include: {
|
||||||
teamEmail: true,
|
teamEmail: true,
|
||||||
@ -121,6 +125,10 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
const { members, ...team } = result;
|
const { members, ...team } = result;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user