mirror of
https://github.com/documenso/documenso.git
synced 2025-11-23 05:01:54 +10:00
chore: merge main
This commit is contained in:
@ -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`),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
432
apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Normal file
432
apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
125
apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
Normal file
125
apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user