From 9d626473c8f0b83bb7c6b65f4507c670332f16b6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 27 Dec 2023 13:04:24 +1100 Subject: [PATCH] feat: wip --- .env.example | 2 + apps/web/process-env.d.ts | 1 + apps/web/public/static/add-user.png | Bin 0 -> 3361 bytes apps/web/public/static/mail-open.png | Bin 0 -> 3839 bytes .../app/(dashboard)/admin/users/[id]/page.tsx | 5 +- .../admin/users/[id]/role-combobox.tsx | 80 +++ .../[id]/document-page-component.tsx | 104 ++++ .../documents/[id]/edit-document.tsx | 4 +- .../app/(dashboard)/documents/[id]/page.tsx | 94 +--- .../documents/data-table-action-button.tsx | 8 +- .../documents/data-table-action-dropdown.tsx | 8 +- .../documents/data-table-sender-filter.tsx | 63 +++ .../app/(dashboard)/documents/data-table.tsx | 20 +- .../documents/documents-page-component.tsx | 154 ++++++ .../documents/duplicate-document-dialog.tsx | 6 +- .../src/app/(dashboard)/documents/page.tsx | 114 +---- .../(dashboard)/documents/upload-document.tsx | 23 +- apps/web/src/app/(dashboard)/layout.tsx | 9 +- .../billing/billing-portal-button.tsx | 12 +- .../teams/accept-team-invitation-button.tsx | 45 ++ .../app/(dashboard)/settings/teams/page.tsx | 97 ++++ .../settings/teams/team-invitations.tsx | 89 ++++ .../src/app/(signing)/sign/[token]/layout.tsx | 9 +- apps/web/src/app/(teams)/layout.tsx | 45 ++ .../t/[teamUrl]/documents/[id]/page.tsx | 20 + .../t/[teamUrl]/documents/[id]/sent/page.tsx | 18 + .../(teams)/t/[teamUrl]/documents/page.tsx | 24 + .../web/src/app/(teams)/t/[teamUrl]/error.tsx | 54 ++ .../src/app/(teams)/t/[teamUrl]/not-found.tsx | 32 ++ .../t/[teamUrl]/settings/billing/page.tsx | 85 ++++ .../(teams)/t/[teamUrl]/settings/layout.tsx | 54 ++ .../t/[teamUrl]/settings/members/page.tsx | 34 ++ .../app/(teams)/t/[teamUrl]/settings/page.tsx | 156 ++++++ .../settings/team-email-dropdown.tsx | 143 ++++++ .../settings/team-transfer-status.tsx | 106 ++++ .../src/app/(unauthenticated)/signin/page.tsx | 12 +- .../src/app/(unauthenticated)/signup/page.tsx | 12 +- .../team/invite/[token]/page.tsx | 119 +++++ .../team/verify/email/[token]/page.tsx | 81 +++ .../team/verify/transfer/[token]/page.tsx | 74 +++ .../components/(dashboard)/layout/header.tsx | 26 +- .../(dashboard)/layout/profile-dropdown.tsx | 254 ++++++---- .../settings/layout/desktop-nav.tsx | 17 +- .../(dashboard)/settings/layout/header.tsx | 25 + .../settings/layout/mobile-nav.tsx | 18 +- .../(teams)/dialogs/add-team-email-dialog.tsx | 188 +++++++ .../(teams)/dialogs/create-team-dialog.tsx | 244 +++++++++ .../(teams)/dialogs/delete-team-dialog.tsx | 160 ++++++ .../dialogs/delete-team-member-dialog.tsx | 112 ++++ .../dialogs/invite-team-member-dialog.tsx | 241 +++++++++ .../(teams)/dialogs/leave-team-dialog.tsx | 103 ++++ .../(teams)/dialogs/transfer-team-dialog.tsx | 237 +++++++++ .../dialogs/update-team-email-dialog.tsx | 165 ++++++ .../dialogs/update-team-member-dialog.tsx | 165 ++++++ .../(teams)/forms/update-team-form.tsx | 172 +++++++ .../(teams)/settings/layout/desktop-nav.tsx | 70 +++ .../(teams)/settings/layout/mobile-nav.tsx | 65 +++ .../team-billing-invoices-data-table.tsx | 159 ++++++ .../tables/team-member-invites-data-table.tsx | 203 ++++++++ .../tables/team-members-data-table.tsx | 206 ++++++++ .../tables/teams-member-page-data-table.tsx | 89 ++++ .../(teams)/tables/user-teams-data-table.tsx | 157 ++++++ .../tables/user-teams-page-data-table.tsx | 83 +++ .../tables/user-teams-pending-data-table.tsx | 188 +++++++ apps/web/src/components/forms/signin.tsx | 5 +- apps/web/src/components/forms/signup.tsx | 5 +- apps/web/src/middleware.ts | 32 ++ packages/ee/server-only/limits/server.ts | 1 + .../stripe/get-checkout-session.ts | 7 + .../ee/server-only/stripe/get-customer.ts | 8 + .../server-only/stripe/get-team-invoices.ts | 23 + .../stripe/transfer-team-subscription.ts | 96 ++++ .../update-subscription-item-quantity.ts | 28 + .../ee/server-only/stripe/webhook/handler.ts | 25 + .../stripe/webhook/on-subscription-updated.ts | 11 +- packages/email/static/add-user.png | Bin 0 -> 3361 bytes packages/email/static/mail-open.png | Bin 0 -> 3839 bytes .../template-components/template-image.tsx | 17 + packages/email/templates/confirm-email.tsx | 4 +- .../email/templates/confirm-team-email.tsx | 124 +++++ packages/email/templates/team-invite.tsx | 107 ++++ .../email/templates/team-transfer-request.tsx | 111 ++++ packages/lib/constants/app.ts | 5 + packages/lib/constants/teams.ts | 34 ++ packages/lib/errors/app-error.ts | 144 ++++++ .../server-only/document/create-document.ts | 36 +- .../server-only/document/find-documents.ts | 306 +++++++++-- .../document/get-document-by-id.ts | 18 + .../lib/server-only/document/get-stats.ts | 181 +++++-- .../field/set-fields-for-document.ts | 18 + .../recipient/set-recipients-for-document.ts | 18 + .../team/accept-team-invitation.ts | 60 +++ .../team/add-team-email-verification.ts | 133 +++++ .../team/create-team-checkout-session.ts | 51 ++ .../team/create-team-member-invites.ts | 157 ++++++ packages/lib/server-only/team/create-team.ts | 200 ++++++++ .../team/delete-team-email-verification.ts | 34 ++ .../lib/server-only/team/delete-team-email.ts | 46 ++ .../team/delete-team-invitations.ts | 47 ++ .../server-only/team/delete-team-members.ts | 73 +++ .../server-only/team/delete-team-pending.ts | 15 + .../team/delete-team-transfer-request.ts | 42 ++ packages/lib/server-only/team/delete-team.ts | 39 ++ .../server-only/team/find-team-invoices.ts | 48 ++ .../team/find-team-member-invites.ts | 88 ++++ .../lib/server-only/team/find-team-members.ts | 99 ++++ .../server-only/team/find-teams-pending.ts | 58 +++ packages/lib/server-only/team/find-teams.ts | 77 +++ .../team/get-team-email-by-email.ts | 22 + .../server-only/team/get-team-invitations.ts | 22 + .../lib/server-only/team/get-team-members.ts | 52 ++ packages/lib/server-only/team/get-teams.ts | 113 +++++ packages/lib/server-only/team/leave-team.ts | 27 + .../team/request-team-ownership-transfer.ts | 97 ++++ .../team/resend-team-email-verification.ts | 65 +++ .../team/resend-team-member-invitation.ts | 76 +++ .../team/transfer-team-ownership.ts | 86 ++++ .../lib/server-only/team/update-team-email.ts | 41 ++ .../server-only/team/update-team-member.ts | 50 ++ packages/lib/server-only/team/update-team.ts | 51 ++ packages/lib/server-only/user/create-user.ts | 95 +++- packages/lib/types/search-params.ts | 20 + packages/lib/utils/billing.ts | 26 + packages/lib/utils/params.ts | 17 + packages/lib/utils/teams.ts | 7 + packages/lib/utils/token-verification.ts | 21 + .../20231227015340_teamwip/migration.sql | 156 ++++++ packages/prisma/schema.prisma | 123 ++++- .../trpc/server/document-router/router.ts | 20 +- .../trpc/server/document-router/schema.ts | 1 + packages/trpc/server/router.ts | 4 +- packages/trpc/server/team-router/router.ts | 479 ++++++++++++++++++ packages/trpc/server/team-router/schema.ts | 170 +++++++ packages/tsconfig/process-env.d.ts | 1 + packages/ui/primitives/avatar.tsx | 35 +- packages/ui/primitives/badge.tsx | 3 +- packages/ui/primitives/combobox.tsx | 167 ++++-- packages/ui/primitives/data-table.tsx | 34 +- packages/ui/primitives/input.tsx | 39 +- turbo.json | 26 +- 140 files changed, 9604 insertions(+), 536 deletions(-) create mode 100644 apps/web/public/static/add-user.png create mode 100644 apps/web/public/static/mail-open.png create mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/role-combobox.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-component.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/documents-page-component.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/teams/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx create mode 100644 apps/web/src/app/(teams)/layout.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/sent/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/error.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx create mode 100644 apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx create mode 100644 apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx create mode 100644 apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx create mode 100644 apps/web/src/components/(dashboard)/settings/layout/header.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/add-team-email-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx create mode 100644 apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx create mode 100644 apps/web/src/components/(teams)/forms/update-team-form.tsx create mode 100644 apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx create mode 100644 apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx create mode 100644 apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx create mode 100644 apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx create mode 100644 apps/web/src/components/(teams)/tables/team-members-data-table.tsx create mode 100644 apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx create mode 100644 apps/web/src/components/(teams)/tables/user-teams-data-table.tsx create mode 100644 apps/web/src/components/(teams)/tables/user-teams-page-data-table.tsx create mode 100644 apps/web/src/components/(teams)/tables/user-teams-pending-data-table.tsx create mode 100644 packages/ee/server-only/stripe/get-team-invoices.ts create mode 100644 packages/ee/server-only/stripe/transfer-team-subscription.ts create mode 100644 packages/ee/server-only/stripe/update-subscription-item-quantity.ts create mode 100644 packages/email/static/add-user.png create mode 100644 packages/email/static/mail-open.png create mode 100644 packages/email/template-components/template-image.tsx create mode 100644 packages/email/templates/confirm-team-email.tsx create mode 100644 packages/email/templates/team-invite.tsx create mode 100644 packages/email/templates/team-transfer-request.tsx create mode 100644 packages/lib/constants/teams.ts create mode 100644 packages/lib/errors/app-error.ts create mode 100644 packages/lib/server-only/team/accept-team-invitation.ts create mode 100644 packages/lib/server-only/team/add-team-email-verification.ts create mode 100644 packages/lib/server-only/team/create-team-checkout-session.ts create mode 100644 packages/lib/server-only/team/create-team-member-invites.ts create mode 100644 packages/lib/server-only/team/create-team.ts create mode 100644 packages/lib/server-only/team/delete-team-email-verification.ts create mode 100644 packages/lib/server-only/team/delete-team-email.ts create mode 100644 packages/lib/server-only/team/delete-team-invitations.ts create mode 100644 packages/lib/server-only/team/delete-team-members.ts create mode 100644 packages/lib/server-only/team/delete-team-pending.ts create mode 100644 packages/lib/server-only/team/delete-team-transfer-request.ts create mode 100644 packages/lib/server-only/team/delete-team.ts create mode 100644 packages/lib/server-only/team/find-team-invoices.ts create mode 100644 packages/lib/server-only/team/find-team-member-invites.ts create mode 100644 packages/lib/server-only/team/find-team-members.ts create mode 100644 packages/lib/server-only/team/find-teams-pending.ts create mode 100644 packages/lib/server-only/team/find-teams.ts create mode 100644 packages/lib/server-only/team/get-team-email-by-email.ts create mode 100644 packages/lib/server-only/team/get-team-invitations.ts create mode 100644 packages/lib/server-only/team/get-team-members.ts create mode 100644 packages/lib/server-only/team/get-teams.ts create mode 100644 packages/lib/server-only/team/leave-team.ts create mode 100644 packages/lib/server-only/team/request-team-ownership-transfer.ts create mode 100644 packages/lib/server-only/team/resend-team-email-verification.ts create mode 100644 packages/lib/server-only/team/resend-team-member-invitation.ts create mode 100644 packages/lib/server-only/team/transfer-team-ownership.ts create mode 100644 packages/lib/server-only/team/update-team-email.ts create mode 100644 packages/lib/server-only/team/update-team-member.ts create mode 100644 packages/lib/server-only/team/update-team.ts create mode 100644 packages/lib/types/search-params.ts create mode 100644 packages/lib/utils/billing.ts create mode 100644 packages/lib/utils/params.ts create mode 100644 packages/lib/utils/teams.ts create mode 100644 packages/lib/utils/token-verification.ts create mode 100644 packages/prisma/migrations/20231227015340_teamwip/migration.sql create mode 100644 packages/trpc/server/team-router/router.ts create mode 100644 packages/trpc/server/team-router/schema.ts diff --git a/.env.example b/.env.example index d188894de..c1e4fa588 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index 0c00cb4c1..3005881f7 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -6,6 +6,7 @@ declare namespace NodeJS { NEXT_PRIVATE_DATABASE_URL: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; + NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string; NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; diff --git a/apps/web/public/static/add-user.png b/apps/web/public/static/add-user.png new file mode 100644 index 0000000000000000000000000000000000000000..abd337ceb70d306c70f31d07f1c74e2ca34399be GIT binary patch literal 3361 zcmV++4c_vJP)Gv`oIefP1`wEzQaL0KGNQ&`y?Uh=78W#-p`*s);-W_U zGcz;iRRRbdVk;eN}Y;suHZD8npcaxE1d`GfO#C`k`2XJ3-i082610B20TR31@FMaS~g zQ2n%>OiOwhAenH=G)1|T;zU(6q<3Os!Yh?ZBXrnj3OF?w?a@cZ9`b|AoI8>eBbe3w z`1rV{f*rztIUFe(LL2ogqL9@TWB63kXP#&vne&=xGTl}U0F_VW5Q$sS#)4i{83OWy%E!lf zlHx?(gKc$;0>$`j7Jz&pKd|m`Z1%aaw!Av7xBRfIqRoHwL66e$fA;R(8_A%4v%vHo zOa`DleSh%a!70vPT<`yBpq2zj^`PAL`Uf=l{y*$#*iNMhOx6QPitqEYq9R7AqA}Jz zGc)sxN(o5~Hk8#LwxdUnE>a;*fBN)Er3q9pdd8`tp`o{`Qkz94!K9%u)%TQ235mRj zaz&Ab484Qif{LWRV3h_Sy&#zOzd?FY@q{Ja8S8!`?Abx#p)z@9u)(``@7``|ix=L# zd#BR?=6)f4>NXul-Z)=6VBuLSpfrlKJym&zRdD`(Y;0`Pix)2@W@l&hmoHy*l0bTx zfQQ28if5CoB0|~(C(;>g43`aUIvF4TBC&aY(X~lZ)v=L`co9F+IdtgIgv^($aCsY1N^CTVyRzhx8)VlMN$i+8pD>_kq#BI8i6eHPM4%eMKcdpRW)AMg_W?f=yWO5#iHhRDI_Y?=Z zMgL0H`ZT3czQ;DMgnbaH2!mkSojGvez_3aIH9f*a{lF&93RTG3n+f1ObwZpaV*d`J zF(`p4HXxeG#yP};UMXxO@rtC@?;WG_)3l$wa^=b-r^E5GF%XcCn$L>9`x>*2$PU3iTu6bk<$wK{p>!iD4X z&?jm86Ll|+hD5zcao;DB|4&ifrm#~Wl;ZLbmoex(*c1PV_R*@^RR?(+_<6bYb>Qah|le5^QA-PRfj)?xkPc}2PtlT}$gmRYu?kBJ1iT5s%yV3JLx&_PC&B$Y{4 zIhuFXHqh(dS#ellgxSM-s3CBt*I^@Wq@GJ0>Q*Q zLpG%PhrBo{jeW`vmQGSU=OL-^Ntu@8vwFburj7IPTzm>3HVH;M+uPd<*ZiX(7Owf* z1?*RvaDQcGklM z4f7d*QF`72j@v=o^=ev0%WE*MI)Pxoc!@v8bGNgurd#&WVTSoe@@ib!Fa#Iy6SM{@YiQQntX!Fg zSEN;ZO)T$iFu=}SXgqoM(9n?IKb_@;Y)yHa<+;e%N~Kakbs(^EU#~lF$!uHPl($(o zJ#`Jj$46!hq^W7Ma;SIoXgM5}@hRbuRD`wNBW;LRrfa!c3UaL^osxEmbLB9_fbS~ll-)B$ zf_gg9g9<^3LM#S2F4LA@D_zAemaGJV_ze!ybbuqYpmddRre^jkEKQEfzOLGt)J9Q_ zps@d8(hx>+(Q?0Ejl7`i*7?8>b=joam8)Y325hNRRY(XTL|H`0LbVAF(-1+Vif~G) zP!D-w6+~HiQ|+jhW6P*6d(Gd_8Sjbf8-nrmAVV(beAsZEWdH?T*Rlz+9j)m~r)#+# zIg7Lc$O5gBhF8b`y6)E*9;bD1h#yV@@{i847CQ}2f*o=R`C4S33%ft;dQcqI?zh7P r9fzHBaBy&NaBy&NaBy&NsEOYJQ|7+=Yxnzq00000NkvXXu0mjfB)pO| literal 0 HcmV?d00001 diff --git a/apps/web/public/static/mail-open.png b/apps/web/public/static/mail-open.png new file mode 100644 index 0000000000000000000000000000000000000000..306313b03f900dde2c71456df73ad92b898f04db GIT binary patch literal 3839 zcmVAiL9)^TB|4`8*oZ{L3N&Ye5<)YOz61cb-7D1)+Mk+G;;^uWMCjx_sl<;s=0RjXDB za?PR2k*gLDA3hXh)decEzrDS^APm`ptng*B!jbXual2ATWLWYDy+C^9Dl7nEEO~F}#qV2kN+40m!3c!t$xF4@xy*fv)*u(|c*P1nJ#M4hdjT`7= zsyX?Ah(Mh@FAnG~Ft{WGPC^;#Y90a>;9J}6JcIGV@Zn3$!xsxjSl?@v< zh{nc7ktTC5;d$8(L)REHF(a|v~GEAlgPRk_+QBN*Avo1PKmmEYrx$MkY@U?5# z){-lJH7hPTh^)KpObJ{l6grkKUw$cTE;)!SyX?%=Alw{@dnJo5IfyK~?9?sT8I;dA zH8s6l4=y9P~6@p|sKNSa!@ z?AR@MsYS`vlC;Z?Rp6x>C09$5E;}{_mU5I_ElIlUsx8>KTMhAGTa*m2lUPsGV@jM%>zi zI&W>2-<)}KWMm}tS7<^jg~uFFI+QbQZhBDt3u+#7QPg5QcPt6%)Ji`FukdEB4#~qS9R=x4~yhjfx1a$VCW4 zdQ4AG%ZQo+t&(y2S#y)u3W)2zLlDKmMWEM;qG@5M9ZK(({NK?B!di+kmf@)q#N|Ts z`$^l&lnVl8h^?bim|PTN~ruxZmKX}LJi=;)|? z&CbpWLo%Rl;g0X$zc1??F%UEW)CNlf?CR>W{MzE6M!IDF)X7mKzb_ZUis3GBP^3+8 z*)-rF4Dms!kbJ>4BL{}Zm6|G=*nVxWuZw2i4nKe5#ECg}(QY6vESMua7T?L)3<1Desr#`MM4`#^8((J^ ztqAMsqC*Mn?6l}5FvCMze4-|$^2eR}1X(QufwyegBGq?Xv>n++R{~BDQ65g3+h>zf z=|Sp)ww8k^1W-|%Hf};4L)2QaZ;*v|Zi}P9f({Q4F9;fpHJ1lbuvS=Y)Il6YT=}op zcD{Kq{&2n(6`b(KjT>cKjL)!UxNmkxOA*8=r~`=0({d`E6{7~hm3DN<;EDvcqgt>E zEEyMpceo>@RCn**T~Zw+Gcz+%*s-xOSqDgc(61jy-+?Z}Q3**>k;bgUBK~X_BZGSTXKijvP4eF9B(8`PZhLUC zoKhheTP!J+JN3cvDy}z)v2?#tq_8{Sj%-Z0vy*_@CH(Sk)BVr%A3ZKrXplP!*7e;SMt=eEe zc$5mjnrksig;;>Q7%Lt1i5qpw4yb0@Y*k&9al?XgN|od&RjpH>O5m{qzipZVb|CK( z^mS5lEwTUyH9YYT?-*N+)3W>~yk!M-tiVs?SZ8$9nz&P{G*h1_aFB)O(;4lHKBL2S zW>s=y9>-uq+{86XRq523G~wu(VGgXK0s z(`K^>Te0T*JPXKb5akEA!HNY}JlJ4`3B|JrM8mWgw9#8Rq$R=UL1%Y2(G446r1O-~5 zf-Imuqr;FcYEKrZbjps!k8h~Qo~(LiWO7Jc8R`+e4{nHoM+GrCJ3q?R(zH^vugr-J z);968%SUsxs8ID_gRObWjv*OFrUu1dKmQ{Mdp*?lB+FTAR-An8#qM6uZG!fJKPC@O zo_`En5SRM8OV--4W`}ro-B-oB<~C`yU}S1Y+$M|u^!`WcO)zSUhOS?H{V%6<`wg6Q z$9~=joE#Vb*Db{b5c6QiaT54B-!gFD?|=T57$vKhSXTb^z2~~z+XY%McURWiaACc0 zWw3|b@>}xr-u46Hz%$216fWF6E3F?`jn?s>?>YC5y52<5DS|fl?SFTX6%JJ-E`u8c zHwrPjys>e2e$RUaUgwzytJ6WuphGfT>U&>`yS8~_#o~g49mhl~?Sp8YDb97O>wG&p zcwRInz6(RFzq-WQCC3G2B{qL^+SiJk$uVuRA z<~d5MfMpeT@iIRP=IJ0t-2_0}3t)LV(3`d3etp$XB zlp(#YPEAea%rhsGXDx|`zzIE9Z-3GyEiFh~FZ^8e{6i3(bcw9@Z~Pq~L-nxbj&M(u z0$VPN99G=XC;KY>=5XMY9cFB7?1Sm)=}|mA)=)iPaQc@A#tYi@=(1#y!&)v!=G1+4 zm6!PmTv8XQ($1YbM`vbczRi!oHB`qlPG9qtq05x|=shDu{?QA65Z{0PSE1bU%_jp8 zm-&NVnpBFOo$tVivuDqqCy{@PC({{`s{<(wtP^?dzi8~&iE4x-xxzea9#_K1AE)XuDm)5Q%%!{o?$<@5NN{aVdIkZP^&AEG!q|Vpjk|xTj3j|j0?Q3GBBNc#>*&#=R(H2_5wde+-@biTPfw3c%S#2Y9&P#c>({Lv zJ9fx-EOT$8mgV*2DdaM&Yinz>d0SqGWn7j&tL5GB@!jX4?2Dh1T`I3TSHJ(=%bofu zog*I$%W~gs*tNX5c#-~Tg~)rh9276Ky(rzd4p=S5fd6^>g8U3|?FO;`^}XF!-P=YI znA>FCR9Y6B;R@vCPDR?z&0wqByB}z+o9M^o^O}cWD>s?#Uha2pUf1B>UOk3$P4bZm z%Kx7GQO}$Aaqir?qS6&C%S`V5Pp;vq*^9K>9R7#6=*Dbcv7I_T<}Dh%U%g{D6Um>UU7pud}EZp^B+qr0$}quRvJ}_Qokl|1|7~b!JpbCB_j*L!u#2{x2Zg&|Vm>NY|CbM}=4DvV>+(;2 z!J4;Ou+F_NEaMlK;g?dT?AWg3FL-yKhx)&JJM|pY{d-(z=~~^_p*xm(?M~U!OFf3F zLE^Z@byt*EMf!c#RVrVoN9C7(g5F&jVfX1(v0eh}#P+%2`Lo~~KvyT~I^D6Ab+yB* z0^K5r^1Ib7st~S<^xL;;mFm8AH;Pbh0->>D*9{?rhlLBhg_iA0vrnyvy7Tedzq2eI zysSc$SFRA{ccpNbb!}6!P`BVhV}-hkbCngk{@VH6bpLP2WTCcNcr9MRJ!un7W zQ&f-6HCaB}dTkT5j?YAnOC_e z!HF8=d7ZDiPxral0Y8QjbdF)R_1Q)Z)_l(s{2yZxW!--FndATf002ovPDHLkV1ftJ BjST<* literal 0 HcmV?d00001 diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 3baf5d63b..d430eee1b 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -9,7 +9,6 @@ import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { Button } from '@documenso/ui/primitives/button'; -import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, FormControl, @@ -21,6 +20,8 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { RoleCombobox } from './role-combobox'; + const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); type TUserFormSchema = z.infer; @@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
Roles - onChange(values)} /> diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/role-combobox.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/role-combobox.tsx new file mode 100644 index 000000000..cad6085fa --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/role-combobox.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; + +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Role } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +type RoleComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +export const RoleCombobox = ({ listValues, onChange }: RoleComboboxProps) => { + const [open, setOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + const dbRoles = Object.values(Role); + + React.useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allRoles = [...new Set([...dbRoles, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setOpen(false); + }; + + return ( + + + + + + + + No value found. + + {allRoles.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-component.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-component.tsx new file mode 100644 index 000000000..652c50b8d --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-component.tsx @@ -0,0 +1,104 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft, Users2 } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import type { Team } from '@documenso/prisma/client'; +import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; + +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; + +export type DocumentPageComponentProps = { + params: { + id: string; + }; + team?: Team; +}; + +export default async function DocumentPageComponent({ params, team }: DocumentPageComponentProps) { + const { id } = params; + + const documentId = Number(id); + + const documentRootPath = team ? `/t/${team.url}/documents` : '/documents'; + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const document = await getDocumentById({ + id: documentId, + userId: user.id, + }).catch(() => null); + + if (!document || !document.documentData) { + redirect(documentRootPath); + } + + const { documentData } = document; + + const [recipients, fields] = await Promise.all([ + await getRecipientsForDocument({ + documentId, + userId: user.id, + }), + await getFieldsForDocument({ + documentId, + userId: user.id, + }), + ]); + + return ( +
+ + + Documents + + +

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + {recipients.length} Recipient(s) + +
+ )} +
+ + {document.status !== InternalDocumentStatus.COMPLETED && ( + + )} + + {document.status === InternalDocumentStatus.COMPLETED && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index ffce3bd6c..b83d86081 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -31,6 +31,7 @@ export type EditDocumentFormProps = { recipients: Recipient[]; fields: Field[]; documentData: DocumentData; + documentRootPath: string; }; type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject'; @@ -43,6 +44,7 @@ export const EditDocumentForm = ({ fields, user: _user, documentData, + documentRootPath, }: EditDocumentFormProps) => { const { toast } = useToast(); const router = useRouter(); @@ -162,7 +164,7 @@ export const EditDocumentForm = ({ duration: 5000, }); - router.push('/documents'); + router.push(documentRootPath); } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index b26b6308c..1dd14c076 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -1,18 +1,4 @@ -import Link from 'next/link'; -import { redirect } from 'next/navigation'; - -import { ChevronLeft, Users2 } from 'lucide-react'; - -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; -import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; - -import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { DocumentStatus } from '~/components/formatter/document-status'; +import DocumentPageComponent from './document-page-component'; export type DocumentPageProps = { params: { @@ -20,80 +6,6 @@ export type DocumentPageProps = { }; }; -export default async function DocumentPage({ params }: DocumentPageProps) { - const { id } = params; - - const documentId = Number(id); - - if (!documentId || Number.isNaN(documentId)) { - redirect('/documents'); - } - - const { user } = await getRequiredServerComponentSession(); - - const document = await getDocumentById({ - id: documentId, - userId: user.id, - }).catch(() => null); - - if (!document || !document.documentData) { - redirect('/documents'); - } - - const { documentData } = document; - - const [recipients, fields] = await Promise.all([ - getRecipientsForDocument({ - documentId, - userId: user.id, - }), - getFieldsForDocument({ - documentId, - userId: user.id, - }), - ]); - - return ( -
- - - Documents - - -

- {document.title} -

- -
- - - {recipients.length > 0 && ( -
- - - - {recipients.length} Recipient(s) - -
- )} -
- - {document.status !== InternalDocumentStatus.COMPLETED && ( - - )} - - {document.status === InternalDocumentStatus.COMPLETED && ( -
- -
- )} -
- ); +export default function DocumentPage({ params }: DocumentPageProps) { + return ; } diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 54a8f6184..2668b6cc8 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -19,9 +19,10 @@ export type DataTableActionButtonProps = { User: Pick; Recipient: Recipient[]; }; + teamUrl?: string; }; -export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { +export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonProps) => { const { data: session } = useSession(); const { toast } = useToast(); @@ -38,6 +39,9 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isComplete = row.status === DocumentStatus.COMPLETED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + // Todo: Teams - Extract calculation. + const documentsPath = teamUrl ? `/t/${teamUrl}/documents` : '/documents'; + const onDownloadClick = async () => { try { let document: DocumentWithData | null = null; @@ -92,7 +96,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { }) .with({ isOwner: true, isDraft: true }, () => ( ); diff --git a/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx new file mode 100644 index 000000000..8aa81653d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AcceptTeamInvitationButtonProps = { + teamId: number; +}; + +export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => { + const { toast } = useToast(); + + const { + mutateAsync: acceptTeamInvitation, + isLoading, + isSuccess, + } = trpc.team.acceptTeamInvitation.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Accepted team invitation', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to join this team at this time.', + }); + }, + }); + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/teams/page.tsx b/apps/web/src/app/(dashboard)/settings/teams/page.tsx new file mode 100644 index 000000000..282a516fe --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/page.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { AnimatePresence, motion } from 'framer-motion'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import SettingsHeader from '~/components/(dashboard)/settings/layout/header'; +import CreateTeamDialog from '~/components/(teams)/dialogs/create-team-dialog'; +import UserTeamsPageDataTable from '~/components/(teams)/tables/user-teams-page-data-table'; + +import { TeamInvitations } from './team-invitations'; + +export default function TeamsSettingsPage() { + const { toast } = useToast(); + + const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery(); + + const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = + trpc.team.deleteTeamEmail.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully revoked access.', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to revoke access. Please try again or contact support.', + }); + }, + }); + + return ( +
+ + + + + + + + {teamEmail && ( + +
+
+

Team email

+ +

+ Your email is currently being used by team{' '} + {teamEmail.team.name} ({teamEmail.team.url} + ). +

+ +

+ They have permission on your behalf to: +

+ +
    +
  • Display your name and email in documents
  • +
  • View all documents sent to your account
  • +
+
+ + {/* Todo: Teams - Add 'are you sure'. */} + +
+
+ )} +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx new file mode 100644 index 000000000..1d8b28757 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { BellIcon } from 'lucide-react'; + +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; + +import { AcceptTeamInvitationButton } from './accept-team-invitation-button'; + +export const TeamInvitations = () => { + const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery(); + + return ( + + {data && data.length > 0 && !isInitialLoading && ( + + {/* Todo: Teams - Extract into `Alerts` component? */} + + +
+ You have {data.length} pending team invitation + {data.length > 1 ? 's' : ''}. +
+ + + + + + + + + Pending invitations + + + You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}. + + + +
    + {data.map((invitation) => ( +
  • + + {invitation.team.name} + + } + secondaryText={formatTeamUrl(invitation.team.url)} + rightSideComponent={ +
    + +
    + } + /> +
  • + ))} +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx index cfec41cdf..ce9ecbcea 100644 --- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { GetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams'; import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; import { NextAuthProvider } from '~/providers/next-auth'; @@ -12,10 +13,16 @@ export type SigningLayoutProps = { export default async function SigningLayout({ children }: SigningLayoutProps) { const { user, session } = await getServerComponentSession(); + let teams: GetTeamsResponse = []; + + if (user && session) { + teams = await getTeams({ userId: user.id }); + } + return (
- {user && } + {user && }
{children}
diff --git a/apps/web/src/app/(teams)/layout.tsx b/apps/web/src/app/(teams)/layout.tsx new file mode 100644 index 000000000..8df7a9071 --- /dev/null +++ b/apps/web/src/app/(teams)/layout.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { getServerSession } from 'next-auth'; + +import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; +import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; + +import { Header } from '~/components/(dashboard)/layout/header'; +import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; +import { NextAuthProvider } from '~/providers/next-auth'; + +export type AuthenticatedDashboardLayoutProps = { + children: React.ReactNode; +}; + +export default async function AuthenticatedTeamsDashboardLayout({ + children, +}: AuthenticatedDashboardLayoutProps) { + const session = await getServerSession(NEXT_AUTH_OPTIONS); + + if (!session) { + redirect('/signin'); + } + + const [{ user }, teams] = await Promise.all([ + getRequiredServerComponentSession(), + getTeams({ userId: session.user.id }), + ]); + + return ( + + +
+ +
{children}
+ + + + + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx new file mode 100644 index 000000000..50ccd8e72 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx @@ -0,0 +1,20 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; + +import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-component'; + +export type DocumentPageProps = { + params: { + id: string; + teamUrl: string; + }; +}; + +export default async function DocumentPage({ params }: DocumentPageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/sent/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/sent/page.tsx new file mode 100644 index 000000000..1b27f643d --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/sent/page.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + +export default function DocumentSentPage() { + return ( +
+ + + Documents + + +

+ Loading Document... +

+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx new file mode 100644 index 000000000..a42386cf5 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx @@ -0,0 +1,24 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; + +import type { DocumentsPageComponentProps } from '~/app/(dashboard)/documents/documents-page-component'; +import DocumentsPageComponent from '~/app/(dashboard)/documents/documents-page-component'; + +export type TeamsDocumentPageProps = { + params: { + teamUrl: string; + }; + searchParams?: DocumentsPageComponentProps['searchParams']; +}; + +export default async function TeamsDocumentPage({ + params, + searchParams = {}, +}: TeamsDocumentPageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx new file mode 100644 index 000000000..1e1eb9921 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx @@ -0,0 +1,54 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { AppErrorCode } from '@documenso/lib/errors/app-error'; +import { Button } from '@documenso/ui/primitives/button'; + +type ErrorProps = { + error: Error & { digest?: string }; +}; + +export default function ErrorPage({ error }: ErrorProps) { + const router = useRouter(); + + let errorMessage = 'Unknown error'; + let errorDetails = ''; + + if (error.message === AppErrorCode.UNAUTHORIZED) { + errorMessage = 'Unauthorized'; + errorDetails = 'You are not authorized to view this page.'; + } + + return ( +
+
+

{errorMessage}

+ +

Oops! Something went wrong.

+ +

{errorDetails}

+ +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx new file mode 100644 index 000000000..35962e264 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx @@ -0,0 +1,32 @@ +'use client'; + +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export default function NotFound() { + return ( +
+
+

404 Team not found

+ +

Oops! Something went wrong.

+ +

+ The team you are looking for may have been removed, renamed or may have never existed. +

+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx new file mode 100644 index 000000000..5dab1c85f --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx @@ -0,0 +1,85 @@ +import { DateTime } from 'luxon'; +import type Stripe from 'stripe'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { stripe } from '@documenso/lib/server-only/stripe'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { BillingPortalButton } from '~/app/(dashboard)/settings/billing/billing-portal-button'; +import SettingsHeader from '~/components/(dashboard)/settings/layout/header'; +import TeamBillingInvoicesDataTable from '~/components/(teams)/tables/team-billing-invoices-data-table'; + +export type TeamsSettingsBillingPageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) { + const { teamUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + const isUserOwnerOfTeam = team.ownerUserId === session.user.id; + + let teamSubscription: Stripe.Subscription | null = null; + + if (team.subscriptionId) { + teamSubscription = await stripe.subscriptions.retrieve(team.subscriptionId); + } + + const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => { + if (!subscription) { + return 'No payment required'; + } + + const numberOfSeats = subscription.items.data[0].quantity ?? 0; + + const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member'; + + const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat( + 'LLL dd, yyyy', + ); + + return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`; + }; + + return ( +
+ + + + +
+

+ Current plan: {teamSubscription ? 'Team' : 'Community Team'} +

+ +

+ {formatTeamSubscriptionDetails(teamSubscription)} +

+
+ + {teamSubscription && ( +
+ +
+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx new file mode 100644 index 000000000..b93c76c1e --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { notFound } from 'next/navigation'; + +import { canExecuteTeamAction } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; + +import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav'; +import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav'; + +export type DashboardSettingsLayoutProps = { + children: React.ReactNode; + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsLayout({ + children, + params: { teamUrl }, +}: DashboardSettingsLayoutProps) { + const session = await getRequiredServerComponentSession(); + + try { + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) { + throw new Error(AppErrorCode.UNAUTHORIZED); + } + } catch (e) { + const error = AppError.parseError(e); + + if (error.code === 'P2025') { + notFound(); + } + + throw e; + } + + return ( +
+

Team Settings

+ +
+ + + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx new file mode 100644 index 000000000..65aa3954a --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx @@ -0,0 +1,34 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; + +import SettingsHeader from '~/components/(dashboard)/settings/layout/header'; +import InviteTeamMembersDialog from '~/components/(teams)/dialogs/invite-team-member-dialog'; +import TeamsMemberPageDataTable from '~/components/(teams)/tables/teams-member-page-data-table'; + +export type TeamsSettingsMembersPageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) { + const { teamUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + return ( +
+ + + + + +
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx new file mode 100644 index 000000000..e910d5594 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -0,0 +1,156 @@ +import { CheckCircle2, Clock } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; +import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; + +import SettingsHeader from '~/components/(dashboard)/settings/layout/header'; +import AddTeamEmailDialog from '~/components/(teams)/dialogs/add-team-email-dialog'; +import DeleteTeamDialog from '~/components/(teams)/dialogs/delete-team-dialog'; +import TransferTeamDialog from '~/components/(teams)/dialogs/transfer-team-dialog'; +import UpdateTeamForm from '~/components/(teams)/forms/update-team-form'; + +import TeamEmailDropdown from './team-email-dropdown'; +import { TeamTransferStatus } from './team-transfer-status'; + +export type TeamsSettingsPageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) { + const { teamUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + const isTransferVerificationExpired = + !team.transferVerification || isTokenExpired(team.transferVerification.expiresAt); + + return ( +
+ + + + + + +
+ {(team.teamEmail || team.emailVerification) && ( +
+

Team email

+ +

+ You can view documents associated with this email and use this identity when sending + documents. +

+ +
+ +
+ + {team.teamEmail?.name || team.emailVerification?.name} + + } + secondaryText={ + + {team.teamEmail?.email || team.emailVerification?.email} + + } + /> + +
+
+ {team.teamEmail ? ( + <> + + Active + + ) : team.emailVerification && team.emailVerification.expiresAt < new Date() ? ( + <> + + Expired + + ) : ( + team.emailVerification && ( + <> + + Awaiting email confirmation + + ) + )} +
+ + +
+
+
+ )} + + {!team.teamEmail && !team.emailVerification && ( +
+
+

Team email

+ +
    +
  • Display this name and email when sending documents
  • +
  • View documents associated with this email
  • +
+
+ + +
+ )} + + {team.ownerUserId === session.user.id && ( + <> + {isTransferVerificationExpired && ( +
+
+

Transfer team

+ +

+ Transfer the ownership of the team to another team member. +

+
+ + +
+ )} + +
+
+

Delete team

+ +

+ This team, and any associated data excluding billing invoices will be permanently + deleted. +

+
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx new file mode 100644 index 000000000..c545e432a --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react'; + +import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams'; +import { trpc } from '@documenso/trpc/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import UpdateTeamEmailDialog from '~/components/(teams)/dialogs/update-team-email-dialog'; + +export type TeamsSettingsPageProps = { + team: Awaited>; +}; + +export default function TeamEmailDropdown({ team }: TeamsSettingsPageProps) { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } = + trpc.team.resendTeamEmailVerification.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Email verification has been resent', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to resend verification at this time. Please try again.', + }); + }, + }); + + const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = + trpc.team.deleteTeamEmail.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Team email has been removed', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to remove team email at this time. Please try again.', + }); + }, + }); + + const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } = + trpc.team.deleteTeamEmailVerification.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Email verification has been removed', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to remove email verification at this time. Please try again.', + }); + }, + }); + + const onRemove = async () => { + if (team.teamEmail) { + await deleteTeamEmail({ teamId: team.id }); + } + + if (team.emailVerification) { + await deleteTeamEmailVerification({ teamId: team.id }); + } + + router.refresh(); + }; + + return ( + + + + + + + {!team.teamEmail && team.emailVerification && ( + { + e.preventDefault(); + void resendEmailVerification({ teamId: team.id }); + }} + > + {isResendingEmailVerification ? ( + + ) : ( + + )} + Resend verification + + )} + + {team.teamEmail && ( + e.preventDefault()}> + + Edit + + } + /> + )} + + onRemove()} + > + + Remove + + + + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx new file mode 100644 index 000000000..4ef4002e5 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { AnimatePresence, motion } from 'framer-motion'; + +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import type { TeamTransferVerification } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamTransferStatusProps = { + className?: string; + teamId: number; + transferVerification: TeamTransferVerification | null; +}; + +export const TeamTransferStatus = ({ + className, + teamId, + transferVerification, +}: TeamTransferStatusProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt); + + const { mutateAsync: deleteTeamTransferRequest, isLoading } = + trpc.team.deleteTeamTransferRequest.useMutation({ + onSuccess: () => { + if (!isExpired) { + toast({ + title: 'Success', + description: 'The team transfer invitation has been successfully deleted.', + duration: 5000, + }); + } + + router.refresh(); + }, + onError: () => { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.', + }); + }, + }); + + return ( + + {transferVerification && ( + +
+

+ {isExpired ? 'Team transfer request expired' : 'Team transfer in progress'} +

+ + {isExpired ? ( +

+ The team transfer request to {transferVerification.name} has + expired. +

+ ) : ( +
+

+ A request to transfer the ownership of this team has been sent to{' '} + {transferVerification.name} +

+ +

If they accept this request, the team will be transferred to their account.

+
+ )} +
+ + +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 0b0333b65..6170bd073 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -2,7 +2,15 @@ import Link from 'next/link'; import { SignInForm } from '~/components/forms/signin'; -export default function SignInPage() { +type SignInPageProps = { + searchParams: { + email?: string; + }; +}; + +export default function SignInPage({ searchParams }: SignInPageProps) { + const email = typeof searchParams.email === 'string' ? searchParams.email : undefined; + return (

Sign in to your account

@@ -11,7 +19,7 @@ export default function SignInPage() { Welcome back, we are lucky to have you.

- + {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index 353716d9b..8ddb3e039 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -3,11 +3,19 @@ import { redirect } from 'next/navigation'; import { SignUpForm } from '~/components/forms/signup'; -export default function SignUpPage() { +type SignUpPageProps = { + searchParams: { + email?: string; + }; +}; + +export default function SignUpPage({ searchParams }: SignUpPageProps) { if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { redirect('/signin'); } + const email = typeof searchParams.email === 'string' ? searchParams.email : undefined; + return (

Create a new account

@@ -17,7 +25,7 @@ export default function SignUpPage() { signing is within your grasp.

- +

Already have an account?{' '} diff --git a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx new file mode 100644 index 000000000..7c4893bbe --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx @@ -0,0 +1,119 @@ +import Link from 'next/link'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation'; +import { getTeamById } from '@documenso/lib/server-only/team/get-teams'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type AcceptInvitationPageProps = { + params: { + token: string; + }; +}; + +export default async function AcceptInvitationPage({ + params: { token }, +}: AcceptInvitationPageProps) { + const session = await getServerComponentSession(); + + const teamMemberInvite = await prisma.teamMemberInvite.findUnique({ + where: { + token, + }, + }); + + if (!teamMemberInvite) { + return ( +

+

Invalid token

+ +

+ This token is invalid or has expired. Please contact your team for a new invitation. +

+ + +
+ ); + } + + const team = await getTeamById({ teamId: teamMemberInvite.teamId }); + + const user = await prisma.user.findFirst({ + where: { + email: { + equals: teamMemberInvite.email, + mode: 'insensitive', + }, + }, + }); + + // Directly convert the team member invite to a team member if they already have an account. + if (user) { + await acceptTeamInvitation({ userId: user.id, teamId: team.id }); + } + + // Set the team invite status to accepted, which is checked during user creation + // to determine if we should add the user to the team at that time. + if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) { + await prisma.teamMemberInvite.update({ + where: { + id: teamMemberInvite.id, + }, + data: { + status: TeamMemberInviteStatus.ACCEPTED, + }, + }); + } + + const isSessionUserTheInvitedUser = user && user.id === session.user?.id; + + if (!user) { + return ( +
+

Team invitation

+ +

+ You have been invited by {team.name} to join their team. +

+ +

+ To accept this invitation you must create an account. +

+ + +
+ ); + } + + return ( +
+

Invitation accepted!

+ +

+ You have accepted an invitation from {team.name} to join their team. +

+ + {user && !isSessionUserTheInvitedUser && ( + + )} + + {isSessionUserTheInvitedUser && ( + + )} +
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx new file mode 100644 index 000000000..617dc0cad --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx @@ -0,0 +1,81 @@ +import Link from 'next/link'; + +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +type VerifyTeamEmailPageProps = { + params: { + token: string; + }; +}; + +export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) { + const teamEmailVerification = await prisma.teamEmailVerification.findUnique({ + where: { + token, + }, + include: { + team: true, + }, + }); + + if (!teamEmailVerification || teamEmailVerification.expiresAt < new Date()) { + return ( +
+

Invalid link

+ +

+ This link is invalid or has expired. Please contact your team to resend a verification. +

+ + +
+ ); + } + + const { team } = teamEmailVerification; + + try { + await prisma.$transaction([ + prisma.teamEmailVerification.deleteMany({ + where: { + teamId: team.id, + }, + }), + prisma.teamEmail.create({ + data: { + teamId: team.id, + email: teamEmailVerification.email, + name: teamEmailVerification.name, + }, + }), + ]); + } catch { + return ( +
+

Team email verification

+ +

+ Something went wrong while attempting to verify your email address for{' '} + {team.name}. Please try again later. +

+
+ ); + } + + return ( +
+

Team email verified!

+ +

+ You have verified your email address for {team.name}. +

+ + +
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx new file mode 100644 index 000000000..cedf99950 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx @@ -0,0 +1,74 @@ +import Link from 'next/link'; + +import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +type VerifyTeamTransferPage = { + params: { + token: string; + }; +}; + +export default async function VerifyTeamTransferPage({ + params: { token }, +}: VerifyTeamTransferPage) { + const teamTransferVerification = await prisma.teamTransferVerification.findUnique({ + where: { + token, + }, + include: { + team: true, + }, + }); + + if (!teamTransferVerification || teamTransferVerification.expiresAt < new Date()) { + return ( +
+

Invalid link

+ +

+ This link is invalid or has expired. Please contact your team to resend a transfer + request. +

+ + +
+ ); + } + + const { team } = teamTransferVerification; + + try { + await transferTeamOwnership({ token }); + } catch (e) { + console.error(e); + + return ( +
+

Team ownership transfer

+ +

+ Something went wrong while attempting to transfer the ownership of team{' '} + {team.name} to your. Please try again later or contact support. +

+
+ ); + } + + return ( +
+

Team ownership transferred!

+ +

+ The ownership of team {team.name} has been successfully transferred to you. +

+ + +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index cf8873a1a..2825460a1 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -1,10 +1,11 @@ 'use client'; -import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; +import { type HTMLAttributes, useEffect, useState } from 'react'; import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import type { User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; @@ -15,9 +16,12 @@ import { ProfileDropdown } from './profile-dropdown'; export type HeaderProps = HTMLAttributes & { user: User; + teams: GetTeamsResponse; }; -export const Header = ({ className, user, ...props }: HeaderProps) => { +export const Header = ({ className, user, teams, ...props }: HeaderProps) => { + const params = useParams(); + const [scrollY, setScrollY] = useState(0); useEffect(() => { @@ -30,6 +34,14 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { return () => window.removeEventListener('scroll', onScroll); }, []); + const getRootHref = () => { + if (typeof params?.teamUrl === 'string') { + return `/t/${params.teamUrl}`; + } + + return '/'; + }; + return (
{ >
@@ -50,11 +62,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
- - - {/* */} +
diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 2dcbb9864..722cb6578 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -1,162 +1,210 @@ 'use client'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; -import { - CreditCard, - FileSpreadsheet, - Lock, - LogOut, - User as LucideUser, - Monitor, - Moon, - Palette, - Sun, - UserCog, -} from 'lucide-react'; +import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; import { signOut } from 'next-auth/react'; -import { useTheme } from 'next-themes'; -import { LuGithub } from 'react-icons/lu'; -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { TEAM_MEMBER_ROLE_MAP, canExecuteTeamAction } from '@documenso/lib/constants/teams'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import type { User } from '@documenso/prisma/client'; -import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; export type ProfileDropdownProps = { user: User; + teams: GetTeamsResponse; }; -export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - const { getFlag } = useFeatureFlags(); - const { theme, setTheme } = useTheme(); +export const ProfileDropdown = ({ user, teams: initialTeamsData }: ProfileDropdownProps) => { + const pathname = usePathname(); + const isUserAdmin = isAdmin(user); - const isBillingEnabled = getFlag('app_billing'); + const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, { + initialData: initialTeamsData, + }); - const avatarFallback = user.name - ? recipientInitials(user.name) - : user.email.slice(0, 1).toUpperCase(); + const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null; + + const isPathTeamUrl = (teamUrl: string) => { + if (!pathname || !pathname.startsWith(`/t/`)) { + return false; + } + + return pathname.split('/')[2] === teamUrl; + }; + + const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url)); + + const formatAvatarFallback = (teamName?: string) => { + if (teamName !== undefined) { + return teamName.slice(0, 1).toUpperCase(); + } + + return user.name ? recipientInitials(user.name) : user.email.slice(0, 1).toUpperCase(); + }; + + const formatSecondaryAvatarText = (team?: typeof selectedTeam) => { + if (!team) { + return 'Personal Account'; + } + + if (team.ownerUserId === user.id) { + return 'Owner'; + } + + return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]; + }; return ( - - Account - - {isUserAdmin && ( + + {teams ? ( <> + Personal + - - - Admin + + + ) + } + /> - + + + +
+

Teams

+ +
+ + + + + + + +
+
+
+ + {teams.map((team) => ( + + + + ) + } + /> + + + ))} - )} - - - - - Profile - - - - - - - Security - - - - {isBillingEnabled && ( - - - - Billing + ) : ( + + + Create team + )} - - - - - Templates - - - - - - Themes - - - - - - Light - - - - Dark - - - - System - - - - - - - - - - Star on Github - + {isUserAdmin && ( + + Admin panel + + )} + + + User settings - + {selectedTeam && + canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && ( + + Team settings + + )} - void signOut({ + className="text-destructive/90 hover:!text-destructive px-4 py-2" + onSelect={async () => + signOut({ callbackUrl: '/', }) } > - Sign Out
diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index f4b2aae5e..c7ab61d8a 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -1,11 +1,11 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Lock, User } from 'lucide-react'; +import { CreditCard, Lock, User, Users } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + + {/* Todo: Teams */} + + + + + )} + + + + + Add team email + + + A verification email will be sent to the provided email. + + + +
+ +
+ ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + + + + + +
+
+ +
+ + ); +} diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx new file mode 100644 index 000000000..56092d409 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { CreditCard } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_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 { 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 CreateTeamDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +export const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ + name: true, + url: true, +}); + +export type TCreateTeamFormSchema = z.infer; + +export default function CreateTeamDialog({ trigger, ...props }: CreateTeamDialogProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const [open, setOpen] = useState(false); + const [checkoutUrl, setCheckoutUrl] = useState(null); + + const { toast } = useToast(); + + const actionSearchParam = searchParams?.get('action'); + + const form = useForm({ + resolver: zodResolver(ZCreateTeamFormSchema), + defaultValues: { + name: '', + url: '', + }, + }); + + const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation(); + + const onFormSubmit = async ({ name, url }: TCreateTeamFormSchema) => { + try { + const response = await createTeam({ + name, + url, + }); + + if (!response.paymentRequired) { + toast({ + title: 'Success', + description: 'Your team has been successfully created.', + duration: 5000, + }); + + setOpen(false); + + return; + } + + setCheckoutUrl(response.checkoutUrl); + router.push(`/settings/teams?tab=pending`); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('url', { + type: 'manual', + message: 'This URL is already in use.', + }); + + return; + } + + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to create a team. Please try again later.', + }); + } + }; + + const mapTextToUrl = (text: string) => { + return text.toLowerCase().replace(/\s+/g, '-'); + }; + + useEffect(() => { + if (actionSearchParam === 'add-team') { + setOpen(true); + updateSearchParams({ action: null }); + } + }, [actionSearchParam, open, setOpen, updateSearchParams]); + + useEffect(() => { + setCheckoutUrl(null); + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? } + + + + + Create team + + + Create a team to collaborate with your team members. + + + + {checkoutUrl ? ( + <> +
+ + + Payment is required to finalise the creation of your team. + +
+ + + + + + + + ) : ( +
+ +
+ ( + + Team Name + + { + const oldGenericUrl = mapTextToUrl(field.value); + const newGenericUrl = mapTextToUrl(event.target.value); + + const urlField = form.getValues('url'); + if (urlField === oldGenericUrl) { + form.setValue('url', newGenericUrl); + } + + field.onChange(event); + }} + /> + + + + )} + /> + + ( + + Team URL + + + + {!form.formState.errors.url && ( + + {field.value + ? `${WEBAPP_BASE_URL}/t/${field.value}` + : 'A unique URL to identify your team'} + + )} + + + + )} + /> + + + + + + +
+
+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx new file mode 100644 index 000000000..062be992e --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +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 type { Toast } from '@documenso/ui/primitives/use-toast'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteTeamDialogProps = { + teamId: number; + teamName: string; + trigger?: React.ReactNode; +}; + +export default function DeleteTeamDialog({ trigger, teamId, teamName }: DeleteTeamDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const deleteMessage = `delete ${teamName}`; + + const ZDeleteTeamFormSchema = z.object({ + teamName: z.literal(deleteMessage, { + errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }), + }), + }); + + const form = useForm({ + resolver: zodResolver(ZDeleteTeamFormSchema), + defaultValues: { + teamName: '', + }, + }); + + const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation(); + + const onFormSubmit = async () => { + try { + await deleteTeam({ teamId }); + + toast({ + title: 'Success', + description: 'Your team has been successfully deleted.', + duration: 5000, + }); + + setOpen(false); + + router.push('/settings/teams'); + } catch (err) { + const error = AppError.parseError(err); + + let toastError: Toast = { + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to delete this team. Please try again later.', + }; + + if (error.code === 'resource_missing') { + toastError = { + title: 'Unable to delete team', + variant: 'destructive', + duration: 15000, + description: + 'Something went wrong while updating the team billing subscription, please contact support.', + }; + } + + toast(toastError); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? } + + + + + Delete team + + + Are you sure? This is irreversable. + + + +
+ +
+ ( + + + Confirm by typing {deleteMessage} + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx new file mode 100644 index 000000000..1f5628613 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState } from 'react'; + +import { trpc } from '@documenso/trpc/react'; +import { Avatar, AvatarFallback } 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 DeleteTeamMemberDialogProps = { + teamId: number; + teamName: string; + teamMemberId: number; + teamMemberName: string; + teamMemberEmail: string; + trigger?: React.ReactNode; +}; + +export default function DeleteTeamMemberDialog({ + trigger, + teamId, + teamName, + teamMemberId, + teamMemberName, + teamMemberEmail, +}: DeleteTeamMemberDialogProps) { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: deleteTeamMembers, isLoading: isDeletingTeamMember } = + trpc.team.deleteTeamMembers.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully removed this user from the team.', + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to remove this user. Please try again later.', + }); + }, + }); + + return ( + !isDeletingTeamMember && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to remove the following user from{' '} + {teamName}. + + + +
+
+ + + {teamMemberName.slice(0, 1).toUpperCase()} + + + +
+ {teamMemberName} + {teamMemberEmail} +
+
+
+ +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx new file mode 100644 index 000000000..e7cfc7a1f --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Mail, PlusCircle, Trash } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { TeamMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +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 const ZInviteTeamMembersFormSchema = z + .object({ + invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, + }) + // Todo: Teams + .refine( + (schema) => { + const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Members must have unique emails', path: ['members__root'] }, + ); + +export type TInviteTeamMembersFormSchema = z.infer; + +export type InviteTeamMembersDialogProps = { + teamId: number; + trigger?: React.ReactNode; +} & Omit; + +export default function InviteTeamMembersDialog({ + teamId, + trigger, + ...props +}: InviteTeamMembersDialogProps) { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZInviteTeamMembersFormSchema), + defaultValues: { + invitations: [ + { + email: '', + role: TeamMemberRole.MEMBER, + }, + ], + }, + }); + + const { + append: appendTeamMemberInvite, + fields: teamMemberInvites, + remove: removeTeamMemberInvite, + } = useFieldArray({ + control: form.control, + name: 'invitations', + }); + + const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation(); + + const onAddTeamMemberInvite = () => { + appendTeamMemberInvite({ + email: '', + role: TeamMemberRole.MEMBER, + }); + }; + + const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => { + try { + await createTeamMemberInvites({ + teamId, + invitations, + }); + + toast({ + title: 'Success', + description: 'Team invitations have been sent.', + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to invite team members. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Invite team members + + + An email containing an invitation will be sent to each member. + + + +
+ +
+
+ {teamMemberInvites.map((teamMemberInvite, index) => ( +
+ ( + + {index === 0 && Email address} + + + + + + )} + /> + + ( + + {index === 0 && Role} + + + + + + )} + /> + + +
+ ))} + + + + + + + + +
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx new file mode 100644 index 000000000..5288a76cd --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState } from 'react'; + +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import type { TeamMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Avatar, AvatarFallback } 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 LeaveTeamDialogProps = { + teamId: number; + teamName: string; + role: TeamMemberRole; + trigger?: React.ReactNode; +}; + +export default function LeaveTeamDialog({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: leaveTeam, isLoading: isLeavingTeam } = trpc.team.leaveTeam.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully left this team.', + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to leave this team. Please try again later.', + }); + }, + }); + + return ( + !isLeavingTeam && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to leave the following team. + + + +
+
+ + + {teamName.slice(0, 1).toUpperCase()} + + + +
+ {teamName} + {TEAM_MEMBER_ROLE_MAP[role]} +
+
+
+ +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx new file mode 100644 index 000000000..e51d9997e --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TransferTeamDialogProps = { + teamId: number; + teamName: string; + ownerUserId: number; + trigger?: React.ReactNode; +}; + +export default function TransferTeamDialog({ + trigger, + teamId, + teamName, + ownerUserId, +}: TransferTeamDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: requestTeamOwnershipTransfer } = + trpc.team.requestTeamOwnershipTransfer.useMutation(); + + const { + data, + refetch: refetchTeamMembers, + isLoading: loadingTeamMembers, + isLoadingError: loadingTeamMembersError, + } = trpc.team.getTeamMembers.useQuery({ + teamId, + }); + + const confirmTransferMessage = `transfer ${teamName}`; + + const ZDeleteTeamFormSchema = z.object({ + teamName: z.literal(confirmTransferMessage, { + errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }), + }), + newOwnerUserId: z.string(), + }); + + const form = useForm>({ + resolver: zodResolver(ZDeleteTeamFormSchema), + defaultValues: { + teamName: '', + }, + }); + + const onFormSubmit = async ({ newOwnerUserId }: z.infer) => { + try { + await requestTeamOwnershipTransfer({ + teamId, + newOwnerUserId: Number.parseInt(newOwnerUserId), + }); + + router.refresh(); + + toast({ + title: 'Success', + description: 'An email requesting the transfer of this team has been sent.', + duration: 5000, + }); + + setOpen(false); + } catch (err) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to request a transfer of this team. Please try again later.', + }); + } + }; + + 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} + + + + + + + )} + /> + +
+

+ 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/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx new file mode 100644 index 000000000..b2f582126 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { TeamEmail } from '@documenso/prisma/client'; +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'; + +export type AddTeamEmailDialogProps = { + teamEmail: TeamEmail; + trigger?: React.ReactNode; +} & Omit; + +export const ZUpdateTeamEmailFormSchema = z.object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), +}); + +export type TUpdateTeamEmailFormSchema = z.infer; + +export default function UpdateTeamEmailDialog({ + teamEmail, + trigger, + ...props +}: AddTeamEmailDialogProps) { + const router = useRouter(); + + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamEmailFormSchema), + defaultValues: { + name: teamEmail.name, + }, + }); + + const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation(); + + const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { + try { + await updateTeamEmail({ + teamId: teamEmail.teamId, + data: { + name, + }, + }); + + toast({ + title: 'Success', + description: 'Team email was updated.', + duration: 5000, + }); + + router.refresh(); + + setOpen(false); + } catch (err) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting update the team email. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + Update team email + + + To change the email you must remove and add a new email address. + + + +
+ +
+ ( + + Name + + + + + + )} + /> + + + Email + + + + + + + + + + +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx new file mode 100644 index 000000000..543806901 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { TeamMemberRole } from '@documenso/prisma/client'; +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 UpdateTeamMemberDialogProps = { + trigger?: React.ReactNode; + teamId: number; + teamMemberId: number; + teamMemberName: string; + teamMemberRole: TeamMemberRole; +} & Omit; + +export const ZUpdateTeamMemberFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +export type ZUpdateTeamMemberSchema = z.infer; + +export default function UpdateTeamMemberDialog({ + trigger, + teamId, + teamMemberId, + teamMemberName, + teamMemberRole, + ...props +}: UpdateTeamMemberDialogProps) { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamMemberFormSchema), + defaultValues: { + role: teamMemberRole, + }, + }); + + const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => { + try { + await updateTeamMember({ + teamId, + teamMemberId, + data: { + role, + }, + }); + + toast({ + title: 'Success', + description: `You have updated ${teamMemberName}.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update this team member. Please try again later.', + }); + } + }; + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Update team member + + + You are currently updating {teamMemberName}. + + + +
+ +
+ ( + + Role + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/web/src/components/(teams)/forms/update-team-form.tsx new file mode 100644 index 000000000..334a4bded --- /dev/null +++ b/apps/web/src/components/(teams)/forms/update-team-form.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UpdateTeamDialogProps = { + teamId: number; + teamName: string; + teamUrl: string; +}; + +export const ZUpdateTeamFormSchema = z.object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + url: z.string().min(1, 'Please enter a value.'), // Todo: Teams - Restrict certain symbols. +}); + +export type TUpdateTeamFormSchema = z.infer; + +export default function UpdateTeamForm({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) { + const router = useRouter(); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamFormSchema), + defaultValues: { + name: teamName, + url: teamUrl, + }, + }); + + const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation(); + + const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => { + try { + await updateTeam({ + data: { + name, + url, + }, + teamId, + }); + + toast({ + title: 'Success', + description: 'Your team has been successfully updated.', + duration: 5000, + }); + + form.reset({ + name, + url, + }); + + if (url !== teamUrl) { + router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`); + } + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('url', { + type: 'manual', + message: 'This URL is already in use.', + }); + + return; + } + + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update your team. Please try again later.', + }); + } + }; + + return ( +
+ +
+ ( + + Team Name + + + + + + )} + /> + + ( + + Team URL + + + + {!form.formState.errors.url && ( + + {field.value + ? `${WEBAPP_BASE_URL}/t/${field.value}` + : 'A unique URL to identify your team'} + + )} + + + + )} + /> + +
+ + {form.formState.isDirty && ( + + + + )} + + + +
+
+
+ + ); +} diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx new file mode 100644 index 000000000..bac63ab3b --- /dev/null +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { HTMLAttributes } from 'react'; + +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; + +import { CreditCard, Key, User } from 'lucide-react'; + +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DesktopNavProps = HTMLAttributes; + +export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + const pathname = usePathname(); + const params = useParams(); + + const { getFlag } = useFeatureFlags(); + + const isBillingEnabled = getFlag('app_billing'); + + const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; + + return ( +
+ + + + + + + + + {isBillingEnabled && ( + + + + )} +
+ ); +}; diff --git a/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx new file mode 100644 index 000000000..210ee3426 --- /dev/null +++ b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { HTMLAttributes } from 'react'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type MobileNavProps = HTMLAttributes; + +// Todo: Teams +export const MobileNav = ({ className, ...props }: MobileNavProps) => { + // const pathname = usePathname(); + + // const { getFlag } = useFeatureFlags(); + + // const isBillingEnabled = getFlag('app_billing'); + + return ( +
+ todo + {/* + + + + + + + + {isBillingEnabled && ( + + + + )} */} +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx new file mode 100644 index 000000000..f156d6abb --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx @@ -0,0 +1,159 @@ +'use client'; + +import Link from 'next/link'; + +import { File } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +export type TeamBillingInvoicesDataTableProps = { + teamId: number; +}; + +export default function TeamBillingInvoicesDataTable({ + teamId, +}: TeamBillingInvoicesDataTableProps) { + const { + data: result, + isLoading, + isInitialLoading, + isLoadingError, + } = trpc.team.findTeamInvoices.useQuery( + { + teamId, + }, + { + keepPreviousData: true, + }, + ); + + const formatCurrency = (currency: string, amount: number) => { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + }); + + return formatter.format(amount); + }; + + const results = { + data: result?.data ?? [], + perPage: 100, + currentPage: 1, + totalPages: 1, + }; + + return ( + ( +
+ + +
+ + {DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')} + + + {row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'} + +
+
+ ), + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => { + const { status, paid } = row.original; + if (!status) { + return paid ? 'Paid' : 'Unpaid'; + } + + return status.charAt(0).toUpperCase() + status.slice(1); + }, + }, + { + header: 'Amount', + accessorKey: 'total', + cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100), + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + {/* Todo: Teams */} + +
+ + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +} diff --git a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx new file mode 100644 index 000000000..59178e389 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { History, MoreHorizontal, Trash2 } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +export type TeamMemberInvitesDataTableProps = { + teamId: number; +}; + +export default function TeamMemberInvitesDataTable({ teamId }: TeamMemberInvitesDataTableProps) { + const searchParams = useSearchParams()!; + const updateSearchParams = useUpdateSearchParams(); + + const { toast } = useToast(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.team.findTeamMemberInvites.useQuery( + { + teamId, + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const { mutateAsync: resendTeamMemberInvitation } = + trpc.team.resendTeamMemberInvitation.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Invitation has been resent', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: 'Unable to resend invitation. Please try again.', + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: deleteTeamMemberInvitations } = + trpc.team.deleteTeamMemberInvitations.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Invitation has been deleted', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: 'Unable to delete invitation. Please try again.', + variant: 'destructive', + }); + }, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + { + return ( + {row.original.email} + } + /> + ); + }, + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role, + }, + { + header: 'Invited At', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + + resendTeamMemberInvitation({ + teamId, + invitationId: row.original.id, + }) + } + > + + Resend + + + + deleteTeamMemberInvitations({ + teamId, + invitationIds: [row.original.id], + }) + } + > + + Remove + + + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +} diff --git a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx new file mode 100644 index 000000000..34e4e11f4 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { Edit, MoreHorizontal, Trash2 } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import DeleteTeamMemberDialog from '../dialogs/delete-team-member-dialog'; +import UpdateTeamMemberDialog from '../dialogs/update-team-member-dialog'; + +export type TeamMembersDataTableProps = { + teamOwnerUserId: number; + teamId: number; + teamName: string; +}; + +export default function TeamMembersDataTable({ + teamOwnerUserId, + teamId, + teamName, +}: TeamMembersDataTableProps) { + const searchParams = useSearchParams()!; + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery( + { + teamId, + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + { + const avatarFallbackText = row.original.user.name + ? recipientInitials(row.original.user.name) // Todo: Teams - Extract to `nameInitials` + : row.original.user.email.slice(0, 1).toUpperCase(); + + return ( + {row.original.user.name} + } + secondaryText={row.original.user.email} + /> + ); + }, + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => + teamOwnerUserId === row.original.userId + ? 'Owner' + : TEAM_MEMBER_ROLE_MAP[row.original.role], + }, + { + header: 'Member Since', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + e.preventDefault()} + title={ + teamOwnerUserId === row.original.userId + ? 'You cannot update the team owner role' + : 'Update team member role' + } + > + + Update role + + } + /> + + e.preventDefault()} + disabled={teamOwnerUserId === row.original.userId} + title={ + teamOwnerUserId === row.original.userId + ? 'You cannot remove the team owner' + : 'Remove team member' + } + > + + Remove + + } + /> + + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +} diff --git a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx new file mode 100644 index 000000000..b87709226 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import TeamMemberInvitesDataTable from '~/components/(teams)/tables/team-member-invites-data-table'; +import TeamMembersDataTable from '~/components/(teams)/tables/team-members-data-table'; + +export type TeamsMemberPageDataTableProps = { + teamId: number; + teamName: string; + teamOwnerUserId: number; +}; + +export default function TeamsMemberPageDataTable({ + teamId, + teamName, + teamOwnerUserId, +}: TeamsMemberPageDataTableProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members'; + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + router.push(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery, pathname, router, searchParams]); + + return ( +
+
+ setSearchQuery(e.target.value)} + placeholder="Search" + /> + + + + + All + + + + Pending + + + +
+ + {currentTab === 'invites' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/(teams)/tables/user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/user-teams-data-table.tsx new file mode 100644 index 000000000..f05ac09b5 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/user-teams-data-table.tsx @@ -0,0 +1,157 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_MAP, canExecuteTeamAction } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import LeaveTeamDialog from '../dialogs/leave-team-dialog'; + +export default function UserTeamsDataTable() { + const searchParams = useSearchParams()!; + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery( + { + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + ( + + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + + ), + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => + row.original.ownerUserId === row.original.currentTeamMember.userId + ? 'Owner' + : TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role], + }, + { + header: 'Member Since', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && ( + + )} + + e.preventDefault()} + > + Leave + + } + /> +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +} diff --git a/apps/web/src/components/(teams)/tables/user-teams-page-data-table.tsx b/apps/web/src/components/(teams)/tables/user-teams-page-data-table.tsx new file mode 100644 index 000000000..507b957b5 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/user-teams-page-data-table.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { trpc } from '@documenso/trpc/react'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import UserTeamsDataTable from './user-teams-data-table'; +import UserTeamsPendingDataTable from './user-teams-pending-data-table'; + +export default function UserTeamsPageDataTable() { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active'; + + const { data } = trpc.team.findTeamsPending.useQuery( + {}, + { + keepPreviousData: true, + }, + ); + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + router.push(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery, pathname, router, searchParams]); + + return ( +
+
+ setSearchQuery(e.target.value)} + placeholder="Search" + /> + + + + + Active + + + + + Pending + {data && data.count > 0 && ( + {data.count} + )} + + + + +
+ + {currentTab === 'pending' ? : } +
+ ); +} diff --git a/apps/web/src/components/(teams)/tables/user-teams-pending-data-table.tsx b/apps/web/src/components/(teams)/tables/user-teams-pending-data-table.tsx new file mode 100644 index 000000000..c960f1950 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/user-teams-pending-data-table.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useState } from 'react'; + +import { useSearchParams } from 'next/navigation'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +export default function UserTeamsPendingDataTable() { + const searchParams = useSearchParams()!; + const updateSearchParams = useUpdateSearchParams(); + + const { toast } = useToast(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const [mutatingTeamIds, setMutatingTeamIds] = useState<{ [id: number]: 'checkout' | 'delete' }>( + {}, + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery( + { + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const { mutateAsync: createTeamPendingCheckout } = + trpc.team.createTeamPendingCheckout.useMutation({ + onSettled: (_data, _error, { pendingTeamId }) => removeIdFromMutatingTeamIds(pendingTeamId), + onMutate: ({ pendingTeamId }) => addIdToMutatingTeamIds(pendingTeamId, 'checkout'), + onSuccess: (checkoutUrl) => window.open(checkoutUrl, '_blank'), + }); + + const { mutateAsync: deleteTeamPending } = trpc.team.deleteTeamPending.useMutation({ + onSettled: (_data, _error, { pendingTeamId }) => removeIdFromMutatingTeamIds(pendingTeamId), + onMutate: ({ pendingTeamId }) => addIdToMutatingTeamIds(pendingTeamId, 'delete'), + onSuccess: () => { + toast({ + title: 'Success', + description: 'Pending team deleted.', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: + 'We encountered an unknown error while attempting to delete the pending team. Please try again later.', + duration: 10000, + variant: 'destructive', + }); + }, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const addIdToMutatingTeamIds = (pendingTeamId: number, type: 'checkout' | 'delete') => { + setMutatingTeamIds((prev) => ({ + ...prev, + [pendingTeamId]: type, + })); + }; + + const removeIdFromMutatingTeamIds = (pendingTeamId: number) => { + setMutatingTeamIds((prev) => { + const ids = { ...prev }; + delete ids[pendingTeamId]; + return ids; + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + ( + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + ), + }, + { + header: 'Created on', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +} diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 4e671a569..c781b112e 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -48,9 +48,10 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; + initialEmail?: string; }; -export const SignInForm = ({ className }: SignInFormProps) => { +export const SignInForm = ({ className, initialEmail }: SignInFormProps) => { const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); @@ -61,7 +62,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { const form = useForm({ values: { - email: '', + email: initialEmail ?? '', password: '', totpCode: '', backupCode: '', diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index b91b4a9fd..808550e36 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -37,16 +37,17 @@ export type TSignUpFormSchema = z.infer; export type SignUpFormProps = { className?: string; + initialEmail?: string; }; -export const SignUpForm = ({ className }: SignUpFormProps) => { +export const SignUpForm = ({ className, initialEmail }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); const form = useForm({ values: { name: '', - email: '', + email: initialEmail ?? '', password: '', signature: '', }, diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 25bfbbb40..f5ea028fd 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,8 +1,40 @@ +import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; +const todoRegex = new RegExp('^/t/[^/]+$'); + export default async function middleware(req: NextRequest) { + const preferredTeamUrl = cookies().get('preferred-team-url'); + + // Redirect to preferred team if user has selected one. + if ( + !req.url && + (req.nextUrl.pathname === '/' || req.nextUrl.pathname === '/documents') && + preferredTeamUrl?.value + ) { + const redirectUrl = new URL(`/t/${preferredTeamUrl.value}`, req.url); + + return NextResponse.redirect(redirectUrl); + } + + // Redirect `/t/` to `/t//documents` + if (todoRegex.test(req.nextUrl.pathname)) { + const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url); + + const response = NextResponse.redirect(redirectUrl); + response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', '')); + + return response; + } + + if (req.nextUrl.pathname === '/t') { + const redirectUrl = new URL('/settings/teams', req.url); + + return NextResponse.redirect(redirectUrl); + } + if (req.nextUrl.pathname === '/') { const redirectUrl = new URL('/documents', req.url); diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index f256c6356..9f2a6ffb0 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -71,6 +71,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { const documents = await prisma.document.count({ where: { userId: user.id, + teamId: null, createdAt: { gte: DateTime.utc().startOf('month').toJSDate(), }, diff --git a/packages/ee/server-only/stripe/get-checkout-session.ts b/packages/ee/server-only/stripe/get-checkout-session.ts index fd15d538a..7c89c1f8c 100644 --- a/packages/ee/server-only/stripe/get-checkout-session.ts +++ b/packages/ee/server-only/stripe/get-checkout-session.ts @@ -1,17 +1,21 @@ 'use server'; +import type Stripe from 'stripe'; + import { stripe } from '@documenso/lib/server-only/stripe'; export type GetCheckoutSessionOptions = { customerId: string; priceId: string; returnUrl: string; + subscriptionMetadata?: Stripe.Metadata; }; export const getCheckoutSession = async ({ customerId, priceId, returnUrl, + subscriptionMetadata, }: GetCheckoutSessionOptions) => { 'use server'; @@ -26,6 +30,9 @@ export const getCheckoutSession = async ({ ], success_url: `${returnUrl}?success=true`, cancel_url: `${returnUrl}?canceled=true`, + subscription_data: { + metadata: subscriptionMetadata, + }, }); return session.url; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts index c85488e6f..2d3db9c50 100644 --- a/packages/ee/server-only/stripe/get-customer.ts +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -78,6 +78,14 @@ export const getStripeCustomerByUser = async (user: User) => { }; }; +export const getStripeCustomerIdByUser = async (user: User) => { + if (user.customerId !== null) { + return user.customerId; + } + + return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id); +}; + const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => { const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, diff --git a/packages/ee/server-only/stripe/get-team-invoices.ts b/packages/ee/server-only/stripe/get-team-invoices.ts new file mode 100644 index 000000000..0841b7775 --- /dev/null +++ b/packages/ee/server-only/stripe/get-team-invoices.ts @@ -0,0 +1,23 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type GetTeamInvoicesOptions = { + teamId: number; +}; + +export const getTeamInvoices = async ({ teamId }: GetTeamInvoicesOptions) => { + const teamSubscriptions = await stripe.subscriptions.search({ + limit: 100, + query: `metadata["teamId"]:"${teamId}"`, + }); + + const subscriptionIds = teamSubscriptions.data.map((subscription) => subscription.id); + + if (subscriptionIds.length === 0) { + return null; + } + + return await stripe.invoices.search({ + query: subscriptionIds.map((id) => `subscription:"${id}"`).join(' OR '), + limit: 100, + }); +}; diff --git a/packages/ee/server-only/stripe/transfer-team-subscription.ts b/packages/ee/server-only/stripe/transfer-team-subscription.ts new file mode 100644 index 000000000..93a3f1ea8 --- /dev/null +++ b/packages/ee/server-only/stripe/transfer-team-subscription.ts @@ -0,0 +1,96 @@ +import type Stripe from 'stripe'; + +import { stripe } from '@documenso/lib/server-only/stripe'; +import { + getTeamSeatPriceId, + isSomeSubscriptionsActiveAndCommunityPlan, +} from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import type { Subscription, Team, User } from '@documenso/prisma/client'; + +import { getStripeCustomerByUser } from './get-customer'; + +type TransferStripeSubscriptionOptions = { + user: User & { Subscription: Subscription[] }; + team: Team; +}; + +/** + * Transfer the Stripe Team seats subscription from one user to another. + * + * Will create a new subscription for the new owner and cancel the old one. + * + * Returns the new subscription, null if no subscription is needed (for community plan). + */ +export const transferTeamSubscription = async ({ + user, + team, +}: TransferStripeSubscriptionOptions) => { + const teamSeatPriceId = getTeamSeatPriceId(); + const { stripeCustomer } = await getStripeCustomerByUser(user); + + const newOwnerHasCommunityPlan = isSomeSubscriptionsActiveAndCommunityPlan(user.Subscription); + const currentTeamSubscriptionId = team.subscriptionId; + + let oldSubscription: Stripe.Subscription | null = null; + let newSubscription: Stripe.Subscription | null = null; + + if (currentTeamSubscriptionId) { + oldSubscription = await stripe.subscriptions.retrieve(currentTeamSubscriptionId); + } + + const numberOfSeats = await prisma.teamMember.count({ + where: { + teamId: team.id, + }, + }); + + if (!newOwnerHasCommunityPlan) { + let stripeCreateSubscriptionPayload: Stripe.SubscriptionCreateParams = { + customer: stripeCustomer.id, + items: [ + { + price: teamSeatPriceId, + quantity: numberOfSeats, + }, + ], + metadata: { + teamId: team.id.toString(), + }, + }; + + // If no payment method is attached to the new owner Stripe customer account, send an + // invoice instead. + if (!stripeCustomer.invoice_settings.default_payment_method) { + stripeCreateSubscriptionPayload = { + ...stripeCreateSubscriptionPayload, + collection_method: 'send_invoice', + days_until_due: 7, + }; + } + + newSubscription = await stripe.subscriptions.create(stripeCreateSubscriptionPayload); + } + + if (oldSubscription) { + try { + // Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount. + await stripe.subscriptions.update(oldSubscription.id, { + items: oldSubscription.items.data.map((item) => ({ + id: item.id, + quantity: 0, + })), + }); + + await stripe.subscriptions.cancel(oldSubscription.id, { + invoice_now: true, + prorate: false, + }); + } catch (e) { + // Do not error out since we can't easily undo the transfer. + // Todo: Teams - Alert us. + } + } + + return newSubscription; +}; diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts new file mode 100644 index 000000000..19823841f --- /dev/null +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -0,0 +1,28 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type UpdateSubscriptionItemQuantityOptions = { + subscriptionId: string; + quantity?: number; + priceId: string; +}; + +export const updateSubscriptionItemQuantity = async ({ + subscriptionId, + quantity, + priceId, +}: UpdateSubscriptionItemQuantityOptions) => { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + const items = subscription.items.data.filter((item) => item.price.id === priceId); + + if (items.length === 0) { + return; + } + + await stripe.subscriptions.update(subscriptionId, { + items: items.map((item) => ({ + id: item.id, + quantity, + })), + }); +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 047de7962..0906e3dcc 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -5,6 +5,7 @@ import { match } from 'ts-pattern'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; +import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team'; import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { prisma } from '@documenso/prisma'; @@ -110,6 +111,12 @@ export const stripeWebhookHandler = async ( await onSubscriptionUpdated({ userId, subscription }); + if ( + subscription.items.data[0].price.id === process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID + ) { + await handleTeamSeatCheckout({ subscription }); + } + return res.status(200).json({ success: true, message: 'Webhook received', @@ -282,3 +289,21 @@ export const stripeWebhookHandler = async ( }); } }; + +export type HandleTeamSeatCheckoutOptions = { + subscription: Stripe.Subscription; +}; + +const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => { + if (subscription.metadata?.pendingTeamId === undefined) { + return; + } + + const pendingTeamId = Number(subscription.metadata.pendingTeamId); + + if (Number.isNaN(pendingTeamId)) { + throw new Error('Invalid pending team ID'); + } + + await createTeamFromPendingTeam({ pendingTeamId, subscriptionId: subscription.id }); +}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts index d7ce7b062..f60cae7e9 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -13,12 +13,19 @@ export const onSubscriptionUpdated = async ({ userId, subscription, }: OnSubscriptionUpdatedOptions) => { + await prisma.subscription.upsert(mapStripeSubscriptionToPrismaUpsertAction(userId, subscription)); +}; + +export const mapStripeSubscriptionToPrismaUpsertAction = ( + userId: number, + subscription: Stripe.Subscription, +) => { const status = match(subscription.status) .with('active', () => SubscriptionStatus.ACTIVE) .with('past_due', () => SubscriptionStatus.PAST_DUE) .otherwise(() => SubscriptionStatus.INACTIVE); - await prisma.subscription.upsert({ + return { where: { planId: subscription.id, }, @@ -37,5 +44,5 @@ export const onSubscriptionUpdated = async ({ periodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }, - }); + }; }; diff --git a/packages/email/static/add-user.png b/packages/email/static/add-user.png new file mode 100644 index 0000000000000000000000000000000000000000..abd337ceb70d306c70f31d07f1c74e2ca34399be GIT binary patch literal 3361 zcmV++4c_vJP)Gv`oIefP1`wEzQaL0KGNQ&`y?Uh=78W#-p`*s);-W_U zGcz;iRRRbdVk;eN}Y;suHZD8npcaxE1d`GfO#C`k`2XJ3-i082610B20TR31@FMaS~g zQ2n%>OiOwhAenH=G)1|T;zU(6q<3Os!Yh?ZBXrnj3OF?w?a@cZ9`b|AoI8>eBbe3w z`1rV{f*rztIUFe(LL2ogqL9@TWB63kXP#&vne&=xGTl}U0F_VW5Q$sS#)4i{83OWy%E!lf zlHx?(gKc$;0>$`j7Jz&pKd|m`Z1%aaw!Av7xBRfIqRoHwL66e$fA;R(8_A%4v%vHo zOa`DleSh%a!70vPT<`yBpq2zj^`PAL`Uf=l{y*$#*iNMhOx6QPitqEYq9R7AqA}Jz zGc)sxN(o5~Hk8#LwxdUnE>a;*fBN)Er3q9pdd8`tp`o{`Qkz94!K9%u)%TQ235mRj zaz&Ab484Qif{LWRV3h_Sy&#zOzd?FY@q{Ja8S8!`?Abx#p)z@9u)(``@7``|ix=L# zd#BR?=6)f4>NXul-Z)=6VBuLSpfrlKJym&zRdD`(Y;0`Pix)2@W@l&hmoHy*l0bTx zfQQ28if5CoB0|~(C(;>g43`aUIvF4TBC&aY(X~lZ)v=L`co9F+IdtgIgv^($aCsY1N^CTVyRzhx8)VlMN$i+8pD>_kq#BI8i6eHPM4%eMKcdpRW)AMg_W?f=yWO5#iHhRDI_Y?=Z zMgL0H`ZT3czQ;DMgnbaH2!mkSojGvez_3aIH9f*a{lF&93RTG3n+f1ObwZpaV*d`J zF(`p4HXxeG#yP};UMXxO@rtC@?;WG_)3l$wa^=b-r^E5GF%XcCn$L>9`x>*2$PU3iTu6bk<$wK{p>!iD4X z&?jm86Ll|+hD5zcao;DB|4&ifrm#~Wl;ZLbmoex(*c1PV_R*@^RR?(+_<6bYb>Qah|le5^QA-PRfj)?xkPc}2PtlT}$gmRYu?kBJ1iT5s%yV3JLx&_PC&B$Y{4 zIhuFXHqh(dS#ellgxSM-s3CBt*I^@Wq@GJ0>Q*Q zLpG%PhrBo{jeW`vmQGSU=OL-^Ntu@8vwFburj7IPTzm>3HVH;M+uPd<*ZiX(7Owf* z1?*RvaDQcGklM z4f7d*QF`72j@v=o^=ev0%WE*MI)Pxoc!@v8bGNgurd#&WVTSoe@@ib!Fa#Iy6SM{@YiQQntX!Fg zSEN;ZO)T$iFu=}SXgqoM(9n?IKb_@;Y)yHa<+;e%N~Kakbs(^EU#~lF$!uHPl($(o zJ#`Jj$46!hq^W7Ma;SIoXgM5}@hRbuRD`wNBW;LRrfa!c3UaL^osxEmbLB9_fbS~ll-)B$ zf_gg9g9<^3LM#S2F4LA@D_zAemaGJV_ze!ybbuqYpmddRre^jkEKQEfzOLGt)J9Q_ zps@d8(hx>+(Q?0Ejl7`i*7?8>b=joam8)Y325hNRRY(XTL|H`0LbVAF(-1+Vif~G) zP!D-w6+~HiQ|+jhW6P*6d(Gd_8Sjbf8-nrmAVV(beAsZEWdH?T*Rlz+9j)m~r)#+# zIg7Lc$O5gBhF8b`y6)E*9;bD1h#yV@@{i847CQ}2f*o=R`C4S33%ft;dQcqI?zh7P r9fzHBaBy&NaBy&NaBy&NsEOYJQ|7+=Yxnzq00000NkvXXu0mjfB)pO| literal 0 HcmV?d00001 diff --git a/packages/email/static/mail-open.png b/packages/email/static/mail-open.png new file mode 100644 index 0000000000000000000000000000000000000000..306313b03f900dde2c71456df73ad92b898f04db GIT binary patch literal 3839 zcmVAiL9)^TB|4`8*oZ{L3N&Ye5<)YOz61cb-7D1)+Mk+G;;^uWMCjx_sl<;s=0RjXDB za?PR2k*gLDA3hXh)decEzrDS^APm`ptng*B!jbXual2ATWLWYDy+C^9Dl7nEEO~F}#qV2kN+40m!3c!t$xF4@xy*fv)*u(|c*P1nJ#M4hdjT`7= zsyX?Ah(Mh@FAnG~Ft{WGPC^;#Y90a>;9J}6JcIGV@Zn3$!xsxjSl?@v< zh{nc7ktTC5;d$8(L)REHF(a|v~GEAlgPRk_+QBN*Avo1PKmmEYrx$MkY@U?5# z){-lJH7hPTh^)KpObJ{l6grkKUw$cTE;)!SyX?%=Alw{@dnJo5IfyK~?9?sT8I;dA zH8s6l4=y9P~6@p|sKNSa!@ z?AR@MsYS`vlC;Z?Rp6x>C09$5E;}{_mU5I_ElIlUsx8>KTMhAGTa*m2lUPsGV@jM%>zi zI&W>2-<)}KWMm}tS7<^jg~uFFI+QbQZhBDt3u+#7QPg5QcPt6%)Ji`FukdEB4#~qS9R=x4~yhjfx1a$VCW4 zdQ4AG%ZQo+t&(y2S#y)u3W)2zLlDKmMWEM;qG@5M9ZK(({NK?B!di+kmf@)q#N|Ts z`$^l&lnVl8h^?bim|PTN~ruxZmKX}LJi=;)|? z&CbpWLo%Rl;g0X$zc1??F%UEW)CNlf?CR>W{MzE6M!IDF)X7mKzb_ZUis3GBP^3+8 z*)-rF4Dms!kbJ>4BL{}Zm6|G=*nVxWuZw2i4nKe5#ECg}(QY6vESMua7T?L)3<1Desr#`MM4`#^8((J^ ztqAMsqC*Mn?6l}5FvCMze4-|$^2eR}1X(QufwyegBGq?Xv>n++R{~BDQ65g3+h>zf z=|Sp)ww8k^1W-|%Hf};4L)2QaZ;*v|Zi}P9f({Q4F9;fpHJ1lbuvS=Y)Il6YT=}op zcD{Kq{&2n(6`b(KjT>cKjL)!UxNmkxOA*8=r~`=0({d`E6{7~hm3DN<;EDvcqgt>E zEEyMpceo>@RCn**T~Zw+Gcz+%*s-xOSqDgc(61jy-+?Z}Q3**>k;bgUBK~X_BZGSTXKijvP4eF9B(8`PZhLUC zoKhheTP!J+JN3cvDy}z)v2?#tq_8{Sj%-Z0vy*_@CH(Sk)BVr%A3ZKrXplP!*7e;SMt=eEe zc$5mjnrksig;;>Q7%Lt1i5qpw4yb0@Y*k&9al?XgN|od&RjpH>O5m{qzipZVb|CK( z^mS5lEwTUyH9YYT?-*N+)3W>~yk!M-tiVs?SZ8$9nz&P{G*h1_aFB)O(;4lHKBL2S zW>s=y9>-uq+{86XRq523G~wu(VGgXK0s z(`K^>Te0T*JPXKb5akEA!HNY}JlJ4`3B|JrM8mWgw9#8Rq$R=UL1%Y2(G446r1O-~5 zf-Imuqr;FcYEKrZbjps!k8h~Qo~(LiWO7Jc8R`+e4{nHoM+GrCJ3q?R(zH^vugr-J z);968%SUsxs8ID_gRObWjv*OFrUu1dKmQ{Mdp*?lB+FTAR-An8#qM6uZG!fJKPC@O zo_`En5SRM8OV--4W`}ro-B-oB<~C`yU}S1Y+$M|u^!`WcO)zSUhOS?H{V%6<`wg6Q z$9~=joE#Vb*Db{b5c6QiaT54B-!gFD?|=T57$vKhSXTb^z2~~z+XY%McURWiaACc0 zWw3|b@>}xr-u46Hz%$216fWF6E3F?`jn?s>?>YC5y52<5DS|fl?SFTX6%JJ-E`u8c zHwrPjys>e2e$RUaUgwzytJ6WuphGfT>U&>`yS8~_#o~g49mhl~?Sp8YDb97O>wG&p zcwRInz6(RFzq-WQCC3G2B{qL^+SiJk$uVuRA z<~d5MfMpeT@iIRP=IJ0t-2_0}3t)LV(3`d3etp$XB zlp(#YPEAea%rhsGXDx|`zzIE9Z-3GyEiFh~FZ^8e{6i3(bcw9@Z~Pq~L-nxbj&M(u z0$VPN99G=XC;KY>=5XMY9cFB7?1Sm)=}|mA)=)iPaQc@A#tYi@=(1#y!&)v!=G1+4 zm6!PmTv8XQ($1YbM`vbczRi!oHB`qlPG9qtq05x|=shDu{?QA65Z{0PSE1bU%_jp8 zm-&NVnpBFOo$tVivuDqqCy{@PC({{`s{<(wtP^?dzi8~&iE4x-xxzea9#_K1AE)XuDm)5Q%%!{o?$<@5NN{aVdIkZP^&AEG!q|Vpjk|xTj3j|j0?Q3GBBNc#>*&#=R(H2_5wde+-@biTPfw3c%S#2Y9&P#c>({Lv zJ9fx-EOT$8mgV*2DdaM&Yinz>d0SqGWn7j&tL5GB@!jX4?2Dh1T`I3TSHJ(=%bofu zog*I$%W~gs*tNX5c#-~Tg~)rh9276Ky(rzd4p=S5fd6^>g8U3|?FO;`^}XF!-P=YI znA>FCR9Y6B;R@vCPDR?z&0wqByB}z+o9M^o^O}cWD>s?#Uha2pUf1B>UOk3$P4bZm z%Kx7GQO}$Aaqir?qS6&C%S`V5Pp;vq*^9K>9R7#6=*Dbcv7I_T<}Dh%U%g{D6Um>UU7pud}EZp^B+qr0$}quRvJ}_Qokl|1|7~b!JpbCB_j*L!u#2{x2Zg&|Vm>NY|CbM}=4DvV>+(;2 z!J4;Ou+F_NEaMlK;g?dT?AWg3FL-yKhx)&JJM|pY{d-(z=~~^_p*xm(?M~U!OFf3F zLE^Z@byt*EMf!c#RVrVoN9C7(g5F&jVfX1(v0eh}#P+%2`Lo~~KvyT~I^D6Ab+yB* z0^K5r^1Ib7st~S<^xL;;mFm8AH;Pbh0->>D*9{?rhlLBhg_iA0vrnyvy7Tedzq2eI zysSc$SFRA{ccpNbb!}6!P`BVhV}-hkbCngk{@VH6bpLP2WTCcNcr9MRJ!un7W zQ&f-6HCaB}dTkT5j?YAnOC_e z!HF8=d7ZDiPxral0Y8QjbdF)R_1Q)Z)_l(s{2yZxW!--FndATf002ovPDHLkV1ftJ BjST<* literal 0 HcmV?d00001 diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx new file mode 100644 index 000000000..8f821c10f --- /dev/null +++ b/packages/email/template-components/template-image.tsx @@ -0,0 +1,17 @@ +import { Img } from '../components'; + +export interface TemplateImageProps { + assetBaseUrl: string; + className?: string; + staticAsset: string; +} + +export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ; +}; + +export default TemplateImage; diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx index b3acd1ecd..59c7add10 100644 --- a/packages/email/templates/confirm-email.tsx +++ b/packages/email/templates/confirm-email.tsx @@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export const ConfirmEmailTemplate = ({ confirmationLink, - assetBaseUrl, + assetBaseUrl = 'http://localhost:3002', }: TemplateConfirmationEmailProps) => { const previewText = `Please confirm your email address`; @@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({ ); }; + +export default ConfirmEmailTemplate; diff --git a/packages/email/templates/confirm-team-email.tsx b/packages/email/templates/confirm-team-email.tsx new file mode 100644 index 000000000..2030332b6 --- /dev/null +++ b/packages/email/templates/confirm-team-email.tsx @@ -0,0 +1,124 @@ +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type ConfirmTeamEmailProps = { + assetBaseUrl: string; + baseUrl: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const ConfirmTeamEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: ConfirmTeamEmailProps) => { + const previewText = `Accept team email request for ${teamName} on Documenso`; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + Verify your team email address + + + + {teamName} has requested to use your email + address for their team on Documenso. + + +
+ {baseUrl.replace('https://', '')}/t/{teamUrl} +
+ +
+ + By accepting this request, you will be granting {teamName}{' '} + access to: + + +
    +
  • View all documents sent to this email address
  • +
  • + Allow document recipients to reply directly to this email address +
  • +
+ + + You can revoke access at any time in your team settings on Documenso{' '} + here. + +
+ +
+ +
+
+ + Link expires in 1 hour. +
+ +
+ + + + +
+ +
+ + ); +}; + +export default ConfirmTeamEmailTemplate; diff --git a/packages/email/templates/team-invite.tsx b/packages/email/templates/team-invite.tsx new file mode 100644 index 000000000..9451741a1 --- /dev/null +++ b/packages/email/templates/team-invite.tsx @@ -0,0 +1,107 @@ +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamInviteEmailProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const TeamInviteEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: TeamInviteEmailProps) => { + const previewText = `Accept invitation to join a team on Documenso`; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + Join {teamName} on Documenso + + + + You have been invited to join the following team + + +
+ {baseUrl.replace('https://', '')}/t/{teamUrl} +
+ + + by {senderName} + + +
+ +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamInviteEmailTemplate; diff --git a/packages/email/templates/team-transfer-request.tsx b/packages/email/templates/team-transfer-request.tsx new file mode 100644 index 000000000..eb250cb98 --- /dev/null +++ b/packages/email/templates/team-transfer-request.tsx @@ -0,0 +1,111 @@ +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamTransferRequestTemplateProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const TeamTransferRequestTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: TeamTransferRequestTemplateProps) => { + const previewText = 'Accept team transfer request on Documenso'; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + {teamName} ownership transfer request + + + + {senderName} has requested that you take + ownership of the following team + + +
+ {baseUrl.replace('https://', '')}/t/{teamUrl} +
+ + + By accepting this request, you will take responsibility for any billing items + associated with this team. + + +
+ +
+
+ + Link expires in 1 hour. +
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamTransferRequestTemplate; diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 827fcef0a..321d87aec 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -1,8 +1,13 @@ export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing'; export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web'; +export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true'; export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web'; export const APP_BASE_URL = IS_APP_WEB ? process.env.NEXT_PUBLIC_WEBAPP_URL : process.env.NEXT_PUBLIC_MARKETING_URL; + +export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'; + +export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001'; diff --git a/packages/lib/constants/teams.ts b/packages/lib/constants/teams.ts new file mode 100644 index 000000000..eb83f95e3 --- /dev/null +++ b/packages/lib/constants/teams.ts @@ -0,0 +1,34 @@ +import { TeamMemberRole } from '@documenso/prisma/client'; + +export const TEAM_MEMBER_ROLE_MAP: Record = { + ADMIN: 'Admin', + MANAGER: 'Manager', + MEMBER: 'Member', +}; + +export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = { + /** + * Includes updating team name, url, logo, emails. + * + * Todo: Teams - Clean this up, merge etc. + */ + MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + DELETE_INVITATIONS: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + DELETE_TEAM_MEMBERS: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN], + UPDATE_TEAM_MEMBERS: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], +} satisfies Record; + +/** + * Determines whether a team member can execute a given action. + * + * @param action The action the user is trying to execute. + * @param role The current role of the user. + * @returns Whether the user can execute the action. + */ +export const canExecuteTeamAction = ( + action: keyof typeof TEAM_MEMBER_ROLE_PERMISSIONS_MAP, + role: keyof typeof TEAM_MEMBER_ROLE_MAP, +) => { + return TEAM_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role); +}; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts new file mode 100644 index 000000000..3337bab4c --- /dev/null +++ b/packages/lib/errors/app-error.ts @@ -0,0 +1,144 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { TRPCClientError } from '@documenso/trpc/client'; + +/** + * Generic application error codes. + */ +export enum AppErrorCode { + 'ALREADY_EXISTS' = 'AlreadyExists', + 'EXPIRED_CODE' = 'ExpiredCode', + 'INVALID_BODY' = 'InvalidBody', + 'INVALID_REQUEST' = 'InvalidRequest', + 'NOT_FOUND' = 'NotFound', + 'NOT_SETUP' = 'NotSetup', + 'UNAUTHORIZED' = 'Unauthorized', + 'UNKNOWN_ERROR' = 'UnknownError', + 'RETRY_EXCEPTION' = 'RetryException', + 'SCHEMA_FAILED' = 'SchemaFailed', + 'TOO_MANY_REQUESTS' = 'TooManyRequests', +} + +const genericErrorCodeToTrpcErrorCodeMap: Record = { + [AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST', + [AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST', + [AppErrorCode.INVALID_BODY]: 'BAD_REQUEST', + [AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST', + [AppErrorCode.NOT_FOUND]: 'NOT_FOUND', + [AppErrorCode.NOT_SETUP]: 'BAD_REQUEST', + [AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED', + [AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS', +}; + +export const ZAppErrorJsonSchema = z.object({ + code: z.string(), + message: z.string().optional(), + userMessage: z.string().optional(), +}); + +export type TAppErrorJsonSchema = z.infer; + +export class AppError extends Error { + /** + * The error code. + */ + code: string; + + /** + * An error message which can be displayed to the user. + */ + userMessage?: string; + + /** + * Create a new AppError. + * + * @param errorCode A string representing the error code. + * @param message An internal error message. + * @param userMessage A error message which can be displayed to the user. + */ + public constructor(errorCode: string, message?: string, userMessage?: string) { + super(message || errorCode); + this.code = errorCode; + this.userMessage = userMessage; + } + + /** + * Parse an unknown value into an AppError. + * + * @param error An unknown type. + */ + static parseError(error: unknown): AppError { + if (error instanceof AppError) { + return error; + } + + // Handle TRPC errors. + if (error instanceof TRPCClientError) { + const parsedJsonError = AppError.parseFromJSONString(error.message); + return parsedJsonError || new AppError('UnknownError', error.message); + } + + // Handle completely unknown errors. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { code, message, userMessage } = error as { + code: unknown; + message: unknown; + status: unknown; + userMessage: unknown; + }; + + const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR; + const validMessage: string | undefined = typeof message === 'string' ? message : undefined; + const validUserMessage: string | undefined = + typeof userMessage === 'string' ? userMessage : undefined; + + return new AppError(validCode, validMessage, validUserMessage); + } + + static parseErrorToTRPCError(error: unknown): TRPCError { + const appError = AppError.parseError(error); + + return new TRPCError({ + code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST', + message: AppError.toJSONString(appError), + }); + } + + /** + * Convert an AppError into a JSON object which represents the error. + * + * @param appError The AppError to convert to JSON. + * @returns A JSON object representing the AppError. + */ + static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema { + return { + code, + message, + userMessage, + }; + } + + /** + * Convert an AppError into a JSON string containing the relevant information. + * + * @param appError The AppError to stringify. + * @returns A JSON string representing the AppError. + */ + static toJSONString(appError: AppError): string { + return JSON.stringify(AppError.toJSON(appError)); + } + + static parseFromJSONString(jsonString: string): AppError | null { + const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); + + if (!parsed.success) { + return null; + } + + return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); + } +} diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index b84f8e46e..93307a7b4 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentOptions = { title: string; userId: number; + teamId?: number; documentDataId: string; }; -export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => { - return await prisma.document.create({ - data: { - title, - documentDataId, - userId, - }, +export const createDocument = async ({ + userId, + title, + documentDataId, + teamId, +}: CreateDocumentOptions) => { + return await prisma.$transaction(async (tx) => { + if (teamId !== undefined) { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + } + + return await tx.document.create({ + data: { + title, + documentDataId, + userId, + teamId, + }, + }); }); }; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 18600ebe6..e128addea 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import { P, match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; -import type { Document, Prisma } from '@documenso/prisma/client'; +import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; @@ -10,6 +10,7 @@ import type { FindResultSet } from '../../types/find-result-set'; export type FindDocumentsOptions = { userId: number; + teamId?: number; term?: string; status?: ExtendedDocumentStatus; page?: number; @@ -19,21 +20,51 @@ export type FindDocumentsOptions = { direction: 'asc' | 'desc'; }; period?: '' | '7d' | '14d' | '30d'; + senderIds?: number[]; }; export const findDocuments = async ({ userId, + teamId, term, status = ExtendedDocumentStatus.ALL, page = 1, perPage = 10, orderBy, period, + senderIds, }: FindDocumentsOptions) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, + // Todo: Teams - deletedAt + + const { user, team } = await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + let team = null; + + if (teamId !== undefined) { + team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + include: { + teamEmail: true, + }, + }); + } + + return { + user, + team, + }; }); const orderByColumn = orderBy?.column ?? 'createdAt'; @@ -50,11 +81,79 @@ export const findDocuments = async ({ }) .otherwise(() => undefined); - const filters = match(status) + const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user); + + if (filters === null) { + return { + data: [], + count: 0, + currentPage: 1, + perPage, + totalPages: 0, + }; + } + + const whereClause: Prisma.DocumentWhereInput = { + ...termFilters, + ...filters, + }; + + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + whereClause.createdAt = { + gte: startOfPeriod.toJSDate(), + }; + } + + if (senderIds && senderIds.length > 0) { + whereClause.userId = { + in: senderIds, + }; + } + + const [data, count] = await Promise.all([ + prisma.document.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + User: { + select: { + id: true, + name: true, + email: true, + }, + }, + Recipient: true, + }, + }), + prisma.document.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; + +const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => { + return match(status) .with(ExtendedDocumentStatus.ALL, () => ({ OR: [ { - userId, + userId: user.id, + teamId: null, deletedAt: null, }, { @@ -89,14 +188,16 @@ export const findDocuments = async ({ deletedAt: null, })) .with(ExtendedDocumentStatus.DRAFT, () => ({ - userId, + userId: user.id, + teamId: null, status: ExtendedDocumentStatus.DRAFT, deletedAt: null, })) .with(ExtendedDocumentStatus.PENDING, () => ({ OR: [ { - userId, + userId: user.id, + teamId: null, status: ExtendedDocumentStatus.PENDING, deletedAt: null, }, @@ -115,7 +216,8 @@ export const findDocuments = async ({ .with(ExtendedDocumentStatus.COMPLETED, () => ({ OR: [ { - userId, + userId: user.id, + teamId: null, status: ExtendedDocumentStatus.COMPLETED, deletedAt: null, }, @@ -130,54 +232,154 @@ export const findDocuments = async ({ ], })) .exhaustive(); +}; - const whereClause = { - ...termFilters, - ...filters, - }; +/** + * Create a Prisma filter for the Document schema to find documents for a team. + * + * Status All: + * - Documents that belong to the team + * - Documents that have been sent by the team email + * - Non draft documents that have been sent to the team email + * + * Status Inbox: + * - Non draft documents that have been sent to the team email that have not been signed + * + * Status Draft: + * - Documents that belong to the team that are draft + * + * Status Pending: + * - Documents that belong to the team that are pending + * - Documents that have been sent to the team email that is pending to be signed + * - Documents that have been sent by the team email that is pending to be signed + * + * Status Completed: + * - Documents that belong to the team that are completed + * - Documents that have been sent to the team email that have been signed + * - Documents that have been sent by the team email that have been signed + * + * @param status The status of the documents to find. + * @param team The team to find the documents for. + * @returns A filter which can be applied to the Prisma Document schema. + */ +const findTeamDocumentsFilter = ( + status: ExtendedDocumentStatus, + team: Team & { teamEmail: TeamEmail | null }, +) => { + const teamEmail = team.teamEmail?.email ?? null; - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); + return match(status) + .with(ExtendedDocumentStatus.ALL, () => { + const filter: Prisma.DocumentWhereInput = { + // Filter to display all documents that belong to the team. + OR: [ + { + teamId: team.id, + }, + ], + }; - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + if (teamEmail && filter.OR) { + // Filter to display all documents received by the team email that are not draft. + filter.OR.push({ + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: teamEmail, + }, + }, + }); - whereClause.createdAt = { - gte: startOfPeriod.toJSDate(), - }; - } + // Filter to display all documents that have been sent by the team email. + filter.OR.push({ + User: { + email: teamEmail, + }, + }); + } - const [data, count] = await Promise.all([ - prisma.document.findMany({ - where: whereClause, - skip: Math.max(page - 1, 0) * perPage, - take: perPage, - orderBy: { - [orderByColumn]: orderByDirection, - }, - include: { - User: { - select: { - id: true, - name: true, - email: true, + return filter; + }) + .with(ExtendedDocumentStatus.INBOX, () => { + // Return a filter that will return nothing. + // Todo: Teams - Should be a better way to do this. + if (!teamEmail) { + return null; + } + + return { + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.NOT_SIGNED, }, }, - Recipient: true, - }, - }), - prisma.document.count({ - where: { - ...termFilters, - ...filters, - }, - }), - ]); + }; + }) + .with(ExtendedDocumentStatus.DRAFT, () => ({ + teamId: team.id, + status: ExtendedDocumentStatus.DRAFT, + })) + .with(ExtendedDocumentStatus.PENDING, () => { + const filter: Prisma.DocumentWhereInput = { + OR: [ + { + teamId: team.id, + status: ExtendedDocumentStatus.PENDING, + }, + ], + }; - return { - data, - count, - currentPage: Math.max(page, 1), - perPage, - totalPages: Math.ceil(count / perPage), - } satisfies FindResultSet; + if (teamEmail && filter.OR) { + // Filter to display all documents received by the team email that are pending. + filter.OR.push({ + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + }, + }, + }); + + // Filter to display all documents that have been sent by the team email that are pending. + filter.OR.push({ + status: ExtendedDocumentStatus.PENDING, + User: { + email: teamEmail, + }, + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.COMPLETED, () => { + const filter: Prisma.DocumentWhereInput = { + OR: [ + { + teamId: team.id, + status: ExtendedDocumentStatus.COMPLETED, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push({ + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: teamEmail, + }, + }, + }); + } + + return filter; + }) + .exhaustive(); }; diff --git a/packages/lib/server-only/document/get-document-by-id.ts b/packages/lib/server-only/document/get-document-by-id.ts index 0b599a71c..f16b4940a 100644 --- a/packages/lib/server-only/document/get-document-by-id.ts +++ b/packages/lib/server-only/document/get-document-by-id.ts @@ -10,6 +10,24 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) => where: { id, userId, + OR: [ + { + team: { + is: null, + }, + }, + { + team: { + is: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + ], }, include: { documentData: true, diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index a446b0007..ee91974e8 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,15 +1,63 @@ import { prisma } from '@documenso/prisma'; import type { User } from '@documenso/prisma/client'; +import type { Prisma } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; -export type GetStatsInput = { - user: User; +type TeamStatsOptions = { + teamId: number; + teamEmail?: string; + senderIds?: number[]; }; -export const getStats = async ({ user }: GetStatsInput) => { - const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ +export type GetStatsInput = { + user: User; + team?: TeamStatsOptions; +}; + +export const getStats = async ({ user, ...options }: GetStatsInput) => { + const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team + ? getTeamCounts({ team: options.team }) + : getCounts(user)); + + const stats: Record = { + [ExtendedDocumentStatus.DRAFT]: 0, + [ExtendedDocumentStatus.PENDING]: 0, + [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.INBOX]: 0, + [ExtendedDocumentStatus.ALL]: 0, + }; + + ownerCounts.forEach((stat) => { + stats[stat.status] = stat._count._all; + }); + + notSignedCounts.forEach((stat) => { + stats[ExtendedDocumentStatus.INBOX] += stat._count._all; + }); + + hasSignedCounts.forEach((stat) => { + if (stat.status === ExtendedDocumentStatus.COMPLETED) { + stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; + } + + if (stat.status === ExtendedDocumentStatus.PENDING) { + stats[ExtendedDocumentStatus.PENDING] += stat._count._all; + } + }); + + Object.keys(stats).forEach((key) => { + if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { + stats[ExtendedDocumentStatus.ALL] += stats[key]; + } + }); + + return stats; +}; + +const getCounts = async (user: User) => { + return Promise.all([ prisma.document.groupBy({ by: ['status'], _count: { @@ -17,6 +65,7 @@ export const getStats = async ({ user }: GetStatsInput) => { }, where: { userId: user.id, + teamId: null, deletedAt: null, }, }), @@ -66,38 +115,110 @@ export const getStats = async ({ user }: GetStatsInput) => { }, }), ]); +}; - const stats: Record = { - [ExtendedDocumentStatus.DRAFT]: 0, - [ExtendedDocumentStatus.PENDING]: 0, - [ExtendedDocumentStatus.COMPLETED]: 0, - [ExtendedDocumentStatus.INBOX]: 0, - [ExtendedDocumentStatus.ALL]: 0, +const getTeamCounts = async ({ team }: { team: TeamStatsOptions }) => { + const { teamId, teamEmail } = team; + + const senderIds = team.senderIds ?? []; + + const userIdWhereClause: Prisma.DocumentWhereInput['userId'] = + senderIds.length > 0 + ? { + in: senderIds, + } + : undefined; + + let ownerCountsWhereInput: Prisma.DocumentWhereInput = { + userId: userIdWhereClause, + teamId, + deletedAt: null, }; - ownerCounts.forEach((stat) => { - stats[stat.status] = stat._count._all; - }); + if (teamEmail && senderIds.length === 0) { + ownerCountsWhereInput = { + OR: [ + { + teamId, + }, + { + User: { + email: teamEmail, + }, + }, + ], + }; + } - notSignedCounts.forEach((stat) => { - stats[ExtendedDocumentStatus.INBOX] += stat._count._all; - }); + if (teamEmail && senderIds.length > 0) { + ownerCountsWhereInput = { + userId: userIdWhereClause, + OR: [ + { + teamId, + }, + { + User: { + email: teamEmail, + }, + }, + ], + deletedAt: null, + }; + } - hasSignedCounts.forEach((stat) => { - if (stat.status === ExtendedDocumentStatus.COMPLETED) { - stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; - } + let notSignedCountsGroupByArgs = null; - if (stat.status === ExtendedDocumentStatus.PENDING) { - stats[ExtendedDocumentStatus.PENDING] += stat._count._all; - } - }); + if (teamEmail) { + notSignedCountsGroupByArgs = { + by: ['status'], + _count: { + _all: true, + }, + where: { + userId: userIdWhereClause, + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.NOT_SIGNED, + }, + }, + deletedAt: null, + }, + } satisfies Prisma.DocumentGroupByArgs; + } - Object.keys(stats).forEach((key) => { - if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { - stats[ExtendedDocumentStatus.ALL] += stats[key]; - } - }); + let hasSignedCountsGroupByArgs = null; - return stats; + if (teamEmail) { + hasSignedCountsGroupByArgs = { + by: ['status'], + _count: { + _all: true, + }, + where: { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + }, + }, + deletedAt: null, + }, + } satisfies Prisma.DocumentGroupByArgs; + } + + return Promise.all([ + prisma.document.groupBy({ + by: ['status'], + _count: { + _all: true, + }, + where: ownerCountsWhereInput, + }), + notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [], + hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [], + ]); }; diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index bd14d49b2..49a7a7a8c 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -26,6 +26,24 @@ export const setFieldsForDocument = async ({ where: { id: documentId, userId, + OR: [ + { + team: { + is: null, + }, + }, + { + team: { + is: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 198f79be1..3aaf47677 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -22,6 +22,24 @@ export const setRecipientsForDocument = async ({ where: { id: documentId, userId, + OR: [ + { + team: { + is: null, + }, + }, + { + team: { + is: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/team/accept-team-invitation.ts b/packages/lib/server-only/team/accept-team-invitation.ts new file mode 100644 index 000000000..54f40b9bf --- /dev/null +++ b/packages/lib/server-only/team/accept-team-invitation.ts @@ -0,0 +1,60 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; +import { getTeamSeatPriceId } from '../../utils/billing'; + +export type AcceptTeamInvitationOptions = { + userId: number; + teamId: number; +}; + +export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ + where: { + teamId, + email: user.email, + }, + include: { + team: true, + }, + }); + + const { team } = teamMemberInvite; + + await tx.teamMember.create({ + data: { + teamId: teamMemberInvite.teamId, + userId: user.id, + role: teamMemberInvite.role, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: teamMemberInvite.id, + }, + }); + + if (IS_BILLING_ENABLED && team.subscriptionId) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId: teamMemberInvite.teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: getTeamSeatPriceId(), + subscriptionId: team.subscriptionId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/add-team-email-verification.ts b/packages/lib/server-only/team/add-team-email-verification.ts new file mode 100644 index 000000000..18c038620 --- /dev/null +++ b/packages/lib/server-only/team/add-team-email-verification.ts @@ -0,0 +1,133 @@ +import { createElement } from 'react'; + +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +import { WEBAPP_BASE_URL } from '../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { createTokenVerification } from '../../utils/token-verification'; + +export type AddTeamEmailVerificationOptions = { + userId: number; + teamId: number; + data: { + email: string; + name: string; + }; +}; + +export const addTeamEmailVerification = async ({ + userId, + teamId, + data, +}: AddTeamEmailVerificationOptions) => { + try { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + teamEmail: true, + emailVerification: true, + }, + }); + + if (team.teamEmail || team.emailVerification) { + throw new AppError( + AppErrorCode.INVALID_REQUEST, + 'Team already has an email or existing email verification.', + ); + } + + const existingTeamEmail = await tx.teamEmail.findFirst({ + where: { + email: data.email, + }, + }); + + if (existingTeamEmail) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } + + const { token, expiresAt } = createTokenVerification({ hours: 1 }); + + await tx.teamEmailVerification.create({ + data: { + token, + expiresAt, + email: data.email, + name: data.name, + teamId, + }, + }); + + await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url); + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('email')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } + + throw err; + } +}; + +/** + * Send an email to a user asking them to accept a team email request. + * + * @param email The email address to use for the team. + * @param token The token used to authenticate that the user has granted access. + * @param teamName The name of the team the user is being invited to. + * @param teamUrl The url of the team the user is being invited to. + */ +export const sendTeamEmailVerificationEmail = async ( + email: string, + token: string, + teamName: string, + teamUrl: string, +) => { + const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; + + const template = createElement(ConfirmTeamEmailTemplate, { + assetBaseUrl, + baseUrl: WEBAPP_BASE_URL, + teamName, + teamUrl, + token, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `A request to use your email has been initiated by ${teamName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/team/create-team-checkout-session.ts b/packages/lib/server-only/team/create-team-checkout-session.ts new file mode 100644 index 000000000..100ebd566 --- /dev/null +++ b/packages/lib/server-only/team/create-team-checkout-session.ts @@ -0,0 +1,51 @@ +import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; +import { getStripeCustomerIdByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { prisma } from '@documenso/prisma'; + +import { WEBAPP_BASE_URL } from '../../constants/app'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { getTeamSeatPriceId } from '../../utils/billing'; + +export type CreateTeamPendingCheckoutSession = { + userId: number; + pendingTeamId: number; +}; + +export const createTeamPendingCheckoutSession = async ({ + userId, + pendingTeamId, +}: CreateTeamPendingCheckoutSession) => { + const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({ + where: { + id: pendingTeamId, + ownerUserId: userId, + }, + include: { + owner: true, + }, + }); + + const stripeCustomerId = await getStripeCustomerIdByUser(teamPendingCreation.owner); + + try { + const stripeCheckoutSession = await getCheckoutSession({ + customerId: stripeCustomerId, + priceId: getTeamSeatPriceId(), + returnUrl: `${WEBAPP_BASE_URL}/settings/teams`, + subscriptionMetadata: { + pendingTeamId: pendingTeamId.toString(), + }, + }); + + if (!stripeCheckoutSession) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR); + } + + return stripeCheckoutSession; + } catch (e) { + console.error(e); + + // Absorb all the errors incase stripe throws something sensitive. + throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.'); + } +}; diff --git a/packages/lib/server-only/team/create-team-member-invites.ts b/packages/lib/server-only/team/create-team-member-invites.ts new file mode 100644 index 000000000..214fe4f45 --- /dev/null +++ b/packages/lib/server-only/team/create-team-member-invites.ts @@ -0,0 +1,157 @@ +import { createElement } from 'react'; + +import { nanoid } from 'nanoid'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite'; +import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; + +import { WEBAPP_BASE_URL } from '../../constants/app'; +import { AppError } from '../../errors/app-error'; +import { getTeamById } from './get-teams'; + +export type CreateTeamMemberInvitesOptions = { + userId: number; + userName: string; + teamId: number; + invitations: TCreateTeamMemberInvitesMutationSchema['invitations']; +}; + +/** + * Invite team members via email to join a team. + */ +export const createTeamMemberInvites = async ({ + userId, + userName, + teamId, + invitations, +}: CreateTeamMemberInvitesOptions) => { + const [team, currentTeamMemberEmails, currentTeamMemberInviteEmails] = await Promise.all([ + getTeamById({ userId, teamId }), + getTeamMemberEmails(teamId), + getTeamInvites(teamId), + ]); + + const usersToInvite = invitations.filter((invitation) => { + // Filter out users that are already members of the team. + if (currentTeamMemberEmails.includes(invitation.email)) { + return false; + } + + // Filter out users that have already been invited to the team. + if (currentTeamMemberInviteEmails.includes(invitation.email)) { + return false; + } + + return true; + }); + + const teamMemberInvites = usersToInvite.map(({ email, role }) => ({ + email, + teamId, + role, + status: TeamMemberInviteStatus.PENDING, + token: nanoid(32), + })); + + await prisma.teamMemberInvite.createMany({ + data: teamMemberInvites, + }); + + const sendEmailResult = await Promise.allSettled( + teamMemberInvites.map(async ({ email, token }) => + sendTeamMemberInviteEmail({ + email, + token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }), + ), + ); + + const sendEmailResultErrorList = sendEmailResult.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (sendEmailResultErrorList.length > 0) { + console.error(JSON.stringify(sendEmailResultErrorList)); + + throw new AppError( + 'EmailDeliveryFailed', + 'Failed to send invite emails to one or more users.', + `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`, + ); + } +}; + +type SendTeamMemberInviteEmailOptions = Omit & { + email: string; +}; + +/** + * Send an email to a user inviting them to join a team. + */ +export const sendTeamMemberInviteEmail = async ({ + email, + ...emailTemplateOptions +}: SendTeamMemberInviteEmailOptions) => { + const template = createElement(TeamInviteEmailTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + ...emailTemplateOptions, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; + +/** + * Returns a list of emails of the team members for a given team. + * + * @param teamId The ID of the team. + * @returns All team member emails for a given team. + */ +const getTeamMemberEmails = async (teamId: number) => { + const teamMembers = await prisma.teamMember.findMany({ + where: { + teamId, + }, + include: { + user: true, + }, + }); + + return teamMembers.map((teamMember) => teamMember.user.email); +}; + +/** + * Returns a list of emails that have been invited to join a team. + * + * This list will not include users who have accepted and created an account. + * + * @param teamId The ID of the team. + * @returns All the emails of users that have been invited to join a team. + */ +const getTeamInvites = async (teamId: number) => { + const teamMemberInvites = await prisma.teamMemberInvite.findMany({ + where: { + teamId, + }, + }); + + return teamMemberInvites.map((teamMemberInvite) => teamMemberInvite.email); +}; diff --git a/packages/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts new file mode 100644 index 000000000..4417dd5f6 --- /dev/null +++ b/packages/lib/server-only/team/create-team.ts @@ -0,0 +1,200 @@ +import { z } from 'zod'; + +import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; +import { getStripeCustomerIdByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { + getTeamSeatPriceId, + isSomeSubscriptionsActiveAndCommunityPlan, +} from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import { Prisma, TeamMemberRole } from '@documenso/prisma/client'; + +import { IS_BILLING_ENABLED, WEBAPP_BASE_URL } from '../../constants/app'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { stripe } from '../stripe'; + +export type CreateTeamOptions = { + /** + * ID of the user creating the Team. + */ + userId: number; + + /** + * Name of the team to display. + */ + name: string; + + /** + * Unique URL of the team. + * + * Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings + */ + teamUrl: string; +}; + +export type CreateTeamResponse = + | { + paymentRequired: false; + } + | { + paymentRequired: true; + checkoutUrl: string; + }; + +/** + * Create a team or pending team depending on the user's subscription or application's billing settings. + */ +export const createTeam = async ({ + name, + userId, + teamUrl, +}: CreateTeamOptions): Promise => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + include: { + Subscription: true, + }, + }); + + const isUserSubscriptionValidForTeams = isSomeSubscriptionsActiveAndCommunityPlan( + user.Subscription, + ); + + const isPaymentRequired = IS_BILLING_ENABLED && !isUserSubscriptionValidForTeams; + + try { + // Create the team directly if no payment is required. + if (!isPaymentRequired) { + await prisma.team.create({ + data: { + name, + url: teamUrl, + ownerUserId: user.id, + members: { + create: [ + { + userId, + role: TeamMemberRole.ADMIN, + }, + ], + }, + }, + }); + + return { + paymentRequired: false, + }; + } + + // Create a pending team if payment is required. + return await prisma.$transaction(async (tx) => { + const existingTeamWithUrl = await tx.team.findUnique({ + where: { + url: teamUrl, + }, + }); + + if (existingTeamWithUrl) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + const pendingTeam = await tx.teamPending.create({ + data: { + name, + url: teamUrl, + ownerUserId: user.id, + }, + }); + + const stripeCustomerId = await getStripeCustomerIdByUser(user); + + const stripeCheckoutSession = await getCheckoutSession({ + customerId: stripeCustomerId, + priceId: getTeamSeatPriceId(), + returnUrl: `${WEBAPP_BASE_URL}/settings/teams`, + subscriptionMetadata: { + pendingTeamId: pendingTeam.id.toString(), + }, + }); + + if (!stripeCheckoutSession) { + throw new AppError('Unable to create checkout session'); + } + + return { + paymentRequired: true, + checkoutUrl: stripeCheckoutSession, + }; + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('url')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + throw err; + } +}; + +export type CreateTeamFromPendingTeamOptions = { + pendingTeamId: number; + subscriptionId: string; +}; + +export const createTeamFromPendingTeam = async ({ + pendingTeamId, + subscriptionId, +}: CreateTeamFromPendingTeamOptions) => { + await prisma.$transaction(async (tx) => { + const pendingTeam = await tx.teamPending.findUniqueOrThrow({ + where: { + id: pendingTeamId, + }, + }); + + await tx.teamPending.delete({ + where: { + id: pendingTeamId, + }, + }); + + const team = await tx.team.create({ + data: { + name: pendingTeam.name, + url: pendingTeam.url, + ownerUserId: pendingTeam.ownerUserId, + subscriptionId, + members: { + create: [ + { + userId: pendingTeam.ownerUserId, + role: TeamMemberRole.ADMIN, + }, + ], + }, + }, + }); + + // Attach the team ID to the subscription metadata so we can keep track of it if the team changes ownership. + await stripe.subscriptions + .update(subscriptionId, { + metadata: { + teamId: team.id.toString(), + }, + }) + .catch((e) => { + console.error(e); + // Non-critical error, but we want to log it so we can rectify it. + // Todo: Teams - Send alert. + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-email-verification.ts b/packages/lib/server-only/team/delete-team-email-verification.ts new file mode 100644 index 000000000..fee39553f --- /dev/null +++ b/packages/lib/server-only/team/delete-team-email-verification.ts @@ -0,0 +1,34 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamEmailVerificationOptions = { + userId: number; + teamId: number; +}; + +export const deleteTeamEmailVerification = async ({ + userId, + teamId, +}: DeleteTeamEmailVerificationOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + await tx.teamEmailVerification.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-email.ts b/packages/lib/server-only/team/delete-team-email.ts new file mode 100644 index 000000000..1cbed4eb4 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-email.ts @@ -0,0 +1,46 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamEmailOptions = { + userId: number; + userEmail: string; + teamId: number; +}; + +/** + * Delete a team email. + * + * The user must either be part of the team with the required permissions, or the owner of the email. + */ +export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + OR: [ + { + teamEmail: { + email: userEmail, + }, + }, + { + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + ], + }, + }); + + await tx.teamEmail.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-invitations.ts b/packages/lib/server-only/team/delete-team-invitations.ts new file mode 100644 index 000000000..10551baa1 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-invitations.ts @@ -0,0 +1,47 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type DeleteTeamMemberInvitationsOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the team to remove members from. + */ + teamId: number; + + /** + * The IDs of the team members to remove. + */ + invitationIds: number[]; +}; + +export const deleteTeamMemberInvitations = async ({ + userId, + teamId, + invitationIds, +}: DeleteTeamMemberInvitationsOptions) => { + await prisma.$transaction(async (tx) => { + await tx.teamMember.findFirstOrThrow({ + where: { + userId, + teamId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_INVITATIONS'], + }, + }, + }); + + await tx.teamMemberInvite.deleteMany({ + where: { + id: { + in: invitationIds, + }, + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-members.ts b/packages/lib/server-only/team/delete-team-members.ts new file mode 100644 index 000000000..6c8196c42 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-members.ts @@ -0,0 +1,73 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; +import { getTeamSeatPriceId } from '../../utils/billing'; + +export type DeleteTeamMembersOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the team to remove members from. + */ + teamId: number; + + /** + * The IDs of the team members to remove. + */ + teamMemberIds: number[]; +}; + +export const deleteTeamMembers = async ({ + userId, + teamId, + teamMemberIds, +}: DeleteTeamMembersOptions) => { + await prisma.$transaction(async (tx) => { + // Find the team and validate that the user is allowed to remove members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_MEMBERS'], + }, + }, + }, + }, + }); + + // Remove the team members. + await tx.teamMember.deleteMany({ + where: { + id: { + in: teamMemberIds, + }, + teamId, + userId: { + not: team.ownerUserId, + }, + }, + }); + + if (IS_BILLING_ENABLED && team.subscriptionId) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: getTeamSeatPriceId(), + subscriptionId: team.subscriptionId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/delete-team-pending.ts b/packages/lib/server-only/team/delete-team-pending.ts new file mode 100644 index 000000000..b339fd862 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-pending.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamPendingOptions = { + userId: number; + pendingTeamId: number; +}; + +export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => { + await prisma.teamPending.delete({ + where: { + id: pendingTeamId, + ownerUserId: userId, + }, + }); +}; diff --git a/packages/lib/server-only/team/delete-team-transfer-request.ts b/packages/lib/server-only/team/delete-team-transfer-request.ts new file mode 100644 index 000000000..72b2330ed --- /dev/null +++ b/packages/lib/server-only/team/delete-team-transfer-request.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type DeleteTeamTransferRequestOptions = { + /** + * The ID of the user deleting the transfer. + */ + userId: number; + + /** + * The ID of the team whose team transfer invitation should be deleted. + */ + teamId: number; +}; + +export const deleteTeamTransferRequest = async ({ + userId, + teamId, +}: DeleteTeamTransferRequestOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'], + }, + }, + }, + }, + }); + + await tx.teamTransferVerification.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts new file mode 100644 index 000000000..624b8bc53 --- /dev/null +++ b/packages/lib/server-only/team/delete-team.ts @@ -0,0 +1,39 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError } from '../../errors/app-error'; +import { stripe } from '../stripe'; + +export type DeleteTeamOptions = { + userId: number; + teamId: number; +}; + +export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + }, + }); + + if (team.subscriptionId !== null) { + await stripe.subscriptions + .cancel(team.subscriptionId, { + prorate: true, + invoice_now: true, + }) + .catch((err) => { + console.error(err); + throw AppError.parseError(err); + }); + } + + await tx.team.delete({ + where: { + id: teamId, + ownerUserId: userId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/find-team-invoices.ts b/packages/lib/server-only/team/find-team-invoices.ts new file mode 100644 index 000000000..995b2b614 --- /dev/null +++ b/packages/lib/server-only/team/find-team-invoices.ts @@ -0,0 +1,48 @@ +import { getTeamInvoices } from '@documenso/ee/server-only/stripe/get-team-invoices'; +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export interface FindTeamInvoicesOptions { + userId: number; + teamId: number; +} + +export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => { + await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + const results = await getTeamInvoices({ teamId }); + + if (!results) { + return null; + } + + return { + ...results, + data: results.data.map((invoice) => ({ + invoicePdf: invoice.invoice_pdf, + hostedInvoicePdf: invoice.hosted_invoice_url, + status: invoice.status, + subtotal: invoice.subtotal, + total: invoice.total, + amountPaid: invoice.amount_paid, + amountDue: invoice.amount_due, + created: invoice.created, + paid: invoice.paid, + quantity: invoice.lines.data[0].quantity ?? 0, + currency: invoice.currency, + })), + }; +}; diff --git a/packages/lib/server-only/team/find-team-member-invites.ts b/packages/lib/server-only/team/find-team-member-invites.ts new file mode 100644 index 000000000..13dd1cea7 --- /dev/null +++ b/packages/lib/server-only/team/find-team-member-invites.ts @@ -0,0 +1,88 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { TeamMemberInvite } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamMemberInvitesOptions { + userId: number; + teamId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof TeamMemberInvite; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamMemberInvites = async ({ + userId, + teamId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamMemberInvitesOptions) => { + const orderByColumn = orderBy?.column ?? 'email'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the team they are trying to find invites in. + const userTeam = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + // Todo: Teams - Should only certain roles be able to find members? + }, + }); + + const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + email: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.TeamMemberInviteWhereInput = { + ...termFilters, + teamId: userTeam.id, + }; + + const [data, count] = await Promise.all([ + prisma.teamMemberInvite.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + // Exclude token attribute. + select: { + id: true, + teamId: true, + email: true, + role: true, + createdAt: true, + }, + }), + prisma.teamMemberInvite.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/find-team-members.ts b/packages/lib/server-only/team/find-team-members.ts new file mode 100644 index 000000000..fe75958e6 --- /dev/null +++ b/packages/lib/server-only/team/find-team-members.ts @@ -0,0 +1,99 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import { Prisma, TeamMember } from '@documenso/prisma/client'; + +import { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamMembersOptions { + userId: number; + teamId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof TeamMember | 'name'; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamMembers = async ({ + userId, + teamId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamMembersOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the team they are trying to find members in. + const userTeam = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + // Todo: Teams - Should only certain roles be able to find members? + }, + }); + + const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + user: { + name: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.TeamMemberWhereInput = { + ...termFilters, + teamId: userTeam.id, + }; + + let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = { + [orderByColumn]: orderByDirection, + }; + + if (orderByColumn === 'name') { + orderByClause = { + user: { + name: orderByDirection, + }, + }; + } + + const [data, count] = await Promise.all([ + prisma.teamMember.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: orderByClause, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }), + prisma.teamMember.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/find-teams-pending.ts b/packages/lib/server-only/team/find-teams-pending.ts new file mode 100644 index 000000000..d079c6f5f --- /dev/null +++ b/packages/lib/server-only/team/find-teams-pending.ts @@ -0,0 +1,58 @@ +import { prisma } from '@documenso/prisma'; +import type { Team } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindTeamsPendingOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Team; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamsPending = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamsPendingOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.TeamPendingWhereInput = { + ownerUserId: userId, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.teamPending.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.teamPending.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/lib/server-only/team/find-teams.ts b/packages/lib/server-only/team/find-teams.ts new file mode 100644 index 000000000..17a98e6f8 --- /dev/null +++ b/packages/lib/server-only/team/find-teams.ts @@ -0,0 +1,77 @@ +import { prisma } from '@documenso/prisma'; +import type { Team } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamsOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Team; + direction: 'asc' | 'desc'; + }; +} + +export const findTeams = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamsOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.TeamWhereInput = { + members: { + some: { + userId, + }, + }, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.team.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + members: { + where: { + userId, + }, + }, + }, + }), + prisma.team.count({ + where: whereClause, + }), + ]); + + const maskedData = data.map((team) => ({ + ...team, + currentTeamMember: team.members[0], + members: undefined, + })); + + return { + data: maskedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/get-team-email-by-email.ts b/packages/lib/server-only/team/get-team-email-by-email.ts new file mode 100644 index 000000000..665694db4 --- /dev/null +++ b/packages/lib/server-only/team/get-team-email-by-email.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamEmailByEmailOptions = { + email: string; +}; + +export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => { + return await prisma.teamEmail.findFirst({ + where: { + email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team-invitations.ts b/packages/lib/server-only/team/get-team-invitations.ts new file mode 100644 index 000000000..737f1b3f7 --- /dev/null +++ b/packages/lib/server-only/team/get-team-invitations.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamInvitationsOptions = { + email: string; +}; + +export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => { + return await prisma.teamMemberInvite.findMany({ + where: { + email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team-members.ts b/packages/lib/server-only/team/get-team-members.ts new file mode 100644 index 000000000..8194cca75 --- /dev/null +++ b/packages/lib/server-only/team/get-team-members.ts @@ -0,0 +1,52 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type GetTeamMembersOptions = { + /** + * The optional ID of the user initiating the request. + * + * If provided, the user will be checked to ensure they are a member of the team. + */ + userId?: number; + + /** + * The ID of the team to retrieve members from. + */ + teamId: number; +}; + +/** + * Get all team members for a given teamId. + * + * Provide an optional userId to check that the user is a member of the team. + */ +export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => { + const teamMembers = await prisma.teamMember.findMany({ + where: { + teamId, + }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + + if (userId !== undefined) { + const teamMember = teamMembers.find((teamMember) => teamMember.userId === userId); + + if (!teamMember) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + `User ${userId} is not a member of team ${teamId}`, + ); + } + } + + return teamMembers; +}; diff --git a/packages/lib/server-only/team/get-teams.ts b/packages/lib/server-only/team/get-teams.ts new file mode 100644 index 000000000..38736ee40 --- /dev/null +++ b/packages/lib/server-only/team/get-teams.ts @@ -0,0 +1,113 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +export type GetTeamsOptions = { + userId: number; +}; +export type GetTeamsResponse = Awaited>; + +export const getTeams = async ({ userId }: GetTeamsOptions) => { + const teams = await prisma.team.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + include: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + return teams.map(({ members, ...team }) => ({ + ...team, + currentTeamMember: members[0], + })); +}; + +export type GetTeamByIdOptions = { + userId?: number; + teamId: number; +}; + +/** + * Get a team given a teamId. + * + * Provide an optional userId to check that the user is a member of the team. + */ +export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => { + const whereFilter: Prisma.TeamWhereUniqueInput = { + id: teamId, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + return await prisma.team.findUniqueOrThrow({ + where: whereFilter, + include: { + teamEmail: true, + }, + }); +}; + +export type GetTeamByUrlOptions = { + userId?: number; + teamUrl: string; +}; + +/** + * Get a team given a teamId. + * + * Provide an optional userId to check that the user is a member of the team. + */ +export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => { + const whereFilter: Prisma.TeamWhereUniqueInput = { + url: teamUrl, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.team.findUniqueOrThrow({ + where: whereFilter, + include: { + teamEmail: true, + emailVerification: true, + transferVerification: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...team } = result; + + return { + ...team, + currentTeamMember: members[0], + }; +}; diff --git a/packages/lib/server-only/team/leave-team.ts b/packages/lib/server-only/team/leave-team.ts new file mode 100644 index 000000000..a873cb5fe --- /dev/null +++ b/packages/lib/server-only/team/leave-team.ts @@ -0,0 +1,27 @@ +import { prisma } from '@documenso/prisma'; + +export type LeaveTeamOptions = { + /** + * The ID of the user who is leaving the team. + */ + userId: number; + + /** + * The ID of the team the user is leaving. + */ + teamId: number; +}; + +export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { + await prisma.teamMember.deleteMany({ + where: { + teamId, + userId, + team: { + ownerUserId: { + not: userId, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/request-team-ownership-transfer.ts b/packages/lib/server-only/team/request-team-ownership-transfer.ts new file mode 100644 index 000000000..aa2e963cf --- /dev/null +++ b/packages/lib/server-only/team/request-team-ownership-transfer.ts @@ -0,0 +1,97 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; + +export type RequestTeamOwnershipTransferOptions = { + /** + * The ID of the user initiating the transfer. + */ + userId: number; + + /** + * The name of the user initiating the transfer. + */ + userName: string; + + /** + * The ID of the team whose ownership is being transferred. + */ + teamId: number; + + /** + * The user ID of the new owner. + */ + newOwnerUserId: number; +}; + +export const requestTeamOwnershipTransfer = async ({ + userId, + userName, + teamId, + newOwnerUserId, +}: RequestTeamOwnershipTransferOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + members: { + some: { + userId: newOwnerUserId, + }, + }, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + }, + }); + + const { token, expiresAt } = createTokenVerification({ minute: 10 }); + + const teamVerificationPayload = { + teamId, + token, + expiresAt, + userId: newOwnerUserId, + name: newOwnerUser.name ?? '', + email: newOwnerUser.email, + }; + + await tx.teamTransferVerification.upsert({ + where: { + teamId, + }, + create: teamVerificationPayload, + update: teamVerificationPayload, + }); + + const template = createElement(TeamTransferRequestTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + senderName: userName, + teamName: team.name, + teamUrl: team.url, + token, + }); + + await mailer.sendMail({ + to: newOwnerUser.email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been requested to take ownership of team ${team.name} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); + }); +}; diff --git a/packages/lib/server-only/team/resend-team-email-verification.ts b/packages/lib/server-only/team/resend-team-email-verification.ts new file mode 100644 index 000000000..c1b9c1e79 --- /dev/null +++ b/packages/lib/server-only/team/resend-team-email-verification.ts @@ -0,0 +1,65 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; +import { AppError } from '../../errors/app-error'; +import { createTokenVerification } from '../../utils/token-verification'; +import { sendTeamEmailVerificationEmail } from './add-team-email-verification'; + +export type ResendTeamMemberInvitationOptions = { + userId: number; + teamId: number; +}; + +/** + * Resend a team email verification with a new token. + */ +export const resendTeamEmailVerification = async ({ + userId, + teamId, +}: ResendTeamMemberInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + emailVerification: true, + }, + }); + + if (!team) { + throw new AppError('TeamNotFound', 'User is not a member of the team.'); + } + + const { emailVerification } = team; + + if (!emailVerification) { + throw new AppError( + 'VerificationNotFound', + 'No team email verification exists for this team.', + ); + } + + const { token, expiresAt } = createTokenVerification({ hours: 1 }); + + await tx.teamEmailVerification.update({ + where: { + teamId, + }, + data: { + token, + expiresAt, + }, + }); + + await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url); + }); +}; diff --git a/packages/lib/server-only/team/resend-team-member-invitation.ts b/packages/lib/server-only/team/resend-team-member-invitation.ts new file mode 100644 index 000000000..950986fad --- /dev/null +++ b/packages/lib/server-only/team/resend-team-member-invitation.ts @@ -0,0 +1,76 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; +import { AppError } from '../../errors/app-error'; +import { sendTeamMemberInviteEmail } from './create-team-member-invites'; + +export type ResendTeamMemberInvitationOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The name of hte user who is initiating this action. + */ + userName: string; + + /** + * The ID of the team. + */ + teamId: number; + + /** + * The IDs of the invitations to resend. + */ + invitationId: number; +}; + +/** + * Resend an email for a given team member invite. + */ +export const resendTeamMemberInvitation = async ({ + userId, + userName, + teamId, + invitationId, +}: ResendTeamMemberInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + if (!team) { + throw new AppError('TeamNotFound', 'User is not a member of the team.'); + } + + const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ + where: { + id: invitationId, + teamId, + }, + }); + + if (!teamMemberInvite) { + throw new AppError('InviteNotFound', 'No invite exists for this user.'); + } + + await sendTeamMemberInviteEmail({ + email: teamMemberInvite.email, + token: teamMemberInvite.token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }); + }); +}; diff --git a/packages/lib/server-only/team/transfer-team-ownership.ts b/packages/lib/server-only/team/transfer-team-ownership.ts new file mode 100644 index 000000000..bc494d96e --- /dev/null +++ b/packages/lib/server-only/team/transfer-team-ownership.ts @@ -0,0 +1,86 @@ +import type Stripe from 'stripe'; + +import { transferTeamSubscription } from '@documenso/ee/server-only/stripe/transfer-team-subscription'; +import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +export type TransferTeamOwnershipOptions = { + token: string; +}; + +export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => { + await prisma.$transaction(async (tx) => { + const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({ + where: { + token, + }, + include: { + team: true, + }, + }); + + const { team, userId: newOwnerUserId } = teamTransferVerification; + + await tx.teamTransferVerification.deleteMany({ + where: { + teamId: team.id, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + }, + include: { + Subscription: true, + }, + }); + + let newTeamSubscription: Stripe.Subscription | null = null; + + if (IS_BILLING_ENABLED) { + newTeamSubscription = await transferTeamSubscription({ + user: newOwnerUser, + team, + }); + } + + if (newTeamSubscription) { + await tx.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(newOwnerUser.id, newTeamSubscription), + ); + } + + // Todo: Teams - Add billing message in email indicating that billing will be passed on when transferring a team. + + await tx.team.update({ + where: { + id: team.id, + members: { + some: { + userId: newOwnerUserId, + }, + }, + }, + data: { + ownerUserId: newOwnerUserId, + subscriptionId: newTeamSubscription?.id ?? null, + members: { + update: { + where: { + userId_teamId: { + teamId: team.id, + userId: newOwnerUserId, + }, + }, + data: { + role: TeamMemberRole.ADMIN, + }, + }, + }, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team-email.ts b/packages/lib/server-only/team/update-team-email.ts new file mode 100644 index 000000000..a405b4172 --- /dev/null +++ b/packages/lib/server-only/team/update-team-email.ts @@ -0,0 +1,41 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type UpdateTeamEmailOptions = { + userId: number; + teamId: number; + data: { + name?: string; + }; +}; + +export const updateTeamEmail = async ({ userId, teamId, data }: UpdateTeamEmailOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + teamEmail: { + isNot: null, + }, + }, + }); + + await tx.teamEmail.update({ + where: { + teamId, + }, + data: { + ...data, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team-member.ts b/packages/lib/server-only/team/update-team-member.ts new file mode 100644 index 000000000..65e82ca26 --- /dev/null +++ b/packages/lib/server-only/team/update-team-member.ts @@ -0,0 +1,50 @@ +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type UpdateTeamMemberOptions = { + userId: number; + teamId: number; + teamMemberId: number; + data: { + role: TeamMemberRole; + }; +}; + +export const updateTeamMember = async ({ + userId, + teamId, + teamMemberId, + data, +}: UpdateTeamMemberOptions) => { + await prisma.$transaction(async (tx) => { + // Find the team and validate that the user is allowed to update members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['UPDATE_TEAM_MEMBERS'], + }, + }, + }, + }, + }); + + return await tx.teamMember.update({ + where: { + id: teamMemberId, + teamId, + userId: { + not: team.ownerUserId, + }, + }, + data: { + role: data.role, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team.ts b/packages/lib/server-only/team/update-team.ts new file mode 100644 index 000000000..4b5e03c93 --- /dev/null +++ b/packages/lib/server-only/team/update-team.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type UpdateTeamOptions = { + userId: number; + teamId: number; + data: { + name?: string; + url?: string; + }; +}; + +export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) => { + try { + return await prisma.team.update({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + data: { + ...data, + }, + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('url')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + throw err; + } +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index f7db60c85..b86f4f41b 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -1,11 +1,13 @@ import { hash } from 'bcrypt'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { prisma } from '@documenso/prisma'; -import { IdentityProvider } from '@documenso/prisma/client'; +import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { IS_BILLING_ENABLED } from '../../constants/app'; import { SALT_ROUNDS } from '../../constants/auth'; -import { getFlag } from '../../universal/get-feature-flag'; +import { getTeamSeatPriceId } from '../../utils/billing'; export interface CreateUserOptions { name: string; @@ -15,8 +17,6 @@ export interface CreateUserOptions { } export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { - const isBillingEnabled = await getFlag('app_billing'); - const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -29,24 +29,77 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new Error('User already exists'); } - let user = await prisma.user.create({ - data: { - name, - email: email.toLowerCase(), - password: hashedPassword, - signature, - identityProvider: IdentityProvider.DOCUMENSO, - }, - }); + return prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + name, + email: email.toLowerCase(), + password: hashedPassword, + signature, + identityProvider: IdentityProvider.DOCUMENSO, + }, + }); - if (isBillingEnabled) { - try { - const stripeSession = await getStripeCustomerByUser(user); - user = stripeSession.user; - } catch (e) { - console.error(e); + const acceptedTeamInvites = await tx.teamMemberInvite.findMany({ + where: { + email: { + equals: email, + mode: Prisma.QueryMode.insensitive, + }, + status: TeamMemberInviteStatus.ACCEPTED, + }, + }); + + // For each team invite, add the user to the team and delete the team invite. + await Promise.all( + acceptedTeamInvites.map(async (invite) => { + await tx.teamMember.create({ + data: { + teamId: invite.teamId, + userId: user.id, + role: invite.role, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: invite.id, + }, + }); + + if (IS_BILLING_ENABLED) { + const team = await tx.team.findFirstOrThrow({ + where: { + id: invite.teamId, + }, + include: { + members: { + select: { + id: true, + }, + }, + }, + }); + + if (team.subscriptionId) { + await updateSubscriptionItemQuantity({ + priceId: getTeamSeatPriceId(), + subscriptionId: team.subscriptionId, + quantity: team.members.length, + }); + } + } + }), + ); + + if (IS_BILLING_ENABLED) { + try { + return await getStripeCustomerByUser(user).then((session) => session.user); + } catch (err) { + console.error(err); + } } - } - return user; + return user; + }); }; diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts new file mode 100644 index 000000000..ff3fdc4e2 --- /dev/null +++ b/packages/lib/types/search-params.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const ZBaseTableSearchParamsSchema = z.object({ + query: z + .string() + .optional() + .catch(() => undefined), + page: z.coerce + .number() + .min(1) + .optional() + .catch(() => undefined), + perPage: z.coerce + .number() + .min(1) + .optional() + .catch(() => undefined), +}); + +export type TBaseTableSearchParamsSchema = z.infer; diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts new file mode 100644 index 000000000..3cb39c43c --- /dev/null +++ b/packages/lib/utils/billing.ts @@ -0,0 +1,26 @@ +import { AppError } from '../errors/app-error'; +import type { Subscription } from '.prisma/client'; +import { SubscriptionStatus } from '.prisma/client'; + +export const isPriceIdCommunityPlan = (priceId: string) => + priceId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID || + priceId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID; + +/** + * Returns true if there is a subscription that is active and is a community plan. + */ +export const isSomeSubscriptionsActiveAndCommunityPlan = (subscriptions: Subscription[]) => { + return subscriptions.some( + (subscription) => + subscription.status === SubscriptionStatus.ACTIVE && + isPriceIdCommunityPlan(subscription.planId), + ); +}; + +export const getTeamSeatPriceId = () => { + if (!process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID) { + throw new AppError('MISSING_STRIPE_TEAM_SEAT_PRICE_ID'); + } + + return process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID; +}; diff --git a/packages/lib/utils/params.ts b/packages/lib/utils/params.ts new file mode 100644 index 000000000..c0780c4e8 --- /dev/null +++ b/packages/lib/utils/params.ts @@ -0,0 +1,17 @@ +// Common util functions for parsing params. + +/** + * From an unknown string, parse it into a number array. + * + * Filter out unknown values. + */ +export const parseToNumberArray = (value: unknown): number[] => { + if (typeof value !== 'string') { + return []; + } + + return value + .split(',') + .map((value) => parseInt(value, 10)) + .filter((value) => !isNaN(value)); +}; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts new file mode 100644 index 000000000..a7f844f71 --- /dev/null +++ b/packages/lib/utils/teams.ts @@ -0,0 +1,7 @@ +import { WEBAPP_BASE_URL } from '../constants/app'; + +export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => { + const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, ''); + + return `${formattedBaseUrl}/t/${teamUrl}`; +}; diff --git a/packages/lib/utils/token-verification.ts b/packages/lib/utils/token-verification.ts new file mode 100644 index 000000000..c57ddd1e5 --- /dev/null +++ b/packages/lib/utils/token-verification.ts @@ -0,0 +1,21 @@ +import type { DurationLike } from 'luxon'; +import { DateTime } from 'luxon'; +import { nanoid } from 'nanoid'; + +/** + * Create a token verification object. + * + * @param expiry The date the token expires, or the duration until the token expires. + */ +export const createTokenVerification = (expiry: Date | DurationLike) => { + const expiresAt = expiry instanceof Date ? expiry : DateTime.now().plus(expiry).toJSDate(); + + return { + expiresAt, + token: nanoid(32), + }; +}; + +export const isTokenExpired = (expiresAt: Date) => { + return expiresAt < new Date(); +}; diff --git a/packages/prisma/migrations/20231227015340_teamwip/migration.sql b/packages/prisma/migrations/20231227015340_teamwip/migration.sql new file mode 100644 index 000000000..8194f6a5b --- /dev/null +++ b/packages/prisma/migrations/20231227015340_teamwip/migration.sql @@ -0,0 +1,156 @@ +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('ADMIN', 'MANAGER', 'MEMBER'); + +-- CreateEnum +CREATE TYPE "TeamMemberInviteStatus" AS ENUM ('ACCEPTED', 'PENDING'); + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "teamId" INTEGER; + +-- CreateTable +CREATE TABLE "Team" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "subscriptionId" TEXT, + "ownerUserId" INTEGER NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamPending" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ownerUserId" INTEGER NOT NULL, + + CONSTRAINT "TeamPending_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "role" "TeamMemberRole" NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEmail" ( + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + + CONSTRAINT "TeamEmail_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamEmailVerification" ( + "teamId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamEmailVerification_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamTransferVerification" ( + "teamId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamTransferVerification_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamMemberInvite" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "email" TEXT NOT NULL, + "status" "TeamMemberInviteStatus" NOT NULL DEFAULT 'PENDING', + "role" "TeamMemberRole" NOT NULL, + "token" TEXT NOT NULL, + + CONSTRAINT "TeamMemberInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_url_key" ON "Team"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_subscriptionId_key" ON "Team"("subscriptionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamPending_url_key" ON "TeamPending"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_userId_teamId_key" ON "TeamMember"("userId", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmail_teamId_key" ON "TeamEmail"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmail_email_key" ON "TeamEmail"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmailVerification_teamId_key" ON "TeamEmailVerification"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmailVerification_token_key" ON "TeamEmailVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamTransferVerification_teamId_key" ON "TeamTransferVerification"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamTransferVerification_token_key" ON "TeamTransferVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberInvite_token_key" ON "TeamMemberInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberInvite_teamId_email_key" ON "TeamMemberInvite"("teamId", "email"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("planId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamPending" ADD CONSTRAINT "TeamPending_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEmail" ADD CONSTRAINT "TeamEmail_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEmailVerification" ADD CONSTRAINT "TeamEmailVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamTransferVerification" ADD CONSTRAINT "TeamTransferVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMemberInvite" ADD CONSTRAINT "TeamMemberInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 67fb182a7..0db0d9422 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -37,11 +37,14 @@ model User { Document Document[] Subscription Subscription[] PasswordResetToken PasswordResetToken[] + teamMembers TeamMember[] + teams Team[] + teamsPending TeamPending[] twoFactorSecret String? twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? VerificationToken VerificationToken[] - Template Template[] + Template Template[] @@index([email]) } @@ -82,7 +85,8 @@ model Subscription { updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean @default(false) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + Team Team? @@index([userId]) } @@ -136,6 +140,8 @@ model Document { updatedAt DateTime @default(now()) @updatedAt completedAt DateTime? deletedAt DateTime? + teamId Int? + team Team? @relation(fields: [teamId], references: [id]) @@unique([documentDataId]) @@index([userId]) @@ -181,19 +187,19 @@ enum SigningStatus { } model Recipient { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) documentId Int? templateId Int? - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) token String expired DateTime? signedAt DateTime? readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) sendStatus SendStatus @default(NOT_SENT) - Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) - Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] @@ -263,6 +269,101 @@ model DocumentShareLink { @@unique([documentId, email]) } +enum TeamMemberRole { + ADMIN + MANAGER + MEMBER +} + +enum TeamMemberInviteStatus { + ACCEPTED + PENDING +} + +model Team { + id Int @id @default(autoincrement()) + name String + url String @unique + createdAt DateTime @default(now()) + subscriptionId String? @unique + ownerUserId Int + members TeamMember[] + invites TeamMemberInvite[] + teamEmail TeamEmail? + emailVerification TeamEmailVerification? + transferVerification TeamTransferVerification? + + owner User @relation(fields: [ownerUserId], references: [id]) + subscription Subscription? @relation(fields: [subscriptionId], references: [planId]) + document Document[] +} + +model TeamPending { + id Int @id @default(autoincrement()) + name String + url String @unique + createdAt DateTime @default(now()) + ownerUserId Int + + owner User @relation(fields: [ownerUserId], references: [id]) +} + +model TeamMember { + id Int @id @default(autoincrement()) + teamId Int + createdAt DateTime @default(now()) + role TeamMemberRole + userId Int + user User @relation(fields: [userId], references: [id]) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([userId, teamId]) +} + +model TeamEmail { + teamId Int @id @unique + createdAt DateTime @default(now()) + name String + email String @unique + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamEmailVerification { + teamId Int @id @unique + name String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamTransferVerification { + teamId Int @id @unique + userId Int + name String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamMemberInvite { + id Int @id @default(autoincrement()) + teamId Int + createdAt DateTime @default(now()) + email String + status TeamMemberInviteStatus @default(PENDING) + role TeamMemberRole + token String @unique + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([teamId, email]) +} + enum TemplateType { PUBLIC PRIVATE @@ -277,10 +378,10 @@ model Template { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) - Recipient Recipient[] - Field Field[] + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + Recipient Recipient[] + Field Field[] @@unique([templateDocumentDataId]) } diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index fc6ea2377..cb52280c8 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -70,20 +70,24 @@ export const documentRouter = router({ .input(ZCreateDocumentMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { title, documentDataId } = input; + const { title, documentDataId, teamId } = input; - const { remaining } = await getServerLimits({ email: ctx.user.email }); + // Teams bypass document limits. + if (teamId !== undefined) { + const { remaining } = await getServerLimits({ email: ctx.user.email }); - if (remaining.documents <= 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - 'You have reached your document limit for this month. Please upgrade your plan.', - }); + if (remaining.documents <= 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'You have reached your document limit for this month. Please upgrade your plan.', + }); + } } return await createDocument({ userId: ctx.user.id, + teamId, title, documentDataId, }); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 71ee9766d..5d0ae8393 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -17,6 +17,7 @@ export type TGetDocumentByTokenQuerySchema = z.infer; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 77d18e06d..2d1060ea2 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; +import { teamRouter } from './team-router/router'; import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; @@ -19,8 +20,9 @@ export const appRouter = router({ admin: adminRouter, shareLink: shareLinkRouter, singleplayer: singleplayerRouter, - twoFactorAuthentication: twoFactorAuthenticationRouter, + team: teamRouter, template: templateRouter, + twoFactorAuthentication: twoFactorAuthenticationRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts new file mode 100644 index 000000000..5338ead8b --- /dev/null +++ b/packages/trpc/server/team-router/router.ts @@ -0,0 +1,479 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation'; +import { addTeamEmailVerification } from '@documenso/lib/server-only/team/add-team-email-verification'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { createTeamPendingCheckoutSession } from '@documenso/lib/server-only/team/create-team-checkout-session'; +import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites'; +import { deleteTeam } from '@documenso/lib/server-only/team/delete-team'; +import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email'; +import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification'; +import { deleteTeamMemberInvitations } from '@documenso/lib/server-only/team/delete-team-invitations'; +import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members'; +import { deleteTeamPending } from '@documenso/lib/server-only/team/delete-team-pending'; +import { deleteTeamTransferRequest } from '@documenso/lib/server-only/team/delete-team-transfer-request'; +import { findTeamInvoices } from '@documenso/lib/server-only/team/find-team-invoices'; +import { findTeamMemberInvites } from '@documenso/lib/server-only/team/find-team-member-invites'; +import { findTeamMembers } from '@documenso/lib/server-only/team/find-team-members'; +import { findTeams } from '@documenso/lib/server-only/team/find-teams'; +import { findTeamsPending } from '@documenso/lib/server-only/team/find-teams-pending'; +import { getTeamEmailByEmail } from '@documenso/lib/server-only/team/get-team-email-by-email'; +import { getTeamInvitations } from '@documenso/lib/server-only/team/get-team-invitations'; +import { getTeamMembers } from '@documenso/lib/server-only/team/get-team-members'; +import { getTeamById, getTeams } from '@documenso/lib/server-only/team/get-teams'; +import { leaveTeam } from '@documenso/lib/server-only/team/leave-team'; +import { requestTeamOwnershipTransfer } from '@documenso/lib/server-only/team/request-team-ownership-transfer'; +import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/resend-team-email-verification'; +import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/resend-team-member-invitation'; +import { updateTeam } from '@documenso/lib/server-only/team/update-team'; +import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email'; +import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZAcceptTeamInvitationMutationSchema, + ZAddTeamEmailVerificationMutationSchema, + ZCreateTeamMemberInvitesMutationSchema, + ZCreateTeamMutationSchema, + ZCreateTeamPendingCheckoutMutationSchema, + ZDeleteTeamEmailMutationSchema, + ZDeleteTeamEmailVerificationMutationSchema, + ZDeleteTeamMemberInvitationsMutationSchema, + ZDeleteTeamMembersMutationSchema, + ZDeleteTeamMutationSchema, + ZDeleteTeamPendingMutationSchema, + ZDeleteTeamTransferRequestMutationSchema, + ZFindTeamInvoicesQuerySchema, + ZFindTeamMemberInvitesQuerySchema, + ZFindTeamMembersQuerySchema, + ZFindTeamsPendingQuerySchema, + ZFindTeamsQuerySchema, + ZGetTeamMembersQuerySchema, + ZGetTeamQuerySchema, + ZLeaveTeamMutationSchema, + ZRequestTeamOwnerhsipTransferMutationSchema, + ZResendTeamEmailVerificationMutationSchema, + ZResendTeamMemberInvitationMutationSchema, + ZUpdateTeamEmailMutationSchema, + ZUpdateTeamMemberMutationSchema, + ZUpdateTeamMutationSchema, +} from './schema'; + +export const teamRouter = router({ + acceptTeamInvitation: authenticatedProcedure + .input(ZAcceptTeamInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await acceptTeamInvitation({ + teamId: input.teamId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + addTeamEmailVerification: authenticatedProcedure + .input(ZAddTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await addTeamEmailVerification({ + teamId: input.teamId, + userId: ctx.user.id, + data: { + email: input.email, + name: input.name, + }, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeam: authenticatedProcedure + .input(ZCreateTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { name, url } = input; + + return await createTeam({ + userId: ctx.user.id, + name, + teamUrl: url, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamMemberInvites: authenticatedProcedure + .input(ZCreateTeamMemberInvitesMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamMemberInvites({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamPendingCheckout: authenticatedProcedure + .input(ZCreateTeamPendingCheckoutMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamPendingCheckoutSession({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeam: authenticatedProcedure + .input(ZDeleteTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamEmail: authenticatedProcedure + .input(ZDeleteTeamEmailMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamEmail({ + teamId: input.teamId, + userId: ctx.user.id, + userEmail: ctx.user.email, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamEmailVerification: authenticatedProcedure + .input(ZDeleteTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamEmailVerification({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamMemberInvitations: authenticatedProcedure + .input(ZDeleteTeamMemberInvitationsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamMemberInvitations({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamMembers: authenticatedProcedure + .input(ZDeleteTeamMembersMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamMembers({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamPending: authenticatedProcedure + .input(ZDeleteTeamPendingMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamPending({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamTransferRequest: authenticatedProcedure + .input(ZDeleteTeamTransferRequestMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamTransferRequest({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamInvoices: authenticatedProcedure + .input(ZFindTeamInvoicesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamInvoices({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamMemberInvites: authenticatedProcedure + .input(ZFindTeamMemberInvitesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamMemberInvites({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamMembers: authenticatedProcedure + .input(ZFindTeamMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamMembers({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeams: authenticatedProcedure.input(ZFindTeamsQuerySchema).query(async ({ input, ctx }) => { + try { + return await findTeams({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamsPending: authenticatedProcedure + .input(ZFindTeamsPendingQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamsPending({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeam: authenticatedProcedure.input(ZGetTeamQuerySchema).query(async ({ input, ctx }) => { + try { + return await getTeamById({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamEmailByEmail: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeamEmailByEmail({ email: ctx.user.email }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamInvitations: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeamInvitations({ email: ctx.user.email }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamMembers: authenticatedProcedure + .input(ZGetTeamMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await getTeamMembers({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeams: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeams({ userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + leaveTeam: authenticatedProcedure + .input(ZLeaveTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await leaveTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeam: authenticatedProcedure + .input(ZUpdateTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeamEmail: authenticatedProcedure + .input(ZUpdateTeamEmailMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeamEmail({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeamMember: authenticatedProcedure + .input(ZUpdateTeamMemberMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeamMember({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + requestTeamOwnershipTransfer: authenticatedProcedure + .input(ZRequestTeamOwnerhsipTransferMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await requestTeamOwnershipTransfer({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendTeamEmailVerification: authenticatedProcedure + .input(ZResendTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendTeamEmailVerification({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendTeamMemberInvitation: authenticatedProcedure + .input(ZResendTeamMemberInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendTeamMemberInvitation({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), +}); diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts new file mode 100644 index 000000000..8072b9c73 --- /dev/null +++ b/packages/trpc/server/team-router/schema.ts @@ -0,0 +1,170 @@ +import { z } from 'zod'; + +import { TeamMemberRole } from '@documenso/prisma/client'; + +const GenericFindQuerySchema = z.object({ + term: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), +}); + +export const ZAcceptTeamInvitationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZAddTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().trim().email().min(1, 'Please enter a valid email.'), +}); + +export const ZCreateTeamMutationSchema = z.object({ + name: z.string().min(1), + url: z.string().min(1), // Todo: Teams - Apply lowercase, disallow certain symbols, disallow profanity. +}); + +export const ZCreateTeamMemberInvitesMutationSchema = z.object({ + teamId: z.number(), + invitations: z.array( + z.object({ + email: z.string().email(), + role: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +export const ZCreateTeamPendingCheckoutMutationSchema = z.object({ + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamEmailMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamMembersMutationSchema = z.object({ + teamId: z.number(), + teamMemberIds: z.array(z.number()), +}); + +export const ZDeleteTeamMemberInvitationsMutationSchema = z.object({ + teamId: z.number(), + invitationIds: z.array(z.number()), +}); + +export const ZDeleteTeamMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamPendingMutationSchema = z.object({ + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamTransferRequestMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZFindTeamInvoicesQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZFindTeamMemberInvitesQuerySchema = GenericFindQuerySchema.extend({ + teamId: z.number(), +}); + +export const ZFindTeamMembersQuerySchema = GenericFindQuerySchema.extend({ + teamId: z.number(), +}); + +export const ZFindTeamsQuerySchema = GenericFindQuerySchema; + +export const ZFindTeamsPendingQuerySchema = GenericFindQuerySchema; + +export const ZGetTeamQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZGetTeamMembersQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZLeaveTeamMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZUpdateTeamMutationSchema = z.object({ + teamId: z.number(), + data: z.object({ + // Todo: Teams + name: z.string().min(1), + url: z.string().min(1), // Todo: Apply regex. Todo: lowercase, etc + }), +}); + +export const ZUpdateTeamEmailMutationSchema = z.object({ + teamId: z.number(), + data: z.object({ + name: z.string().min(1), + }), +}); + +export const ZUpdateTeamMemberMutationSchema = z.object({ + teamId: z.number(), + teamMemberId: z.number(), + data: z.object({ + role: z.nativeEnum(TeamMemberRole), + }), +}); + +export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({ + teamId: z.number(), + newOwnerUserId: z.number(), +}); + +export const ZResendTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZResendTeamMemberInvitationMutationSchema = z.object({ + teamId: z.number(), + invitationId: z.number(), +}); + +export type TAddTeamEmailVerificationMutationSchema = z.infer< + typeof ZAddTeamEmailVerificationMutationSchema +>; +export type TCreateTeamMutationSchema = z.infer; +export type TCreateTeamMemberInvitesMutationSchema = z.infer< + typeof ZCreateTeamMemberInvitesMutationSchema +>; +export type TCreateTeamPendingCheckoutMutationSchema = z.infer< + typeof ZCreateTeamPendingCheckoutMutationSchema +>; +export type TDeleteTeamEmailMutationSchema = z.infer; +export type TDeleteTeamMembersMutationSchema = z.infer; +export type TDeleteTeamMutationSchema = z.infer; +export type TDeleteTeamPendingMutationSchema = z.infer; +export type TDeleteTeamTransferRequestMutationSchema = z.infer< + typeof ZDeleteTeamTransferRequestMutationSchema +>; +export type TFindTeamMemberInvitesQuerySchema = z.infer; +export type TFindTeamMembersQuerySchema = z.infer; +export type TFindTeamsQuerySchema = z.infer; +export type TFindTeamsPendingQuerySchema = z.infer; +export type TGetTeamQuerySchema = z.infer; +export type TGetTeamMembersQuerySchema = z.infer; +export type TLeaveTeamMutationSchema = z.infer; +export type TUpdateTeamMutationSchema = z.infer; +export type TUpdateTeamEmailMutationSchema = z.infer; +export type TRequestTeamOwnerhsipTransferMutationSchema = z.infer< + typeof ZRequestTeamOwnerhsipTransferMutationSchema +>; +export type TResendTeamEmailVerificationMutationSchema = z.infer< + typeof ZResendTeamEmailVerificationMutationSchema +>; +export type TResendTeamMemberInvitationMutationSchema = z.infer< + typeof ZResendTeamMemberInvitationMutationSchema +>; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index badc05931..4ab7a18e7 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -10,6 +10,7 @@ declare namespace NodeJS { NEXT_PRIVATE_ENCRYPTION_KEY: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; + NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string; NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; diff --git a/packages/ui/primitives/avatar.tsx b/packages/ui/primitives/avatar.tsx index 0039ad4eb..8c052aeae 100644 --- a/packages/ui/primitives/avatar.tsx +++ b/packages/ui/primitives/avatar.tsx @@ -48,4 +48,37 @@ const AvatarFallback = React.forwardRef< AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback }; +type AvatarWithTextProps = { + avatarClass?: string; + avatarFallback: string; + className?: string; + primaryText: React.ReactNode; + secondaryText?: React.ReactNode; + rightSideComponent?: React.ReactNode; +}; + +const AvatarWithText = ({ + avatarClass, + avatarFallback, + className, + primaryText, + secondaryText, + rightSideComponent, +}: AvatarWithTextProps) => ( +
+ + {avatarFallback} + + +
+ {primaryText} + {secondaryText} +
+ + {rightSideComponent} +
+); + +export { Avatar, AvatarImage, AvatarFallback, AvatarWithText }; diff --git a/packages/ui/primitives/badge.tsx b/packages/ui/primitives/badge.tsx index 1ff153f79..fd56bc1ce 100644 --- a/packages/ui/primitives/badge.tsx +++ b/packages/ui/primitives/badge.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { cn } from '../lib/utils'; diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx index 85f86056d..bf3705763 100644 --- a/packages/ui/primitives/combobox.tsx +++ b/packages/ui/primitives/combobox.tsx @@ -1,77 +1,162 @@ +'use client'; + import * as React from 'react'; -import { Check, ChevronsUpDown } from 'lucide-react'; - -import { Role } from '@documenso/prisma/client'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Check, ChevronsUpDown, Loader, XIcon } from 'lucide-react'; import { cn } from '../lib/utils'; import { Button } from './button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; -type ComboboxProps = { - listValues: string[]; - onChange: (_values: string[]) => void; +type OptionValue = string | number | boolean | null; + +type ComboBoxOption = { + label: string; + value: T; + disabled?: boolean; }; -const Combobox = ({ listValues, onChange }: ComboboxProps) => { +type ComboboxProps = { + emptySelectionPlaceholder?: React.ReactNode | string; + enableClearAllButton?: boolean; + loading?: boolean; + inputPlaceholder?: string; + onChange: (_values: T[]) => void; + options: ComboBoxOption[]; + selectedValues: T[]; +}; + +export function Combobox({ + emptySelectionPlaceholder = 'Select values...', + enableClearAllButton, + inputPlaceholder, + loading, + onChange, + options, + selectedValues, +}: ComboboxProps) { const [open, setOpen] = React.useState(false); - const [selectedValues, setSelectedValues] = React.useState([]); - const dbRoles = Object.values(Role); - React.useEffect(() => { - setSelectedValues(listValues); - }, [listValues]); + const handleSelect = (selectedOption: T) => { + let newSelectedOptions = [...selectedValues, selectedOption]; - const allRoles = [...new Set([...dbRoles, ...selectedValues])]; - - const handleSelect = (currentValue: string) => { - let newSelectedValues; - if (selectedValues.includes(currentValue)) { - newSelectedValues = selectedValues.filter((value) => value !== currentValue); - } else { - newSelectedValues = [...selectedValues, currentValue]; + if (selectedValues.includes(selectedOption)) { + newSelectedOptions = selectedValues.filter((v) => v !== selectedOption); } - setSelectedValues(newSelectedValues); - onChange(newSelectedValues); + onChange(newSelectedOptions); + setOpen(false); }; + const selectedOptions = React.useMemo(() => { + return selectedValues.map((value): ComboBoxOption => { + const foundOption = options.find((option) => option.value === value); + + if (foundOption) { + return foundOption; + } + + let label = ''; + + if (typeof value === 'string' || typeof value === 'number') { + label = value.toString(); + } + + return { + label, + value, + }; + }); + }, [selectedValues, options]); + + const buttonLabel = React.useMemo(() => { + if (loading) { + return ''; + } + + if (selectedOptions.length === 0) { + return emptySelectionPlaceholder; + } + + return selectedOptions.map((option) => option.label).join(', '); + }, [selectedOptions, emptySelectionPlaceholder, loading]); + return ( - + + )} + + +
+ + )} + + - + No value found. - {allRoles.map((value: string, i: number) => ( - handleSelect(value)}> - - {value} - - ))} + {options.map((option, i) => { + return ( + handleSelect(option.value)}> + + {typeof option === 'string' ? option : option.label} + + ); + })} ); -}; - -export { Combobox }; +} diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index e4a89e141..9d7d6aa75 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -2,36 +2,49 @@ import React, { useMemo } from 'react'; -import { +import type { ColumnDef, PaginationState, Table as TTable, Updater, - flexRender, - getCoreRowModel, - useReactTable, + VisibilityState, } from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { Skeleton } from './skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table'; export type DataTableChildren = (_table: TTable) => React.ReactNode; export interface DataTableProps { columns: ColumnDef[]; + columnVisibility?: VisibilityState; data: TData[]; perPage?: number; currentPage?: number; totalPages?: number; onPaginationChange?: (_page: number, _perPage: number) => void; children?: DataTableChildren; + skeleton?: { + enable: boolean; + rows: number; + component?: React.ReactNode; + }; + error?: { + enable: boolean; + component?: React.ReactNode; + }; } export function DataTable({ columns, + columnVisibility, data, + error, perPage, currentPage, totalPages, + skeleton, onPaginationChange, children, }: DataTableProps) { @@ -67,6 +80,7 @@ export function DataTable({ getCoreRowModel: getCoreRowModel(), state: { pagination: manualPagination ? pagination : undefined, + columnVisibility, }, manualPagination, pageCount: totalPages, @@ -103,6 +117,18 @@ export function DataTable({ ))} )) + ) : error?.enable ? ( + + {error.component ?? ( + + Something went wrong. + + )} + + ) : skeleton?.enable ? ( + Array.from({ length: skeleton.rows }).map((_, i) => ( + {skeleton.component ?? } + )) ) : ( diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index 1a5fba1bb..ac739c984 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; +import { Eye, EyeOff } from 'lucide-react'; + import { cn } from '../lib/utils'; +import { Button } from './button'; export type InputProps = React.InputHTMLAttributes; @@ -25,4 +28,38 @@ const Input = React.forwardRef( Input.displayName = 'Input'; -export { Input }; +const PasswordInput = React.forwardRef( + ({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + + return ( +
+ + + +
+ ); + }, +); + +PasswordInput.displayName = 'Input'; + +export { Input, PasswordInput }; diff --git a/turbo.json b/turbo.json index 3a96c2a07..2d8375d30 100644 --- a/turbo.json +++ b/turbo.json @@ -2,8 +2,13 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**" + ] }, "lint": { "cache": false @@ -19,7 +24,9 @@ "persistent": true }, "start": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false, "persistent": true }, @@ -27,10 +34,14 @@ "cache": false }, "test:e2e": { - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] } }, - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local" + ], "globalEnv": [ "APP_VERSION", "NEXT_PRIVATE_ENCRYPTION_KEY", @@ -42,7 +53,8 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", - "NEXT_PUBLIC_STRIPE_FREE_PLAN_ID", + "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID", + "NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID", "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL", @@ -94,4 +106,4 @@ "E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_PASSWORD" ] -} +} \ No newline at end of file