diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index f51c370024d73e..ca001cdeba877d 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -23,7 +23,7 @@ import { Button, ButtonProps } from "@podkit/buttons/Button"; import { cn } from "@podkit/lib/cn"; import { userClient } from "./service/public-api"; import { ProductLogo } from "./components/ProductLogo"; -import { useIsDataOps } from "./data/featureflag-query"; +import { useIsDataOps, useFeatureFlag } from "./data/featureflag-query"; import { LoadingState } from "@podkit/loading/LoadingState"; import { isGitpodIo } from "./utils"; import onaWordmark from "./images/ona-wordmark.svg"; @@ -217,6 +217,7 @@ const LoginContent = ({ const { setUser } = useContext(UserContext); const isDataOps = useIsDataOps(); const isGitpodIoUser = isGitpodIo(); + const classicSunsetConfig = useFeatureFlag("classic_payg_sunset_enabled"); const authProviders = useAuthProviderDescriptions(); const [errorMessage, setErrorMessage] = useState(undefined); @@ -224,6 +225,14 @@ const LoginContent = ({ const { data: installationConfig } = useInstallationConfiguration(); const enterprise = !!installationConfig?.isDedicatedInstallation; + // Check if user wants to see all login options (for exempted orgs) + const searchParams = useMemo(() => new URLSearchParams(window.location.search), []); + const oldLogin = searchParams.get("oldLogin") === "true"; + + // Show sunset UI only if: sunset enabled, on gitpod.io, and user hasn't requested old login + const showSunsetUI = + (typeof classicSunsetConfig === "object" ? classicSunsetConfig.enabled : false) && isGitpodIoUser && !oldLogin; + const updateUser = useCallback(async () => { await getGitpodService().reconnect(); const { user } = await userClient.getAuthenticatedUser({}); @@ -292,18 +301,49 @@ const LoginContent = ({ Open a cloud development environment for the repository {repoPathname?.slice(1)} + ) : showSunsetUI ? ( + <> + Start building with Ona + What do you want to get done today? + ) : !isGitpodIoUser ? ( Log in to Gitpod ) : ( <> - Log in to Gitpod Classic - Hosted by us + Start building with Ona + What do you want to get done today? )}
- {providerFromContext ? ( + {showSunsetUI ? ( + <> + +
+

+ Need to access your organization?{" "} + + Show all login options + +

+
+ + ) : providerFromContext ? ( openLogin(providerFromContext!.host)} @@ -323,7 +363,7 @@ const LoginContent = ({ )) )} - + {!showSunsetUI && }
{errorMessage && } @@ -331,14 +371,24 @@ const LoginContent = ({ {!enterprise && (

- Gitpod Classic sunsets Oct 15.{" "} + Gitpod Classic has been sunset on Oct 15.{" "} - Start here for free + {" "} + Gitpod is now Ona + + ,{" "} + + start for free {" "} and get $100 credits.

@@ -378,9 +428,9 @@ const RightProductDescriptionPanel = () => { > Start for free {" "} - and get $100 credits.
+ and get $100 in credits.

- Gitpod Classic sunsets Oct 15 |{" "} + Gitpod Classic has been sunset on Oct 15 |{" "} { Delegate software tasks to Ona. It writes code, runs tests, and opens a pull request. Or jump in to inspect output or pair program in your IDE.

-

- Ona runs inside your infrastructure (VPC), with full audit trails, zero data exposure, and - support for any LLM. -

diff --git a/components/dashboard/src/data/featureflag-query.ts b/components/dashboard/src/data/featureflag-query.ts index 653cc02694b34c..570227c61a0ebd 100644 --- a/components/dashboard/src/data/featureflag-query.ts +++ b/components/dashboard/src/data/featureflag-query.ts @@ -9,6 +9,9 @@ import { useQuery } from "@tanstack/react-query"; import { getExperimentsClient } from "../experiments/client"; import { useCurrentUser } from "../user-context"; import { useCurrentOrg } from "./organizations/orgs-query"; +import { ClassicPaygSunsetConfig } from "@gitpod/gitpod-protocol/lib/experiments/configcat"; + +const defaultClassicPaygSunsetConfig: ClassicPaygSunsetConfig = { enabled: false, exemptedOrganizations: [] }; const featureFlags = { oidcServiceEnabled: false, @@ -26,10 +29,31 @@ const featureFlags = { enabled_configuration_prebuild_full_clone: false, enterprise_onboarding_enabled: false, commit_annotation_setting_enabled: false, + classic_payg_sunset_enabled: defaultClassicPaygSunsetConfig, }; type FeatureFlags = typeof featureFlags; +// Helper to parse JSON feature flags +function parseFeatureFlagValue(flagName: string, rawValue: any, defaultValue: T): T { + // Special handling for JSON-based feature flags + if (flagName === "classic_payg_sunset_enabled") { + try { + if (typeof rawValue === "string") { + return JSON.parse(rawValue) as T; + } + // If it's already an object, return as-is + if (typeof rawValue === "object" && rawValue !== null) { + return rawValue as T; + } + } catch (error) { + console.error(`Failed to parse feature flag ${flagName}:`, error); + return defaultValue; + } + } + return rawValue; +} + export const useFeatureFlag = (featureFlag: K): FeatureFlags[K] | boolean => { const user = useCurrentUser(); const org = useCurrentOrg().data; @@ -37,7 +61,12 @@ export const useFeatureFlag = (featureFlag: K): Fe const queryKey = ["featureFlag", featureFlag, user?.id || "", org?.id || ""]; const query = useQuery(queryKey, async () => { - const flagValue = await getExperimentsClient().getValueAsync(featureFlag, featureFlags[featureFlag], { + const defaultValue = featureFlags[featureFlag]; + // For JSON flags, send stringified default to ConfigCat + const configCatDefault = + featureFlag === "classic_payg_sunset_enabled" ? JSON.stringify(defaultValue) : defaultValue; + + const rawValue = await getExperimentsClient().getValueAsync(featureFlag, configCatDefault, { user: user && { id: user.id, email: getPrimaryEmail(user), @@ -46,7 +75,8 @@ export const useFeatureFlag = (featureFlag: K): Fe teamName: org?.name, gitpodHost: window.location.host, }); - return flagValue; + + return parseFeatureFlagValue(featureFlag, rawValue, defaultValue); }); return query.data !== undefined ? query.data : featureFlags[featureFlag]; diff --git a/components/gitpod-protocol/src/experiments/configcat.ts b/components/gitpod-protocol/src/experiments/configcat.ts index ff707930167021..e1a456551c0e0e 100644 --- a/components/gitpod-protocol/src/experiments/configcat.ts +++ b/components/gitpod-protocol/src/experiments/configcat.ts @@ -60,3 +60,8 @@ export function attributesToUser(attributes: Attributes): ConfigCatUser { return new ConfigCatUser(userId, email, "", custom); } + +export interface ClassicPaygSunsetConfig { + enabled: boolean; + exemptedOrganizations: string[]; +} diff --git a/components/server/src/api/workspace-service-api.ts b/components/server/src/api/workspace-service-api.ts index 4a99ba3890966d..febe968c558134 100644 --- a/components/server/src/api/workspace-service-api.ts +++ b/components/server/src/api/workspace-service-api.ts @@ -61,6 +61,9 @@ import { UserService } from "../user/user-service"; import { ContextParser } from "../workspace/context-parser-service"; import { isWorkspaceId } from "@gitpod/gitpod-protocol/lib/util/parse-workspace-id"; import { SYSTEM_USER, SYSTEM_USER_ID } from "../authorization/authorizer"; +import { isWorkspaceStartBlockedBySunset } from "../util/featureflags"; +import { User } from "@gitpod/gitpod-protocol"; +import { Config } from "../config"; @injectable() export class WorkspaceServiceAPI implements ServiceImpl { @@ -69,6 +72,16 @@ export class WorkspaceServiceAPI implements ServiceImpl { + if (await isWorkspaceStartBlockedBySunset(user, organizationId, this.config.isDedicatedInstallation)) { + throw new ApplicationError( + ErrorCodes.PERMISSION_DENIED, + "Gitpod Classic PAYG has sunset. Please visit https://app.ona.com/login to continue.", + ); + } + } async getWorkspace(req: GetWorkspaceRequest, _: HandlerContext): Promise { if (!isWorkspaceId(req.workspaceId)) { @@ -198,6 +211,9 @@ export class WorkspaceServiceAPI implements ServiceImpl GitpodServerImpl; @@ -69,6 +70,19 @@ export class UserController { router.get("/login", async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (req.isAuthenticated()) { log.info("(Auth) User is already authenticated.", { "login-flow": true }); + + // Check if authenticated user is blocked by sunset + const user = req.user as User; + if (await isUserLoginBlockedBySunset(user, this.config.isDedicatedInstallation)) { + log.info("(Auth) User blocked by Classic PAYG sunset", { + userId: user.id, + organizationId: user.organizationId, + "login-flow": true, + }); + res.redirect(302, "https://app.ona.com/login"); + return; + } + // redirect immediately const redirectTo = this.ensureSafeReturnToParam(req) || this.config.hostUrl.asDashboard().toString(); safeFragmentRedirect(res, redirectTo); diff --git a/components/server/src/util/featureflags.ts b/components/server/src/util/featureflags.ts index 479da1b09eda65..67df69bd1e6682 100644 --- a/components/server/src/util/featureflags.ts +++ b/components/server/src/util/featureflags.ts @@ -5,9 +5,83 @@ */ import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { ClassicPaygSunsetConfig } from "@gitpod/gitpod-protocol/lib/experiments/configcat"; +import { User } from "@gitpod/gitpod-protocol"; export async function getFeatureFlagEnableExperimentalJBTB(userId: string): Promise { return getExperimentsClientForBackend().getValueAsync("enable_experimental_jbtb", false, { user: { id: userId }, }); } + +export async function getClassicPaygSunsetConfig(userId: string): Promise { + const defaultConfig: ClassicPaygSunsetConfig = { enabled: false, exemptedOrganizations: [] }; + const rawValue = await getExperimentsClientForBackend().getValueAsync( + "classic_payg_sunset_enabled", + JSON.stringify(defaultConfig), + { user: { id: userId } }, + ); + + // Parse JSON string from ConfigCat + try { + if (typeof rawValue === "string") { + return JSON.parse(rawValue) as ClassicPaygSunsetConfig; + } + // Fallback if somehow we get an object (shouldn't happen with ConfigCat text flags) + return rawValue as ClassicPaygSunsetConfig; + } catch (error) { + console.error("Failed to parse classic_payg_sunset_enabled feature flag:", error); + return defaultConfig; + } +} + +export async function isWorkspaceStartBlockedBySunset( + user: User, + organizationId: string, + isDedicatedInstallation: boolean, +): Promise { + // Dedicated installations are never blocked + if (isDedicatedInstallation) { + return false; + } + + const config = await getClassicPaygSunsetConfig(user.id); + + if (!config.enabled) { + return false; + } + + // If user has an org, check if it's exempted + if (organizationId) { + return !config.exemptedOrganizations.includes(organizationId); + } + + // Installation-owned users (no organizationId) are blocked + return true; +} + +export async function isUserLoginBlockedBySunset(user: User, isDedicatedInstallation: boolean): Promise { + // Dedicated installations are never blocked + if (isDedicatedInstallation) { + return false; + } + + const config = await getClassicPaygSunsetConfig(user.id); + + if (!config.enabled) { + return false; + } + + // Users with roles/permissions are exempted (admins, etc.) + if (user.rolesOrPermissions && user.rolesOrPermissions.length > 0) { + return false; + } + + // If user has an org, check if it's exempted + if (user.organizationId) { + return !config.exemptedOrganizations.includes(user.organizationId); + } + + // Installation-owned users (no organizationId) are blocked + return true; +}