From 7abfc9e271f302cc1992054fc15dfec10db8ad4a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 7 May 2025 15:03:20 +1000 Subject: [PATCH] fix: wip --- .../dialogs/document-delete-dialog.tsx | 1 - .../dialogs/document-duplicate-dialog.tsx | 4 +- .../dialogs/document-move-dialog.tsx | 124 -- .../dialogs/document-resend-dialog.tsx | 4 +- .../dialogs/organisation-create-dialog.tsx | 221 +++ .../dialogs/organisation-delete-dialog.tsx | 164 ++ .../organisation-group-create-dialog.tsx | 254 +++ .../organisation-group-delete-dialog.tsx | 118 ++ .../dialogs/organisation-leave-dialog.tsx | 120 ++ .../organisation-member-delete-dialog.tsx | 124 ++ ... => organisation-member-invite-dialog.tsx} | 100 +- .../organisation-member-update-dialog.tsx | 207 +++ .../public-profile-template-manage-dialog.tsx | 4 +- .../components/dialogs/team-create-dialog.tsx | 48 +- .../components/dialogs/team-delete-dialog.tsx | 16 +- .../dialogs/team-group-create-dialog.tsx | 304 ++++ .../dialogs/team-group-delete-dialog.tsx | 139 ++ .../dialogs/team-group-update-dialog.tsx | 211 +++ .../components/dialogs/team-leave-dialog.tsx | 117 -- .../dialogs/team-member-create-dialog.tsx | 304 ++++ .../dialogs/team-member-delete-dialog.tsx | 28 +- .../dialogs/team-member-update-dialog.tsx | 28 +- .../dialogs/team-transfer-dialog.tsx | 272 --- .../dialogs/template-bulk-send-dialog.tsx | 4 +- .../dialogs/template-create-dialog.tsx | 2 +- .../dialogs/template-move-dialog.tsx | 158 -- .../dialogs/token-delete-dialog.tsx | 4 +- .../dialogs/webhook-create-dialog.tsx | 10 +- .../dialogs/webhook-delete-dialog.tsx | 6 +- ...form.tsx => branding-preferences-form.tsx} | 93 +- ...form.tsx => document-preferences-form.tsx} | 247 +-- .../forms/organisation-update-form.tsx | 178 ++ .../components/forms/public-profile-form.tsx | 8 +- .../app/components/forms/team-update-form.tsx | 6 +- .../app/components/general/app-header.tsx | 31 +- .../components/general/app-nav-desktop.tsx | 47 +- .../document-signing-page-view.tsx | 15 +- .../general/document/document-edit-form.tsx | 8 +- .../document/document-page-view-dropdown.tsx | 4 +- .../general/document/document-upload.tsx | 4 +- .../app/components/general/menu-switcher.tsx | 468 ++--- .../organisation-invitations.tsx} | 54 +- .../general/settings-nav-desktop.tsx | 47 +- .../general/settings-nav-mobile.tsx | 47 +- .../skeletons/document-edit-skeleton.tsx | 2 +- .../teams/team-settings-nav-desktop.tsx | 22 +- .../teams/team-settings-nav-mobile.tsx | 22 +- .../general/teams/team-transfer-status.tsx | 126 -- .../general/template/template-edit-form.tsx | 6 +- .../template-page-view-documents-table.tsx | 4 +- .../general/user-profile-skeleton.tsx | 6 +- .../tables/documents-table-action-button.tsx | 4 +- .../documents-table-action-dropdown.tsx | 22 +- .../tables/documents-table-sender-filter.tsx | 13 +- .../app/components/tables/documents-table.tsx | 4 +- .../app/components/tables/inbox-table.tsx | 199 +++ .../tables/organisation-groups-table.tsx | 150 ++ ... => organisation-member-invites-table.tsx} | 40 +- .../tables/organisation-members-table.tsx | 219 +++ ...x => organisation-pending-teams-table.tsx} | 8 +- ...table.tsx => organisation-teams-table.tsx} | 65 +- .../components/tables/team-groups-table.tsx | 183 ++ ...mbers-table.tsx => team-members-table.tsx} | 65 +- .../templates-table-action-dropdown.tsx | 28 +- .../app/components/tables/templates-table.tsx | 4 +- .../user-settings-organisations-table.tsx | 169 ++ .../tables/user-settings-teams-page-table.tsx | 87 - apps/remix/app/providers/organisation.tsx | 33 + apps/remix/app/providers/team.tsx | 6 +- apps/remix/app/root.tsx | 16 +- .../app/routes/_authenticated+/_layout.tsx | 118 +- .../app/routes/_authenticated+/dashboard.tsx | 232 +++ .../_authenticated+/documents.$id._index.tsx | 263 --- .../_authenticated+/documents.$id.edit.tsx | 138 -- .../_authenticated+/documents.$id.logs.tsx | 188 -- .../_authenticated+/documents._index.tsx | 168 -- .../_authenticated+/org.$orgUrl._index.tsx | 223 +++ .../_authenticated+/org.$orgUrl._layout.tsx | 22 + .../org.$orgUrl.settings._index.tsx | 11 + .../org.$orgUrl.settings._layout.tsx | 114 ++ .../org.$orgUrl.settings.billing.tsx | 135 ++ .../org.$orgUrl.settings.general.tsx | 65 + .../org.$orgUrl.settings.groups.$id.tsx | 304 ++++ .../org.$orgUrl.settings.groups._index.tsx | 24 + .../org.$orgUrl.settings.members.tsx | 91 + .../org.$orgUrl.settings.preferences.tsx | 183 ++ .../org.$orgUrl.settings.teams.tsx | 90 + .../_authenticated+/settings+/billing.tsx | 136 +- .../settings+/organisations.tsx | 28 + .../settings+/public-profile.tsx | 237 --- .../_authenticated+/settings+/teams.tsx | 43 - .../_authenticated+/settings+/tokens.tsx | 116 -- .../settings+/webhooks.$id.tsx | 212 --- .../settings+/webhooks._index.tsx | 106 -- .../_authenticated+/t.$teamUrl+/_layout.tsx | 74 +- .../t.$teamUrl+/documents.$id._index.tsx | 244 ++- .../t.$teamUrl+/documents.$id.edit.tsx | 141 +- .../t.$teamUrl+/documents.$id.logs.tsx | 185 +- .../t.$teamUrl+/documents._index.tsx | 169 +- .../t.$teamUrl+/settings._index.tsx | 80 +- .../t.$teamUrl+/settings._layout.tsx | 33 +- .../t.$teamUrl+/settings.billing.tsx | 109 -- .../t.$teamUrl+/settings.groups.tsx | 291 +++ .../t.$teamUrl+/settings.members.tsx | 54 +- .../t.$teamUrl+/settings.preferences.tsx | 181 +- .../t.$teamUrl+/settings.public-profile.tsx | 190 +- .../t.$teamUrl+/settings.tokens.tsx | 117 +- .../t.$teamUrl+/settings.webhooks.$id.tsx | 4 +- .../t.$teamUrl+/templates.$id._index.tsx | 215 ++- .../t.$teamUrl+/templates.$id.edit.tsx | 107 +- .../t.$teamUrl+/templates._index.tsx | 95 +- .../_authenticated+/templates.$id._index.tsx | 221 --- .../_authenticated+/templates.$id.edit.tsx | 107 -- .../_authenticated+/templates._index.tsx | 94 - apps/remix/app/routes/_index.tsx | 4 +- .../_internal+/[__htmltopdf]+/audit-log.tsx | 2 +- apps/remix/app/routes/_profile+/_layout.tsx | 2 +- apps/remix/app/routes/_recipient+/_layout.tsx | 4 +- .../_recipient+/sign.$token+/_index.tsx | 8 +- .../_recipient+/sign.$token+/complete.tsx | 2 +- .../_recipient+/sign.$token+/waiting.tsx | 2 +- .../articles.signature-disclosure.tsx | 4 +- .../organisation.decline.$token.tsx | 100 ++ ...ken.tsx => organisation.invite.$token.tsx} | 59 +- .../_unauthenticated+/team.decline.$token.tsx | 165 -- .../team.verify.transfer.token.tsx | 138 +- .../routes/api+/branding.logo.team.$teamId.ts | 8 +- apps/remix/app/routes/embed+/direct.$url.tsx | 10 +- apps/remix/app/routes/embed+/sign.$url.tsx | 10 +- apps/remix/server/router.ts | 3 +- package.json | 2 +- packages/api/v1/implementation.ts | 24 +- .../app-tests/e2e/teams/transfer-team.spec.ts | 65 - packages/auth/server/lib/session/session.ts | 2 - .../lib/utils/handle-oauth-callback-url.ts | 2 - packages/auth/server/routes/email-password.ts | 8 +- packages/ee/server-only/limits/client.ts | 2 +- .../ee/server-only/limits/provider/client.tsx | 2 +- packages/ee/server-only/limits/server.ts | 2 +- .../stripe/create-team-customer.ts | 11 +- .../ee/server-only/stripe/get-customer.ts | 1 + .../stripe/transfer-team-subscription.ts | 128 -- .../stripe/webhook/on-subscription-updated.ts | 2 +- .../ee/server-only/util/is-community-plan.ts | 2 +- .../util/is-document-enterprise.ts | 2 +- ...eam-invite.tsx => organisation-invite.tsx} | 26 +- packages/email/templates/team-delete.tsx | 14 +- .../email/templates/team-transfer-request.tsx | 103 -- .../lib/client-only/providers/session.tsx | 16 +- packages/lib/constants/billing.ts | 2 +- packages/lib/constants/organisations.ts | 159 ++ packages/lib/constants/teams.ts | 45 +- .../send-document-cancelled-emails.handler.ts | 19 +- .../send-recipient-signed-email.handler.ts | 24 +- .../emails/send-rejection-emails.handler.ts | 28 +- .../emails/send-signing-email.handler.ts | 24 +- .../emails/send-team-deleted-email.handler.ts | 1 - .../emails/send-team-deleted-email.ts | 1 - .../send-team-member-joined-email.handler.ts | 14 +- .../send-team-member-left-email.handler.ts | 13 +- .../internal/bulk-send-template.handler.ts | 26 +- .../internal/bulk-send-template.ts | 2 +- .../internal/seal-document.handler.ts | 28 +- .../document-meta/upsert-document-meta.ts | 28 +- .../document/create-document-v2.ts | 62 +- .../server-only/document/create-document.ts | 70 +- .../server-only/document/delete-document.ts | 50 +- .../document/duplicate-document-by-id.ts | 9 +- .../document/find-document-audit-logs.ts | 28 +- .../server-only/document/find-documents.ts | 145 +- .../document/get-document-by-id.ts | 67 +- .../document/get-document-by-token.ts | 1 + .../get-document-with-details-by-id.ts | 30 +- .../lib/server-only/document/get-stats.ts | 1 - .../document/move-document-to-team.ts | 73 - .../server-only/document/resend-document.tsx | 24 +- .../lib/server-only/document/seal-document.ts | 28 +- .../document/search-documents-with-keyword.ts | 46 +- .../document/send-completed-email.ts | 31 +- .../server-only/document/send-delete-email.ts | 22 +- .../server-only/document/send-document.tsx | 30 +- .../document/send-pending-email.ts | 22 +- .../document/super-delete-document.ts | 22 +- .../server-only/document/update-document.ts | 107 +- .../lib/server-only/document/update-title.ts | 81 - .../field/create-document-fields.ts | 28 +- .../lib/server-only/field/create-field.ts | 62 +- .../field/create-template-fields.ts | 19 +- .../field/delete-document-field.ts | 28 +- .../lib/server-only/field/delete-field.ts | 24 +- .../field/delete-template-field.ts | 21 +- .../lib/server-only/field/get-field-by-id.ts | 55 +- .../field/get-fields-for-document.ts | 24 +- .../field/set-fields-for-document.ts | 28 +- .../field/set-fields-for-template.ts | 20 +- .../field/update-document-fields.ts | 28 +- .../lib/server-only/field/update-field.ts | 24 +- .../field/update-template-fields.ts | 19 +- .../accept-organisation-invitation.ts | 126 ++ .../create-organisation-billing-portal.ts} | 0 .../create-organisation-member-invites.ts | 223 +++ .../organisation/create-organisation.ts | 114 ++ .../profile/get-public-profile-by-url.ts | 186 +- .../server-only/profile/set-avatar-image.ts | 13 +- .../public-api/create-api-token.ts | 2 +- .../public-api/delete-api-token-by-id.ts | 2 +- .../server-only/public-api/get-api-tokens.ts | 23 +- .../recipient/create-document-recipients.ts | 28 +- .../recipient/create-template-recipients.ts | 19 +- .../recipient/delete-document-recipient.ts | 19 +- .../server-only/recipient/delete-recipient.ts | 24 +- .../recipient/delete-template-recipient.ts | 19 +- .../recipient/get-recipient-by-id.ts | 21 +- .../recipient/get-recipients-for-document.ts | 27 +- .../recipient/get-recipients-for-template.ts | 2 +- .../recipient/set-document-recipients.ts | 50 +- .../recipient/set-template-recipients.ts | 19 +- .../recipient/update-document-recipients.ts | 28 +- .../server-only/recipient/update-recipient.ts | 24 +- .../recipient/update-template-recipients.ts | 19 +- .../team/accept-team-invitation.ts | 101 -- .../team/create-team-email-verification.ts | 63 +- .../team/create-team-member-invites.ts | 190 -- packages/lib/server-only/team/create-team.ts | 225 ++- .../team/decline-team-invitation.ts | 34 - .../team/delete-team-email-verification.ts | 28 +- .../lib/server-only/team/delete-team-email.ts | 82 +- .../team/delete-team-invitations.ts | 47 - .../server-only/team/delete-team-members.ts | 2 +- .../team/delete-team-transfer-request.ts | 42 - packages/lib/server-only/team/delete-team.ts | 149 +- ...oices.ts => find-organisation-invoices.ts} | 2 +- .../team/find-team-member-invites.ts | 105 -- .../lib/server-only/team/find-team-members.ts | 117 +- packages/lib/server-only/team/find-teams.ts | 44 +- .../lib/server-only/team/get-member-roles.ts | 128 ++ .../server-only/team/get-team-invitations.ts | 40 - .../lib/server-only/team/get-team-members.ts | 69 +- .../team/get-team-public-profile.ts | 10 +- .../lib/server-only/team/get-team-settings.ts | 37 + packages/lib/server-only/team/get-team.ts | 119 +- packages/lib/server-only/team/get-teams.ts | 87 +- packages/lib/server-only/team/leave-team.ts | 81 - .../team/request-team-ownership-transfer.ts | 122 -- .../team/resend-team-email-verification.ts | 46 +- .../team/resend-team-member-invitation.ts | 81 - .../team/transfer-team-ownership.ts | 104 -- .../team/update-team-branding-settings.ts | 61 - .../lib/server-only/team/update-team-email.ts | 50 +- .../server-only/team/update-team-member.ts | 1 + .../team/update-team-public-profile.ts | 11 +- packages/lib/server-only/team/update-team.ts | 22 +- .../create-document-from-direct-template.ts | 22 +- .../create-document-from-template-legacy.ts | 36 +- .../template/create-document-from-template.ts | 36 +- .../template/create-template-direct-link.ts | 19 +- .../server-only/template/create-template.ts | 41 +- .../template/delete-template-direct-link.ts | 19 +- .../server-only/template/delete-template.ts | 20 +- .../template/duplicate-template.ts | 20 +- .../server-only/template/find-templates.ts | 23 +- .../template/get-template-by-id.ts | 19 +- .../template/move-template-to-team.ts | 55 - .../template/toggle-template-direct-link.ts | 19 +- .../server-only/template/update-template.ts | 27 +- packages/lib/server-only/user/create-user.ts | 122 +- .../lib/server-only/user/get-all-users.ts | 1 - .../user/get-user-public-profile.ts | 56 - .../server-only/webhooks/create-webhook.ts | 23 +- .../webhooks/delete-webhook-by-id.ts | 21 +- .../lib/server-only/webhooks/edit-webhook.ts | 21 +- .../get-all-webhooks-by-event-trigger.ts | 25 +- .../server-only/webhooks/get-webhook-by-id.ts | 26 +- .../webhooks/get-webhooks-by-team-id.ts | 12 +- .../webhooks/get-webhooks-by-user-id.ts | 13 - .../webhooks/trigger/trigger-webhook.ts | 2 +- packages/lib/translations/de/web.po | 1575 ++++++++++------ packages/lib/translations/en/web.po | 1583 +++++++++++------ packages/lib/translations/es/web.po | 1575 ++++++++++------ packages/lib/translations/fr/web.po | 1577 ++++++++++------ packages/lib/translations/it/web.po | 1575 ++++++++++------ packages/lib/translations/pl/web.po | 1575 ++++++++++------ packages/lib/types/organisation.ts | 87 + packages/lib/utils/organisations.ts | 119 ++ .../utils/team-global-settings-to-branding.ts | 27 +- packages/lib/utils/teams.ts | 128 +- .../20250425034324_asdfasdfasdf/migration.sql | 238 +++ .../20250425052828_asdf/migration.sql | 14 + .../20250428015956_ljlkjkl/migration.sql | 5 + .../20250505042902_jkljlk/migration.sql | 17 + .../20250505043759_sdf/migration.sql | 8 + .../migrations/20250506035754_/migration.sql | 82 + .../20250506045320_jkljlk/migration.sql | 42 + packages/prisma/schema.prisma | 291 ++- packages/prisma/seed-database.ts | 41 +- packages/prisma/seed/templates.ts | 30 +- .../trpc/server/document-router/router.ts | 80 +- .../trpc/server/document-router/schema.ts | 7 - .../accept-organisation-member-invite.ts | 18 + ...accept-organisation-member-invite.types.ts | 11 + .../create-organisation-group.ts | 73 + .../create-organisation-group.types.ts | 21 + .../create-organisation-member-invites.ts | 23 + ...reate-organisation-member-invites.types.ts | 33 + .../create-organisation.ts | 22 + .../create-organisation.types.ts | 25 + .../decline-organisation-member-invite.ts | 38 + ...ecline-organisation-member-invite.types.ts | 11 + .../delete-organisation-group.ts | 64 + .../delete-organisation-group.types.ts | 18 + .../delete-organisation-member-invites.ts | 40 + ...elete-organisation-member-invites.types.ts | 20 + .../delete-organisation-member.ts | 21 + .../delete-organisation-member.types.ts | 18 + .../delete-organisation-members.ts | 105 ++ .../delete-organisation-members.types.ts | 22 + .../delete-organisation.ts | 54 + .../delete-organisation.types.ts | 17 + .../find-organisation-groups.ts | 170 ++ .../find-organisation-groups.types.ts | 54 + .../find-organisation-member-invites.ts | 105 ++ .../find-organisation-member-invites.types.ts | 35 + .../find-organisation-members.ts | 137 ++ .../find-organisation-members.types.ts | 46 + .../get-organisation-member-invites.ts | 33 + .../get-organisation-member-invites.types.ts | 29 + .../get-organisation-session.ts | 91 + .../get-organisation-session.types.ts | 26 + .../organisation-router/get-organisation.ts | 77 + .../get-organisation.types.ts | 35 + .../organisation-router/get-organisations.ts | 58 + .../get-organisations.types.ts | 23 + .../resend-organisation-member-invite.ts | 99 ++ ...resend-organisation-member-invite.types.ts | 18 + .../trpc/server/organisation-router/router.ts | 58 + .../update-organisation-group.ts | 125 ++ .../update-organisation-group.types.ts | 24 + .../update-organisation-members.ts | 163 ++ .../update-organisation-members.types.ts | 22 + .../update-organisation-settings.ts | 102 ++ .../update-organisation-settings.types.ts | 27 + .../update-organisation.ts | 45 + .../update-organisation.types.ts | 23 + packages/trpc/server/profile-router/router.ts | 38 - packages/trpc/server/profile-router/schema.ts | 23 - packages/trpc/server/router.ts | 2 + .../server/team-router/create-team-groups.ts | 101 ++ .../team-router/create-team-groups.types.ts | 28 + .../server/team-router/create-team-members.ts | 159 ++ .../team-router/create-team-members.types.ts | 40 + .../trpc/server/team-router/create-team.ts | 21 + .../server/team-router/create-team.types.ts | 38 + .../server/team-router/delete-team-group.ts | 80 + .../team-router/delete-team-group.types.ts | 18 + .../server/team-router/delete-team-member.ts | 103 ++ .../team-router/delete-team-member.types.ts | 18 + .../trpc/server/team-router/delete-team.ts | 18 + .../server/team-router/delete-team.types.ts | 17 + .../server/team-router/find-team-groups.ts | 144 ++ .../team-router/find-team-groups.types.ts | 47 + .../server/team-router/find-team-members.ts | 23 + .../team-router/find-team-members.types.ts | 27 + .../trpc/server/team-router/find-teams.ts | 15 + .../server/team-router/find-teams.types.ts | 39 + .../server/team-router/get-team-members.ts | 21 + .../team-router/get-team-members.types.ts | 34 + packages/trpc/server/team-router/get-team.ts | 79 + .../trpc/server/team-router/get-team.types.ts | 39 + packages/trpc/server/team-router/router.ts | 451 +---- packages/trpc/server/team-router/schema.ts | 139 +- .../update-team-document-settings.ts | 71 - .../update-team-document-settings.types.ts | 23 - .../server/team-router/update-team-group.ts | 76 + .../team-router/update-team-group.types.ts | 24 + .../server/team-router/update-team-member.ts | 169 ++ .../team-router/update-team-member.types.ts | 22 + .../team-router/update-team-settings.ts | 89 + .../team-router/update-team-settings.types.ts | 32 + .../trpc/server/team-router/update-team.ts | 18 + .../server/team-router/update-team.types.ts | 23 + .../trpc/server/template-router/router.ts | 29 - .../trpc/server/template-router/schema.ts | 9 +- packages/trpc/server/trpc.ts | 14 +- packages/trpc/server/webhook-router/router.ts | 25 +- packages/trpc/server/webhook-router/schema.ts | 26 +- packages/ui/primitives/data-table.tsx | 26 +- .../primitives/document-flow/add-fields.tsx | 2 +- .../field-item-advanced-settings.tsx | 2 +- .../ui/primitives/multi-select-combobox.tsx | 4 +- .../template-flow/add-template-fields.tsx | 2 +- 390 files changed, 21254 insertions(+), 12607 deletions(-) delete mode 100644 apps/remix/app/components/dialogs/document-move-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/organisation-create-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/organisation-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/organisation-leave-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx rename apps/remix/app/components/dialogs/{team-member-invite-dialog.tsx => organisation-member-invite-dialog.tsx} (77%) create mode 100644 apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/team-group-create-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/team-group-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/team-group-update-dialog.tsx delete mode 100644 apps/remix/app/components/dialogs/team-leave-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/team-member-create-dialog.tsx delete mode 100644 apps/remix/app/components/dialogs/team-transfer-dialog.tsx delete mode 100644 apps/remix/app/components/dialogs/template-move-dialog.tsx rename apps/remix/app/components/forms/{team-branding-preferences-form.tsx => branding-preferences-form.tsx} (78%) rename apps/remix/app/components/forms/{team-document-preferences-form.tsx => document-preferences-form.tsx} (57%) create mode 100644 apps/remix/app/components/forms/organisation-update-form.tsx rename apps/remix/app/components/general/{teams/team-invitations.tsx => organisations/organisation-invitations.tsx} (73%) delete mode 100644 apps/remix/app/components/general/teams/team-transfer-status.tsx create mode 100644 apps/remix/app/components/tables/inbox-table.tsx create mode 100644 apps/remix/app/components/tables/organisation-groups-table.tsx rename apps/remix/app/components/tables/{team-settings-member-invites-table.tsx => organisation-member-invites-table.tsx} (81%) create mode 100644 apps/remix/app/components/tables/organisation-members-table.tsx rename apps/remix/app/components/tables/{user-settings-pending-teams-table.tsx => organisation-pending-teams-table.tsx} (95%) rename apps/remix/app/components/tables/{user-settings-current-teams-table.tsx => organisation-teams-table.tsx} (71%) create mode 100644 apps/remix/app/components/tables/team-groups-table.tsx rename apps/remix/app/components/tables/{team-settings-members-table.tsx => team-members-table.tsx} (75%) create mode 100644 apps/remix/app/components/tables/user-settings-organisations-table.tsx delete mode 100644 apps/remix/app/components/tables/user-settings-teams-page-table.tsx create mode 100644 apps/remix/app/providers/organisation.tsx create mode 100644 apps/remix/app/routes/_authenticated+/dashboard.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/documents.$id._index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/documents.$id.edit.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/documents.$id.logs.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/documents._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl._layout.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings._layout.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.billing.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.general.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.groups.$id.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.groups._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.members.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.preferences.tsx create mode 100644 apps/remix/app/routes/_authenticated+/org.$orgUrl.settings.teams.tsx create mode 100644 apps/remix/app/routes/_authenticated+/settings+/organisations.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/public-profile.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/teams.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/tokens.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/webhooks.$id.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.billing.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.groups.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/templates.$id._index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/templates.$id.edit.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/templates._index.tsx create mode 100644 apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx rename apps/remix/app/routes/_unauthenticated+/{team.invite.$token.tsx => organisation.invite.$token.tsx} (67%) delete mode 100644 apps/remix/app/routes/_unauthenticated+/team.decline.$token.tsx delete mode 100644 packages/app-tests/e2e/teams/transfer-team.spec.ts delete mode 100644 packages/ee/server-only/stripe/transfer-team-subscription.ts rename packages/email/templates/{team-invite.tsx => organisation-invite.tsx} (84%) delete mode 100644 packages/email/templates/team-transfer-request.tsx create mode 100644 packages/lib/constants/organisations.ts delete mode 100644 packages/lib/server-only/document/move-document-to-team.ts delete mode 100644 packages/lib/server-only/document/update-title.ts create mode 100644 packages/lib/server-only/organisation/accept-organisation-invitation.ts rename packages/lib/server-only/{team/create-team-billing-portal.ts => organisation/create-organisation-billing-portal.ts} (100%) create mode 100644 packages/lib/server-only/organisation/create-organisation-member-invites.ts create mode 100644 packages/lib/server-only/organisation/create-organisation.ts delete mode 100644 packages/lib/server-only/team/accept-team-invitation.ts delete mode 100644 packages/lib/server-only/team/create-team-member-invites.ts delete mode 100644 packages/lib/server-only/team/decline-team-invitation.ts delete mode 100644 packages/lib/server-only/team/delete-team-invitations.ts delete mode 100644 packages/lib/server-only/team/delete-team-transfer-request.ts rename packages/lib/server-only/team/{find-team-invoices.ts => find-organisation-invoices.ts} (93%) delete mode 100644 packages/lib/server-only/team/find-team-member-invites.ts create mode 100644 packages/lib/server-only/team/get-member-roles.ts delete mode 100644 packages/lib/server-only/team/get-team-invitations.ts create mode 100644 packages/lib/server-only/team/get-team-settings.ts delete mode 100644 packages/lib/server-only/team/leave-team.ts delete mode 100644 packages/lib/server-only/team/request-team-ownership-transfer.ts delete mode 100644 packages/lib/server-only/team/resend-team-member-invitation.ts delete mode 100644 packages/lib/server-only/team/transfer-team-ownership.ts delete mode 100644 packages/lib/server-only/team/update-team-branding-settings.ts delete mode 100644 packages/lib/server-only/template/move-template-to-team.ts delete mode 100644 packages/lib/server-only/user/get-user-public-profile.ts delete mode 100644 packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts create mode 100644 packages/lib/types/organisation.ts create mode 100644 packages/lib/utils/organisations.ts create mode 100644 packages/prisma/migrations/20250425034324_asdfasdfasdf/migration.sql create mode 100644 packages/prisma/migrations/20250425052828_asdf/migration.sql create mode 100644 packages/prisma/migrations/20250428015956_ljlkjkl/migration.sql create mode 100644 packages/prisma/migrations/20250505042902_jkljlk/migration.sql create mode 100644 packages/prisma/migrations/20250505043759_sdf/migration.sql create mode 100644 packages/prisma/migrations/20250506035754_/migration.sql create mode 100644 packages/prisma/migrations/20250506045320_jkljlk/migration.sql create mode 100644 packages/trpc/server/organisation-router/accept-organisation-member-invite.ts create mode 100644 packages/trpc/server/organisation-router/accept-organisation-member-invite.types.ts create mode 100644 packages/trpc/server/organisation-router/create-organisation-group.ts create mode 100644 packages/trpc/server/organisation-router/create-organisation-group.types.ts create mode 100644 packages/trpc/server/organisation-router/create-organisation-member-invites.ts create mode 100644 packages/trpc/server/organisation-router/create-organisation-member-invites.types.ts create mode 100644 packages/trpc/server/organisation-router/create-organisation.ts create mode 100644 packages/trpc/server/organisation-router/create-organisation.types.ts create mode 100644 packages/trpc/server/organisation-router/decline-organisation-member-invite.ts create mode 100644 packages/trpc/server/organisation-router/decline-organisation-member-invite.types.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-group.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-group.types.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-member-invites.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-member-invites.types.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-member.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-member.types.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-members.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation-members.types.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation.ts create mode 100644 packages/trpc/server/organisation-router/delete-organisation.types.ts create mode 100644 packages/trpc/server/organisation-router/find-organisation-groups.ts create mode 100644 packages/trpc/server/organisation-router/find-organisation-groups.types.ts create mode 100644 packages/trpc/server/organisation-router/find-organisation-member-invites.ts create mode 100644 packages/trpc/server/organisation-router/find-organisation-member-invites.types.ts create mode 100644 packages/trpc/server/organisation-router/find-organisation-members.ts create mode 100644 packages/trpc/server/organisation-router/find-organisation-members.types.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-member-invites.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-member-invites.types.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-session.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation-session.types.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation.ts create mode 100644 packages/trpc/server/organisation-router/get-organisation.types.ts create mode 100644 packages/trpc/server/organisation-router/get-organisations.ts create mode 100644 packages/trpc/server/organisation-router/get-organisations.types.ts create mode 100644 packages/trpc/server/organisation-router/resend-organisation-member-invite.ts create mode 100644 packages/trpc/server/organisation-router/resend-organisation-member-invite.types.ts create mode 100644 packages/trpc/server/organisation-router/router.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-group.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-group.types.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-members.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-members.types.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-settings.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation-settings.types.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation.ts create mode 100644 packages/trpc/server/organisation-router/update-organisation.types.ts create mode 100644 packages/trpc/server/team-router/create-team-groups.ts create mode 100644 packages/trpc/server/team-router/create-team-groups.types.ts create mode 100644 packages/trpc/server/team-router/create-team-members.ts create mode 100644 packages/trpc/server/team-router/create-team-members.types.ts create mode 100644 packages/trpc/server/team-router/create-team.ts create mode 100644 packages/trpc/server/team-router/create-team.types.ts create mode 100644 packages/trpc/server/team-router/delete-team-group.ts create mode 100644 packages/trpc/server/team-router/delete-team-group.types.ts create mode 100644 packages/trpc/server/team-router/delete-team-member.ts create mode 100644 packages/trpc/server/team-router/delete-team-member.types.ts create mode 100644 packages/trpc/server/team-router/delete-team.ts create mode 100644 packages/trpc/server/team-router/delete-team.types.ts create mode 100644 packages/trpc/server/team-router/find-team-groups.ts create mode 100644 packages/trpc/server/team-router/find-team-groups.types.ts create mode 100644 packages/trpc/server/team-router/find-team-members.ts create mode 100644 packages/trpc/server/team-router/find-team-members.types.ts create mode 100644 packages/trpc/server/team-router/find-teams.ts create mode 100644 packages/trpc/server/team-router/find-teams.types.ts create mode 100644 packages/trpc/server/team-router/get-team-members.ts create mode 100644 packages/trpc/server/team-router/get-team-members.types.ts create mode 100644 packages/trpc/server/team-router/get-team.ts create mode 100644 packages/trpc/server/team-router/get-team.types.ts delete mode 100644 packages/trpc/server/team-router/update-team-document-settings.ts delete mode 100644 packages/trpc/server/team-router/update-team-document-settings.types.ts create mode 100644 packages/trpc/server/team-router/update-team-group.ts create mode 100644 packages/trpc/server/team-router/update-team-group.types.ts create mode 100644 packages/trpc/server/team-router/update-team-member.ts create mode 100644 packages/trpc/server/team-router/update-team-member.types.ts create mode 100644 packages/trpc/server/team-router/update-team-settings.ts create mode 100644 packages/trpc/server/team-router/update-team-settings.types.ts create mode 100644 packages/trpc/server/team-router/update-team.ts create mode 100644 packages/trpc/server/team-router/update-team.types.ts diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index c89e346a0..746ef1570 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = { onDelete?: () => Promise | void; status: DocumentStatus; documentTitle: string; - teamId?: number; canManageDocument: boolean; }; diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 754fa2596..fc329d191 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -16,7 +16,7 @@ import { import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; type DocumentDuplicateDialogProps = { id: number; @@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({ const { toast } = useToast(); const { _ } = useLingui(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery( { diff --git a/apps/remix/app/components/dialogs/document-move-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx deleted file mode 100644 index 1e0632531..000000000 --- a/apps/remix/app/components/dialogs/document-move-dialog.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; - -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -type DocumentMoveDialogProps = { - documentId: number; - open: boolean; - onOpenChange: (_open: boolean) => void; -}; - -export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const [selectedTeamId, setSelectedTeamId] = useState(null); - - const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery(); - - const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({ - onSuccess: () => { - toast({ - title: _(msg`Document moved`), - description: _(msg`The document has been successfully moved to the selected team.`), - duration: 5000, - }); - - onOpenChange(false); - }, - onError: (error) => { - toast({ - title: _(msg`Error`), - description: error.message || _(msg`An error occurred while moving the document.`), - variant: 'destructive', - duration: 7500, - }); - }, - }); - - const onMove = async () => { - if (!selectedTeamId) { - return; - } - - await moveDocument({ documentId, teamId: selectedTeamId }); - }; - - return ( - - - - - Move Document to Team - - - Select a team to move this document to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index bcd1d61a3..4d7d6b64e 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -36,7 +36,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; import { StackAvatar } from '../general/stack-avatar'; @@ -59,7 +59,7 @@ export type TResendDocumentFormSchema = z.infer { const { user } = useSession(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const { toast } = useToast(); const { _ } = useLingui(); diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx new file mode 100644 index 000000000..30c6d6a3f --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from 'react'; + +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 * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import type { z } from 'zod'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({ + name: true, + url: true, +}); + +type TCreateOrganisationFormSchema = z.infer; + +export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationFormSchema), + defaultValues: { + name: '', + url: '', + }, + }); + + const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation(); + + const onFormSubmit = async ({ name, url }: TCreateOrganisationFormSchema) => { + try { + const response = await createOrganisation({ + name, + url, + }); + + setOpen(false); + + // if (response.paymentRequired) { + // await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); + // return; + // } + + toast({ + title: _(msg`Success`), + description: _(msg`Your organisation has been created.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('url', { + type: 'manual', + message: _(msg`This URL is already in use.`), + }); + + return; + } + + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to create a organisation. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + const mapTextToUrl = (text: string) => { + return text.toLowerCase().replace(/\s+/g, '-'); + }; + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create organisation + + + + Create an organisation to collaborate with teams + + + +
+ +
+ ( + + + Organisation Name + + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); + + const urlField = form.getValues('url'); + if (urlField === oldGeneratedUrl) { + form.setValue('url', newGeneratedUrl); + } + + field.onChange(event); + }} + /> + + + + )} + /> + + ( + + + Organisation URL + + + + + {!form.formState.errors.url && ( + + {field.value ? ( + `${NEXT_PUBLIC_WEBAPP_URL()}/org/${field.value}` + ) : ( + A unique URL to identify your organisation + )} + + )} + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx new file mode 100644 index 000000000..5b23aabf3 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentOrganisation } from '~/providers/organisation'; + +export type OrganisationDeleteDialogProps = { + trigger?: React.ReactNode; +}; + +export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const deleteMessage = _(msg`delete ${organisation.name}`); + + const ZDeleteOrganisationFormSchema = z.object({ + organisationName: z.literal(deleteMessage, { + errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }), + }), + }); + + const form = useForm({ + resolver: zodResolver(ZDeleteOrganisationFormSchema), + defaultValues: { + organisationName: '', + }, + }); + + const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation(); + + const onFormSubmit = async () => { + try { + await deleteOrganisation({ organisationId: organisation.id }); + + toast({ + title: _(msg`Success`), + description: _(msg`Your organisation has been successfully deleted.`), + duration: 5000, + }); + + await navigate('/settings/organisations'); + + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + console.error(error); + + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure you wish to delete this organisation? + + + + + You are about to delete {organisation.name}. + All data related to this organisation such as teams, documents, and all other + resources will be deleted. This action is irreversible. + + + + +
+ +
+ ( + + + + Confirm by typing {deleteMessage} + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx new file mode 100644 index 000000000..be8aa81e6 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { + ORGANISATION_MEMBER_ROLE_HIERARCHY, + ORGANISATION_MEMBER_ROLE_MAP, +} from '@documenso/lib/constants/organisations'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentOrganisation } from '~/providers/organisation'; + +export type OrganisationGroupCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({ + name: true, + memberIds: true, + organisationRole: true, +}); + +type TCreateOrganisationGroupFormSchema = z.infer; + +export const OrganisationGroupCreateDialog = ({ + trigger, + ...props +}: OrganisationGroupCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + const organisation = useCurrentOrganisation(); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationGroupFormSchema), + defaultValues: { + name: '', + organisationRole: OrganisationMemberRole.MEMBER, + memberIds: [], + }, + }); + + const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation(); + + const { data: membersFindResult, isLoading: isLoadingMembers } = + trpc.organisation.member.find.useQuery({ + organisationId: organisation.id, + }); + + const members = membersFindResult?.data ?? []; + + const onFormSubmit = async ({ + name, + organisationRole, + memberIds, + }: TCreateOrganisationGroupFormSchema) => { + try { + await createOrganisationGroup({ + organisationId: organisation.id, + name, + organisationRole, + memberIds, + }); + + setOpen(false); + + toast({ + title: t`Success`, + description: t`Group has been created.`, + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to create a group. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create group + + + + Organise your members into groups which can be assigned to teams + + + +
+ +
+ ( + + + Group Name + + + + + + + )} + /> + + ( + + + Organisation role + + + + + + + + )} + /> + + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={isLoadingMembers} + selectedValues={field.value} + onChange={field.onChange} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select the members to add to this group + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx new file mode 100644 index 000000000..adeb23f9f --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentOrganisation } from '~/providers/organisation'; + +export type OrganisationGroupDeleteDialogProps = { + organisationGroupId: string; + organisationGroupName: string; + trigger?: React.ReactNode; +}; + +export const OrganisationGroupDeleteDialog = ({ + trigger, + organisationGroupId, + organisationGroupName, +}: OrganisationGroupDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const { mutateAsync: deleteGroup, isPending: isDeleting } = + trpc.organisation.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this group from the organisation.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this group. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {organisation.name}. + + + + + + + {organisationGroupName} + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx new file mode 100644 index 000000000..ba66c69a4 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { OrganisationMemberRole } from '@prisma/client'; + +import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationLeaveDialogProps = { + organisationId: string; + organisationName: string; + organisationAvatarImageId?: string | null; + organisationMemberId: string; + role: OrganisationMemberRole; + trigger?: React.ReactNode; +}; + +export const OrganisationLeaveDialog = ({ + trigger, + organisationId, + organisationName, + organisationAvatarImageId, + organisationMemberId, + role, +}: OrganisationLeaveDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } = + trpc.organisation.member.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully left this organisation.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to leave this organisation. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isLeavingOrganisation && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + You are about to leave the following organisation. + + + + + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx new file mode 100644 index 000000000..d3e36b2f3 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentOrganisation } from '~/providers/organisation'; + +export type OrganisationMemberDeleteDialogProps = { + organisationMemberId: string; + organisationMemberName: string; + organisationMemberEmail: string; + trigger?: React.ReactNode; +}; + +export const OrganisationMemberDeleteDialog = ({ + trigger, + organisationMemberId, + organisationMemberName, + organisationMemberEmail, +}: OrganisationMemberDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } = + trpc.organisation.member.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this user from the organisation.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this user. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeletingOrganisationMember && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following user from{' '} + {organisation.name}. + + + + + + {organisationMemberName}} + secondaryText={organisationMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx similarity index 77% rename from apps/remix/app/components/dialogs/team-member-invite-dialog.tsx rename to apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx index dac4f8fce..b4115dcfb 100644 --- a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { TeamMemberRole } from '@prisma/client'; +import { OrganisationMemberRole } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; import Papa, { type ParseResult } from 'papaparse'; @@ -12,9 +12,12 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; import { downloadFile } from '@documenso/lib/client-only/download-file'; -import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { + ORGANISATION_MEMBER_ROLE_HIERARCHY, + ORGANISATION_MEMBER_ROLE_MAP, +} from '@documenso/lib/constants/organisations'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -46,15 +49,15 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useCurrentTeam } from '~/providers/team'; +import { useCurrentOrganisation } from '~/providers/organisation'; -export type TeamMemberInviteDialogProps = { +export type OrganisationMemberInviteDialogProps = { trigger?: React.ReactNode; } & Omit; -const ZInviteTeamMembersFormSchema = z +const ZInviteOrganisationMembersFormSchema = z .object({ - invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, + invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations, }) // Display exactly which rows are duplicates. .superRefine((items, ctx) => { @@ -84,18 +87,21 @@ const ZInviteTeamMembersFormSchema = z } }); -type TInviteTeamMembersFormSchema = z.infer; +type TInviteOrganisationMembersFormSchema = z.infer; type TabTypes = 'INDIVIDUAL' | 'BULK'; -const ZImportTeamMemberSchema = z.array( +const ZImportOrganisationMemberSchema = z.array( z.object({ email: z.string().email(), - role: z.nativeEnum(TeamMemberRole), + organisationRole: z.nativeEnum(OrganisationMemberRole), }), ); -export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => { +export const OrganisationMemberInviteDialog = ({ + trigger, + ...props +}: OrganisationMemberInviteDialogProps) => { const [open, setOpen] = useState(false); const fileInputRef = useRef(null); const [invitationType, setInvitationType] = useState('INDIVIDUAL'); @@ -103,48 +109,49 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi const { _ } = useLingui(); const { toast } = useToast(); - const team = useCurrentTeam(); + const organisation = useCurrentOrganisation(); - const form = useForm({ - resolver: zodResolver(ZInviteTeamMembersFormSchema), + const form = useForm({ + resolver: zodResolver(ZInviteOrganisationMembersFormSchema), defaultValues: { invitations: [ { email: '', - role: TeamMemberRole.MEMBER, + organisationRole: OrganisationMemberRole.MEMBER, }, ], }, }); const { - append: appendTeamMemberInvite, - fields: teamMemberInvites, - remove: removeTeamMemberInvite, + append: appendOrganisationMemberInvite, + fields: organisationMemberInvites, + remove: removeOrganisationMemberInvite, } = useFieldArray({ control: form.control, name: 'invitations', }); - const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation(); + const { mutateAsync: createOrganisationMemberInvites } = + trpc.organisation.member.invite.createMany.useMutation(); - const onAddTeamMemberInvite = () => { - appendTeamMemberInvite({ + const onAddOrganisationMemberInvite = () => { + appendOrganisationMemberInvite({ email: '', - role: TeamMemberRole.MEMBER, + organisationRole: OrganisationMemberRole.MEMBER, }); }; - const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => { + const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => { try { - await createTeamMemberInvites({ - teamId: team.id, + await createOrganisationMemberInvites({ + organisationId: organisation.id, invitations, }); toast({ title: _(msg`Success`), - description: _(msg`Team invitations have been sent.`), + description: _(msg`Organisation invitations have been sent.`), duration: 5000, }); @@ -153,7 +160,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi toast({ title: _(msg`An unknown error occurred`), description: _( - msg`We encountered an unknown error while attempting to invite team members. Please try again later.`, + msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`, ), variant: 'destructive', }); @@ -183,17 +190,17 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi return { email: email.trim(), - role: role.trim().toUpperCase(), + organisationRole: role.trim().toUpperCase(), }; }); // Remove the first row if it contains the headers. - if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') { + if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') { members.shift(); } try { - const importedInvitations = ZImportTeamMemberSchema.parse(members); + const importedInvitations = ZImportOrganisationMemberSchema.parse(members); form.setValue('invitations', importedInvitations); form.clearErrors('invitations'); @@ -229,7 +236,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi }); downloadFile({ - filename: 'documenso-team-member-invites-template.csv', + filename: 'documenso-organisation-member-invites-template.csv', data: blob, }); }; @@ -251,7 +258,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi - Invite team members + Invite organisation members @@ -284,8 +291,11 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi disabled={form.formState.isSubmitting} >
- {teamMemberInvites.map((teamMemberInvite, index) => ( -
+ {organisationMemberInvites.map((organisationMemberInvite, index) => ( +
( {index === 0 && ( @@ -321,13 +331,13 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi - {TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map( - (role) => ( - - {_(TEAM_MEMBER_ROLE_MAP[role]) ?? role} - - ), - )} + {ORGANISATION_MEMBER_ROLE_HIERARCHY[ + organisation.currentOrganisationRole + ].map((role) => ( + + {_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role} + + ))} @@ -342,8 +352,8 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi 'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50', index === 0 ? 'mt-8' : 'mt-0', )} - disabled={teamMemberInvites.length === 1} - onClick={() => removeTeamMemberInvite(index)} + disabled={organisationMemberInvites.length === 1} + onClick={() => removeOrganisationMemberInvite(index)} > @@ -356,7 +366,7 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi size="sm" variant="outline" className="w-fit" - onClick={() => onAddTeamMemberInvite()} + onClick={() => onAddOrganisationMemberInvite()} > Add more diff --git a/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx new file mode 100644 index 000000000..1d138d59f --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { + ORGANISATION_MEMBER_ROLE_HIERARCHY, + ORGANISATION_MEMBER_ROLE_MAP, +} from '@documenso/lib/constants/organisations'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationMemberUpdateDialogProps = { + currentUserOrganisationRole: OrganisationMemberRole; + trigger?: React.ReactNode; + organisationId: string; + organisationMemberId: string; + organisationMemberName: string; + organisationMemberRole: OrganisationMemberRole; +} & Omit; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.nativeEnum(OrganisationMemberRole), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const OrganisationMemberUpdateDialog = ({ + currentUserOrganisationRole, + trigger, + organisationId, + organisationMemberId, + organisationMemberName, + organisationMemberRole, + ...props +}: OrganisationMemberUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationMemberFormSchema), + defaultValues: { + role: organisationMemberRole, + }, + }); + + const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => { + try { + await updateOrganisationMember({ + organisationId, + organisationMemberId, + data: { + role, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`You have updated ${organisationMemberName}.`), + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + if ( + !isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole) + ) { + setOpen(false); + + toast({ + title: _(msg`You cannot modify a organisation member who has a higher role than you.`), + variant: 'destructive', + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update organisation member + + + + + You are currently updating{' '} + {organisationMemberName}. + + + + +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx index d6a13f456..bcd624290 100644 --- a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx +++ b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx @@ -49,7 +49,7 @@ import { import { Textarea } from '@documenso/ui/primitives/textarea'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type ManagePublicTemplateDialogProps = { directTemplates: (Template & { @@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({ const [open, onOpenChange] = useState(isOpen); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [selectedTemplateId, setSelectedTemplateId] = useState(initialTemplateId); diff --git a/apps/remix/app/components/dialogs/team-create-dialog.tsx b/apps/remix/app/components/dialogs/team-create-dialog.tsx index 0b49e9b6d..e07117e4d 100644 --- a/apps/remix/app/components/dialogs/team-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-create-dialog.tsx @@ -14,8 +14,9 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -36,24 +37,29 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useCurrentOrganisation } from '~/providers/organisation'; + export type TeamCreateDialogProps = { trigger?: React.ReactNode; + onCreated?: () => Promise; } & Omit; -const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ +const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({ teamName: true, teamUrl: true, + inheritMembers: true, }); type TCreateTeamFormSchema = z.infer; -export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => { +export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); + const organisation = useCurrentOrganisation(); const [open, setOpen] = useState(false); @@ -64,16 +70,19 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = defaultValues: { teamName: '', teamUrl: '', + inheritMembers: true, }, }); - const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation(); + const { mutateAsync: createTeam } = trpc.team.create.useMutation(); - const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => { + const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => { try { const response = await createTeam({ + organisationId: organisation.id, teamName, teamUrl, + inheritMembers, }); setOpen(false); @@ -83,6 +92,8 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = return; } + await onCreated?.(); + toast({ title: _(msg`Success`), description: _(msg`Your team has been created.`), @@ -145,7 +156,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = Create team - + Create a team to collaborate with your team members. @@ -212,6 +223,31 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = )} /> + ( + + +
+ + + +
+
+
+ )} + /> + )} diff --git a/apps/remix/app/components/dialogs/team-group-create-dialog.tsx b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx new file mode 100644 index 000000000..e33f8d928 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx @@ -0,0 +1,304 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationGroupType, TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupCreateDialogProps = Omit; + +const ZAddTeamMembersFormSchema = z.object({ + groups: z.array( + z.object({ + organisationGroupId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT'); + + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZAddTeamMembersFormSchema), + defaultValues: { + groups: [], + }, + }); + + const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation(); + + const organisationGroupQuery = trpc.organisation.group.find.useQuery({ + organisationId: team.organisationId, + perPage: 100, // Won't really work if they somehow have more than 100 groups. + types: [OrganisationGroupType.CUSTOM], + }); + + const teamGroupQuery = trpc.team.group.find.useQuery({ + teamId: team.id, + perPage: 100, // Won't really work if they somehow have more than 100 groups. + }); + + const avaliableOrganisationGroups = useMemo(() => { + const organisationGroups = organisationGroupQuery.data?.data ?? []; + const teamGroups = teamGroupQuery.data?.data ?? []; + + return organisationGroups.filter( + (group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id), + ); + }, [organisationGroupQuery, teamGroupQuery]); + + const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => { + try { + await createTeamGroups({ + teamId: team.id, + groups, + }); + + toast({ + title: t`Success`, + description: t`Team members have been added.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add team members. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + setStep('SELECT'); + } + }, [open, form]); + + return ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('ROLES', () => ( + + + Add group roles + + + + Configure the team roles for each group + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Groups + + + + ({ + label: group.name ?? group.organisationRole, + value: group.id, + }))} + loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading} + selectedValues={field.value.map( + ({ organisationGroupId }) => organisationGroupId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationGroupId) => ({ + organisationGroupId, + teamRole: + field.value.find( + (value) => value.organisationGroupId === organisationGroupId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select groups`} + /> + + + + Select groups to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'ROLES' && ( + <> +
+ {form.getValues('groups').map((group, index) => ( +
+
+ {index === 0 && ( + + Group + + )} + id === group.organisationGroupId, + )?.name || t`Untitled Group` + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx new file mode 100644 index 000000000..f42fd4630 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { TeamMemberRole } from '@prisma/client'; + +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupDeleteDialogProps = { + trigger?: React.ReactNode; + teamGroupId: string; + teamGroupName: string; + teamGroupRole: TeamMemberRole; +}; + +export const TeamGroupDeleteDialog = ({ + trigger, + teamGroupId, + teamGroupName, + teamGroupRole, +}: TeamGroupDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this group from the team.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this group. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {team.name}. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( + <> + + + {teamGroupName} + + + +
+ + + + + +
+ + ) : ( + <> + + + You cannot delete a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-update-dialog.tsx b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx new file mode 100644 index 000000000..7e3ed9dc0 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { + EXTENDED_TEAM_MEMBER_ROLE_MAP, + TEAM_MEMBER_ROLE_HIERARCHY, +} from '@documenso/lib/constants/teams'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupUpdateDialogProps = { + trigger?: React.ReactNode; + teamGroupId: string; + teamGroupName: string; + teamGroupRole: TeamMemberRole; +} & Omit; + +const ZUpdateTeamGroupFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamGroupSchema = z.infer; + +export const TeamGroupUpdateDialog = ({ + trigger, + teamGroupId, + teamGroupName, + teamGroupRole, + ...props +}: TeamGroupUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamGroupFormSchema), + defaultValues: { + role: teamGroupRole, + }, + }); + + const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => { + try { + await updateTeamGroup({ + id: teamGroupId, + data: { + teamRole: role, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`You have updated the team group.`), + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update this team member. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, team.currentTeamRole, teamGroupRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update team group + + + + + You are currently updating the {teamGroupName} team + group. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ + ) : ( + <> + + + You cannot modify a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-leave-dialog.tsx b/apps/remix/app/components/dialogs/team-leave-dialog.tsx deleted file mode 100644 index a6b6246a6..000000000 --- a/apps/remix/app/components/dialogs/team-leave-dialog.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { TeamMemberRole } from '@prisma/client'; - -import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Alert } from '@documenso/ui/primitives/alert'; -import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamLeaveDialogProps = { - teamId: number; - teamName: string; - teamAvatarImageId?: string | null; - role: TeamMemberRole; - trigger?: React.ReactNode; -}; - -export const TeamLeaveDialog = ({ - trigger, - teamId, - teamName, - teamAvatarImageId, - role, -}: TeamLeaveDialogProps) => { - const [open, setOpen] = useState(false); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({ - onSuccess: () => { - toast({ - title: _(msg`Success`), - description: _(msg`You have successfully left this team.`), - duration: 5000, - }); - - setOpen(false); - }, - onError: () => { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to leave this team. Please try again later.`, - ), - variant: 'destructive', - duration: 10000, - }); - }, - }); - - return ( - !isLeavingTeam && setOpen(value)}> - - {trigger ?? ( - - )} - - - - - - Are you sure? - - - - You are about to leave the following team. - - - - - - - -
- - - - - -
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx new file mode 100644 index 000000000..e83727c0f --- /dev/null +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -0,0 +1,304 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamMemberCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZAddTeamMembersFormSchema = z.object({ + members: z.array( + z.object({ + organisationMemberId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT'); + + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZAddTeamMembersFormSchema), + defaultValues: { + members: [], + }, + }); + + const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation(); + + const organisationMemberQuery = trpc.organisation.member.find.useQuery({ + organisationId: team.organisationId, + }); + + const teamMemberQuery = trpc.team.member.find.useQuery({ + teamId: team.id, + }); + + const avaliableOrganisationMembers = useMemo(() => { + const organisationMembers = organisationMemberQuery.data?.data ?? []; + const teamMembers = teamMemberQuery.data?.data ?? []; + + return organisationMembers.filter( + (member) => !teamMembers.some((teamMember) => teamMember.id === member.id), + ); + }, [organisationMemberQuery, teamMemberQuery]); + + const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => { + try { + await createTeamMembers({ + teamId: team.id, + organisationMembers: members, + }); + + toast({ + title: t`Success`, + description: t`Team members have been added.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add team members. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + setStep('SELECT'); + } + }, [open, form]); + + return ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('MEMBERS', () => ( + + + Add members roles + + + + Configure the team roles for each member + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={organisationMemberQuery.isLoading} + selectedValues={field.value.map( + (member) => member.organisationMemberId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationMemberId) => ({ + organisationMemberId, + teamRole: + field.value.find( + (member) => + member.organisationMemberId === organisationMemberId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select members to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'MEMBERS' && ( + <> +
+ {form.getValues('members').map((member, index) => ( +
+
+ {index === 0 && ( + + Member + + )} + id === member.organisationMemberId, + )?.name || '' + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx index dedda2003..721e42b1b 100644 --- a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx @@ -22,9 +22,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamMemberDeleteDialogProps = { teamId: number; teamName: string; - teamMemberId: number; - teamMemberName: string; - teamMemberEmail: string; + memberId: string; + memberName: string; + memberEmail: string; trigger?: React.ReactNode; }; @@ -32,17 +32,17 @@ export const TeamMemberDeleteDialog = ({ trigger, teamId, teamName, - teamMemberId, - teamMemberName, - teamMemberEmail, + memberId, + memberName, + memberEmail, }: TeamMemberDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); - const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } = - trpc.team.deleteTeamMembers.useMutation({ + const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } = + trpc.team.member.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), @@ -69,7 +69,7 @@ export const TeamMemberDeleteDialog = ({ {trigger ?? ( )} @@ -91,9 +91,9 @@ export const TeamMemberDeleteDialog = ({ {teamMemberName}} - secondaryText={teamMemberEmail} + avatarFallback={memberName.slice(0, 1).toUpperCase()} + primaryText={{memberName}} + secondaryText={memberEmail} /> @@ -107,9 +107,9 @@ export const TeamMemberDeleteDialog = ({ type="submit" variant="destructive" loading={isDeletingTeamMember} - onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })} + onClick={async () => deleteTeamMember({ teamId, memberId })} > - Delete + Remove
diff --git a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx index e9c3a021b..b27866e69 100644 --- a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx @@ -43,9 +43,9 @@ export type TeamMemberUpdateDialogProps = { currentUserTeamRole: TeamMemberRole; trigger?: React.ReactNode; teamId: number; - teamMemberId: number; - teamMemberName: string; - teamMemberRole: TeamMemberRole; + memberId: string; + memberName: string; + memberTeamRole: TeamMemberRole; } & Omit; const ZUpdateTeamMemberFormSchema = z.object({ @@ -58,9 +58,9 @@ export const TeamMemberUpdateDialog = ({ currentUserTeamRole, trigger, teamId, - teamMemberId, - teamMemberName, - teamMemberRole, + memberId, + memberName, + memberTeamRole, ...props }: TeamMemberUpdateDialogProps) => { const [open, setOpen] = useState(false); @@ -71,17 +71,17 @@ export const TeamMemberUpdateDialog = ({ const form = useForm({ resolver: zodResolver(ZUpdateTeamMemberFormSchema), defaultValues: { - role: teamMemberRole, + role: memberTeamRole, }, }); - const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation(); + const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation(); const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => { try { await updateTeamMember({ teamId, - teamMemberId, + memberId, data: { role, }, @@ -89,7 +89,7 @@ export const TeamMemberUpdateDialog = ({ toast({ title: _(msg`Success`), - description: _(msg`You have updated ${teamMemberName}.`), + description: _(msg`You have updated ${memberName}.`), duration: 5000, }); @@ -112,7 +112,7 @@ export const TeamMemberUpdateDialog = ({ form.reset(); - if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) { + if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) { setOpen(false); toast({ @@ -121,7 +121,7 @@ export const TeamMemberUpdateDialog = ({ }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentUserTeamRole, teamMemberRole, form, toast]); + }, [open, currentUserTeamRole, memberTeamRole, form, toast]); return ( Update team member - + - You are currently updating {teamMemberName}. + You are currently updating {memberName}. diff --git a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx deleted file mode 100644 index 4e46233cc..000000000 --- a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { Loader } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { useRevalidator } from 'react-router'; -import { z } from 'zod'; - -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { trpc } from '@documenso/trpc/react'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamTransferDialogProps = { - teamId: number; - teamName: string; - ownerUserId: number; - trigger?: React.ReactNode; -}; - -export const TeamTransferDialog = ({ - trigger, - teamId, - teamName, - ownerUserId, -}: TeamTransferDialogProps) => { - const [open, setOpen] = useState(false); - - const { _ } = useLingui(); - const { toast } = useToast(); - const { revalidate } = useRevalidator(); - - const { mutateAsync: requestTeamOwnershipTransfer } = - trpc.team.requestTeamOwnershipTransfer.useMutation(); - - const { - data, - refetch: refetchTeamMembers, - isPending: loadingTeamMembers, - isLoadingError: loadingTeamMembersError, - } = trpc.team.getTeamMembers.useQuery({ - teamId, - }); - - const confirmTransferMessage = _(msg`transfer ${teamName}`); - - const ZTransferTeamFormSchema = z.object({ - teamName: z.literal(confirmTransferMessage, { - errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }), - }), - newOwnerUserId: z.string(), - clearPaymentMethods: z.boolean(), - }); - - const form = useForm>({ - resolver: zodResolver(ZTransferTeamFormSchema), - defaultValues: { - teamName: '', - clearPaymentMethods: false, - }, - }); - - const onFormSubmit = async ({ - newOwnerUserId, - clearPaymentMethods, - }: z.infer) => { - try { - await requestTeamOwnershipTransfer({ - teamId, - newOwnerUserId: Number.parseInt(newOwnerUserId), - clearPaymentMethods, - }); - - await revalidate(); - - toast({ - title: _(msg`Success`), - description: _(msg`An email requesting the transfer of this team has been sent.`), - duration: 5000, - }); - - setOpen(false); - } catch (err) { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`, - ), - variant: 'destructive', - duration: 10000, - }); - } - }; - - useEffect(() => { - if (!open) { - form.reset(); - } - }, [open, form]); - - useEffect(() => { - if (open && loadingTeamMembersError) { - void refetchTeamMembers(); - } - }, [open, loadingTeamMembersError, refetchTeamMembers]); - - const teamMembers = data - ? data.filter((teamMember) => teamMember.userId !== ownerUserId) - : undefined; - - return ( - !form.formState.isSubmitting && setOpen(value)}> - - {trigger ?? ( - - )} - - - {teamMembers && teamMembers.length > 0 ? ( - - - - Transfer team - - - - Transfer ownership of this team to a selected team member. - - - -
- -
- ( - - - New team owner - - - - - - - )} - /> - - ( - - - - Confirm by typing{' '} - {confirmTransferMessage} - - - - - - - - )} - /> - - - -
    - {IS_BILLING_ENABLED() && ( -
  • - - Any payment methods attached to this team will remain attached to this - team. Please contact us if you need to update this information. - -
  • - )} -
  • - - The selected team member will receive an email which they must accept - before the team is transferred - -
  • -
-
-
- - - - - - -
-
- -
- ) : ( - - {loadingTeamMembers ? ( - - ) : ( -

- {loadingTeamMembersError ? ( - An error occurred while loading team members. Please try again later. - ) : ( - You must have at least one other team member to transfer ownership. - )} -

- )} -
- )} -
- ); -}; diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index d210550c6..50f2518c4 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -21,7 +21,7 @@ import { import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZBulkSendFormSchema = z.object({ file: z.instanceof(File), @@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({ const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), diff --git a/apps/remix/app/components/dialogs/template-create-dialog.tsx b/apps/remix/app/components/dialogs/template-create-dialog.tsx index e623c806b..5e1fe38e3 100644 --- a/apps/remix/app/components/dialogs/template-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-create-dialog.tsx @@ -24,7 +24,7 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { useToast } from '@documenso/ui/primitives/use-toast'; type TemplateCreateDialogProps = { - teamId?: number; + teamId: number; templateRootPath: string; }; diff --git a/apps/remix/app/components/dialogs/template-move-dialog.tsx b/apps/remix/app/components/dialogs/template-move-dialog.tsx deleted file mode 100644 index f113317eb..000000000 --- a/apps/remix/app/components/dialogs/template-move-dialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { match } from 'ts-pattern'; - -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -type TemplateMoveDialogProps = { - templateId: number; - open: boolean; - onOpenChange: (_open: boolean) => void; - onMove?: ({ - templateId, - teamUrl, - }: { - templateId: number; - teamUrl: string; - }) => Promise | void; -}; - -export const TemplateMoveDialog = ({ - templateId, - open, - onOpenChange, - onMove, -}: TemplateMoveDialogProps) => { - const { toast } = useToast(); - const { _ } = useLingui(); - - const [selectedTeamId, setSelectedTeamId] = useState(null); - - const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); - - const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({ - onSuccess: async () => { - const team = teams?.find((team) => team.id === selectedTeamId); - - if (team) { - await onMove?.({ templateId, teamUrl: team.url }); - } - - toast({ - title: _(msg`Template moved`), - description: _(msg`The template has been successfully moved to the selected team.`), - duration: 5000, - }); - - onOpenChange(false); - }, - onError: (err) => { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with( - AppErrorCode.NOT_FOUND, - () => msg`Template not found or already associated with a team.`, - ) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`) - .otherwise(() => msg`An error occurred while moving the template.`); - - toast({ - title: _(msg`Error`), - description: _(errorMessage), - variant: 'destructive', - duration: 7500, - }); - }, - }); - - const handleOnMove = async () => { - if (!selectedTeamId) { - return; - } - - await moveTemplate({ templateId, teamId: selectedTeamId }); - }; - - return ( - - - - - Move Template to Team - - - Select a team to move this template to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 511ce04db..aa557132b 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -30,7 +30,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type TokenDeleteDialogProps = { token: Pick; @@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [isOpen, setIsOpen] = useState(false); diff --git a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx index f8c5c94d2..ce1109322 100644 --- a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; -const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true }); +const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true }); type TCreateWebhookFormSchema = z.infer; @@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [open, setOpen] = useState(false); @@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr eventTriggers, secret, webhookUrl, - teamId: team?.id, + teamId: team.id, }); setOpen(false); diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 5842f4fb7..6fb369577 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -30,7 +30,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type WebhookDeleteDialogProps = { webhook: Pick; @@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [open, setOpen] = useState(false); @@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const onSubmit = async () => { try { - await deleteWebhook({ id: webhook.id, teamId: team?.id }); + await deleteWebhook({ id: webhook.id, teamId: team.id }); toast({ title: _(msg`Webhook deleted`), diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx similarity index 78% rename from apps/remix/app/components/forms/team-branding-preferences-form.tsx rename to apps/remix/app/components/forms/branding-preferences-form.tsx index 5cc519960..61ffa1e5d 100644 --- a/apps/remix/app/components/forms/team-branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -1,17 +1,14 @@ import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; +import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; -import type { Team, TeamGlobalSettings } from '@prisma/client'; +import type { TeamGlobalSettings } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; -import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -25,12 +22,11 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { Switch } from '@documenso/ui/primitives/switch'; import { Textarea } from '@documenso/ui/primitives/textarea'; -import { useToast } from '@documenso/ui/primitives/use-toast'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; -const ZTeamBrandingPreferencesFormSchema = z.object({ +const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean(), brandingLogo: z .instanceof(File) @@ -44,74 +40,36 @@ const ZTeamBrandingPreferencesFormSchema = z.object({ brandingCompanyDetails: z.string().max(500).optional(), }); -type TTeamBrandingPreferencesFormSchema = z.infer; +export type TBrandingPreferencesFormSchema = z.infer; -export type TeamBrandingPreferencesFormProps = { - team: Team; - settings?: TeamGlobalSettings | null; +type SettingsSubset = Pick< + TeamGlobalSettings, + 'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails' +>; + +export type BrandingPreferencesFormProps = { + settings: SettingsSubset; + onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise; }; -export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) { - const { _ } = useLingui(); - const { toast } = useToast(); +export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPreferencesFormProps) { + const { t } = useLingui(); const [previewUrl, setPreviewUrl] = useState(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); - const { mutateAsync: updateTeamBrandingSettings } = - trpc.team.updateTeamBrandingSettings.useMutation(); - - const form = useForm({ + const form = useForm({ defaultValues: { brandingEnabled: settings?.brandingEnabled ?? false, brandingUrl: settings?.brandingUrl ?? '', brandingLogo: undefined, brandingCompanyDetails: settings?.brandingCompanyDetails ?? '', }, - resolver: zodResolver(ZTeamBrandingPreferencesFormSchema), + resolver: zodResolver(ZBrandingPreferencesFormSchema), }); const isBrandingEnabled = form.watch('brandingEnabled'); - const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => { - try { - const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; - - let uploadedBrandingLogo = settings?.brandingLogo; - - if (brandingLogo) { - uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); - } - - if (brandingLogo === null) { - uploadedBrandingLogo = ''; - } - - await updateTeamBrandingSettings({ - teamId: team.id, - settings: { - brandingEnabled, - brandingLogo: uploadedBrandingLogo, - brandingUrl, - brandingCompanyDetails, - }, - }); - - toast({ - title: _(msg`Branding preferences updated`), - description: _(msg`Your branding preferences have been updated`), - }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`We were unable to update your branding preferences at this time, please try again later`, - ), - variant: 'destructive', - }); - } - }; - useEffect(() => { if (settings?.brandingLogo) { const file = JSON.parse(settings.brandingLogo); @@ -140,13 +98,17 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref }; }, [previewUrl]); + // Todo: orgs remove + useEffect(() => { + console.log({ + errors: form.formState.errors, + }); + }, [form.formState.errors]); + return (
- -
+ +
) : (
- Please upload a logo + Please upload a logo + {!hasLoadedPreview && (
@@ -291,7 +254,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref