mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: return 404 instead of 401 for missing OAuth2 apps (#18755)
## Problem Users were being automatically logged out when deleting OAuth2 applications. ## Root Cause 1. User deletes OAuth2 app successfully 2. React Query automatically refetches the app data 3. Management API incorrectly returned **401 Unauthorized** for the missing app 4. Frontend axios interceptor sees 401 and calls `signOut()` 5. User gets logged out unexpectedly ## Solution - Change management API to return **404 Not Found** for missing OAuth2 apps - OAuth2 protocol endpoints continue returning 401 per RFC 6749 - Rename `writeInvalidClient` to `writeClientNotFound` for clarity ## Additional Changes - Add conditional OAuth2 navigation when experiment is enabled or in dev builds - Add `isDevBuild()` utility and `buildInfo` to dashboard context - Minor improvements to format script and warning dialogs Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { appearance } from "api/queries/appearance";
|
||||
import { buildInfo } from "api/queries/buildInfo";
|
||||
import { entitlements } from "api/queries/entitlements";
|
||||
import { experiments } from "api/queries/experiments";
|
||||
import { organizations } from "api/queries/organizations";
|
||||
import type {
|
||||
AppearanceConfig,
|
||||
BuildInfoResponse,
|
||||
Entitlements,
|
||||
Experiment,
|
||||
Organization,
|
||||
@@ -21,6 +23,7 @@ export interface DashboardValue {
|
||||
entitlements: Entitlements;
|
||||
experiments: Experiment[];
|
||||
appearance: AppearanceConfig;
|
||||
buildInfo: BuildInfoResponse;
|
||||
organizations: readonly Organization[];
|
||||
showOrganizations: boolean;
|
||||
canViewOrganizationSettings: boolean;
|
||||
@@ -36,12 +39,14 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
|
||||
const experimentsQuery = useQuery(experiments(metadata.experiments));
|
||||
const appearanceQuery = useQuery(appearance(metadata.appearance));
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
const organizationsQuery = useQuery(organizations());
|
||||
|
||||
const error =
|
||||
entitlementsQuery.error ||
|
||||
appearanceQuery.error ||
|
||||
experimentsQuery.error ||
|
||||
buildInfoQuery.error ||
|
||||
organizationsQuery.error;
|
||||
|
||||
if (error) {
|
||||
@@ -52,6 +57,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
!entitlementsQuery.data ||
|
||||
!appearanceQuery.data ||
|
||||
!experimentsQuery.data ||
|
||||
!buildInfoQuery.data ||
|
||||
!organizationsQuery.data;
|
||||
|
||||
if (isLoading) {
|
||||
@@ -70,6 +76,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
entitlements: entitlementsQuery.data,
|
||||
experiments: experimentsQuery.data,
|
||||
appearance: appearanceQuery.data,
|
||||
buildInfo: buildInfoQuery.data,
|
||||
organizations: organizationsQuery.data,
|
||||
showOrganizations,
|
||||
canViewOrganizationSettings:
|
||||
|
||||
@@ -8,7 +8,8 @@ import { DeploymentSidebarView } from "./DeploymentSidebarView";
|
||||
*/
|
||||
export const DeploymentSidebar: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { entitlements, showOrganizations } = useDashboard();
|
||||
const { entitlements, showOrganizations, experiments, buildInfo } =
|
||||
useDashboard();
|
||||
const hasPremiumLicense =
|
||||
entitlements.features.multiple_organizations.enabled;
|
||||
|
||||
@@ -17,6 +18,8 @@ export const DeploymentSidebar: FC = () => {
|
||||
permissions={permissions}
|
||||
showOrganizations={showOrganizations}
|
||||
hasPremiumLicense={hasPremiumLicense}
|
||||
experiments={experiments}
|
||||
buildInfo={buildInfo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BuildInfoResponse, Experiment } from "api/typesGenerated";
|
||||
import {
|
||||
Sidebar as BaseSidebar,
|
||||
SettingsSidebarNavItem as SidebarNavItem,
|
||||
@@ -6,12 +7,15 @@ import { Stack } from "components/Stack/Stack";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import type { Permissions } from "modules/permissions";
|
||||
import type { FC } from "react";
|
||||
import { isDevBuild } from "utils/buildInfo";
|
||||
|
||||
interface DeploymentSidebarViewProps {
|
||||
/** Site-wide permissions. */
|
||||
permissions: Permissions;
|
||||
showOrganizations: boolean;
|
||||
hasPremiumLicense: boolean;
|
||||
experiments: Experiment[];
|
||||
buildInfo: BuildInfoResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,6 +26,8 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
|
||||
permissions,
|
||||
showOrganizations,
|
||||
hasPremiumLicense,
|
||||
experiments,
|
||||
buildInfo,
|
||||
}) => {
|
||||
return (
|
||||
<BaseSidebar>
|
||||
@@ -47,10 +53,12 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
|
||||
External Authentication
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{/* Not exposing this yet since token exchange is not finished yet.
|
||||
<SidebarNavItem href="oauth2-provider/apps">
|
||||
OAuth2 Applications
|
||||
</SidebarNavItem>*/}
|
||||
{permissions.viewDeploymentConfig &&
|
||||
(experiments.includes("oauth2") || isDevBuild(buildInfo)) && (
|
||||
<SidebarNavItem href="/deployment/oauth2-provider/apps">
|
||||
OAuth2 Applications
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewDeploymentConfig && (
|
||||
<SidebarNavItem href="/deployment/network">Network</SidebarNavItem>
|
||||
)}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
|
||||
confirmLoading={mutatingResource.deleteApp}
|
||||
name={app.name}
|
||||
entity="OAuth2 application"
|
||||
info="Deleting this OAuth2 application will immediately invalidate all active sessions and API keys associated with it. Users currently authenticated through this application will be logged out and need to re-authenticate."
|
||||
onConfirm={() => deleteApp(app.name)}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
/>
|
||||
|
||||
@@ -13,17 +13,19 @@ import {
|
||||
FingerprintIcon,
|
||||
KeyIcon,
|
||||
LockIcon,
|
||||
ShieldIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import type { FC } from "react";
|
||||
import { isDevBuild } from "utils/buildInfo";
|
||||
|
||||
interface SidebarProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({ user }) => {
|
||||
const { entitlements } = useDashboard();
|
||||
const { entitlements, experiments, buildInfo } = useDashboard();
|
||||
const showSchedulePage =
|
||||
entitlements.features.advanced_template_scheduling.enabled;
|
||||
|
||||
@@ -43,6 +45,11 @@ export const Sidebar: FC<SidebarProps> = ({ user }) => {
|
||||
<SidebarNavItem href="external-auth" icon={GitIcon}>
|
||||
External Authentication
|
||||
</SidebarNavItem>
|
||||
{(experiments.includes("oauth2") || isDevBuild(buildInfo)) && (
|
||||
<SidebarNavItem href="oauth2-provider" icon={ShieldIcon}>
|
||||
OAuth2 Applications
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{showSchedulePage && (
|
||||
<SidebarNavItem href="schedule" icon={CalendarCogIcon}>
|
||||
Schedule
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FC } from "react";
|
||||
import { type Location, useLocation } from "react-router-dom";
|
||||
import {
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockDeploymentConfig,
|
||||
MockEntitlements,
|
||||
MockFailedWorkspace,
|
||||
@@ -554,6 +555,10 @@ describe("WorkspacePage", () => {
|
||||
appearance: MockAppearanceConfig,
|
||||
entitlements: MockEntitlements,
|
||||
experiments: [],
|
||||
buildInfo: {
|
||||
...MockBuildInfo,
|
||||
version: "v0.0.0-test",
|
||||
},
|
||||
organizations: [MockOrganization],
|
||||
showOrganizations: true,
|
||||
canViewOrganizationSettings: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { FC } from "react";
|
||||
import { useQueryClient } from "react-query";
|
||||
import {
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockDefaultOrganization,
|
||||
MockDeploymentConfig,
|
||||
MockEntitlements,
|
||||
@@ -56,6 +57,10 @@ export const withDashboardProvider = (
|
||||
entitlements,
|
||||
experiments,
|
||||
appearance: MockAppearanceConfig,
|
||||
buildInfo: {
|
||||
...MockBuildInfo,
|
||||
version: "v0.0.0-test",
|
||||
},
|
||||
organizations,
|
||||
showOrganizations,
|
||||
canViewOrganizationSettings,
|
||||
|
||||
@@ -22,3 +22,16 @@ export const getStaticBuildInfo = () => {
|
||||
|
||||
return CACHED_BUILD_INFO;
|
||||
};
|
||||
|
||||
// Check if the current build is a development build.
|
||||
// Development builds have versions containing "-devel" or "v0.0.0".
|
||||
// This matches the backend's buildinfo.IsDev() logic.
|
||||
export const isDevBuild = (input: BuildInfoResponse): boolean => {
|
||||
const version = input.version;
|
||||
if (!version) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for dev version pattern (contains "-devel") or no version (v0.0.0)
|
||||
return version.includes("-devel") || version === "v0.0.0";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user