feat: implement OAuth2App* page permissions (#21911)

This pull-request implements various permission checks to the
`<OAuth2App* />` stories and components. We're trying to ensure that
we're actually allowed to `create`/`view`/`delete` on both Secrets and
Applications before showing them to the user/allowing action.

Furthermore, I've added various stories to catch when a user lacks these
permissions.

I noticed this particularly because I'm only an `Auditor` on our DEV
instance and can't see these fields.

---------

Co-authored-by: coder-tasks[bot] <254784001+coder-tasks[bot]@users.noreply.github.com>
This commit is contained in:
Jake Howell
2026-02-05 05:05:17 +11:00
committed by GitHub
co-authored by coder-tasks[bot]
parent af0e171595
commit 849eaccd78
12 changed files with 117 additions and 22 deletions
+24
View File
@@ -176,6 +176,30 @@ export const permissionChecks = {
},
action: "read",
},
createOAuth2App: {
object: {
resource_type: "oauth2_app",
},
action: "create",
},
editOAuth2App: {
object: {
resource_type: "oauth2_app",
},
action: "update",
},
deleteOAuth2App: {
object: {
resource_type: "oauth2_app",
},
action: "delete",
},
viewOAuth2AppSecrets: {
object: {
resource_type: "oauth2_app_secret",
},
action: "read",
},
} as const satisfies Record<string, AuthorizationCheck>;
export const canViewDeploymentSettings = (
@@ -1,5 +1,6 @@
import { postApp } from "api/queries/oauth2";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useAuthenticated } from "hooks";
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate, useSearchParams } from "react-router";
@@ -9,8 +10,10 @@ import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
const CreateOAuth2AppPage: FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const postAppMutation = useMutation(postApp(queryClient));
const canCreateApp = permissions.createOAuth2App;
const defaultValues = {
name: searchParams.get("name") ?? "",
@@ -37,6 +40,7 @@ const CreateOAuth2AppPage: FC = () => {
displayError("Failed to create OAuth2 application");
}
}}
canCreateApp={canCreateApp}
/>
</>
);
@@ -5,6 +5,9 @@ import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
const meta: Meta = {
title: "pages/DeploymentSettingsPage/CreateOAuth2AppPageView",
component: CreateOAuth2AppPageView,
args: {
canCreateApp: true,
},
};
export default meta;
@@ -38,4 +41,10 @@ export const WithError: Story = {
},
};
export const NoPermissions: Story = {
args: {
canCreateApp: false,
},
};
export const Default: Story = {};
@@ -21,6 +21,7 @@ type CreateOAuth2AppProps = {
callback_url: string;
icon: string;
};
canCreateApp: boolean;
};
export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
@@ -28,6 +29,7 @@ export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
createApp,
error,
defaultValues,
canCreateApp,
}) => {
return (
<>
@@ -58,6 +60,7 @@ export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
isUpdating={isUpdating}
error={error}
defaultValues={defaultValues}
disabled={!canCreateApp}
/>
</Stack>
</>
@@ -1,6 +1,7 @@
import * as oauth2 from "api/queries/oauth2";
import type * as TypesGen from "api/typesGenerated";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useAuthenticated } from "hooks";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router";
@@ -9,6 +10,7 @@ import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
const EditOAuth2AppPage: FC = () => {
const navigate = useNavigate();
const { permissions } = useAuthenticated();
const { appId } = useParams() as { appId: string };
// When a new secret is created it is returned with the full secret. This is
@@ -22,7 +24,10 @@ const EditOAuth2AppPage: FC = () => {
const appQuery = useQuery(oauth2.getApp(appId));
const putAppMutation = useMutation(oauth2.putApp(queryClient));
const deleteAppMutation = useMutation(oauth2.deleteApp(queryClient));
const secretsQuery = useQuery(oauth2.getAppSecrets(appId));
const secretsQuery = useQuery({
...oauth2.getAppSecrets(appId),
enabled: permissions.viewOAuth2AppSecrets,
});
const postSecretMutation = useMutation(oauth2.postAppSecret(queryClient));
const deleteSecretMutation = useMutation(oauth2.deleteAppSecret(queryClient));
@@ -94,6 +99,9 @@ const EditOAuth2AppPage: FC = () => {
displayError("Failed to delete OAuth2 client secret");
}
}}
canEditApp={permissions.editOAuth2App}
canDeleteApp={permissions.deleteOAuth2App}
canViewAppSecrets={permissions.viewOAuth2AppSecrets}
/>
</>
);
@@ -9,6 +9,11 @@ import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
const meta: Meta = {
title: "pages/DeploymentSettingsPage/EditOAuth2AppPageView",
component: EditOAuth2AppPageView,
args: {
canEditApp: true,
canDeleteApp: true,
canViewAppSecrets: true,
},
};
export default meta;
@@ -49,6 +49,9 @@ type EditOAuth2AppProps = {
deleteApp: (name: string) => void;
generateAppSecret: () => void;
deleteAppSecret: (id: string) => void;
canEditApp: boolean;
canDeleteApp: boolean;
canViewAppSecrets: boolean;
secrets?: readonly TypesGen.OAuth2ProviderAppSecret[];
fullNewSecret?: TypesGen.OAuth2ProviderAppSecretFull;
ackFullNewSecret: () => void;
@@ -64,6 +67,9 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
deleteApp,
generateAppSecret,
deleteAppSecret,
canEditApp,
canDeleteApp,
canViewAppSecrets,
secrets,
fullNewSecret,
ackFullNewSecret,
@@ -180,21 +186,27 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
<Button
variant="destructive"
onClick={() => setShowDelete(true)}
disabled={!canDeleteApp}
>
Delete&hellip;
</Button>
}
disabled={!canEditApp}
/>
<Divider css={{ borderColor: theme.palette.divider }} />
{canViewAppSecrets && (
<>
<Divider css={{ borderColor: theme.palette.divider }} />
<OAuth2AppSecretsTable
secrets={secrets}
generateAppSecret={generateAppSecret}
deleteAppSecret={deleteAppSecret}
isLoadingSecrets={isLoadingSecrets}
mutatingResource={mutatingResource}
/>
<OAuth2AppSecretsTable
secrets={secrets}
generateAppSecret={generateAppSecret}
deleteAppSecret={deleteAppSecret}
isLoadingSecrets={isLoadingSecrets}
mutatingResource={mutatingResource}
/>
</>
)}
</>
)}
</Stack>
@@ -3,7 +3,6 @@ import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import type { FC, ReactNode } from "react";
type OAuth2AppFormProps = {
@@ -21,6 +20,7 @@ type OAuth2AppFormProps = {
callback_url: string;
icon: string;
};
disabled: boolean;
};
export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
@@ -30,6 +30,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
isUpdating,
actions,
defaultValues,
disabled,
}) => {
const apiValidationErrors = isApiValidationError(error)
? mapApiErrorToFieldErrors(error.response.data)
@@ -37,7 +38,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
return (
<form
css={{ marginTop: 10 }}
className="mt-2.5"
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
@@ -48,7 +49,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
});
}}
>
<Stack spacing={2.5}>
<div className="flex flex-col gap-5">
<TextField
name="name"
label="Application name"
@@ -57,6 +58,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
helperText={
apiValidationErrors?.name || "The name of your Coder app."
}
disabled={disabled}
autoFocus
fullWidth
/>
@@ -69,6 +71,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
apiValidationErrors?.callback_url ||
"The full URL to redirect to after a user authorizes an installation."
}
disabled={disabled}
fullWidth
/>
<TextField
@@ -79,17 +82,18 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
helperText={
apiValidationErrors?.icon || "A full or relative URL to an icon."
}
disabled={disabled}
fullWidth
/>
<Stack direction="row">
<Button disabled={isUpdating} type="submit">
<div className="flex flex-row gap-4">
<Button disabled={isUpdating || disabled} type="submit">
<Spinner loading={isUpdating} />
{app ? "Update application" : "Create application"}
</Button>
{actions}
</Stack>
</Stack>
</div>
</div>
</form>
);
};
@@ -1,12 +1,16 @@
import { getApps } from "api/queries/oauth2";
import { useAuthenticated } from "hooks";
import type { FC } from "react";
import { useQuery } from "react-query";
import { pageTitle } from "utils/page";
import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
const OAuth2AppsSettingsPage: FC = () => {
const { permissions } = useAuthenticated();
const appsQuery = useQuery(getApps());
const canCreateApp = permissions.createOAuth2App;
return (
<>
<title>{pageTitle("OAuth2 Applications")}</title>
@@ -15,6 +19,7 @@ const OAuth2AppsSettingsPage: FC = () => {
apps={appsQuery.data}
isLoading={appsQuery.isLoading}
error={appsQuery.error}
canCreateApp={canCreateApp}
/>
</>
);
@@ -5,6 +5,9 @@ import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
const meta: Meta = {
title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPageView",
component: OAuth2AppsSettingsPageView,
args: {
canCreateApp: true,
},
};
export default meta;
@@ -35,3 +38,9 @@ export const Empty: Story = {
isLoading: false,
},
};
export const NoCreatePermissions: Story = {
args: {
canCreateApp: false,
},
};
@@ -28,12 +28,14 @@ type OAuth2AppsSettingsProps = {
apps?: TypesGen.OAuth2ProviderApp[];
isLoading: boolean;
error: unknown;
canCreateApp: boolean;
};
const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
apps,
isLoading,
error,
canCreateApp,
}) => {
return (
<>
@@ -51,12 +53,14 @@ const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
</SettingsHeader>
</div>
<Button variant="outline" asChild>
<Link to="/deployment/oauth2-provider/apps/add">
<PlusIcon />
Add application
</Link>
</Button>
{canCreateApp && (
<Button variant="outline" asChild>
<Link to="/deployment/oauth2-provider/apps/add">
<PlusIcon />
Add application
</Link>
</Button>
)}
</Stack>
{error && <ErrorAlert error={error} />}
+8
View File
@@ -3082,6 +3082,10 @@ export const MockPermissions: Permissions = {
viewAnyIdpSyncSettings: true,
viewAnyMembers: true,
viewAnyAIBridgeInterception: true,
createOAuth2App: true,
editOAuth2App: true,
deleteOAuth2App: true,
viewOAuth2AppSecrets: true,
};
export const MockNoPermissions: Permissions = {
@@ -3111,6 +3115,10 @@ export const MockNoPermissions: Permissions = {
viewAnyIdpSyncSettings: false,
viewAnyMembers: false,
viewAnyAIBridgeInterception: true,
createOAuth2App: false,
editOAuth2App: false,
deleteOAuth2App: false,
viewOAuth2AppSecrets: false,
};
export const MockOrganizationPermissions: OrganizationPermissions = {