diff --git a/messages/en-US.json b/messages/en-US.json index 196fdf4b9..ce32f5363 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -615,7 +615,8 @@ "idpNameInternal": "Internal", "emailInvalid": "Invalid email address", "inviteValidityDuration": "Please select a duration", - "accessRoleSelectPlease": "Please select a role", + "accessRoleSelectPlease": "A user must belong to at least one role.", + "accessRoleRequired": "Role required", "removeOwnAdminRoleConfirmTitle": "Remove your administrator access?", "removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.", "removeOwnAdminRoleConfirmButton": "Remove My Administrator Access", diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 2bb9723ed..a3ed1e2df 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -111,7 +111,7 @@ export default function AccessControlsPage() { if (values.roles.length === 0) { toast({ variant: "destructive", - title: t("accessRoleErrorAdd"), + title: t("accessRoleRequired"), description: t("accessRoleSelectPlease") }); return; @@ -173,15 +173,13 @@ export default function AccessControlsPage() { if (values.roles.length === 0) { toast({ variant: "destructive", - title: t("accessRoleErrorAdd"), + title: t("accessRoleRequired"), description: t("accessRoleSelectPlease") }); return; } - const willHaveAdminRole = values.roles.some( - (r) => r.isAdmin === true - ); + const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true); const isRemovingOwnAdmin = sessionUser.userId === user.userId && @@ -226,7 +224,9 @@ export default function AccessControlsPage() {
void handleAccessControlsSubmit(e)} + onSubmit={(e) => + void handleAccessControlsSubmit(e) + } className="space-y-4" id="access-controls-form" > diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx index bc8e5a0b5..eef1e0570 100644 --- a/src/components/OrgRolesTagField.tsx +++ b/src/components/OrgRolesTagField.tsx @@ -12,6 +12,7 @@ import { import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; +import { useRef } from "react"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import { RolesSelector, type SelectedRole } from "./roles-selector"; @@ -41,6 +42,46 @@ export default function OrgRolesTagField({ disabled }: OrgRolesTagFieldProps) { const t = useTranslations(); + const isPopoverOpenRef = useRef(false); + const lastValidRolesRef = useRef( + (form.getValues(name) as SelectedRole[]) ?? [] + ); + + function validateRolesSelection() { + const current = form.getValues(name) as SelectedRole[]; + + if (current.length === 0 && lastValidRolesRef.current.length > 0) { + form.setValue(name, lastValidRolesRef.current as never, { + shouldDirty: true + }); + toast({ + variant: "destructive", + title: t("accessRoleRequired"), + description: t("accessRoleSelectPlease") + }); + return false; + } + + if (current.length > 0) { + lastValidRolesRef.current = current; + } + + return true; + } + + function handlePopoverOpenChange(open: boolean) { + isPopoverOpenRef.current = open; + + if (open) { + const current = form.getValues(name) as SelectedRole[]; + if (current.length > 0) { + lastValidRolesRef.current = current; + } + return; + } + + validateRolesSelection(); + } function setRoleTags(nextValue: SelectedRole[]) { const prev = form.getValues(name) as SelectedRole[]; @@ -61,39 +102,44 @@ export default function OrgRolesTagField({ return; } - if (next.length === 0) { - toast({ - variant: "destructive", - title: t("accessRoleErrorAdd"), - description: t("accessRoleSelectPlease") - }); - return; - } - form.setValue(name, next as never, { shouldDirty: true }); + + if (next.length > 0 && !isPopoverOpenRef.current) { + lastValidRolesRef.current = next; + } else if (!isPopoverOpenRef.current) { + validateRolesSelection(); + } } return ( ( - - {label ?? t("roles")} - - - - {showMultiRolePaywallMessage && ( - {paywallMessage} - )} - - - )} + render={({ field }) => { + const selectedRoles = (field.value ?? []) as SelectedRole[]; + if (!isPopoverOpenRef.current && selectedRoles.length > 0) { + lastValidRolesRef.current = selectedRoles; + } + + return ( + + {label ?? t("roles")} + + + + {showMultiRolePaywallMessage && ( + {paywallMessage} + )} + + + ); + }} /> ); } diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index dc75311c2..648e4d734 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -17,11 +17,13 @@ export interface MultiSelectInputProps< > extends MultiSelectTagsProps { buttonText?: string; lockedIds?: Set; + onPopoverOpenChange?: (open: boolean) => void; } export function MultiSelectTagInput({ buttonText, lockedIds, + onPopoverOpenChange, ...props }: MultiSelectInputProps) { const selectedValues = new Set(props.value.map((v) => v.id)); @@ -33,6 +35,7 @@ export function MultiSelectTagInput({ // clear input when popover is closed props.onSearch(""); } + onPopoverOpenChange?.(open); }} > diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx index e18d13383..5d3e31dfa 100644 --- a/src/components/roles-selector.tsx +++ b/src/components/roles-selector.tsx @@ -12,6 +12,7 @@ export type RolesSelectorProps = { orgId: string; selectedRoles?: SelectedRole[]; onSelectRoles: (roles: SelectedRole[]) => void; + onPopoverOpenChange?: (open: boolean) => void; disabled?: boolean; restrictAdminRole?: boolean; mapRolesByName?: boolean; @@ -23,6 +24,7 @@ export function RolesSelector({ orgId, selectedRoles = [], onSelectRoles, + onPopoverOpenChange, disabled, restrictAdminRole, mapRolesByName, @@ -77,6 +79,7 @@ export function RolesSelector({ options={rolesShown} value={selectedRoles} onChange={onSelectRoles} + onPopoverOpenChange={onPopoverOpenChange} disabled={disabled} lockedIds={lockedIds} />