diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index 57eff5f67f..595f1089a4 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -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; export const canViewDeploymentSettings = ( diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx index cee9d6344b..85cc14a198 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx @@ -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} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx index f97754143b..781692903b 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx @@ -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 = {}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx index 9d59db1269..c3f1653db7 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx @@ -21,6 +21,7 @@ type CreateOAuth2AppProps = { callback_url: string; icon: string; }; + canCreateApp: boolean; }; export const CreateOAuth2AppPageView: FC = ({ @@ -28,6 +29,7 @@ export const CreateOAuth2AppPageView: FC = ({ createApp, error, defaultValues, + canCreateApp, }) => { return ( <> @@ -58,6 +60,7 @@ export const CreateOAuth2AppPageView: FC = ({ isUpdating={isUpdating} error={error} defaultValues={defaultValues} + disabled={!canCreateApp} /> diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx index b000f180d6..1fddf238ec 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx @@ -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} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx index e5ac1f3946..b1f3ed5c1f 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx @@ -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; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx index 7e2c4e31bc..832c09fd8d 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx @@ -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 = ({ deleteApp, generateAppSecret, deleteAppSecret, + canEditApp, + canDeleteApp, + canViewAppSecrets, secrets, fullNewSecret, ackFullNewSecret, @@ -180,21 +186,27 @@ export const EditOAuth2AppPageView: FC = ({ } + disabled={!canEditApp} /> - + {canViewAppSecrets && ( + <> + - + + + )} )} diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx index 2801e5fa28..cbb61c8d8e 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx @@ -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 = ({ @@ -30,6 +30,7 @@ export const OAuth2AppForm: FC = ({ isUpdating, actions, defaultValues, + disabled, }) => { const apiValidationErrors = isApiValidationError(error) ? mapApiErrorToFieldErrors(error.response.data) @@ -37,7 +38,7 @@ export const OAuth2AppForm: FC = ({ return (
{ event.preventDefault(); const formData = new FormData(event.target as HTMLFormElement); @@ -48,7 +49,7 @@ export const OAuth2AppForm: FC = ({ }); }} > - +
= ({ helperText={ apiValidationErrors?.name || "The name of your Coder app." } + disabled={disabled} autoFocus fullWidth /> @@ -69,6 +71,7 @@ export const OAuth2AppForm: FC = ({ apiValidationErrors?.callback_url || "The full URL to redirect to after a user authorizes an installation." } + disabled={disabled} fullWidth /> = ({ helperText={ apiValidationErrors?.icon || "A full or relative URL to an icon." } + disabled={disabled} fullWidth /> - - {actions} - - +
+ ); }; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx index e739b461bb..8605dd52e5 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -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 ( <> {pageTitle("OAuth2 Applications")} @@ -15,6 +19,7 @@ const OAuth2AppsSettingsPage: FC = () => { apps={appsQuery.data} isLoading={appsQuery.isLoading} error={appsQuery.error} + canCreateApp={canCreateApp} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx index e399044ee8..2db4a1d729 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -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, + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx index 3c40874ef4..581d20bf64 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -28,12 +28,14 @@ type OAuth2AppsSettingsProps = { apps?: TypesGen.OAuth2ProviderApp[]; isLoading: boolean; error: unknown; + canCreateApp: boolean; }; const OAuth2AppsSettingsPageView: FC = ({ apps, isLoading, error, + canCreateApp, }) => { return ( <> @@ -51,12 +53,14 @@ const OAuth2AppsSettingsPageView: FC = ({ - + {canCreateApp && ( + + )}
{error && } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0e4a14a918..3ad401ca42 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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 = {