feat: add initials field type (#1279)
Adds a new field type that enables document recipients to add their `initials` on the document.
This commit is contained in:
@@ -41,6 +41,7 @@ import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
|
|||||||
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
|
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
|
||||||
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
|
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
|
||||||
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
|
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
|
||||||
|
import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field';
|
||||||
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
||||||
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
|
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
|
||||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||||
@@ -182,6 +183,15 @@ export const SignDirectTemplateForm = ({
|
|||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
.with(FieldType.INITIALS, () => (
|
||||||
|
<InitialsField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
recipient={directRecipient}
|
||||||
|
onSignField={onSignField}
|
||||||
|
onUnsignField={onUnsignField}
|
||||||
|
/>
|
||||||
|
))
|
||||||
.with(FieldType.NAME, () => (
|
.with(FieldType.NAME, () => (
|
||||||
<NameField
|
<NameField
|
||||||
key={field.id}
|
key={field.id}
|
||||||
|
|||||||
140
apps/web/src/app/(signing)/sign/[token]/initials-field.tsx
Normal file
140
apps/web/src/app/(signing)/sign/[token]/initials-field.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type {
|
||||||
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
|
TSignFieldWithTokenMutationSchema,
|
||||||
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
|
export type InitialsFieldProps = {
|
||||||
|
field: FieldWithSignature;
|
||||||
|
recipient: Recipient;
|
||||||
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InitialsField = ({
|
||||||
|
field,
|
||||||
|
recipient,
|
||||||
|
onSignField,
|
||||||
|
onUnsignField,
|
||||||
|
}: InitialsFieldProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { fullName } = useRequiredSigningContext();
|
||||||
|
const initials = extractInitials(fullName);
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
|
try {
|
||||||
|
const value = initials ?? '';
|
||||||
|
|
||||||
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken(payload);
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while signing the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
try {
|
||||||
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onUnsignField) {
|
||||||
|
await onUnsignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while removing the signature.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Initials">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!field.inserted && (
|
||||||
|
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||||
|
Initials
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.inserted && (
|
||||||
|
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
|
||||||
|
{field.customText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -39,7 +39,16 @@ export type SignatureFieldProps = {
|
|||||||
*/
|
*/
|
||||||
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
onRemove?: (fieldType?: string) => Promise<void> | void;
|
onRemove?: (fieldType?: string) => Promise<void> | void;
|
||||||
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
|
type?:
|
||||||
|
| 'Date'
|
||||||
|
| 'Initials'
|
||||||
|
| 'Email'
|
||||||
|
| 'Name'
|
||||||
|
| 'Signature'
|
||||||
|
| 'Radio'
|
||||||
|
| 'Dropdown'
|
||||||
|
| 'Number'
|
||||||
|
| 'Checkbox';
|
||||||
tooltipText?: string | null;
|
tooltipText?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { DateField } from './date-field';
|
|||||||
import { DropdownField } from './dropdown-field';
|
import { DropdownField } from './dropdown-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
|
import { InitialsField } from './initials-field';
|
||||||
import { NameField } from './name-field';
|
import { NameField } from './name-field';
|
||||||
import { NumberField } from './number-field';
|
import { NumberField } from './number-field';
|
||||||
import { RadioField } from './radio-field';
|
import { RadioField } from './radio-field';
|
||||||
@@ -101,6 +102,9 @@ export const SigningPageView = ({
|
|||||||
.with(FieldType.SIGNATURE, () => (
|
.with(FieldType.SIGNATURE, () => (
|
||||||
<SignatureField key={field.id} field={field} recipient={recipient} />
|
<SignatureField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
.with(FieldType.INITIALS, () => (
|
||||||
|
<InitialsField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
.with(FieldType.NAME, () => (
|
.with(FieldType.NAME, () => (
|
||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
<NameField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
|||||||
{
|
{
|
||||||
type: P.union(
|
type: P.union(
|
||||||
FieldType.NAME,
|
FieldType.NAME,
|
||||||
|
FieldType.INITIALS,
|
||||||
FieldType.EMAIL,
|
FieldType.EMAIL,
|
||||||
FieldType.NUMBER,
|
FieldType.NUMBER,
|
||||||
FieldType.RADIO,
|
FieldType.RADIO,
|
||||||
|
|||||||
@@ -231,10 +231,17 @@ export const signFieldWithToken = async ({
|
|||||||
type,
|
type,
|
||||||
data: signatureImageAsBase64 || typedSignature || '',
|
data: signatureImageAsBase64 || typedSignature || '',
|
||||||
}))
|
}))
|
||||||
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
|
.with(
|
||||||
type,
|
FieldType.DATE,
|
||||||
data: updatedField.customText,
|
FieldType.EMAIL,
|
||||||
}))
|
FieldType.NAME,
|
||||||
|
FieldType.TEXT,
|
||||||
|
FieldType.INITIALS,
|
||||||
|
(type) => ({
|
||||||
|
type,
|
||||||
|
data: updatedField.customText,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.with(
|
.with(
|
||||||
FieldType.NUMBER,
|
FieldType.NUMBER,
|
||||||
FieldType.RADIO,
|
FieldType.RADIO,
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
.with(
|
.with(
|
||||||
FieldType.DATE,
|
FieldType.DATE,
|
||||||
FieldType.EMAIL,
|
FieldType.EMAIL,
|
||||||
|
FieldType.INITIALS,
|
||||||
FieldType.NAME,
|
FieldType.NAME,
|
||||||
FieldType.TEXT,
|
FieldType.TEXT,
|
||||||
FieldType.NUMBER,
|
FieldType.NUMBER,
|
||||||
|
|||||||
@@ -233,6 +233,10 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
|||||||
|
|
||||||
// Organised into union to allow us to extend each field if required.
|
// Organised into union to allow us to extend each field if required.
|
||||||
field: z.union([
|
field: z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal(FieldType.INITIALS),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal(FieldType.EMAIL),
|
type: z.literal(FieldType.EMAIL),
|
||||||
data: z.string(),
|
data: z.string(),
|
||||||
|
|||||||
@@ -1,481 +0,0 @@
|
|||||||
import type { ColumnType } from 'kysely';
|
|
||||||
|
|
||||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
|
||||||
? ColumnType<S, I | undefined, U>
|
|
||||||
: ColumnType<T, T | undefined, T>;
|
|
||||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
|
||||||
|
|
||||||
export const IdentityProvider = {
|
|
||||||
DOCUMENSO: 'DOCUMENSO',
|
|
||||||
GOOGLE: 'GOOGLE',
|
|
||||||
OIDC: 'OIDC',
|
|
||||||
} as const;
|
|
||||||
export type IdentityProvider = (typeof IdentityProvider)[keyof typeof IdentityProvider];
|
|
||||||
export const Role = {
|
|
||||||
ADMIN: 'ADMIN',
|
|
||||||
USER: 'USER',
|
|
||||||
} as const;
|
|
||||||
export type Role = (typeof Role)[keyof typeof Role];
|
|
||||||
export const UserSecurityAuditLogType = {
|
|
||||||
ACCOUNT_PROFILE_UPDATE: 'ACCOUNT_PROFILE_UPDATE',
|
|
||||||
ACCOUNT_SSO_LINK: 'ACCOUNT_SSO_LINK',
|
|
||||||
AUTH_2FA_DISABLE: 'AUTH_2FA_DISABLE',
|
|
||||||
AUTH_2FA_ENABLE: 'AUTH_2FA_ENABLE',
|
|
||||||
PASSKEY_CREATED: 'PASSKEY_CREATED',
|
|
||||||
PASSKEY_DELETED: 'PASSKEY_DELETED',
|
|
||||||
PASSKEY_UPDATED: 'PASSKEY_UPDATED',
|
|
||||||
PASSWORD_RESET: 'PASSWORD_RESET',
|
|
||||||
PASSWORD_UPDATE: 'PASSWORD_UPDATE',
|
|
||||||
SIGN_OUT: 'SIGN_OUT',
|
|
||||||
SIGN_IN: 'SIGN_IN',
|
|
||||||
SIGN_IN_FAIL: 'SIGN_IN_FAIL',
|
|
||||||
SIGN_IN_2FA_FAIL: 'SIGN_IN_2FA_FAIL',
|
|
||||||
SIGN_IN_PASSKEY_FAIL: 'SIGN_IN_PASSKEY_FAIL',
|
|
||||||
} as const;
|
|
||||||
export type UserSecurityAuditLogType =
|
|
||||||
(typeof UserSecurityAuditLogType)[keyof typeof UserSecurityAuditLogType];
|
|
||||||
export const WebhookTriggerEvents = {
|
|
||||||
DOCUMENT_CREATED: 'DOCUMENT_CREATED',
|
|
||||||
DOCUMENT_SENT: 'DOCUMENT_SENT',
|
|
||||||
DOCUMENT_OPENED: 'DOCUMENT_OPENED',
|
|
||||||
DOCUMENT_SIGNED: 'DOCUMENT_SIGNED',
|
|
||||||
DOCUMENT_COMPLETED: 'DOCUMENT_COMPLETED',
|
|
||||||
} as const;
|
|
||||||
export type WebhookTriggerEvents = (typeof WebhookTriggerEvents)[keyof typeof WebhookTriggerEvents];
|
|
||||||
export const WebhookCallStatus = {
|
|
||||||
SUCCESS: 'SUCCESS',
|
|
||||||
FAILED: 'FAILED',
|
|
||||||
} as const;
|
|
||||||
export type WebhookCallStatus = (typeof WebhookCallStatus)[keyof typeof WebhookCallStatus];
|
|
||||||
export const ApiTokenAlgorithm = {
|
|
||||||
SHA512: 'SHA512',
|
|
||||||
} as const;
|
|
||||||
export type ApiTokenAlgorithm = (typeof ApiTokenAlgorithm)[keyof typeof ApiTokenAlgorithm];
|
|
||||||
export const SubscriptionStatus = {
|
|
||||||
ACTIVE: 'ACTIVE',
|
|
||||||
PAST_DUE: 'PAST_DUE',
|
|
||||||
INACTIVE: 'INACTIVE',
|
|
||||||
} as const;
|
|
||||||
export type SubscriptionStatus = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
|
|
||||||
export const DocumentStatus = {
|
|
||||||
DRAFT: 'DRAFT',
|
|
||||||
PENDING: 'PENDING',
|
|
||||||
COMPLETED: 'COMPLETED',
|
|
||||||
} as const;
|
|
||||||
export type DocumentStatus = (typeof DocumentStatus)[keyof typeof DocumentStatus];
|
|
||||||
export const DocumentSource = {
|
|
||||||
DOCUMENT: 'DOCUMENT',
|
|
||||||
TEMPLATE: 'TEMPLATE',
|
|
||||||
TEMPLATE_DIRECT_LINK: 'TEMPLATE_DIRECT_LINK',
|
|
||||||
} as const;
|
|
||||||
export type DocumentSource = (typeof DocumentSource)[keyof typeof DocumentSource];
|
|
||||||
export const DocumentDataType = {
|
|
||||||
S3_PATH: 'S3_PATH',
|
|
||||||
BYTES: 'BYTES',
|
|
||||||
BYTES_64: 'BYTES_64',
|
|
||||||
} as const;
|
|
||||||
export type DocumentDataType = (typeof DocumentDataType)[keyof typeof DocumentDataType];
|
|
||||||
export const ReadStatus = {
|
|
||||||
NOT_OPENED: 'NOT_OPENED',
|
|
||||||
OPENED: 'OPENED',
|
|
||||||
} as const;
|
|
||||||
export type ReadStatus = (typeof ReadStatus)[keyof typeof ReadStatus];
|
|
||||||
export const SendStatus = {
|
|
||||||
NOT_SENT: 'NOT_SENT',
|
|
||||||
SENT: 'SENT',
|
|
||||||
} as const;
|
|
||||||
export type SendStatus = (typeof SendStatus)[keyof typeof SendStatus];
|
|
||||||
export const SigningStatus = {
|
|
||||||
NOT_SIGNED: 'NOT_SIGNED',
|
|
||||||
SIGNED: 'SIGNED',
|
|
||||||
} as const;
|
|
||||||
export type SigningStatus = (typeof SigningStatus)[keyof typeof SigningStatus];
|
|
||||||
export const RecipientRole = {
|
|
||||||
CC: 'CC',
|
|
||||||
SIGNER: 'SIGNER',
|
|
||||||
VIEWER: 'VIEWER',
|
|
||||||
APPROVER: 'APPROVER',
|
|
||||||
} as const;
|
|
||||||
export type RecipientRole = (typeof RecipientRole)[keyof typeof RecipientRole];
|
|
||||||
export const FieldType = {
|
|
||||||
SIGNATURE: 'SIGNATURE',
|
|
||||||
FREE_SIGNATURE: 'FREE_SIGNATURE',
|
|
||||||
NAME: 'NAME',
|
|
||||||
EMAIL: 'EMAIL',
|
|
||||||
DATE: 'DATE',
|
|
||||||
TEXT: 'TEXT',
|
|
||||||
NUMBER: 'NUMBER',
|
|
||||||
RADIO: 'RADIO',
|
|
||||||
CHECKBOX: 'CHECKBOX',
|
|
||||||
DROPDOWN: 'DROPDOWN',
|
|
||||||
} as const;
|
|
||||||
export type FieldType = (typeof FieldType)[keyof typeof FieldType];
|
|
||||||
export const TeamMemberRole = {
|
|
||||||
ADMIN: 'ADMIN',
|
|
||||||
MANAGER: 'MANAGER',
|
|
||||||
MEMBER: 'MEMBER',
|
|
||||||
} as const;
|
|
||||||
export type TeamMemberRole = (typeof TeamMemberRole)[keyof typeof TeamMemberRole];
|
|
||||||
export const TeamMemberInviteStatus = {
|
|
||||||
ACCEPTED: 'ACCEPTED',
|
|
||||||
PENDING: 'PENDING',
|
|
||||||
} as const;
|
|
||||||
export type TeamMemberInviteStatus =
|
|
||||||
(typeof TeamMemberInviteStatus)[keyof typeof TeamMemberInviteStatus];
|
|
||||||
export const TemplateType = {
|
|
||||||
PUBLIC: 'PUBLIC',
|
|
||||||
PRIVATE: 'PRIVATE',
|
|
||||||
} as const;
|
|
||||||
export type TemplateType = (typeof TemplateType)[keyof typeof TemplateType];
|
|
||||||
export type Account = {
|
|
||||||
id: string;
|
|
||||||
userId: number;
|
|
||||||
type: string;
|
|
||||||
provider: string;
|
|
||||||
providerAccountId: string;
|
|
||||||
refresh_token: string | null;
|
|
||||||
access_token: string | null;
|
|
||||||
expires_at: number | null;
|
|
||||||
created_at: number | null;
|
|
||||||
ext_expires_in: number | null;
|
|
||||||
token_type: string | null;
|
|
||||||
scope: string | null;
|
|
||||||
id_token: string | null;
|
|
||||||
session_state: string | null;
|
|
||||||
};
|
|
||||||
export type AnonymousVerificationToken = {
|
|
||||||
id: string;
|
|
||||||
token: string;
|
|
||||||
expiresAt: Timestamp;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
};
|
|
||||||
export type ApiToken = {
|
|
||||||
id: Generated<number>;
|
|
||||||
name: string;
|
|
||||||
token: string;
|
|
||||||
algorithm: Generated<ApiTokenAlgorithm>;
|
|
||||||
expires: Timestamp | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
userId: number | null;
|
|
||||||
teamId: number | null;
|
|
||||||
};
|
|
||||||
export type Document = {
|
|
||||||
id: Generated<number>;
|
|
||||||
userId: number;
|
|
||||||
authOptions: unknown | null;
|
|
||||||
formValues: unknown | null;
|
|
||||||
title: string;
|
|
||||||
status: Generated<DocumentStatus>;
|
|
||||||
documentDataId: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
completedAt: Timestamp | null;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
teamId: number | null;
|
|
||||||
templateId: number | null;
|
|
||||||
source: DocumentSource;
|
|
||||||
};
|
|
||||||
export type DocumentAuditLog = {
|
|
||||||
id: string;
|
|
||||||
documentId: number;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
type: string;
|
|
||||||
data: unknown;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
userId: number | null;
|
|
||||||
userAgent: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
};
|
|
||||||
export type DocumentData = {
|
|
||||||
id: string;
|
|
||||||
type: DocumentDataType;
|
|
||||||
data: string;
|
|
||||||
initialData: string;
|
|
||||||
};
|
|
||||||
export type DocumentMeta = {
|
|
||||||
id: string;
|
|
||||||
subject: string | null;
|
|
||||||
message: string | null;
|
|
||||||
timezone: Generated<string | null>;
|
|
||||||
password: string | null;
|
|
||||||
dateFormat: Generated<string | null>;
|
|
||||||
documentId: number;
|
|
||||||
redirectUrl: string | null;
|
|
||||||
};
|
|
||||||
export type DocumentShareLink = {
|
|
||||||
id: Generated<number>;
|
|
||||||
email: string;
|
|
||||||
slug: string;
|
|
||||||
documentId: number;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Timestamp;
|
|
||||||
};
|
|
||||||
export type Field = {
|
|
||||||
id: Generated<number>;
|
|
||||||
secondaryId: string;
|
|
||||||
documentId: number | null;
|
|
||||||
templateId: number | null;
|
|
||||||
recipientId: number;
|
|
||||||
type: FieldType;
|
|
||||||
page: number;
|
|
||||||
positionX: Generated<string>;
|
|
||||||
positionY: Generated<string>;
|
|
||||||
width: Generated<string>;
|
|
||||||
height: Generated<string>;
|
|
||||||
customText: string;
|
|
||||||
inserted: boolean;
|
|
||||||
fieldMeta: unknown | null;
|
|
||||||
};
|
|
||||||
export type Passkey = {
|
|
||||||
id: string;
|
|
||||||
userId: number;
|
|
||||||
name: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
lastUsedAt: Timestamp | null;
|
|
||||||
credentialId: Buffer;
|
|
||||||
credentialPublicKey: Buffer;
|
|
||||||
counter: string;
|
|
||||||
credentialDeviceType: string;
|
|
||||||
credentialBackedUp: boolean;
|
|
||||||
transports: string[];
|
|
||||||
};
|
|
||||||
export type PasswordResetToken = {
|
|
||||||
id: Generated<number>;
|
|
||||||
token: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
expiry: Timestamp;
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
export type Recipient = {
|
|
||||||
id: Generated<number>;
|
|
||||||
documentId: number | null;
|
|
||||||
templateId: number | null;
|
|
||||||
email: string;
|
|
||||||
name: Generated<string>;
|
|
||||||
token: string;
|
|
||||||
documentDeletedAt: Timestamp | null;
|
|
||||||
expired: Timestamp | null;
|
|
||||||
signedAt: Timestamp | null;
|
|
||||||
authOptions: unknown | null;
|
|
||||||
role: Generated<RecipientRole>;
|
|
||||||
readStatus: Generated<ReadStatus>;
|
|
||||||
signingStatus: Generated<SigningStatus>;
|
|
||||||
sendStatus: Generated<SendStatus>;
|
|
||||||
};
|
|
||||||
export type Session = {
|
|
||||||
id: string;
|
|
||||||
sessionToken: string;
|
|
||||||
userId: number;
|
|
||||||
expires: Timestamp;
|
|
||||||
};
|
|
||||||
export type Signature = {
|
|
||||||
id: Generated<number>;
|
|
||||||
created: Generated<Timestamp>;
|
|
||||||
recipientId: number;
|
|
||||||
fieldId: number;
|
|
||||||
signatureImageAsBase64: string | null;
|
|
||||||
typedSignature: string | null;
|
|
||||||
};
|
|
||||||
export type SiteSettings = {
|
|
||||||
id: string;
|
|
||||||
enabled: Generated<boolean>;
|
|
||||||
data: unknown;
|
|
||||||
lastModifiedByUserId: number | null;
|
|
||||||
lastModifiedAt: Generated<Timestamp>;
|
|
||||||
};
|
|
||||||
export type Subscription = {
|
|
||||||
id: Generated<number>;
|
|
||||||
status: Generated<SubscriptionStatus>;
|
|
||||||
planId: string;
|
|
||||||
priceId: string;
|
|
||||||
periodEnd: Timestamp | null;
|
|
||||||
userId: number | null;
|
|
||||||
teamId: number | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Timestamp;
|
|
||||||
cancelAtPeriodEnd: Generated<boolean>;
|
|
||||||
};
|
|
||||||
export type Team = {
|
|
||||||
id: Generated<number>;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
customerId: string | null;
|
|
||||||
ownerUserId: number;
|
|
||||||
};
|
|
||||||
export type TeamEmail = {
|
|
||||||
teamId: number;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
export type TeamEmailVerification = {
|
|
||||||
teamId: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
expiresAt: Timestamp;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
};
|
|
||||||
export type TeamMember = {
|
|
||||||
id: Generated<number>;
|
|
||||||
teamId: number;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
role: TeamMemberRole;
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
export type TeamMemberInvite = {
|
|
||||||
id: Generated<number>;
|
|
||||||
teamId: number;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
email: string;
|
|
||||||
status: Generated<TeamMemberInviteStatus>;
|
|
||||||
role: TeamMemberRole;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
export type TeamPending = {
|
|
||||||
id: Generated<number>;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
customerId: string;
|
|
||||||
ownerUserId: number;
|
|
||||||
};
|
|
||||||
export type TeamTransferVerification = {
|
|
||||||
teamId: number;
|
|
||||||
userId: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
expiresAt: Timestamp;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
clearPaymentMethods: Generated<boolean>;
|
|
||||||
};
|
|
||||||
export type Template = {
|
|
||||||
id: Generated<number>;
|
|
||||||
type: Generated<TemplateType>;
|
|
||||||
title: string;
|
|
||||||
userId: number;
|
|
||||||
teamId: number | null;
|
|
||||||
authOptions: unknown | null;
|
|
||||||
templateDocumentDataId: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
};
|
|
||||||
export type TemplateDirectLink = {
|
|
||||||
id: string;
|
|
||||||
templateId: number;
|
|
||||||
token: string;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
enabled: boolean;
|
|
||||||
directTemplateRecipientId: number;
|
|
||||||
};
|
|
||||||
export type TemplateMeta = {
|
|
||||||
id: string;
|
|
||||||
subject: string | null;
|
|
||||||
message: string | null;
|
|
||||||
timezone: Generated<string | null>;
|
|
||||||
password: string | null;
|
|
||||||
dateFormat: Generated<string | null>;
|
|
||||||
templateId: number;
|
|
||||||
redirectUrl: string | null;
|
|
||||||
};
|
|
||||||
export type User = {
|
|
||||||
id: Generated<number>;
|
|
||||||
name: string | null;
|
|
||||||
customerId: string | null;
|
|
||||||
email: string;
|
|
||||||
emailVerified: Timestamp | null;
|
|
||||||
password: string | null;
|
|
||||||
source: string | null;
|
|
||||||
signature: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
lastSignedIn: Generated<Timestamp>;
|
|
||||||
roles: Generated<Role[]>;
|
|
||||||
identityProvider: Generated<IdentityProvider>;
|
|
||||||
twoFactorSecret: string | null;
|
|
||||||
twoFactorEnabled: Generated<boolean>;
|
|
||||||
twoFactorBackupCodes: string | null;
|
|
||||||
url: string | null;
|
|
||||||
};
|
|
||||||
export type UserProfile = {
|
|
||||||
id: number;
|
|
||||||
bio: string | null;
|
|
||||||
};
|
|
||||||
export type UserSecurityAuditLog = {
|
|
||||||
id: Generated<number>;
|
|
||||||
userId: number;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
type: UserSecurityAuditLogType;
|
|
||||||
userAgent: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
};
|
|
||||||
export type VerificationToken = {
|
|
||||||
id: Generated<number>;
|
|
||||||
secondaryId: string;
|
|
||||||
identifier: string;
|
|
||||||
token: string;
|
|
||||||
expires: Timestamp;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
export type Webhook = {
|
|
||||||
id: string;
|
|
||||||
webhookUrl: string;
|
|
||||||
eventTriggers: WebhookTriggerEvents[];
|
|
||||||
secret: string | null;
|
|
||||||
enabled: Generated<boolean>;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
userId: number;
|
|
||||||
teamId: number | null;
|
|
||||||
};
|
|
||||||
export type WebhookCall = {
|
|
||||||
id: string;
|
|
||||||
status: WebhookCallStatus;
|
|
||||||
url: string;
|
|
||||||
event: WebhookTriggerEvents;
|
|
||||||
requestBody: unknown;
|
|
||||||
responseCode: number;
|
|
||||||
responseHeaders: unknown | null;
|
|
||||||
responseBody: unknown | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
webhookId: string;
|
|
||||||
};
|
|
||||||
export type DB = {
|
|
||||||
Account: Account;
|
|
||||||
AnonymousVerificationToken: AnonymousVerificationToken;
|
|
||||||
ApiToken: ApiToken;
|
|
||||||
Document: Document;
|
|
||||||
DocumentAuditLog: DocumentAuditLog;
|
|
||||||
DocumentData: DocumentData;
|
|
||||||
DocumentMeta: DocumentMeta;
|
|
||||||
DocumentShareLink: DocumentShareLink;
|
|
||||||
Field: Field;
|
|
||||||
Passkey: Passkey;
|
|
||||||
PasswordResetToken: PasswordResetToken;
|
|
||||||
Recipient: Recipient;
|
|
||||||
Session: Session;
|
|
||||||
Signature: Signature;
|
|
||||||
SiteSettings: SiteSettings;
|
|
||||||
Subscription: Subscription;
|
|
||||||
Team: Team;
|
|
||||||
TeamEmail: TeamEmail;
|
|
||||||
TeamEmailVerification: TeamEmailVerification;
|
|
||||||
TeamMember: TeamMember;
|
|
||||||
TeamMemberInvite: TeamMemberInvite;
|
|
||||||
TeamPending: TeamPending;
|
|
||||||
TeamTransferVerification: TeamTransferVerification;
|
|
||||||
Template: Template;
|
|
||||||
TemplateDirectLink: TemplateDirectLink;
|
|
||||||
TemplateMeta: TemplateMeta;
|
|
||||||
User: User;
|
|
||||||
UserProfile: UserProfile;
|
|
||||||
UserSecurityAuditLog: UserSecurityAuditLog;
|
|
||||||
VerificationToken: VerificationToken;
|
|
||||||
Webhook: Webhook;
|
|
||||||
WebhookCall: WebhookCall;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "FieldType" ADD VALUE 'INITIALS';
|
||||||
@@ -409,6 +409,7 @@ model Recipient {
|
|||||||
enum FieldType {
|
enum FieldType {
|
||||||
SIGNATURE
|
SIGNATURE
|
||||||
FREE_SIGNATURE
|
FREE_SIGNATURE
|
||||||
|
INITIALS
|
||||||
NAME
|
NAME
|
||||||
EMAIL
|
EMAIL
|
||||||
DATE
|
DATE
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export const documentRouter = router({
|
|||||||
teamId,
|
teamId,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document || document.teamId !== teamId) {
|
if (!document || (teamId && document.teamId !== teamId)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have access to this document.',
|
message: 'You do not have access to this document.',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Contact,
|
||||||
Disc,
|
Disc,
|
||||||
Hash,
|
Hash,
|
||||||
Info,
|
Info,
|
||||||
@@ -650,6 +651,32 @@ export const AddFieldsFormPartial = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
onClick={() => setSelectedField(FieldType.INITIALS)}
|
||||||
|
onMouseDown={() => setSelectedField(FieldType.INITIALS)}
|
||||||
|
data-selected={selectedField === FieldType.INITIALS ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||||
|
// selectedSignerStyles.borderClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Contact className="h-4 w-4" />
|
||||||
|
Initials
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
@@ -663,7 +690,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
// selectedSignerStyles.borderClass,
|
// selectedSignerStyles.borderClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
CheckSquare,
|
||||||
|
ChevronDown,
|
||||||
|
Contact,
|
||||||
|
Disc,
|
||||||
|
Hash,
|
||||||
|
Mail,
|
||||||
|
Type,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { TFieldMetaSchema as FieldMetaType } from '@documenso/lib/types/field-meta';
|
import type { TFieldMetaSchema as FieldMetaType } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
@@ -13,6 +23,7 @@ type FieldIconProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fieldIcons = {
|
const fieldIcons = {
|
||||||
|
[FieldType.INITIALS]: { icon: Contact, label: 'Initials' },
|
||||||
[FieldType.EMAIL]: { icon: Mail, label: 'Email' },
|
[FieldType.EMAIL]: { icon: Mail, label: 'Email' },
|
||||||
[FieldType.NAME]: { icon: User, label: 'Name' },
|
[FieldType.NAME]: { icon: User, label: 'Name' },
|
||||||
[FieldType.DATE]: { icon: CalendarDays, label: 'Date' },
|
[FieldType.DATE]: { icon: CalendarDays, label: 'Date' },
|
||||||
@@ -46,9 +57,11 @@ export const FieldIcon = ({
|
|||||||
|
|
||||||
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
|
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
|
||||||
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
|
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
|
||||||
label = fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
|
label =
|
||||||
|
fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
|
||||||
} else if (fieldMeta.label) {
|
} else if (fieldMeta.label) {
|
||||||
label = fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
|
label =
|
||||||
|
fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
|
||||||
} else {
|
} else {
|
||||||
label = fieldIcons[type]?.label;
|
label = fieldIcons[type]?.label;
|
||||||
}
|
}
|
||||||
@@ -58,7 +71,7 @@ export const FieldIcon = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1.5 text-sm">
|
<div className="text-field-card-foreground flex items-center justify-center gap-x-1.5 text-sm">
|
||||||
<Icon className='h-4 w-4' /> {label}
|
<Icon className="h-4 w-4" /> {label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type TDocumentFlowFormSchema = z.infer<typeof ZDocumentFlowFormSchema>;
|
|||||||
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
||||||
[FieldType.SIGNATURE]: 'Signature',
|
[FieldType.SIGNATURE]: 'Signature',
|
||||||
[FieldType.FREE_SIGNATURE]: 'Free Signature',
|
[FieldType.FREE_SIGNATURE]: 'Free Signature',
|
||||||
|
[FieldType.INITIALS]: 'Initials',
|
||||||
[FieldType.TEXT]: 'Text',
|
[FieldType.TEXT]: 'Text',
|
||||||
[FieldType.DATE]: 'Date',
|
[FieldType.DATE]: 'Date',
|
||||||
[FieldType.EMAIL]: 'Email',
|
[FieldType.EMAIL]: 'Email',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Contact,
|
||||||
Disc,
|
Disc,
|
||||||
Hash,
|
Hash,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -383,10 +384,11 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
{selectedField && (
|
{selectedField && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
|
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
|
||||||
selectedSignerStyles.default.base,
|
selectedSignerStyles.default.base,
|
||||||
{
|
{
|
||||||
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -546,6 +548,32 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group h-full w-full"
|
||||||
|
onClick={() => setSelectedField(FieldType.INITIALS)}
|
||||||
|
onMouseDown={() => setSelectedField(FieldType.INITIALS)}
|
||||||
|
data-selected={selectedField === FieldType.INITIALS ? true : undefined}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||||
|
// selectedSignerStyles.borderClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Contact className="h-4 w-4" />
|
||||||
|
Initials
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
|
|||||||
Reference in New Issue
Block a user