@@ -6,13 +6,11 @@ import {
66 LockOpenIcon ,
77 UserPlusIcon ,
88} from "@heroicons/react/20/solid" ;
9- import type { ActionFunction , LoaderFunctionArgs } from "@remix-run/node" ;
109import { json } from "@remix-run/node" ;
1110import { Form , useActionData } from "@remix-run/react" ;
1211import { Fragment , useRef , useState } from "react" ;
1312import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
1413import simplur from "simplur" ;
15- import invariant from "tiny-invariant" ;
1614import { z } from "zod" ;
1715import { MainCenteredContainer } from "~/components/layout/AppLayout" ;
1816import { Button , LinkButton } from "~/components/primitives/Buttons" ;
@@ -34,63 +32,71 @@ import { redirectWithSuccessMessage } from "~/models/message.server";
3432import { TeamPresenter } from "~/presenters/TeamPresenter.server" ;
3533import { scheduleEmail } from "~/services/scheduleEmail.server" ;
3634import { rbac } from "~/services/rbac.server" ;
37- import { requireUserId } from "~/services/session.server " ;
35+ import { dashboardAction , dashboardLoader } from "~/services/routeBuilders/dashboardBuilder " ;
3836import { acceptInvitePath , organizationTeamPath , v3BillingPath } from "~/utils/pathBuilder" ;
3937import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route" ;
4038
4139const Params = z . object ( {
4240 organizationSlug : z . string ( ) ,
4341} ) ;
4442
45- export const loader = async ( { request, params } : LoaderFunctionArgs ) => {
46- const userId = await requireUserId ( request ) ;
47- const { organizationSlug } = Params . parse ( params ) ;
48-
49- const organization = await $replica . organization . findFirst ( {
50- where : { slug : organizationSlug } ,
51- select : { id : true } ,
52- } ) ;
43+ async function resolveOrgIdFromSlug ( slug : string ) : Promise < string | null > {
44+ const org = await $replica . organization . findFirst ( { where : { slug } , select : { id : true } } ) ;
45+ return org ?. id ?? null ;
46+ }
5347
54- if ( ! organization ) {
55- throw new Response ( "Not Found" , { status : 404 } ) ;
56- }
48+ export const loader = dashboardLoader (
49+ {
50+ params : Params ,
51+ context : async ( params ) => {
52+ const organizationId = await resolveOrgIdFromSlug ( params . organizationSlug ) ;
53+ return organizationId ? { organizationId } : { } ;
54+ } ,
55+ authorization : { action : "manage" , resource : { type : "members" } } ,
56+ } ,
57+ async ( { user, context } ) => {
58+ const organizationId = context . organizationId ;
59+ if ( ! organizationId ) {
60+ throw new Response ( "Not Found" , { status : 404 } ) ;
61+ }
62+ const userId = user . id ;
5763
58- const presenter = new TeamPresenter ( ) ;
59- const result = await presenter . call ( {
60- userId,
61- organizationId : organization . id ,
62- } ) ;
64+ const presenter = new TeamPresenter ( ) ;
65+ const result = await presenter . call ( {
66+ userId,
67+ organizationId ,
68+ } ) ;
6369
64- if ( ! result ) {
65- throw new Response ( "Not Found" , { status : 404 } ) ;
66- }
70+ if ( ! result ) {
71+ throw new Response ( "Not Found" , { status : 404 } ) ;
72+ }
6773
68- // Inviter's own role drives the "below their level" filter on the
69- // dropdown. Plus assignable role IDs already encode the org's plan
70- // tier — the intersection is what we offer.
71- const [ inviterRole , assignableRoleIds , systemRoles ] = await Promise . all ( [
72- rbac . getUserRole ( { userId, organizationId : organization . id } ) ,
73- rbac . getAssignableRoleIds ( organization . id ) ,
74- rbac . systemRoles ( organization . id ) ,
75- ] ) ;
74+ // Inviter's own role drives the "below their level" filter on the
75+ // dropdown. Plus assignable role IDs already encode the org's plan
76+ // tier — the intersection is what we offer.
77+ const [ inviterRole , assignableRoleIds , systemRoles ] = await Promise . all ( [
78+ rbac . getUserRole ( { userId, organizationId } ) ,
79+ rbac . getAssignableRoleIds ( organizationId ) ,
80+ rbac . systemRoles ( organizationId ) ,
81+ ] ) ;
7682
77- // Build the dropdown's offerable set server-side: roles that are
78- // (a) assignable on the current plan AND (b) at or below the
79- // inviter's own level. The client just renders these — it doesn't
80- // need to know about the system-role catalogue or the ladder.
81- const assignableSet = new Set ( assignableRoleIds ) ;
82- const offerableRoleIds = systemRoles
83- ? result . roles
84- . filter (
85- ( r ) =>
86- assignableSet . has ( r . id ) &&
87- isAtOrBelow ( systemRoles , inviterRole ?. id ?? null , r . id )
88- )
89- . map ( ( r ) => r . id )
90- : [ ] ;
83+ // Build the dropdown's offerable set server-side: roles that are
84+ // (a) assignable on the current plan AND (b) at or below the
85+ // inviter's own level. The client just renders these — it doesn't
86+ // need to know about the system-role catalogue or the ladder.
87+ const assignableSet = new Set ( assignableRoleIds ) ;
88+ const offerableRoleIds = systemRoles
89+ ? result . roles
90+ . filter (
91+ ( r ) =>
92+ assignableSet . has ( r . id ) && isAtOrBelow ( systemRoles , inviterRole ?. id ?? null , r . id )
93+ )
94+ . map ( ( r ) => r . id )
95+ : [ ] ;
9196
92- return typedjson ( { ...result , offerableRoleIds } ) ;
93- } ;
97+ return typedjson ( { ...result , offerableRoleIds } ) ;
98+ }
99+ ) ;
94100
95101// Sentinel for "no RBAC role attached to invite" — the runtime
96102// fallback will derive a role from the legacy OrgMember.role write at
@@ -153,101 +159,101 @@ const schema = z.object({
153159 rbacRoleId : z . string ( ) . optional ( ) ,
154160} ) ;
155161
156- export const action : ActionFunction = async ( { request, params } ) => {
157- const userId = await requireUserId ( request ) ;
158- const { organizationSlug } = params ;
159- invariant ( organizationSlug , "organizationSlug is required" ) ;
160-
161- const formData = await request . formData ( ) ;
162- const submission = parse ( formData , { schema } ) ;
162+ export const action = dashboardAction (
163+ {
164+ params : Params ,
165+ context : async ( params ) => {
166+ const organizationId = await resolveOrgIdFromSlug ( params . organizationSlug ) ;
167+ return organizationId ? { organizationId } : { } ;
168+ } ,
169+ authorization : { action : "manage" , resource : { type : "members" } } ,
170+ } ,
171+ async ( { request, params, user } ) => {
172+ const userId = user . id ;
173+ const { organizationSlug } = params ;
163174
164- if ( ! submission . value || submission . intent !== "submit" ) {
165- return json ( submission ) ;
166- }
175+ const formData = await request . formData ( ) ;
176+ const submission = parse ( formData , { schema } ) ;
167177
168- // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
169- // role → don't pass one through; the runtime fallback handles it.
170- // Validation: the chosen role must be in the org's assignable set
171- // (plan-tier) and at or below the inviter's own level.
172- let resolvedRbacRoleId : string | null = null ;
173- const submittedRbacRoleId = submission . value . rbacRoleId ;
174- if (
175- submittedRbacRoleId &&
176- submittedRbacRoleId !== NO_RBAC_ROLE
177- ) {
178- const org = await $replica . organization . findFirst ( {
179- where : { slug : organizationSlug } ,
180- select : { id : true } ,
181- } ) ;
182- if ( ! org ) {
183- return json ( { errors : { body : "Organization not found" } } , { status : 404 } ) ;
178+ if ( ! submission . value || submission . intent !== "submit" ) {
179+ return json ( submission ) ;
184180 }
185- const [ inviterRole , assignableRoleIds , systemRoles ] = await Promise . all ( [
186- rbac . getUserRole ( { userId, organizationId : org . id } ) ,
187- rbac . getAssignableRoleIds ( org . id ) ,
188- rbac . systemRoles ( org . id ) ,
189- ] ) ;
190- if ( ! systemRoles ) {
191- // No plugin installed but the form somehow submitted a role id —
192- // ignore it (fall through to legacy behaviour rather than 400).
193- resolvedRbacRoleId = null ;
194- } else {
195- const assignable = new Set ( assignableRoleIds ) ;
196- if ( ! assignable . has ( submittedRbacRoleId ) ) {
197- return json (
198- { errors : { body : "You can't invite someone with this role on your current plan" } } ,
199- { status : 400 }
200- ) ;
181+
182+ // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
183+ // role → don't pass one through; the runtime fallback handles it.
184+ // Validation: the chosen role must be in the org's assignable set
185+ // (plan-tier) and at or below the inviter's own level.
186+ let resolvedRbacRoleId : string | null = null ;
187+ const submittedRbacRoleId = submission . value . rbacRoleId ;
188+ if ( submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE ) {
189+ const org = await $replica . organization . findFirst ( {
190+ where : { slug : organizationSlug } ,
191+ select : { id : true } ,
192+ } ) ;
193+ if ( ! org ) {
194+ return json ( { errors : { body : "Organization not found" } } , { status : 404 } ) ;
201195 }
202- if (
203- ! isAtOrBelow (
204- systemRoles ,
205- inviterRole ?. id ?? null ,
206- submittedRbacRoleId
207- )
208- ) {
209- return json (
210- { errors : { body : "You can only invite members at or below your own role" } } ,
211- { status : 403 }
212- ) ;
196+ const [ inviterRole , assignableRoleIds , systemRoles ] = await Promise . all ( [
197+ rbac . getUserRole ( { userId, organizationId : org . id } ) ,
198+ rbac . getAssignableRoleIds ( org . id ) ,
199+ rbac . systemRoles ( org . id ) ,
200+ ] ) ;
201+ if ( ! systemRoles ) {
202+ // No plugin installed but the form somehow submitted a role id —
203+ // ignore it (fall through to legacy behaviour rather than 400).
204+ resolvedRbacRoleId = null ;
205+ } else {
206+ const assignable = new Set ( assignableRoleIds ) ;
207+ if ( ! assignable . has ( submittedRbacRoleId ) ) {
208+ return json (
209+ { errors : { body : "You can't invite someone with this role on your current plan" } } ,
210+ { status : 400 }
211+ ) ;
212+ }
213+ if ( ! isAtOrBelow ( systemRoles , inviterRole ?. id ?? null , submittedRbacRoleId ) ) {
214+ return json (
215+ { errors : { body : "You can only invite members at or below your own role" } } ,
216+ { status : 403 }
217+ ) ;
218+ }
219+ resolvedRbacRoleId = submittedRbacRoleId ;
213220 }
214- resolvedRbacRoleId = submittedRbacRoleId ;
215221 }
216- }
217222
218- try {
219- const invites = await inviteMembers ( {
220- slug : organizationSlug ,
221- emails : submission . value . emails ,
222- userId,
223- rbacRoleId : resolvedRbacRoleId ,
224- } ) ;
223+ try {
224+ const invites = await inviteMembers ( {
225+ slug : organizationSlug ,
226+ emails : submission . value . emails ,
227+ userId,
228+ rbacRoleId : resolvedRbacRoleId ,
229+ } ) ;
225230
226- for ( const invite of invites ) {
227- try {
228- await scheduleEmail ( {
229- email : "invite" ,
230- to : invite . email ,
231- orgName : invite . organization . title ,
232- inviterName : invite . inviter . name ?? undefined ,
233- inviterEmail : invite . inviter . email ,
234- inviteLink : `${ env . LOGIN_ORIGIN } ${ acceptInvitePath ( invite . token ) } ` ,
235- } ) ;
236- } catch ( error ) {
237- console . error ( "Failed to send invite email" ) ;
238- console . error ( error ) ;
231+ for ( const invite of invites ) {
232+ try {
233+ await scheduleEmail ( {
234+ email : "invite" ,
235+ to : invite . email ,
236+ orgName : invite . organization . title ,
237+ inviterName : invite . inviter . name ?? undefined ,
238+ inviterEmail : invite . inviter . email ,
239+ inviteLink : `${ env . LOGIN_ORIGIN } ${ acceptInvitePath ( invite . token ) } ` ,
240+ } ) ;
241+ } catch ( error ) {
242+ console . error ( "Failed to send invite email" ) ;
243+ console . error ( error ) ;
244+ }
239245 }
240- }
241246
242- return redirectWithSuccessMessage (
243- organizationTeamPath ( invites [ 0 ] . organization ) ,
244- request ,
245- simplur `${ submission . value . emails . length } member[|s] invited`
246- ) ;
247- } catch ( error : any ) {
248- return json ( { errors : { body : error . message } } , { status : 400 } ) ;
247+ return redirectWithSuccessMessage (
248+ organizationTeamPath ( invites [ 0 ] . organization ) ,
249+ request ,
250+ simplur `${ submission . value . emails . length } member[|s] invited`
251+ ) ;
252+ } catch ( error : any ) {
253+ return json ( { errors : { body : error . message } } , { status : 400 } ) ;
254+ }
249255 }
250- } ;
256+ ) ;
251257
252258export default function Page ( ) {
253259 const {
@@ -274,9 +280,7 @@ export default function Page() {
274280 // Default to the lowest-tier offered role (the loader returns roles
275281 // in its allRoles order, which the plugin emits Owner→Member; the
276282 // last entry is the most restrictive).
277- const defaultRoleId = showRolePicker
278- ? offerable [ offerable . length - 1 ] . id
279- : NO_RBAC_ROLE ;
283+ const defaultRoleId = showRolePicker ? offerable [ offerable . length - 1 ] . id : NO_RBAC_ROLE ;
280284 const [ selectedRoleId , setSelectedRoleId ] = useState ( defaultRoleId ) ;
281285
282286 const [ form , { emails } ] = useForm ( {
@@ -386,9 +390,7 @@ export default function Page() {
386390 items = { offerable }
387391 variant = "tertiary/medium"
388392 dropdownIcon
389- text = { ( v ) =>
390- offerable . find ( ( r ) => r . id === v ) ?. name ?? "Pick a role"
391- }
393+ text = { ( v ) => offerable . find ( ( r ) => r . id === v ) ?. name ?? "Pick a role" }
392394 setValue = { ( next ) => {
393395 if ( typeof next === "string" ) setSelectedRoleId ( next ) ;
394396 } }
@@ -402,8 +404,7 @@ export default function Page() {
402404 }
403405 </ Select >
404406 < Paragraph variant = "extra-small" className = "text-text-dimmed" >
405- Invitees join with this role. They can be promoted later
406- from the Team page.
407+ Invitees join with this role. They can be promoted later from the Team page.
407408 </ Paragraph >
408409 </ InputGroup >
409410 ) : null }
0 commit comments