fix(ui): keep a view-only session read-only on the destinations table

proxy_admin_viewer is mapped to the effective role Admin so it gets read parity with proxy admin, and the write restriction travels separately on isViewOnly. This page derived write access from the role alone, so after that mapping landed a read-only admin saw Add Callback and the per-row Edit scope and Delete actions on admin-owned destinations.

Verified live: the viewer session now renders no Add Callback and keeps only the config-callback Test menu, while a proxy admin keeps all eleven row menus.
This commit is contained in:
Yucheng Zhu 2026-08-08 11:37:07 -07:00
parent d5580b7a51
commit 3e063ee252
3 changed files with 55 additions and 4 deletions

View file

@ -4,6 +4,14 @@ import Settings from "@/components/settings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function LoggingAndAlerts() {
const { accessToken, userRole, userId, premiumUser } = useAuthorized();
return <Settings userID={userId} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />;
const { accessToken, userRole, userId, premiumUser, isViewOnly } = useAuthorized();
return (
<Settings
userID={userId}
userRole={userRole}
accessToken={accessToken}
premiumUser={premiumUser}
isViewOnly={isViewOnly}
/>
);
}

View file

@ -387,3 +387,45 @@ describe("Add Callback dropdown", () => {
expect(screen.getByText("Arize (scoped destination)")).toBeInTheDocument();
});
});
describe("Settings read-only admin", () => {
beforeEach(() => {
vi.clearAllMocks();
credentialsFixture = { credentials: [] };
vi.mocked(getCallbacksCall).mockResolvedValue({ callbacks: [], available_callbacks: [], alerts: [] });
vi.mocked(getCallbackConfigsCall).mockResolvedValue([]);
vi.mocked(alertingSettingsCall).mockResolvedValue([]);
});
it("gives a view-only session no write affordances even though its role reads as Admin", async () => {
// Regression: proxy_admin_viewer is mapped to the effective role "Admin" so it gets
// read parity, and the write restriction travels separately on isViewOnly. Deriving
// write access from the role alone handed the viewer Add, Edit scope and Delete.
const { queryByText } = renderSettings({
accessToken: "token",
userRole: "Admin",
userID: "viewer-1",
premiumUser: false,
isViewOnly: true,
} as never);
await waitFor(() => {
expect(queryByText("Active Logging Callbacks")).toBeInTheDocument();
});
expect(queryByText("Add Callback")).not.toBeInTheDocument();
});
it("keeps the write affordances for a real proxy admin", async () => {
const { queryByText } = renderSettings({
accessToken: "token",
userRole: "Admin",
userID: "admin-1",
premiumUser: false,
});
await waitFor(() => {
expect(queryByText("Active Logging Callbacks")).toBeInTheDocument();
});
expect(queryByText("Add Callback")).toBeInTheDocument();
});
});

View file

@ -57,6 +57,7 @@ interface SettingsPageProps {
userRole: string | null;
userID: string | null;
premiumUser: boolean;
isViewOnly?: boolean;
}
const assetsLogoFolder = "/ui/assets/logos/";
@ -215,7 +216,7 @@ const buildCallbackPayload = (formValues: Record<string, any>, callbackName: str
};
};
const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, premiumUser }) => {
const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, premiumUser, isViewOnly = false }) => {
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true);
const [alerts, setAlerts] = useState<any[]>([]);
@ -253,7 +254,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
// credential_type=logging; they share the one Active Logging Callbacks table as
// rows alongside config callbacks. Only a proxy admin (or admin-viewer, read-only)
// may read them, so non-admins skip the fetch entirely.
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
const isProxyAdmin = !isViewOnly && userRole != null && isProxyAdminRole(userRole);
const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentialsRole(userRole));
const { data: teamsData } = useTeams();
const { data: orgsData } = useOrganizations();