chore: merge main

This commit is contained in:
Catalin Pit
2025-09-11 14:58:42 +03:00
343 changed files with 14952 additions and 3564 deletions

View File

@ -48,7 +48,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
const { toast } = useToast();
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
trpc.admin.document.reseal.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),

View File

@ -33,7 +33,7 @@ export default function AdminDocumentsPage() {
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
page: page || 1,

View File

@ -2,14 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { Link } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@ -27,17 +27,18 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { data: user, isLoading: isLoadingUser } = trpc.profile.getUser.useQuery(
const { data: user, isLoading: isLoadingUser } = trpc.admin.user.get.useQuery(
{
id: Number(params.id),
},
@ -77,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
return <AdminUserPage user={user} />;
}
const AdminUserPage = ({ user }: { user: User }) => {
const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const roles = user.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
const form = useForm<TUserFormSchema>({
resolver: zodResolver(ZUserFormSchema),
@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
</div>
<div className="mt-16 flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div>
</div>
);

View File

@ -6,6 +6,7 @@ import {
GroupIcon,
MailboxIcon,
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
@ -77,6 +78,11 @@ export default function SettingsLayout() {
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/o/${organisation.url}/settings/sso`,
label: t`SSO`,
icon: ShieldCheckIcon,
},
{
path: `/o/${organisation.url}/settings/billing`,
label: t`Billing`,
@ -94,6 +100,13 @@ export default function SettingsLayout() {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
route.path.includes('/sso')
) {
return false;
}
return true;
});

View File

@ -46,6 +46,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@ -54,7 +55,8 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null
includeSigningCertificate === null ||
includeAuditLog === null
) {
throw new Error('Should not be possible.');
}
@ -68,6 +70,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),

View File

@ -171,7 +171,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
<OrganisationEmailDomainRecordsDialog
records={records}
trigger={
<Button variant="secondary">
<Button variant="outline">
<Trans>View DNS Records</Trans>
</Button>
}

View File

@ -0,0 +1,432 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import {
formatOrganisationCallbackUrl,
formatOrganisationLoginUrl,
} from '@documenso/lib/utils/organisation-authentication-portal';
import { trpc } from '@documenso/trpc/react';
import { domainRegex } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
import type { TGetOrganisationAuthenticationPortalResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-authentication-portal.types';
import { ZUpdateOrganisationAuthenticationPortalRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-authentication-portal.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
.pick({
enabled: true,
wellKnownUrl: true,
clientId: true,
autoProvisionUsers: true,
defaultOrganisationRole: true,
})
.extend({
clientSecret: z.string().nullable(),
allowedDomains: z.string().refine(
(value) => {
const domains = value.split(' ').filter(Boolean);
return domains.every((domain) => domainRegex.test(domain));
},
{
message: msg`Invalid domains`.id,
},
),
});
type TProviderFormSchema = z.infer<typeof ZProviderFormSchema>;
export function meta() {
return appMetaTags('Organisation SSO Portal');
}
export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
organisationId: organisation.id,
});
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SSOProviderForm authenticationPortal={authenticationPortal} />
</div>
);
}
type SSOProviderFormProps = {
authenticationPortal: TGetOrganisationAuthenticationPortalResponse;
};
const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: updateOrganisationAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.update.useMutation();
const form = useForm<TProviderFormSchema>({
resolver: zodResolver(ZProviderFormSchema),
defaultValues: {
enabled: authenticationPortal.enabled,
clientId: authenticationPortal.clientId,
clientSecret: authenticationPortal.clientSecretProvided ? null : '',
wellKnownUrl: authenticationPortal.wellKnownUrl,
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
allowedDomains: authenticationPortal.allowedDomains.join(' '),
},
});
const onSubmit = async (values: TProviderFormSchema) => {
const { enabled, clientId, clientSecret, wellKnownUrl } = values;
if (enabled && !clientId) {
form.setError('clientId', {
message: t`Client ID is required`,
});
return;
}
if (enabled && clientSecret === '') {
form.setError('clientSecret', {
message: t`Client secret is required`,
});
return;
}
if (enabled && !wellKnownUrl) {
form.setError('wellKnownUrl', {
message: t`Well-known URL is required`,
});
return;
}
try {
await updateOrganisationAuthenticationPortal({
organisationId: organisation.id,
data: {
enabled,
clientId,
clientSecret: values.clientSecret ?? undefined,
wellKnownUrl,
autoProvisionUsers: values.autoProvisionUsers,
defaultOrganisationRole: values.defaultOrganisationRole,
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
},
});
toast({
title: t`Success`,
description: t`Provider has been updated successfully`,
duration: 5000,
});
} catch (err) {
console.error(err);
toast({
title: t`An error occurred`,
description: t`We couldn't update the provider. Please try again.`,
variant: 'destructive',
});
}
};
const isSsoEnabled = form.watch('enabled');
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-6">
<div className="space-y-2">
<Label>
<Trans>Organisation authentication portal URL</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationLoginUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationLoginUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>This is the URL which users will use to sign in to your organisation.</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Redirect URI</Trans>
</Label>
<div className="relative">
<Input
className="pr-12"
disabled
value={formatOrganisationCallbackUrl(organisation.url)}
/>
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={formatOrganisationCallbackUrl(organisation.url)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
<Trans>Add this URL to your provider's allowed redirect URIs</Trans>
</p>
</div>
<div className="space-y-2">
<Label>
<Trans>Required scopes</Trans>
</Label>
<Input className="pr-12" disabled value={`openid profile email`} />
<p className="text-muted-foreground text-xs">
<Trans>This is the required scopes you must set in your provider's settings</Trans>
</p>
</div>
<FormField
control={form.control}
name="wellKnownUrl"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Issuer URL</Trans>
</FormLabel>
<FormControl>
<Input
placeholder={'https://your-provider.com/.well-known/openid-configuration'}
{...field}
/>
</FormControl>
{!form.formState.errors.wellKnownUrl && (
<p className="text-muted-foreground text-xs">
<Trans>The OpenID discovery endpoint URL for your provider</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client ID</Trans>
</FormLabel>
<FormControl>
<Input id="client-id" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel required={isSsoEnabled}>
<Trans>Client Secret</Trans>
</FormLabel>
<FormControl>
<Input
id="client-secret"
type="password"
{...field}
value={field.value === null ? '**********************' : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="defaultOrganisationRole"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Organisation Role for New Users</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select default role`} />
</SelectTrigger>
<SelectContent>
{ORGANISATION_MEMBER_ROLE_HIERARCHY[OrganisationMemberRole.MANAGER].map(
(role) => (
<SelectItem key={role} value={role}>
{t(ORGANISATION_MEMBER_ROLE_MAP[role])}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowedDomains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Allowed Email Domains</Trans>
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t`your-domain.com another-domain.com`}
className="min-h-[80px]"
/>
</FormControl>
{!form.formState.errors.allowedDomains && (
<p className="text-muted-foreground text-xs">
<Trans>
Space-separated list of domains. Leave empty to allow all domains.
</Trans>
</p>
)}
<FormMessage />
</FormItem>
)}
/>
{/* Todo: This is just dummy toggle, we need to decide what this does first. */}
{/* <FormField
control={form.control}
name="autoProvisionUsers"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Auto-provision Users</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Automatically create accounts for new users on first login</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Enable SSO portal</Trans>
</FormLabel>
<p className="text-muted-foreground text-sm">
<Trans>Whether to enable the SSO portal for your organisation</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="warning">
<AlertDescription>
<Trans>
Please note that anyone who signs in through your portal will be added to your
organisation as a member.
</Trans>
</AlertDescription>
</Alert>
<div className="flex justify-end gap-2">
<Button loading={form.formState.isSubmitting} type="submit">
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
}
export default function SupportPage() {
const [showForm, setShowForm] = useState(false);
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [searchParams] = useSearchParams();
const teamId = searchParams.get('team');
const subscriptionStatus = organisation.subscription?.status;
const handleSuccess = () => {
setShowForm(false);
};
const handleCloseForm = () => {
setShowForm(false);
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<Link
to="https://docs.documenso.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>
Join our community on{' '}
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
Discord
</Link>{' '}
for community support and discussion.
</Trans>
</p>
</div>
{organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
{!showForm ? (
<Button variant="outline" size="sm" onClick={() => setShowForm(true)}>
<Trans>Create a support ticket</Trans>
</Button>
) : (
<SupportTicketForm
organisationId={organisation.id}
teamId={teamId}
onSuccess={handleSuccess}
onClose={handleCloseForm}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>
<Trans>Linked Accounts</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View and manage all login methods linked to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/linked-accounts">
<Trans>Manage linked accounts</Trans>
</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -0,0 +1,179 @@
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
import { DateTime } from 'luxon';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Linked Accounts');
}
export default function SettingsSecurityLinkedAccounts() {
const { t } = useLingui();
const { data, isLoading, isLoadingError, refetch } = useQuery({
queryKey: ['linked-accounts'],
queryFn: async () => await authClient.account.getMany(),
});
const results = data?.accounts ?? [];
const columns = useMemo(() => {
return [
{
header: t`Provider`,
accessorKey: 'provider',
cell: ({ row }) => row.original.provider,
},
{
header: t`Linked At`,
accessorKey: 'createdAt',
cell: ({ row }) =>
row.original.createdAt
? DateTime.fromJSDate(row.original.createdAt).toRelative()
: t`Unknown`,
},
{
id: 'actions',
cell: ({ row }) => (
<AccountUnlinkDialog
accountId={row.original.id}
provider={row.original.provider}
onSuccess={refetch}
/>
),
},
] satisfies DataTableColumnDef<(typeof results)[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={t`Linked Accounts`}
subtitle={t`View and manage all login methods linked to your account.`}
/>
<div className="mt-4">
<DataTable
columns={columns}
data={results}
hasFilters={false}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-16 rounded" />
</TableCell>
</>
),
}}
/>
</div>
</div>
);
}
type AccountUnlinkDialogProps = {
accountId: string;
provider: string;
onSuccess: () => Promise<unknown>;
};
const AccountUnlinkDialog = ({ accountId, onSuccess, provider }: AccountUnlinkDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const handleRevoke = async () => {
setIsLoading(true);
try {
await authClient.account.delete(accountId);
await onSuccess();
toast({
title: t`Account unlinked`,
});
} catch (error) {
console.error(error);
toast({
title: t`Error`,
description: t`Failed to unlink account`,
variant: 'destructive',
});
}
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trans>Unlink</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the <span className="font-semibold">{provider}</span> login
method from your account.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button variant="destructive" loading={isLoading} onClick={handleRevoke}>
<Trans>Unlink</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -10,6 +10,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Badge } from '@documenso/ui/primitives/badge';
@ -66,6 +67,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {
@ -83,6 +85,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document,
documentRootPath,

View File

@ -9,6 +9,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { AttachmentForm } from '~/components/general/document/document-attachment-form';
@ -79,6 +80,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(`${documentRootPath}/${documentId}`);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document: {
...document,

View File

@ -11,6 +11,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
@ -49,25 +50,27 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
if (document.folderId) {
throw redirect(documentRootPath);
}
const recipients = await getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
});
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return {
document,
documentRootPath,
recipients,
documentRootPath,
};
}
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
const { document, documentRootPath, recipients } = loaderData;
const { document, recipients, documentRootPath } = loaderData;
const { _, i18n } = useLingui();
@ -170,7 +173,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<ul className="text-muted-foreground list-inside list-disc">
{recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}>
<span className="-ml-2">{formatRecipientText(recipient)}</span>
<span>{formatRecipientText(recipient)}</span>
</li>
))}
</ul>

View File

@ -12,10 +12,8 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';

View File

@ -38,6 +38,7 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@ -50,6 +51,7 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,

View File

@ -21,7 +21,7 @@ export function meta() {
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();

View File

@ -9,6 +9,7 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@ -36,51 +37,54 @@ export default function TemplatesPage() {
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
<div className="mt-8">
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<TemplateDropZoneWrapper>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
<div className="mt-8">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload one.
</Trans>
</p>
<div className="mt-8">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
</p>
</div>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
</div>
</div>
</div>
</div>
</TemplateDropZoneWrapper>
);
}