feat: add client-side role name uniqueness validation with 409 handling
This commit is contained in:
@@ -179,9 +179,21 @@ interface RoleEditViewProps {
|
|||||||
|
|
||||||
function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||||
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
|
const roles = useSpaceStore((s) => s.roles);
|
||||||
const isEveryone = role.id === spaceId;
|
const isEveryone = role.id === spaceId;
|
||||||
|
|
||||||
const [draftName, setDraftName] = useState(role.name);
|
const [draftName, setDraftName] = useState(role.name);
|
||||||
|
const [nameError, setNameError] = useState('');
|
||||||
|
|
||||||
|
const validateName = (name: string): string => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return 'Role name cannot be empty';
|
||||||
|
const isDuplicate = roles.some(
|
||||||
|
(r) => r.id !== role.id && r.name.toLowerCase() === trimmed.toLowerCase()
|
||||||
|
);
|
||||||
|
return isDuplicate ? 'A role with this name already exists' : '';
|
||||||
|
};
|
||||||
|
|
||||||
const [draftColor, setDraftColor] = useState(role.color);
|
const [draftColor, setDraftColor] = useState(role.color);
|
||||||
const [draftPermissions, setDraftPermissions] = useState<bigint>(
|
const [draftPermissions, setDraftPermissions] = useState<bigint>(
|
||||||
stringToPermissions(role.permissions)
|
stringToPermissions(role.permissions)
|
||||||
@@ -217,7 +229,12 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
setSaveSuccess(true);
|
setSaveSuccess(true);
|
||||||
setTimeout(() => setSaveSuccess(false), 2000);
|
setTimeout(() => setSaveSuccess(false), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveError(err instanceof Error ? err.message : 'Failed to save role');
|
const msg = err instanceof Error ? err.message : 'Failed to save role';
|
||||||
|
if (msg.includes('already exists')) {
|
||||||
|
setNameError(msg);
|
||||||
|
} else {
|
||||||
|
setSaveError(msg);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -229,6 +246,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
setDraftPermissions(stringToPermissions(role.permissions));
|
setDraftPermissions(stringToPermissions(role.permissions));
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
setSaveError('');
|
setSaveError('');
|
||||||
|
setNameError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@@ -277,9 +295,16 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={draftName}
|
value={draftName}
|
||||||
onChange={(e) => setDraftName(e.target.value)}
|
onChange={(e) => {
|
||||||
className="input-standard w-full"
|
setDraftName(e.target.value);
|
||||||
|
setNameError(validateName(e.target.value));
|
||||||
|
}}
|
||||||
|
onBlur={() => setNameError(validateName(draftName))}
|
||||||
|
className={`input-standard w-full${nameError ? ' ring-2 ring-accent-rose' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<p className="text-xs text-txt-danger mt-1">{nameError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
@@ -389,7 +414,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
disabled={saving || (!isEveryone && !draftName.trim()) || !!nameError}
|
||||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
|||||||
Reference in New Issue
Block a user