Merge branch 'main' into feat/add-pdf-image-renderer

This commit is contained in:
David Nguyen
2026-02-24 18:51:04 +11:00
179 changed files with 14753 additions and 3181 deletions
@@ -115,10 +115,12 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
<p className="text-sm text-muted-foreground">
<Trans>
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</Trans>
</p>
<Button
@@ -258,10 +258,10 @@ export const EnvelopeDistributeDialog = ({
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
<Trans>Email</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
<Trans>None</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -149,7 +149,12 @@ export const EnvelopesBulkDeleteDialog = ({
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isPending}>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
@@ -77,7 +77,7 @@ export const OrganisationGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from organisation">
You are about to remove the following group from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
@@ -127,7 +127,11 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
};
const mapTextToUrl = (text: string) => {
return text.toLowerCase().replace(/\s+/g, '-');
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, '-');
};
const dialogState = useMemo(() => {
@@ -260,7 +264,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
<span className="text-xs font-normal text-foreground/50">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
@@ -288,7 +292,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
/>
<label
className="text-muted-foreground ml-2 text-sm"
className="ml-2 text-sm text-muted-foreground"
htmlFor="inherit-members"
>
<Trans>Allow all organisation members to access this team</Trans>
@@ -81,7 +81,7 @@ export const TeamGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from team">
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
</Trans>
@@ -114,7 +114,9 @@ export const ConfigureFieldsView = ({
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
expired: null, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -1,11 +1,14 @@
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
<div className="fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
<span>
<Trans>Loading...</Trans>
</span>
</div>
);
};
@@ -500,7 +500,9 @@ export const EmbedDirectTemplateClientPage = ({
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -511,7 +511,9 @@ export const EmbedSignDocumentV1ClientPage = ({
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
@@ -25,7 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const { envelope, recipient, envelopeData, setFullName, fullName } =
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName } =
useRequiredEnvelopeSigningContext();
const { isCompleted, isRejected, recipientSignature } = envelopeData;
@@ -35,6 +36,7 @@ export const EmbedSignDocumentV2ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT);
const onDocumentCompleted = (data: {
token: string;
@@ -132,6 +134,17 @@ export const EmbedSignDocumentV2ClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (envelope.type === EnvelopeType.TEMPLATE) {
if (!isCompleted && data.email) {
setEmail(data.email);
}
if (data.email) {
setIsEmailLocked(!!data.lockEmail);
}
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
@@ -213,6 +226,7 @@ export const EmbedSignDocumentV2ClientPage = ({
return (
<EmbedSigningProvider
isNameLocked={isNameLocked}
isEmailLocked={isEmailLocked}
hidePoweredBy={hidePoweredBy}
allowDocumentRejection={allowDocumentRejection}
onDocumentCompleted={onDocumentCompleted}
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
export const EmbedRecipientExpired = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent && !hasPostedMessage) {
window.parent.postMessage(
{
action: 'recipient-expired',
data: null,
},
'*',
);
}
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
return (
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-center text-2xl font-bold text-foreground">
<Trans>Signing Window Expired</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-sm text-muted-foreground">
<Trans>
Your signing window for this document has expired. Please contact the sender for a new
invitation.
</Trans>
</p>
<p className="mt-4 text-sm text-muted-foreground">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};
@@ -11,6 +11,10 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -27,6 +31,7 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -70,6 +75,7 @@ export type TDocumentPreferencesFormSchema = {
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
};
type SettingsSubset = Pick<
@@ -87,6 +93,7 @@ type SettingsSubset = Pick<
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
>;
export type DocumentPreferencesFormProps = {
@@ -126,6 +133,7 @@ export const DocumentPreferencesForm = ({
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -146,6 +154,7 @@ export const DocumentPreferencesForm = ({
: null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -669,6 +678,35 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Envelope Expiration</Trans>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document
expires. After expiration, recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
@@ -97,13 +97,13 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
{menuNavigationLinks.map(({ href, text }) => (
<Link
key={href}
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
className="flex items-center gap-2 text-2xl font-semibold text-foreground hover:text-foreground/80"
to={href}
onClick={() => handleMenuItemClick()}
>
{text}
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 text-xs font-semibold text-primary-foreground">
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
</span>
)}
@@ -111,7 +111,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
))}
<button
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
className="text-2xl font-semibold text-foreground hover:text-foreground/80"
onClick={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
@@ -123,8 +123,10 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
<ThemeSwitcher />
</div>
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Documenso, Inc.
<br />
<Trans>All rights reserved.</Trans>
</p>
</div>
</SheetContent>
@@ -118,7 +118,9 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
{price.product.features && price.product.features.length > 0 && (
<div className="mt-4 text-muted-foreground">
<div className="text-sm font-medium">Includes:</div>
<div className="text-sm font-medium">
<Trans>Includes:</Trans>
</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
@@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -27,7 +28,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuth2FAProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -44,7 +44,6 @@ type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
export const DocumentSigningAuth2FA = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -101,14 +100,39 @@ export const DocumentSigningAuth2FA = ({
<Alert variant="warning">
<AlertDescription>
<p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to assist with this document.</Trans>
))
.exhaustive()}
</p>
<p className="mt-2">
<Trans>
By enabling 2FA, you will be required to enter a code from your authenticator app
@@ -138,7 +162,9 @@ export const DocumentSigningAuth2FA = ({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<FormLabel required>
<Trans>2FA token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -13,13 +14,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuthAccountProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
onOpenChange: (value: boolean) => void;
};
export const DocumentSigningAuthAccount = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
@@ -55,32 +54,110 @@ export const DocumentSigningAuthAccount = ({
<fieldset disabled={isSigningOut} className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
{isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
)}
</span>
) : (
<span>
{isDirectTemplate ? (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in.
</Trans>
) : (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in as <strong>{recipient.email}</strong>
</Trans>
)}
</span>
)}
<span>
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To sign this field, you need to be logged in.</Trans>
) : (
<Trans>
To sign this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To sign this document, you need to be logged in.</Trans>
) : (
<Trans>
To sign this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To approve this field, you need to be logged in.</Trans>
) : (
<Trans>
To approve this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To approve this document, you need to be logged in.</Trans>
) : (
<Trans>
To approve this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To view this document, you need to be logged in.</Trans>
) : (
<Trans>
To view this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To assist with this field, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To assist with this document, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.exhaustive()}
</span>
</AlertDescription>
</Alert>
@@ -8,6 +8,7 @@ import { RecipientRole } from '@prisma/client';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -38,7 +39,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuthPasskeyProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -52,7 +52,6 @@ type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
export const DocumentSigningAuthPasskey = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -128,9 +127,62 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
this {actionTarget.toLowerCase()}.
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to sign this field.
</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to sign this document.
</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to approve this field.
</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to approve this
document.
</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this field.
</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to mark this document as
viewed.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this field.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this document.
</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to assist with this
field.
</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to assist with this
document.
</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -178,10 +230,38 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to assist with this document.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -213,7 +293,9 @@ export const DocumentSigningAuthPasskey = ({
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormLabel required>
<Trans>Passkey</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
@@ -241,20 +323,24 @@ export const DocumentSigningAuthPasskey = ({
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
@@ -23,8 +23,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -40,8 +38,6 @@ const ZPasswordAuthFormSchema = z.object({
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -162,10 +162,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Automatically sign fields</DialogTitle>
<DialogTitle>
<Trans>Automatically sign fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
<p>
<Trans>
When you sign a document, we can automatically fill in and sign the following fields
@@ -122,7 +122,9 @@ export const DocumentSigningMobileWidget = () => {
{!hidePoweredBy && (
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -259,7 +259,9 @@ export const DocumentSigningPageViewV2 = () => {
target="_blank"
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
>
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</a>
)}
@@ -9,19 +9,21 @@ import {
AlertTriangle,
CheckIcon,
Clock,
Clock8Icon,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
UserIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -44,7 +46,7 @@ export const DocumentPageViewRecipients = ({
envelope,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { _, i18n } = useLingui();
const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@@ -66,9 +68,9 @@ export const DocumentPageViewRecipients = ({
}, [searchParams, setSearchParams]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recipients</Trans>
</h1>
@@ -87,7 +89,7 @@ export const DocumentPageViewRecipients = ({
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
<ul className="divide-y border-t text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@@ -98,9 +100,9 @@ export const DocumentPageViewRecipients = ({
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
@@ -154,12 +156,41 @@ export const DocumentPageViewRecipients = ({
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
isRecipientExpired(recipient) && (
<Badge variant="destructive">
<Clock8Icon className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
!isRecipientExpired(recipient) &&
(recipient.expiresAt ? (
<PopoverHover
trigger={
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
}
>
<p className="text-xs text-muted-foreground">
<Trans>
Expires{' '}
{recipient.expiresAt
? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED)
: 'N/A'}
</Trans>
</p>
</PopoverHover>
) : (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
))}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
@@ -175,7 +206,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Reason for rejection: </Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
{recipient.rejectionReason}
</p>
</PopoverHover>
@@ -183,7 +214,8 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
recipient.role !== RecipientRole.CC &&
!isRecipientExpired(recipient) && (
<TooltipProvider>
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
<TooltipTrigger asChild>
@@ -434,15 +434,31 @@ export default function EnvelopeEditorFieldsPageRenderer({
renderFieldOnLayer(field);
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => {
if (!fieldGroup.getStage() || !fieldGroup.getParent()) {
return false;
}
return localPageFields.some((field) => field.formId === fieldGroup.id());
});
if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) {
setSelectedFields(liveSelectedFieldGroups);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields]);
}, [localPageFields, selectedKonvaFieldGroups]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
const fieldGroups = nodes.filter(
(node) =>
node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
) as Konva.Group[];
interactiveTransformer.current?.nodes(fieldGroups);
setSelectedKonvaFieldGroups(fieldGroups);
@@ -665,6 +681,10 @@ const FieldActionButtons = ({
selectedFieldFormId.includes(field.formId),
);
if (fields.length === 0) {
return null;
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === fields[0].recipientId,
);
@@ -680,7 +700,7 @@ const FieldActionButtons = ({
}
return null;
}, [editorFields.localFields]);
}, [editorFields.localFields, envelope.recipients, selectedFieldFormId]);
return (
<div className="flex flex-col items-center" {...props}>
@@ -297,19 +297,31 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
<p>
<span className="min-w-12 text-muted-foreground">Pos X:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>
&nbsp;
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Pos Y:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>
&nbsp;
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Width:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>
&nbsp;
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Height:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>
&nbsp;
{selectedField.height.toFixed(2)}
</p>
</div>
@@ -22,6 +22,7 @@ import {
DOCUMENT_DISTRIBUTION_METHODS,
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -62,6 +63,7 @@ import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
@@ -135,6 +137,7 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
}),
});
@@ -207,6 +210,7 @@ export const EnvelopeEditorSettingsDialog = ({
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
},
};
};
@@ -245,6 +249,7 @@ export const EnvelopeEditorSettingsDialog = ({
message,
subject,
emailReplyTo,
envelopeExpirationPeriod,
} = data.meta;
const parsedGlobalAccessAuth = z
@@ -273,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
},
});
@@ -373,7 +379,7 @@ export const EnvelopeEditorSettingsDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 py-6"
disabled={form.formState.isSubmitting}
key={activeTab}
>
@@ -636,6 +642,40 @@ export const EnvelopeEditorSettingsDialog = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
))
.with('email', () => (
@@ -21,17 +21,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
return (
<div
className={cn(
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
'flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background',
className,
)}
>
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-sm lowercase text-muted-foreground">
{baseUrl.host}/u/{user.url}
</div>
<div className="mt-4">
<div className="bg-primary/10 rounded-full p-1.5">
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
<div className="rounded-full bg-primary/10 p-1.5">
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
@@ -41,17 +41,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
<VerifiedIcon className="text-primary h-8 w-8" />
<VerifiedIcon className="h-8 w-8 text-primary" />
</div>
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
</div>
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
<Trans>Documents</Trans>
</div>
{Array(rows)
@@ -59,14 +59,14 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
.map((_, index) => (
<div
key={index}
className="bg-background flex items-center justify-between gap-x-6 p-4"
className="flex items-center justify-between gap-x-6 bg-background p-4"
>
<div className="flex items-center gap-x-2">
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
<div className="space-y-2">
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
</div>
</div>
@@ -1,5 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { WebhookTriggerEvents } from '@prisma/client';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
@@ -41,7 +42,11 @@ export const WebhookMultiSelectCombobox = ({
placeholder={_(msg`Select triggers`)}
hideClearAllButton
hidePlaceholderWhenSelected
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
emptyIndicator={
<p className="text-center text-sm">
<Trans>No triggers available</Trans>
</p>
}
/>
);
};
@@ -96,7 +96,9 @@ export const AdminDocumentLogsTable = ({ envelopeId }: AdminDocumentLogsTablePro
)}
</div>
) : (
<p>N/A</p>
<p>
<Trans>N/A</Trans>
</p>
),
},
{
@@ -0,0 +1,146 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { useLingui as useLinguiMacro } from '@lingui/react/macro';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@documenso/ui/primitives/hover-card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
type AdminUserTeamsTableProps = {
userId: number;
};
export const AdminUserTeamsTable = ({ userId }: AdminUserTeamsTableProps) => {
const { i18n } = useLingui();
const { t } = useLinguiMacro();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery({
userId,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Team`,
accessorKey: 'name',
cell: ({ row }) => (
<HoverCard>
<HoverCardTrigger className="cursor-default underline decoration-dotted underline-offset-4">
{row.original.name}
</HoverCardTrigger>
<HoverCardContent
className="w-auto font-mono text-xs text-muted-foreground"
align="start"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>id</dt>
<dd>{row.original.id}</dd>
<dt>url</dt>
<dd>{row.original.url}</dd>
</dl>
</HoverCardContent>
</HoverCard>
),
},
{
header: t`Organisation`,
accessorKey: 'organisation',
cell: ({ row }) => (
<Link
to={`/admin/organisations/${row.original.organisation.id}`}
className="hover:underline"
>
{row.original.organisation.name}
</Link>
),
},
{
header: t`Role`,
accessorKey: 'teamRole',
cell: ({ row }) => (
<Badge variant="neutral">
{i18n._(TEAM_MEMBER_ROLE_MAP[row.original.teamRole as TeamMemberRole])}
</Badge>
),
},
{
header: t`Created At`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
table.getPageCount() > 1 ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
</DataTable>
);
};
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { useSearchParams } from 'react-router';
@@ -88,7 +89,9 @@ export const DocumentLogsTable = ({ documentId, userId }: DocumentLogsTableProps
)}
</div>
) : (
<p>N/A</p>
<p>
<Trans>N/A</Trans>
</p>
),
},
{
@@ -24,7 +24,7 @@ export const EnvelopesTableBulkActionBar = ({
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="text-sm font-medium">
<Trans>{selectedCount} selected</Trans>
</span>
@@ -36,13 +36,7 @@ export const EnvelopesTableBulkActionBar = ({
<Trans>Move to Folder</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDeleteClick}
className="text-destructive hover:text-destructive"
>
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>