diff --git a/apps/remix/app/components/general/legacy-field-warning-popover.tsx b/apps/remix/app/components/general/legacy-field-warning-popover.tsx
index 6bd489c27..3165b1be7 100644
--- a/apps/remix/app/components/general/legacy-field-warning-popover.tsx
+++ b/apps/remix/app/components/general/legacy-field-warning-popover.tsx
@@ -28,7 +28,7 @@ export const LegacyFieldWarningPopover = ({
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
- trpc.document.updateDocument.useMutation();
+ trpc.document.update.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {
diff --git a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
index 0d9c3fc9e..dbca5a8ea 100644
--- a/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
+++ b/apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
@@ -4,8 +4,9 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
-import { useDropzone } from 'react-dropzone';
+import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
+import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
@@ -67,10 +68,47 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
}
};
- const onFileDropRejected = () => {
+ const onFileDropRejected = (fileRejections: FileRejection[]) => {
+ if (!fileRejections.length) {
+ return;
+ }
+
+ // Since users can only upload only one file (no multi-upload), we only handle the first file rejection
+ const { file, errors } = fileRejections[0];
+
+ if (!errors.length) {
+ return;
+ }
+
+ const errorNodes = errors.map((error, index) => (
+
+ {match(error.code)
+ .with(ErrorCode.FileTooLarge, () => (
+ File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB
+ ))
+ .with(ErrorCode.FileInvalidType, () => Only PDF files are allowed )
+ .with(ErrorCode.FileTooSmall, () => File is too small )
+ .with(ErrorCode.TooManyFiles, () => (
+ Only one file can be uploaded at a time
+ ))
+ .otherwise(() => (
+ Unknown error
+ ))}
+
+ ));
+
+ const description = (
+ <>
+
+ {file.name} couldn't be uploaded:
+
+ {errorNodes}
+ >
+ );
+
toast({
- title: _(msg`Your template failed to upload.`),
- description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
+ title: _(msg`Upload failed`),
+ description,
duration: 5000,
variant: 'destructive',
});
@@ -88,8 +126,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
void onFileDrop(acceptedFile);
}
},
- onDropRejected: () => {
- void onFileDropRejected();
+ onDropRejected: (fileRejections) => {
+ onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,
diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx
index 3cea126c8..41002b54e 100644
--- a/apps/remix/app/components/general/template/template-edit-form.tsx
+++ b/apps/remix/app/components/general/template/template-edit-form.tsx
@@ -124,32 +124,36 @@ export const TemplateEditForm = ({
},
});
- const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
+ const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
+ return updateTemplateSettings({
+ templateId: template.id,
+ data: {
+ title: data.title,
+ externalId: data.externalId || null,
+ visibility: data.visibility,
+ globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
+ globalActionAuth: data.globalActionAuth ?? [],
+ },
+ meta: {
+ ...data.meta,
+ emailReplyTo: data.meta.emailReplyTo || null,
+ typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
+ uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
+ drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
+ language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
+ },
+ });
+ };
+
+ const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
- await updateTemplateSettings({
- templateId: template.id,
- data: {
- title: data.title,
- externalId: data.externalId || null,
- visibility: data.visibility,
- globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
- globalActionAuth: data.globalActionAuth ?? [],
- },
- meta: {
- ...data.meta,
- emailReplyTo: data.meta.emailReplyTo || null,
- typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
- uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
- drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
- language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
- },
- });
+ await saveSettingsData(data);
setStep('signers');
} catch (err) {
@@ -163,24 +167,44 @@ export const TemplateEditForm = ({
}
};
+ const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
+ try {
+ await saveSettingsData(data);
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while auto-saving the template settings.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
+ const [, recipients] = await Promise.all([
+ updateTemplateSettings({
+ templateId: template.id,
+ meta: {
+ signingOrder: data.signingOrder,
+ allowDictateNextSigner: data.allowDictateNextSigner,
+ },
+ }),
+
+ setRecipients({
+ templateId: template.id,
+ recipients: data.signers,
+ }),
+ ]);
+
+ return recipients;
+ };
+
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
- await Promise.all([
- updateTemplateSettings({
- templateId: template.id,
- meta: {
- signingOrder: data.signingOrder,
- allowDictateNextSigner: data.allowDictateNextSigner,
- },
- }),
-
- setRecipients({
- templateId: template.id,
- recipients: data.signers,
- }),
- ]);
+ await saveTemplatePlaceholderData(data);
setStep('fields');
} catch (err) {
@@ -192,12 +216,48 @@ export const TemplateEditForm = ({
}
};
+ const onAddTemplatePlaceholderFormAutoSave = async (
+ data: TAddTemplatePlacholderRecipientsFormSchema,
+ ) => {
+ try {
+ return await saveTemplatePlaceholderData(data);
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while auto-saving the template placeholders.`),
+ variant: 'destructive',
+ });
+
+ throw err;
+ }
+ };
+
+ const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
+ return addTemplateFields({
+ templateId: template.id,
+ fields: data.fields,
+ });
+ };
+
+ const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
+ try {
+ await saveFieldsData(data);
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while auto-saving the template fields.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
- await addTemplateFields({
- templateId: template.id,
- fields: data.fields,
- });
+ await saveFieldsData(data);
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
@@ -270,11 +330,12 @@ export const TemplateEditForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
+ onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
diff --git a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
index bef9189d5..6072a8846 100644
--- a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx
@@ -67,7 +67,7 @@ export const TemplatePageViewDocumentsTable = ({
Object.fromEntries(searchParams ?? []),
);
- const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
+ const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
{
templateId,
page: parsedSearchParams.page,
diff --git a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
index e0f3f67c9..9b39a27a8 100644
--- a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
@@ -18,7 +18,7 @@ export const TemplatePageViewRecentActivity = ({
templateId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
- const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
+ const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({
templateId,
orderByColumn: 'createdAt',
orderByDirection: 'asc',
diff --git a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
index 58e25b179..89a9366b1 100644
--- a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
+++ b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
@@ -52,7 +52,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
},
});
- const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
+ const { mutateAsync: updateRecipient } = trpc.admin.recipient.update.useMutation();
const columns = useMemo(() => {
return [
diff --git a/apps/remix/app/components/tables/document-logs-table.tsx b/apps/remix/app/components/tables/document-logs-table.tsx
index 8cdae26d5..a042c6a44 100644
--- a/apps/remix/app/components/tables/document-logs-table.tsx
+++ b/apps/remix/app/components/tables/document-logs-table.tsx
@@ -34,7 +34,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
- const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery(
+ const { data, isLoading, isLoadingError } = trpc.document.auditLog.find.useQuery(
{
documentId,
page: parsedSearchParams.page,
diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx
index 1333ca912..c1d68c133 100644
--- a/apps/remix/app/components/tables/documents-table-action-button.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-button.tsx
@@ -45,7 +45,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const onDownloadClick = async () => {
try {
const document = !recipient
- ? await trpcClient.document.getDocumentById.query(
+ ? await trpcClient.document.get.query(
{
documentId: row.id,
},
diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
index 1186afb18..8114c6cc1 100644
--- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
@@ -77,7 +77,7 @@ export const DocumentsTableActionDropdown = ({
const onDownloadClick = async () => {
try {
const document = !recipient
- ? await trpcClient.document.getDocumentById.query({
+ ? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
@@ -103,7 +103,7 @@ export const DocumentsTableActionDropdown = ({
const onDownloadOriginalClick = async () => {
try {
const document = !recipient
- ? await trpcClient.document.getDocumentById.query({
+ ? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx
index fa5be7d2d..a003f4d0d 100644
--- a/apps/remix/app/components/tables/documents-table.tsx
+++ b/apps/remix/app/components/tables/documents-table.tsx
@@ -11,7 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
-import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
+import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
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';
diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx
index 45f837c17..f2d138e0d 100644
--- a/apps/remix/app/components/tables/inbox-table.tsx
+++ b/apps/remix/app/components/tables/inbox-table.tsx
@@ -17,7 +17,6 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
-import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
@@ -32,12 +31,12 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
- data?: TFindDocumentsResponse;
+ data?: TFindInboxResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
-type DocumentsTableRow = TFindDocumentsResponse['data'][number];
+type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
const { _, i18n } = useLingui();
diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
index e86800149..835bebf55 100644
--- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
+++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
@@ -62,7 +62,7 @@ export const SettingsSecurityPasskeyTableActions = ({
});
const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } =
- trpc.auth.updatePasskey.useMutation({
+ trpc.auth.passkey.update.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
@@ -84,7 +84,7 @@ export const SettingsSecurityPasskeyTableActions = ({
});
const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } =
- trpc.auth.deletePasskey.useMutation({
+ trpc.auth.passkey.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
diff --git a/apps/remix/app/components/tables/settings-security-passkey-table.tsx b/apps/remix/app/components/tables/settings-security-passkey-table.tsx
index 3d202900a..b2fe09621 100644
--- a/apps/remix/app/components/tables/settings-security-passkey-table.tsx
+++ b/apps/remix/app/components/tables/settings-security-passkey-table.tsx
@@ -26,7 +26,7 @@ export const SettingsSecurityPasskeyTable = () => {
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
- const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
+ const { data, isLoading, isLoadingError } = trpc.auth.passkey.find.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
index db1b4d0e8..623ac0938 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
@@ -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`),
diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
index 35640b28e..27b7509f2 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
@@ -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,
diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
index 0a93d2d66..5aa188895 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
@@ -71,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
});
+ const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
+ trpc.admin.organisationMember.promoteToOwner.useMutation({
+ onSuccess: () => {
+ toast({
+ title: t`Success`,
+ description: t`Member promoted to owner successfully`,
+ });
+ },
+ onError: () => {
+ toast({
+ title: t`Error`,
+ description: t`We couldn't promote the member to owner. Please try again.`,
+ variant: 'destructive',
+ });
+ },
+ });
+
const teamsColumns = useMemo(() => {
return [
{
@@ -101,6 +118,26 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
{row.original.user.email}
),
},
+ {
+ header: t`Actions`,
+ cell: ({ row }) => (
+
+
+ promoteToOwner({
+ organisationId,
+ userId: row.original.userId,
+ })
+ }
+ >
+ Promote to owner
+
+
+ ),
+ },
] satisfies DataTableColumnDef
[];
}, [organisation]);
diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
index 458553dae..3cd0f5853 100644
--- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
@@ -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,
@@ -33,12 +33,12 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
-const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
+const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true });
type TUserFormSchema = z.infer;
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),
},
@@ -78,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
return ;
}
-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({
resolver: zodResolver(ZUserFormSchema),
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
index 87326a5fa..1ef247147 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -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;
});
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
new file mode 100644
index 000000000..db6b7c38d
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -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;
+
+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 ;
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
+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({
+ 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 (
+
+
+ );
+};
diff --git a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
index eacaaf4fc..5d878d971 100644
--- a/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
@@ -192,6 +192,27 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
+
+
+
+
+ Linked Accounts
+
+
+
+ View and manage all login methods linked to your account.
+
+
+
+
+
+ Manage linked accounts
+
+
+
);
}
diff --git a/apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx b/apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
new file mode 100644
index 000000000..1c9c57914
--- /dev/null
+++ b/apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
@@ -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 }) => (
+