improve role required feedback on user

This commit is contained in:
miloschwartz
2026-07-07 15:15:19 -04:00
parent be193731c1
commit d9608bd408
5 changed files with 86 additions and 33 deletions
+2 -1
View File
@@ -615,7 +615,8 @@
"idpNameInternal": "Internal", "idpNameInternal": "Internal",
"emailInvalid": "Invalid email address", "emailInvalid": "Invalid email address",
"inviteValidityDuration": "Please select a duration", "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?", "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.", "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", "removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
@@ -111,7 +111,7 @@ export default function AccessControlsPage() {
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("accessRoleErrorAdd"), title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease") description: t("accessRoleSelectPlease")
}); });
return; return;
@@ -173,15 +173,13 @@ export default function AccessControlsPage() {
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("accessRoleErrorAdd"), title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease") description: t("accessRoleSelectPlease")
}); });
return; return;
} }
const willHaveAdminRole = values.roles.some( const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
(r) => r.isAdmin === true
);
const isRemovingOwnAdmin = const isRemovingOwnAdmin =
sessionUser.userId === user.userId && sessionUser.userId === user.userId &&
@@ -226,7 +224,9 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => void handleAccessControlsSubmit(e)} onSubmit={(e) =>
void handleAccessControlsSubmit(e)
}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
+72 -26
View File
@@ -12,6 +12,7 @@ import {
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { RolesSelector, type SelectedRole } from "./roles-selector"; import { RolesSelector, type SelectedRole } from "./roles-selector";
@@ -41,6 +42,46 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
disabled disabled
}: OrgRolesTagFieldProps<TFieldValues>) { }: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations(); const t = useTranslations();
const isPopoverOpenRef = useRef(false);
const lastValidRolesRef = useRef<SelectedRole[]>(
(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[]) { function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as SelectedRole[]; const prev = form.getValues(name) as SelectedRole[];
@@ -61,39 +102,44 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
return; return;
} }
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue(name, next as never, { shouldDirty: true }); form.setValue(name, next as never, { shouldDirty: true });
if (next.length > 0 && !isPopoverOpenRef.current) {
lastValidRolesRef.current = next;
} else if (!isPopoverOpenRef.current) {
validateRolesSelection();
}
} }
return ( return (
<FormField <FormField
control={form.control} control={form.control}
name={name} name={name}
render={({ field }) => ( render={({ field }) => {
<FormItem className="flex flex-col items-start"> const selectedRoles = (field.value ?? []) as SelectedRole[];
<FormLabel>{label ?? t("roles")}</FormLabel> if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
<FormControl> lastValidRolesRef.current = selectedRoles;
<RolesSelector }
orgId={orgId}
selectedRoles={field.value ?? []} return (
onSelectRoles={setRoleTags} <FormItem className="flex flex-col items-start">
disabled={disabled} <FormLabel>{label ?? t("roles")}</FormLabel>
/> <FormControl>
</FormControl> <RolesSelector
{showMultiRolePaywallMessage && ( orgId={orgId}
<FormDescription>{paywallMessage}</FormDescription> selectedRoles={selectedRoles}
)} onSelectRoles={setRoleTags}
<FormMessage /> onPopoverOpenChange={handlePopoverOpenChange}
</FormItem> disabled={disabled}
)} />
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}}
/> />
); );
} }
@@ -17,11 +17,13 @@ export interface MultiSelectInputProps<
> extends MultiSelectTagsProps<T> { > extends MultiSelectTagsProps<T> {
buttonText?: string; buttonText?: string;
lockedIds?: Set<string>; lockedIds?: Set<string>;
onPopoverOpenChange?: (open: boolean) => void;
} }
export function MultiSelectTagInput<T extends TagValue>({ export function MultiSelectTagInput<T extends TagValue>({
buttonText, buttonText,
lockedIds, lockedIds,
onPopoverOpenChange,
...props ...props
}: MultiSelectInputProps<T>) { }: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id)); const selectedValues = new Set(props.value.map((v) => v.id));
@@ -33,6 +35,7 @@ export function MultiSelectTagInput<T extends TagValue>({
// clear input when popover is closed // clear input when popover is closed
props.onSearch(""); props.onSearch("");
} }
onPopoverOpenChange?.(open);
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
+3
View File
@@ -12,6 +12,7 @@ export type RolesSelectorProps = {
orgId: string; orgId: string;
selectedRoles?: SelectedRole[]; selectedRoles?: SelectedRole[];
onSelectRoles: (roles: SelectedRole[]) => void; onSelectRoles: (roles: SelectedRole[]) => void;
onPopoverOpenChange?: (open: boolean) => void;
disabled?: boolean; disabled?: boolean;
restrictAdminRole?: boolean; restrictAdminRole?: boolean;
mapRolesByName?: boolean; mapRolesByName?: boolean;
@@ -23,6 +24,7 @@ export function RolesSelector({
orgId, orgId,
selectedRoles = [], selectedRoles = [],
onSelectRoles, onSelectRoles,
onPopoverOpenChange,
disabled, disabled,
restrictAdminRole, restrictAdminRole,
mapRolesByName, mapRolesByName,
@@ -77,6 +79,7 @@ export function RolesSelector({
options={rolesShown} options={rolesShown}
value={selectedRoles} value={selectedRoles}
onChange={onSelectRoles} onChange={onSelectRoles}
onPopoverOpenChange={onPopoverOpenChange}
disabled={disabled} disabled={disabled}
lockedIds={lockedIds} lockedIds={lockedIds}
/> />