From 2a2c7777a52349e5bd5df835ec9e80814c8849a7 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:02:33 -0700 Subject: [PATCH 1/2] fix: dedupe @radix-ui/react-dismissable-layer to unblock body clicks Three copies of @radix-ui/react-dismissable-layer were getting installed (1.1.0 nested under react-tooltip, 1.1.1 hoisted via react-menu, 1.1.11 nested under the react-dialog 1.1.15 override). Each module copy holds its own DismissableLayerContext and originalBodyPointerEvents module variable, so when the sidebar Dropdown opens, the user clicks Settings, and the SettingsDialog mounts before the Dropdown portal has finished unmounting, the Dialog's copy snapshots document.body with the Dropdown's "none" still applied. When the Dialog later closes, its cleanup writes that stale "none" back onto the body and leaves the whole app unclickable until reload. Pin a single shared version (1.1.11, the newest already pulled in by react-dialog 1.1.15) so all Radix packages route through one module copy with one shared layer context. --- bun.lock | 23 ++++++----------------- package.json | 1 + 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 5cb0e1e..6bcdebd 100644 --- a/bun.lock +++ b/bun.lock @@ -71,6 +71,7 @@ }, "overrides": { "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dismissable-layer": "1.1.11", "@tanstack/devtools": "0.11.0", "@tanstack/devtools-ui": "0.5.1", "@tanstack/devtools-vite": "0.3.12", @@ -346,7 +347,7 @@ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.2", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA=="], @@ -392,7 +393,7 @@ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="], + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], @@ -1704,15 +1705,9 @@ "@radix-ui/react-context-menu/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="], - "@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - "@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - "@radix-ui/react-dismissable-layer/@radix-ui/primitive": ["@radix-ui/primitive@1.1.0", "", {}, "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.0", "", { "dependencies": { "@radix-ui/react-slot": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw=="], + "@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-dropdown-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.0", "", {}, "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="], @@ -1876,8 +1871,6 @@ "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A=="], - "@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig=="], - "@radix-ui/react-tooltip/@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], "@radix-ui/react-tooltip/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.0", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-context": "1.1.0", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg=="], @@ -1896,6 +1889,8 @@ "@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-escape-keydown/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.0", "", { "dependencies": { "@radix-ui/react-slot": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw=="], "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], @@ -2014,14 +2009,8 @@ "@radix-ui/react-context-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw=="], - "@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], - "@radix-ui/react-dialog/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw=="], - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw=="], "@radix-ui/react-hover-card/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.0", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw=="], diff --git a/package.json b/package.json index a15c01d..7d10528 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ }, "overrides": { "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dismissable-layer": "1.1.11", "@tanstack/devtools": "0.11.0", "@tanstack/devtools-ui": "0.5.1", "@tanstack/devtools-vite": "0.3.12", From f05573d8d74c9bfe630e676a61e13854b00f69ee Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:52:08 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A5=82=20feat:=20Toasts=20on=20All=20?= =?UTF-8?q?Save=20Actions=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: success/error toasts on every admin panel save action Wires click-ui's Toast component into every save mutation in the admin panel: config editor (per-field, bulk, YAML import), groups (create / edit / delete), roles (create / edit / delete), users (invite, delete, role / group assignment, user profile create / delete), system grants (EditCapabilitiesDialog), and per-field profile mutations (useProfileMutations). Uses click-ui's official createToast directly; ClickUIProvider already mounts the matching ToastProvider, so the global createToast routes through it. Toasts inherit click-ui brand colours, WCAG contrast, keyboard accessibility, swipe-to-dismiss, close button, ARIA live region, and 5s auto-dismiss from the library. src/utils/toast.ts is a five-line wrapper exposing notifySuccess and notifyError that delegate to click-ui's createToast. Call sites pass the affected resource through mutation variables (not closed-over component state) so the toast renders the correct name even when the confirm dialog has already cleared its target by the time the response lands. Refs AI-1206. * fix: throw instead of silently no-op when target missing in edit mutations EditGroupDialog, EditRoleDialog and EditCapabilitiesDialog mutationFns used to bail with an empty return when their target (group, role, principal) was missing, which React Query treats as success and which caused the new onSuccess handlers to fire a success toast and close the dialog without anything having been persisted. Throw a localised error in the unavailable case so onError fires instead, and add a matching guard at each call site so mutate is never invoked with a missing target in the first place. * fix: capture submitted name in mutation variables for create/edit toasts The five create and edit dialogs used to read the resource name from component state inside onSuccess instead of from the value that was submitted with the mutation. If the user edited the name field while the request was in flight (or the dialog reset before the toast fired), the toast could render an empty or wrong name even though the server saved the original value. Pass the submitted name through the mutation variables, use it for the actual API call, and read it back from the mutation's data or variables argument in onSuccess so the toast always reflects what was persisted. --- src/components/access/CreateGroupDialog.tsx | 38 ++++++--- src/components/access/CreateRoleDialog.tsx | 43 ++++++---- src/components/access/EditGroupDialog.tsx | 50 +++++++---- src/components/access/EditRoleDialog.tsx | 72 ++++++++++------ src/components/access/GroupsTab.tsx | 10 ++- src/components/access/RolesTab.tsx | 25 ++---- src/components/configuration/ConfigPage.tsx | 70 +++------------ .../grants/EditCapabilitiesDialog.tsx | 19 +++-- src/components/users/CreateUserDialog.tsx | 13 ++- src/components/users/UserDetailDialog.tsx | 85 +++++++++++++------ src/components/users/UsersPage.tsx | 10 ++- src/hooks/useProfileMutations.ts | 15 +++- src/locales/en/translation.json | 20 +++++ src/styles.css | 60 ------------- src/types/config-ui.ts | 6 -- src/utils/index.ts | 1 + src/utils/toast.ts | 5 ++ 17 files changed, 283 insertions(+), 259 deletions(-) create mode 100644 src/utils/toast.ts diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx index 4c064fd..f383777 100644 --- a/src/components/access/CreateGroupDialog.tsx +++ b/src/components/access/CreateGroupDialog.tsx @@ -5,8 +5,8 @@ import type { AdminUserSearchResult } from '@librechat/data-schemas'; import type * as t from '@/types'; import { SelectedMemberList, UserSearchInline } from '@/components/shared'; import { addGroupMemberFn, createGroupFn } from '@/server'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { const localize = useLocalize(); @@ -27,20 +27,24 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { }; const mutation = useMutation({ - mutationFn: async () => { - const { group } = await createGroupFn({ data: { name, description } }); + mutationFn: async ({ name: submittedName }: { name: string }) => { + const { group } = await createGroupFn({ + data: { name: submittedName, description }, + }); for (const user of selectedUsers) { await addGroupMemberFn({ data: { groupId: group.id, userId: user.id } }); } + return { name: submittedName }; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); + notifySuccess(localize('com_toast_group_created', { name: data.name })); resetAndClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { @@ -50,7 +54,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { setActiveTab('details'); return; } - mutation.mutate(); + mutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { @@ -89,14 +93,15 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { ariaLabel={localize('com_access_create_group')} > - - {localize('com_access_tab_details')} - - - {localize('com_access_tab_members')} - + {localize('com_access_tab_details')} + {localize('com_access_tab_members')} - +
- +
u.id)} diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx index 9234fdc..836f353 100644 --- a/src/components/access/CreateRoleDialog.tsx +++ b/src/components/access/CreateRoleDialog.tsx @@ -6,9 +6,9 @@ import type * as t from '@/types'; import { addRoleMemberFn, createRoleFn, updateRolePermissionsFn } from '@/server'; import { SelectedMemberList, UserSearchInline } from '@/components/shared'; import { RolePermissionsPanel } from './RolePermissionsPanel'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { defaultPermissions } from '@/constants'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { const localize = useLocalize(); @@ -31,21 +31,23 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { }; const mutation = useMutation({ - mutationFn: async () => { - const { role } = await createRoleFn({ data: { name, description } }); + mutationFn: async ({ name: submittedName }: { name: string }) => { + const { role } = await createRoleFn({ data: { name: submittedName, description } }); await updateRolePermissionsFn({ data: { id: role.id, permissions } }); for (const user of selectedUsers) { await addRoleMemberFn({ data: { roleId: role.id, userId: user.id } }); } + return { name: submittedName }; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); + notifySuccess(localize('com_toast_role_created', { name: data.name })); resetAndClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { @@ -55,7 +57,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { setActiveTab('details'); return; } - mutation.mutate(); + mutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { @@ -94,17 +96,18 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { ariaLabel={localize('com_access_create_role')} > - - {localize('com_access_tab_details')} - + {localize('com_access_tab_details')} {localize('com_access_tab_permissions')} - - {localize('com_access_tab_members')} - + {localize('com_access_tab_members')} - +
- +
- +
u.id)} diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index e7fb923..8c0d6cf 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -18,8 +18,8 @@ import { TrashButton, UserSearchInline, } from '@/components/shared'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; type EditGroupTab = 'details' | 'members'; @@ -73,10 +73,10 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog }; const mutation = useMutation({ - mutationFn: async () => { - if (!group) return; + mutationFn: async ({ name: submittedName }: { name: string }) => { + if (!group) throw new Error(localize('com_access_group_unavailable')); if (detailsDirty) { - await updateGroupFn({ data: { id: group.id, name, description } }); + await updateGroupFn({ data: { id: group.id, name: submittedName, description } }); } const memberResults = await Promise.allSettled([ ...pendingAdditions.map((user) => @@ -95,24 +95,27 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog parts.push(localize('com_access_member_ops_failed', { count: failures.length })); throw new Error(parts.join(', ')); } + return { name: submittedName }; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers', group?.id] }); + notifySuccess(localize('com_toast_group_updated', { name: data.name })); onClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { setError(''); + if (!group) return; if (!name.trim()) { setError(localize('com_access_name_required')); setActiveTab('details'); return; } - mutation.mutate(); + mutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { @@ -140,14 +143,15 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog ariaLabel={localize('com_access_edit_group')} > - - {localize('com_access_tab_details')} - - - {localize('com_access_tab_members')} - + {localize('com_access_tab_details')} + {localize('com_access_tab_members')} - +
- +
{canManage && ( ({ id: m.userId, name: m.name, email: m.email, avatarUrl: m.avatarUrl }))} + users={pendingRemovals.map((m) => ({ + id: m.userId, + name: m.name, + email: m.email, + avatarUrl: m.avatarUrl, + }))} onRemove={unstageRemoval} disabled={mutation.isPending} /> @@ -254,7 +268,9 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 841b238..891c1ac 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -21,8 +21,8 @@ import { UserSearchInline, } from '@/components/shared'; import { RolePermissionsPanel } from './RolePermissionsPanel'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; type EditRoleTab = 'details' | 'permissions' | 'members'; @@ -91,11 +91,17 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro }; const updateMutation = useMutation({ - mutationFn: async (): Promise => { - if (!role) return ''; + mutationFn: async ({ + name: submittedName, + }: { + name: string; + }): Promise<{ roleId: string; name: string }> => { + if (!role) throw new Error(localize('com_access_role_unavailable')); let roleId = role.id; if (detailsDirty) { - const result = await updateRoleFn({ data: { id: role.id, name, description } }); + const result = await updateRoleFn({ + data: { id: role.id, name: submittedName, description }, + }); roleId = result.role.id; } if (permissionsDirty && permissions) { @@ -113,9 +119,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro } } const memberResults = await Promise.allSettled([ - ...pendingAdditions.map((user) => - addRoleMemberFn({ data: { roleId, userId: user.id } }), - ), + ...pendingAdditions.map((user) => addRoleMemberFn({ data: { roleId, userId: user.id } })), ...pendingRemovals.map((member) => removeRoleMemberFn({ data: { roleId, userId: member.userId } }), ), @@ -130,32 +134,34 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro parts.push(localize('com_access_member_ops_failed', { count: failures.length })); throw new Error(parts.join(', ')); } - return roleId; + return { roleId, name: submittedName }; }, - onSuccess: (newRoleId) => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['role', role?.id] }); - if (newRoleId !== role?.id) { - queryClient.invalidateQueries({ queryKey: ['role', newRoleId] }); + if (data.roleId !== role?.id) { + queryClient.invalidateQueries({ queryKey: ['role', data.roleId] }); } queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers', role?.id] }); - if (newRoleId !== role?.id) { - queryClient.invalidateQueries({ queryKey: ['roleMembers', newRoleId] }); + if (data.roleId !== role?.id) { + queryClient.invalidateQueries({ queryKey: ['roleMembers', data.roleId] }); } + notifySuccess(localize('com_toast_role_updated', { name: data.name })); onClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { setError(''); + if (!role) return; if (!name.trim()) { setError(localize('com_access_name_required')); setActiveTab('details'); return; } - updateMutation.mutate(); + updateMutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { @@ -183,17 +189,18 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro ariaLabel={localize('com_access_edit_role')} > - - {localize('com_access_tab_details')} - + {localize('com_access_tab_details')} {localize('com_access_tab_permissions')} - - {localize('com_access_tab_members')} - + {localize('com_access_tab_members')} - +
{role?.isSystemRole && ( @@ -239,7 +246,12 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
- +
{(() => { if (roleDetail.isLoading) { @@ -269,7 +281,12 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro })()}
- +
{canManage && ( ({ id: m.userId, name: m.name, email: m.email, avatarUrl: m.avatarUrl }))} + users={pendingRemovals.map((m) => ({ + id: m.userId, + name: m.name, + email: m.email, + avatarUrl: m.avatarUrl, + }))} onRemove={unstageRemoval} disabled={updateMutation.isPending} /> diff --git a/src/components/access/GroupsTab.tsx b/src/components/access/GroupsTab.tsx index 4829ade..2819f3c 100644 --- a/src/components/access/GroupsTab.tsx +++ b/src/components/access/GroupsTab.tsx @@ -11,11 +11,11 @@ import { TrashButton, } from '@/components/shared'; import { deleteGroupFn, groupsQueryOptions, GROUPS_PAGE_SIZE } from '@/server'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useCapabilities, useLocalize } from '@/hooks'; import { EditGroupDialog } from './EditGroupDialog'; import { SystemCapabilities } from '@/constants'; import { ConfirmDialog } from './ConfirmDialog'; -import { cn } from '@/utils'; export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { const localize = useLocalize(); @@ -52,17 +52,19 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { const totalPages = Math.ceil(total / GROUPS_PAGE_SIZE); const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteGroupFn({ data: { id } }), - onSuccess: () => { + mutationFn: (group: AdminGroup) => deleteGroupFn({ data: { id: group.id } }), + onSuccess: (_data, group) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); + notifySuccess(localize('com_toast_group_deleted', { name: group.name })); setDeleteTarget(null); if (groups.length === 1) { setPage((prev) => (prev > 1 ? prev - 1 : prev)); } }, + onError: (err: Error) => notifyError(err.message), }); if (isLoading && !data) { @@ -151,7 +153,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { confirmLabel={localize('com_ui_delete')} saving={deleteMutation.isPending} onConfirm={() => { - if (deleteTarget) deleteMutation.mutate(deleteTarget.id); + if (deleteTarget) deleteMutation.mutate(deleteTarget); }} onCancel={() => setDeleteTarget(null)} /> diff --git a/src/components/access/RolesTab.tsx b/src/components/access/RolesTab.tsx index bf945dd..50d53a1 100644 --- a/src/components/access/RolesTab.tsx +++ b/src/components/access/RolesTab.tsx @@ -11,6 +11,7 @@ import { } from '@/components/shared'; import { deleteRoleFn, allRolesQueryOptions, ROLES_PAGE_SIZE } from '@/server'; import { useCapabilities, useLocalize } from '@/hooks'; +import { notifySuccess, notifyError } from '@/utils'; import { EditRoleDialog } from './EditRoleDialog'; import { SystemCapabilities } from '@/constants'; import { ConfirmDialog } from './ConfirmDialog'; @@ -22,7 +23,6 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { const canManage = hasCapability(SystemCapabilities.MANAGE_ROLES); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [deleteError, setDeleteError] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); @@ -48,19 +48,19 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { }; const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteRoleFn({ data: { id } }), - onSuccess: () => { + mutationFn: (role: t.Role) => deleteRoleFn({ data: { id: role.id } }), + onSuccess: (_data, role) => { queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); + notifySuccess(localize('com_toast_role_deleted', { name: role.name })); setDeleteTarget(null); - setDeleteError(''); if (paged.length === 1) { setPage((prev) => (prev > 1 ? prev - 1 : prev)); } }, - onError: (err: Error) => setDeleteError(err.message), + onError: (err: Error) => notifyError(err.message), }); if (isLoading) { @@ -96,11 +96,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { {paged.length === 0 ? ( ) : (
@@ -157,15 +153,10 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { description={localize('com_access_delete_role_desc', { name: deleteTarget?.name ?? '' })} confirmLabel={localize('com_ui_delete')} saving={deleteMutation.isPending} - error={deleteError} onConfirm={() => { - setDeleteError(''); - if (deleteTarget) deleteMutation.mutate(deleteTarget.id); - }} - onCancel={() => { - setDeleteTarget(null); - setDeleteError(''); + if (deleteTarget) deleteMutation.mutate(deleteTarget); }} + onCancel={() => setDeleteTarget(null)} />
); diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index ccd9ed1..ea0a966 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -22,13 +22,16 @@ import { unflattenObject, serializeKVPairs, deepSerializeKVPairs, - cn, normalizeImportConfig, hasConfigCapability, getTabsWithPermission, + notifySuccess, + notifyError, } from '@/utils'; import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; +import { mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; +import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { ConfigTableOfContents } from './ConfigTableOfContents'; import { ConfirmSaveDialog } from './ConfirmSaveDialog'; @@ -36,8 +39,6 @@ import { StickyActionBar } from '@/components/shared'; import { ConfigTabContent } from './ConfigTabContent'; import { ImportYamlDialog } from './ImportYamlDialog'; import { ContentToolbar } from './ContentToolbar'; -import { validateMcpCrossField } from './sections/McpServersRenderer'; -import { mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; import { SystemCapabilities } from '@/constants'; import { ConfigTabBar } from './ConfigTabBar'; import { InfoBanner } from './InfoBanner'; @@ -193,18 +194,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const dismissTimer = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(dismissTimer.current), []); - const [toast, setToast] = useState(null); - const toastTimer = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(toastTimer.current), []); - - const showToast = useCallback((state: t.ToastState, autoHideMs?: number) => { - setToast(state); - clearTimeout(toastTimer.current); - if (autoHideMs) { - toastTimer.current = setTimeout(() => setToast(null), autoHideMs); - } - }, []); - const [showConfiguredOnly, setShowConfiguredOnly] = useState(false); const [scopeSelectorOpen, setScopeSelectorOpen] = useState(false); @@ -477,8 +466,8 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setConfirmSaveOpen(false); setSaving(false); setSaveError(null); - showToast({ type: 'saved' }, 3000); - }, [showToast]); + notifySuccess(localize('com_config_saved')); + }, [localize]); const invalidateAndResetBase = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); @@ -494,8 +483,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const importMutation = useMutation({ mutationFn: (config: Record) => importBaseConfigFn({ data: { config } }), - onMutate: () => showToast({ type: 'saving' }), - onError: (err: Error) => showToast({ type: 'error', message: err.message }, 5000), + onError: (err: Error) => notifyError(err.message), onSuccess: invalidateAndResetBase, }); @@ -544,7 +532,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi field: missingField, }); setSaveError(message); - showToast({ type: 'error', message }, 5000); + notifyError(message); return; } } @@ -568,7 +556,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setSaving(true); setSaveError(null); - showToast({ type: 'saving' }); try { /** Resets must land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */ @@ -629,7 +616,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const message = err instanceof Error ? err.message : String(err); setSaving(false); setSaveError(message); - showToast({ type: 'error', message }, 5000); + notifyError(message); } }, [ touchedPaths, @@ -640,7 +627,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi configValues, baseConfigData, localize, - showToast, editingScope, invalidateAndResetScope, invalidateAndResetBase, @@ -718,7 +704,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const normalized = normalizeImportConfig(appConfig); if (isEditingScope && editingScope) { handleImportAsProfile(normalized, editingScope).catch((err: Error) => { - showToast({ type: 'error', message: err.message }, 5000); + notifyError(err.message); }); } else { importMutation.mutate(normalized, { onSuccess: () => showImportSuccess() }); @@ -954,7 +940,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi showConfiguredOnly={showConfiguredOnly} isEditingScope={isEditingScope} baseRecordKeys={baseRecordKeys} - onValidationError={(message) => showToast({ type: 'error', message }, 5000)} + onValidationError={(message) => notifyError(message)} />
@@ -978,38 +964,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi /> )} - {toast && - createPortal( -
- {toast.type === 'saving' && ( - <> - - {localize('com_config_saving')} - - )} - {toast.type === 'saved' && ( - <> - - {localize('com_config_saved')} - - )} - {toast.type === 'error' && ( - <> - - {toast.message} - - )} -
, - document.body, - )} - showToast({ type: 'error', message: msg }, 5000)} + onError={(msg) => notifyError(msg)} /> { const record: Record = {}; @@ -51,8 +51,10 @@ export function EditCapabilitiesDialog({ }, [open, isLoading, grants]); const saveMutation = useMutation({ - mutationFn: async () => { - if (!principalType || !principalId) return; + mutationFn: async (vars: { name: string }) => { + if (!principalType || !principalId) { + throw new Error(localize('com_cap_principal_unavailable')); + } const toGrant: string[] = []; const toRevoke: string[] = []; for (const [cap, enabled] of Object.entries(capabilities)) { @@ -66,22 +68,25 @@ export function EditCapabilitiesDialog({ for (const cap of toRevoke) { await revokeCapabilityFn({ data: { ...shared, capability: cap } }); } + return vars; }, - onSuccess: () => { + onSuccess: (_data, vars) => { queryClient.invalidateQueries({ queryKey: ['systemGrants'] }); queryClient.invalidateQueries({ queryKey: ['effectiveCapabilities'] }); queryClient.invalidateQueries({ queryKey: ['auditLog'] }); + notifySuccess(localize('com_toast_capabilities_saved', { name: vars.name })); onClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const hasChanges = Object.keys(capabilities).some((cap) => capabilities[cap] !== baseline[cap]); const handleSave = useCallback(() => { setError(''); - saveMutation.mutate(); - }, [saveMutation]); + if (!principalType || !principalId) return; + saveMutation.mutate({ name: principalName }); + }, [saveMutation, principalType, principalId, principalName]); const dialogTitle = principalType ? `${localize('com_cap_edit_title', { name: principalName })}` diff --git a/src/components/users/CreateUserDialog.tsx b/src/components/users/CreateUserDialog.tsx index 87ec8cc..9a2e79a 100644 --- a/src/components/users/CreateUserDialog.tsx +++ b/src/components/users/CreateUserDialog.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { SystemRoles } from 'librechat-data-provider'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type * as t from '@/types'; +import { notifySuccess, notifyError } from '@/utils'; import { FormDialog } from '@/components/shared'; import { createUserFn } from '@/server'; import { useLocalize } from '@/hooks'; @@ -15,12 +16,16 @@ export function CreateUserDialog({ open, onClose }: t.CreateUserDialogProps) { const [error, setError] = useState(''); const mutation = useMutation({ - mutationFn: () => createUserFn({ data: { name, email, role } }), - onSuccess: () => { + mutationFn: async ({ name: submittedName }: { name: string }) => { + await createUserFn({ data: { name: submittedName, email, role } }); + return { name: submittedName }; + }, + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['users'] }); + notifySuccess(localize('com_toast_user_invited', { name: data.name })); resetAndClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const resetAndClose = () => { @@ -41,7 +46,7 @@ export function CreateUserDialog({ open, onClose }: t.CreateUserDialogProps) { setError(localize('com_users_email_required')); return; } - mutation.mutate(); + mutation.mutate({ name }); }; return ( diff --git a/src/components/users/UserDetailDialog.tsx b/src/components/users/UserDetailDialog.tsx index 8c0ac21..c218491 100644 --- a/src/components/users/UserDetailDialog.tsx +++ b/src/components/users/UserDetailDialog.tsx @@ -18,9 +18,9 @@ import { allRolesQueryOptions, } from '@/server'; import { Avatar, TrashButton } from '@/components/shared'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { ConfirmDialog } from '@/components/access'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; const CONFIRM_TITLE_KEYS: Record = { role: 'com_users_remove_role_title', @@ -89,57 +89,73 @@ export function UserDetailDialog({ queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); }; + const handleError = (err: Error) => notifyError(err.message); + const addRoleMutation = useMutation({ - mutationFn: (roleId: string) => { + mutationFn: (role: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return addRoleMemberFn({ data: { roleId, userId } }); + return addRoleMemberFn({ data: { roleId: role.id, userId } }); + }, + onSuccess: (_data, role) => { + invalidateAll(); + notifySuccess(localize('com_toast_role_assigned', { name: role.name })); }, - onSuccess: invalidateAll, + onError: handleError, }); const removeRoleMutation = useMutation({ - mutationFn: (roleId: string) => { + mutationFn: (role: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return removeRoleMemberFn({ data: { roleId, userId } }); + return removeRoleMemberFn({ data: { roleId: role.id, userId } }); }, - onSuccess: () => { + onSuccess: (_data, role) => { invalidateAll(); + notifySuccess(localize('com_toast_role_unassigned', { name: role.name })); setRemoveTarget(null); }, + onError: handleError, }); const addGroupMutation = useMutation({ - mutationFn: (groupId: string) => { + mutationFn: (group: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return addGroupMemberFn({ data: { groupId, userId } }); + return addGroupMemberFn({ data: { groupId: group.id, userId } }); + }, + onSuccess: (_data, group) => { + invalidateAll(); + notifySuccess(localize('com_toast_group_assigned', { name: group.name })); }, - onSuccess: invalidateAll, + onError: handleError, }); const removeGroupMutation = useMutation({ - mutationFn: (groupId: string) => { + mutationFn: (group: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return removeGroupMemberFn({ data: { groupId, userId } }); + return removeGroupMemberFn({ data: { groupId: group.id, userId } }); }, - onSuccess: () => { + onSuccess: (_data, group) => { invalidateAll(); + notifySuccess(localize('com_toast_group_unassigned', { name: group.name })); setRemoveTarget(null); }, + onError: handleError, }); const createProfileMutation = useMutation({ - mutationFn: () => { - if (!userId) throw new Error('No user selected'); - return createScopeFn({ + mutationFn: (vars: { userId: string; name: string }) => + createScopeFn({ data: { principalType: PrincipalType.USER, - name: userName, + name: vars.name, priority: 100, - principalId: userId, + principalId: vars.userId, }, - }); + }), + onSuccess: (_data, vars) => { + invalidateAll(); + notifySuccess(localize('com_toast_user_profile_created', { name: vars.name })); }, - onSuccess: invalidateAll, + onError: handleError, }); const deleteProfileMutation = useMutation({ @@ -149,8 +165,10 @@ export function UserDetailDialog({ }), onSuccess: () => { invalidateAll(); + notifySuccess(localize('com_toast_user_profile_deleted')); setRemoveTarget(null); }, + onError: handleError, }); const busy = @@ -166,9 +184,13 @@ export function UserDetailDialog({ const handleConfirmRemove = () => { if (!removeTarget) return; - if (removeTarget.kind === 'role') removeRoleMutation.mutate(removeTarget.ref.id); - else if (removeTarget.kind === 'group') removeGroupMutation.mutate(removeTarget.ref.id); - else if (removeTarget.kind === 'profile') deleteProfileMutation.mutate(removeTarget.scope); + if (removeTarget.kind === 'role') { + removeRoleMutation.mutate({ id: removeTarget.ref.id, name: removeTarget.ref.name }); + } else if (removeTarget.kind === 'group') { + removeGroupMutation.mutate({ id: removeTarget.ref.id, name: removeTarget.ref.name }); + } else if (removeTarget.kind === 'profile') { + deleteProfileMutation.mutate(removeTarget.scope); + } }; const confirmTitle = removeTarget ? localize(CONFIRM_TITLE_KEYS[removeTarget.kind]) : ''; @@ -240,9 +262,20 @@ export function UserDetailDialog({ canManageRoles={canManageRoles} canManageGroups={canManageGroups} canAssignConfigs={canAssignConfigs} - onAddRole={(id) => addRoleMutation.mutate(id)} - onAddGroup={(id) => addGroupMutation.mutate(id)} - onCreateUserProfile={() => createProfileMutation.mutate()} + onAddRole={(id) => { + if (addRoleMutation.isPending) return; + const role = availableRoles.find((r) => r.id === id); + if (role) addRoleMutation.mutate({ id: role.id, name: role.name }); + }} + onAddGroup={(id) => { + if (addGroupMutation.isPending) return; + const group = availableGroups.find((g) => g.id === id); + if (group) addGroupMutation.mutate({ id: group.id, name: group.name }); + }} + onCreateUserProfile={() => { + if (createProfileMutation.isPending || !userId) return; + createProfileMutation.mutate({ userId, name: userName }); + }} onDone={() => setView('main')} /> )} diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx index e3ea904..fa1158f 100644 --- a/src/components/users/UsersPage.tsx +++ b/src/components/users/UsersPage.tsx @@ -20,11 +20,11 @@ import { SearchInput, } from '@/components/shared'; import { useAnnouncement, useCapabilities, useLocalize } from '@/hooks'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { CreateUserDialog } from './CreateUserDialog'; import { UserDetailDialog } from './UserDetailDialog'; import { ConfirmDialog } from '@/components/access'; import { SystemCapabilities } from '@/constants'; -import { cn } from '@/utils'; const ROLE_FILTER_LABELS: Record = { all: 'com_ui_all', @@ -61,16 +61,18 @@ export function UsersPage() { }, [allScopes]); const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteUserFn({ data: { id } }), - onSuccess: () => { + mutationFn: (user: TUser) => deleteUserFn({ data: { id: user.id } }), + onSuccess: (_data, user) => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); + notifySuccess(localize('com_toast_user_deleted', { name: user.name })); setDeleteTarget(null); }, + onError: (err: Error) => notifyError(err.message), }); const applyFilters = (list: TUser[], q: string, role: t.RoleFilter) => @@ -221,7 +223,7 @@ export function UsersPage() { confirmLabel={localize('com_ui_delete')} saving={deleteMutation.isPending} onConfirm={() => { - if (deleteTarget) deleteMutation.mutate(deleteTarget.id); + if (deleteTarget) deleteMutation.mutate(deleteTarget); }} onCancel={() => setDeleteTarget(null)} /> diff --git a/src/hooks/useProfileMutations.ts b/src/hooks/useProfileMutations.ts index 38e8b54..a81508d 100644 --- a/src/hooks/useProfileMutations.ts +++ b/src/hooks/useProfileMutations.ts @@ -3,12 +3,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { PrincipalType } from 'librechat-data-provider'; import type * as t from '@/types'; import { removeFieldProfileValueFn, saveFieldProfileValueFn } from '@/server'; +import { notifySuccess, notifyError } from '@/utils'; +import { useLocalize } from './useLocalize'; export function useProfileMutations({ fieldPath, onProfileChange, }: t.UseProfileMutationsOptions): t.UseProfileMutationsReturn { const queryClient = useQueryClient(); + const localize = useLocalize(); const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['profileMap'] }); @@ -20,13 +23,21 @@ export function useProfileMutations({ const saveMutation = useMutation({ mutationFn: (params: { principalType: PrincipalType; principalId: string; value: unknown }) => saveFieldProfileValueFn({ data: { fieldPath, ...params } }), - onSuccess: () => invalidate(), + onSuccess: () => { + invalidate(); + notifySuccess(localize('com_toast_profile_value_saved')); + }, + onError: (err: Error) => notifyError(err.message), }); const removeMutation = useMutation({ mutationFn: (params: { principalType: PrincipalType; principalId: string }) => removeFieldProfileValueFn({ data: { fieldPath, ...params } }), - onSuccess: () => invalidate(), + onSuccess: () => { + invalidate(); + notifySuccess(localize('com_toast_profile_value_removed')); + }, + onError: (err: Error) => notifyError(err.message), }); return { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3f95eaa..7f1540c 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -109,6 +109,23 @@ "com_config_saving": "Saving...", "com_config_saved": "Changes saved", "com_config_save_error": "Failed to save", + "com_toast_group_created": "Group \"{{name}}\" created", + "com_toast_group_updated": "Group \"{{name}}\" updated", + "com_toast_group_deleted": "Group \"{{name}}\" deleted", + "com_toast_role_created": "Role \"{{name}}\" created", + "com_toast_role_updated": "Role \"{{name}}\" updated", + "com_toast_role_deleted": "Role \"{{name}}\" deleted", + "com_toast_user_invited": "User \"{{name}}\" invited", + "com_toast_user_deleted": "User \"{{name}}\" deleted", + "com_toast_role_assigned": "Added to role \"{{name}}\"", + "com_toast_role_unassigned": "Removed from role \"{{name}}\"", + "com_toast_group_assigned": "Added to group \"{{name}}\"", + "com_toast_group_unassigned": "Removed from group \"{{name}}\"", + "com_toast_user_profile_created": "User profile created for \"{{name}}\"", + "com_toast_user_profile_deleted": "User profile deleted", + "com_toast_capabilities_saved": "Capabilities for \"{{name}}\" saved", + "com_toast_profile_value_saved": "Profile value saved", + "com_toast_profile_value_removed": "Profile value removed", "com_config_import_yaml": "Import YAML", "com_config_import_yaml_title": "Import configuration", "com_config_import_yaml_desc": "Upload or paste a librechat.yaml file to populate configuration values across the UI.", @@ -882,6 +899,8 @@ "com_access_member_ops_failed_other": "{{count}} member operations failed", "com_access_details_saved_permissions_failed": "Details saved, but permissions failed: {{error}}", "com_access_name_required": "Name is required", + "com_access_group_unavailable": "Group is no longer available", + "com_access_role_unavailable": "Role is no longer available", "com_access_col_name": "Name", "com_access_group_name_placeholder": "e.g. Engineering", "com_access_group_desc_placeholder": "Optional description...", @@ -1025,6 +1044,7 @@ "com_access_denied_title": "Access denied", "com_access_denied_description": "You don't have permission to view this page. Contact your administrator.", "com_cap_no_permission": "You need {{cap}} to perform this action", + "com_cap_principal_unavailable": "Principal is no longer available", "com_a11y_cap_filter_changed": "Showing {{count}} principals", "com_nav_audit_log": "Audit log", "com_audit_title": "Audit log", diff --git a/src/styles.css b/src/styles.css index 38ad3fe..88b7c25 100644 --- a/src/styles.css +++ b/src/styles.css @@ -797,66 +797,6 @@ input[type='number'].config-input::-webkit-outer-spin-button { transition: opacity 150ms ease; } -/* ── Toast notification ──────────────────────────────────────── */ - -.config-toast { - position: fixed; - bottom: 1.5rem; - right: 1.5rem; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1rem; - border-radius: 0.5rem; - font-size: 0.8125rem; - font-weight: 500; - box-shadow: 0 8px 30px var(--cui-color-shadow); - z-index: var(--z-toast); - animation: toast-in 200ms ease-out; - pointer-events: none; -} - -.config-toast-info { - background: var(--cui-color-accent-info); - color: var(--cui-color-text-on-accent); -} - -.config-toast-success { - background: var(--cui-color-accent-success); - color: var(--cui-color-text-on-accent); -} - -.config-toast-error { - background: var(--cui-color-accent-danger); - color: var(--cui-color-text-on-accent); -} - -.config-toast-spinner { - width: 14px; - height: 14px; - border: 2px solid color-mix(in srgb, var(--cui-color-text-on-accent) 30%, transparent); - border-top-color: var(--cui-color-text-on-accent); - border-radius: 50%; - animation: toast-spin 600ms linear infinite; -} - -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes toast-spin { - to { - transform: rotate(360deg); - } -} - .modal-frost[role='dialog'] { border-radius: 12px !important; box-shadow: 0 16px 70px 0 var(--cui-color-shadow) !important; diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index 1503e02..782a263 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -48,12 +48,6 @@ export interface ConfigPageProps { initialScope?: string; } -export type ToastState = - | { type: 'saving' } - | { type: 'saved' } - | { type: 'error'; message: string } - | null; - export interface ConfigTabBarProps { tabs: ConfigTab[]; activeTab: string; diff --git a/src/utils/index.ts b/src/utils/index.ts index 449e2b1..4f887f9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './capabilities'; export * from './cn'; export * from './format'; export * from './interfacePermissions'; +export * from './toast'; diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 0000000..4283532 --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,5 @@ +import { createToast } from '@clickhouse/click-ui'; + +export const notifySuccess = (title: string): void => createToast({ type: 'success', title }); + +export const notifyError = (title: string): void => createToast({ type: 'danger', title });