Skip to content

Commit d42b06d

Browse files
geroplona-agentSiddhant-K-codecorneliusludmann
authored
[CLC-2032] Block login and workspace operations for Classic PAYG sunset (#21100)
* [CLC-2032] Block login and workspace operations for Classic PAYG sunset Implement feature flag-based blocking for Gitpod Classic PAYG users: Backend: - Add utility functions to check if user is blocked by sunset - Block login attempts in /login route handler, redirect to app.ona.com - Block workspace creation and start operations in workspace-service-api - Exempt users with roles/permissions and users in exempted organizations Frontend: - Update login page to show 'Login with Ona' button when sunset is enabled - Keep SSO login form visible for exempted organizations - Hide sunset notice banner when flag is enabled - Update heading to 'Gitpod Classic has sunset' Feature flag: classic_payg_sunset_enabled (JSON with enabled boolean and exemptedOrganizations array) Co-authored-by: Ona <[email protected]> * Add oldLogin parameter to show full login UI for exempted orgs When sunset is enabled on gitpod.io, users now see a simplified UI: - 'Continue with Ona' button (default) - Link to show all login options (?oldLogin=true) With ?oldLogin=true parameter: - Shows all OAuth provider buttons - Shows SSO login form - Full functionality for exempted organizations The link preserves returnToPath parameter if present. Co-authored-by: Ona <[email protected]> * Refactor backend sunset checks into separate functions Split sunset blocking logic into two functions: - isUserLoginBlockedBySunset: checks roles/permissions exemption for login - isWorkspaceStartBlockedBySunset: checks org-level exemption for workspace ops Move ClassicPaygSunsetConfig interface to gitpod-protocol for reusability. Pass organizationId explicitly to workspace blocking checks. Co-authored-by: Ona <[email protected]> * Use typed ClassicPaygSunsetConfig in frontend feature flag Import ClassicPaygSunsetConfig type from gitpod-protocol and use it as the default value for classic_payg_sunset_enabled feature flag. This leverages TypeScript's generic type inference in useFeatureFlag: - useFeatureFlag<K extends keyof FeatureFlags> returns FeatureFlags[K] - For classic_payg_sunset_enabled, it now returns ClassicPaygSunsetConfig - Other flags continue to return their respective types (boolean, string, etc.) Updated Login.tsx to access .enabled property with type guard to handle the union type (ClassicPaygSunsetConfig | boolean) during loading state. This ensures type safety and consistency between frontend and backend. Co-authored-by: Ona <[email protected]> * Parse JSON string for classic_payg_sunset_enabled feature flag ConfigCat text flags return strings, so we need to parse JSON on both frontend and backend. Backend (featureflags.ts): - Send JSON.stringify(defaultConfig) to ConfigCat - Parse returned string with JSON.parse() - Handle errors gracefully with fallback to default Frontend (featureflag-query.ts): - Add parseFeatureFlagValue() helper for JSON flags - Send stringified default for classic_payg_sunset_enabled - Parse returned string value - Maintain type safety with generic return types This allows ConfigCat to store the flag as text while maintaining the typed object structure in our code. Co-authored-by: Ona <[email protected]> * Exempt dedicated installations from sunset blocking Add isDedicatedInstallation parameter to sunset check functions. Dedicated installations always return false (not blocked) regardless of feature flag state. Changes: - isUserLoginBlockedBySunset: add isDedicatedInstallation param - isWorkspaceStartBlockedBySunset: add isDedicatedInstallation param - UserController: pass config.isDedicatedInstallation to login check - WorkspaceServiceAPI: inject Config and pass isDedicatedInstallation This ensures the sunset only affects gitpod.io (PAYG) and not dedicated installations. Co-authored-by: Ona <[email protected]> * update login page for Gitpod classic `gitpod.io` users * fix * Improve sunset UI: use primary button and remove redundant subheading Co-authored-by: Ona <[email protected]> * Update sunset UI heading to focus on Ona value proposition Co-authored-by: Ona <[email protected]> --------- Co-authored-by: Ona <[email protected]> Co-authored-by: Siddhant Khare <[email protected]> Co-authored-by: Cornelius A. Ludmann <[email protected]>
1 parent 9411dfc commit d42b06d

File tree

6 files changed

+205
-16
lines changed

6 files changed

+205
-16
lines changed

components/dashboard/src/Login.tsx

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { Button, ButtonProps } from "@podkit/buttons/Button";
2323
import { cn } from "@podkit/lib/cn";
2424
import { userClient } from "./service/public-api";
2525
import { ProductLogo } from "./components/ProductLogo";
26-
import { useIsDataOps } from "./data/featureflag-query";
26+
import { useIsDataOps, useFeatureFlag } from "./data/featureflag-query";
2727
import { LoadingState } from "@podkit/loading/LoadingState";
2828
import { isGitpodIo } from "./utils";
2929
import onaWordmark from "./images/ona-wordmark.svg";
@@ -217,13 +217,22 @@ const LoginContent = ({
217217
const { setUser } = useContext(UserContext);
218218
const isDataOps = useIsDataOps();
219219
const isGitpodIoUser = isGitpodIo();
220+
const classicSunsetConfig = useFeatureFlag("classic_payg_sunset_enabled");
220221

221222
const authProviders = useAuthProviderDescriptions();
222223
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
223224

224225
const { data: installationConfig } = useInstallationConfiguration();
225226
const enterprise = !!installationConfig?.isDedicatedInstallation;
226227

228+
// Check if user wants to see all login options (for exempted orgs)
229+
const searchParams = useMemo(() => new URLSearchParams(window.location.search), []);
230+
const oldLogin = searchParams.get("oldLogin") === "true";
231+
232+
// Show sunset UI only if: sunset enabled, on gitpod.io, and user hasn't requested old login
233+
const showSunsetUI =
234+
(typeof classicSunsetConfig === "object" ? classicSunsetConfig.enabled : false) && isGitpodIoUser && !oldLogin;
235+
227236
const updateUser = useCallback(async () => {
228237
await getGitpodService().reconnect();
229238
const { user } = await userClient.getAuthenticatedUser({});
@@ -292,18 +301,49 @@ const LoginContent = ({
292301
<Heading2>Open a cloud development environment</Heading2>
293302
<Subheading>for the repository {repoPathname?.slice(1)}</Subheading>
294303
</>
304+
) : showSunsetUI ? (
305+
<>
306+
<Heading1>Start building with Ona</Heading1>
307+
<Subheading>What do you want to get done today?</Subheading>
308+
</>
295309
) : !isGitpodIoUser ? (
296310
<Heading1>Log in to Gitpod</Heading1>
297311
) : (
298312
<>
299-
<Heading1>Log in to Gitpod Classic</Heading1>
300-
<Subheading>Hosted by us</Subheading>
313+
<Heading1>Start building with Ona</Heading1>
314+
<Subheading>What do you want to get done today?</Subheading>
301315
</>
302316
)}
303317
</div>
304318

305319
<div className="w-56 mx-auto flex flex-col space-y-3 items-center">
306-
{providerFromContext ? (
320+
{showSunsetUI ? (
321+
<>
322+
<Button
323+
className="w-full"
324+
onClick={() => {
325+
window.location.href = "https://app.ona.com/login";
326+
}}
327+
>
328+
Continue with Ona
329+
</Button>
330+
<div className="mt-4 text-center text-sm">
331+
<p className="text-gray-500 dark:text-gray-400">
332+
Need to access your organization?{" "}
333+
<a
334+
href={`${window.location.pathname}?oldLogin=true${
335+
searchParams.get("returnToPath")
336+
? `&returnToPath=${encodeURIComponent(searchParams.get("returnToPath")!)}`
337+
: ""
338+
}`}
339+
className="gp-link hover:text-gray-600"
340+
>
341+
Show all login options
342+
</a>
343+
</p>
344+
</div>
345+
</>
346+
) : providerFromContext ? (
307347
<LoginButton
308348
key={"button" + providerFromContext.host}
309349
onClick={() => openLogin(providerFromContext!.host)}
@@ -323,22 +363,32 @@ const LoginContent = ({
323363
</LoginButton>
324364
))
325365
)}
326-
<SSOLoginForm onSuccess={authorizeSuccessful} />
366+
{!showSunsetUI && <SSOLoginForm onSuccess={authorizeSuccessful} />}
327367
</div>
328368
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
329369

330370
{/* Gitpod Classic sunset notice - only show for non-enterprise */}
331371
{!enterprise && (
332372
<div className="mt-6 text-center text-sm">
333373
<p className="text-pk-content-primary">
334-
Gitpod Classic sunsets Oct 15.{" "}
374+
Gitpod Classic has been sunset on Oct 15.{" "}
335375
<a
336-
href="https://app.gitpod.io"
376+
href="https://ona.com/stories/gitpod-is-now-ona"
337377
target="_blank"
338378
rel="noopener noreferrer"
339379
className="gp-link hover:text-gray-600"
340380
>
341-
Start here for free
381+
{" "}
382+
Gitpod is now Ona
383+
</a>
384+
,{" "}
385+
<a
386+
href="https://app.ona.com"
387+
target="_blank"
388+
rel="noopener noreferrer"
389+
className="gp-link hover:text-gray-600"
390+
>
391+
start for free
342392
</a>{" "}
343393
and get $100 credits.
344394
</p>
@@ -378,9 +428,9 @@ const RightProductDescriptionPanel = () => {
378428
>
379429
Start for free
380430
</a>{" "}
381-
and get $100 credits. <br />
431+
and get $100 in credits. <br />
382432
<br />
383-
Gitpod Classic sunsets Oct 15 |{" "}
433+
Gitpod Classic has been sunset on Oct 15 |{" "}
384434
<a
385435
href="https://ona.com/stories/gitpod-classic-payg-sunset"
386436
target="_blank"
@@ -396,10 +446,6 @@ const RightProductDescriptionPanel = () => {
396446
Delegate software tasks to Ona. It writes code, runs tests, and opens a pull request. Or
397447
jump in to inspect output or pair program in your IDE.
398448
</p>
399-
<p className="text-white/70 text-base mt-2">
400-
Ona runs inside your infrastructure (VPC), with full audit trails, zero data exposure, and
401-
support for any LLM.
402-
</p>
403449
</div>
404450

405451
<div className="mt-4">

components/dashboard/src/data/featureflag-query.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { useQuery } from "@tanstack/react-query";
99
import { getExperimentsClient } from "../experiments/client";
1010
import { useCurrentUser } from "../user-context";
1111
import { useCurrentOrg } from "./organizations/orgs-query";
12+
import { ClassicPaygSunsetConfig } from "@gitpod/gitpod-protocol/lib/experiments/configcat";
13+
14+
const defaultClassicPaygSunsetConfig: ClassicPaygSunsetConfig = { enabled: false, exemptedOrganizations: [] };
1215

1316
const featureFlags = {
1417
oidcServiceEnabled: false,
@@ -26,18 +29,44 @@ const featureFlags = {
2629
enabled_configuration_prebuild_full_clone: false,
2730
enterprise_onboarding_enabled: false,
2831
commit_annotation_setting_enabled: false,
32+
classic_payg_sunset_enabled: defaultClassicPaygSunsetConfig,
2933
};
3034

3135
type FeatureFlags = typeof featureFlags;
3236

37+
// Helper to parse JSON feature flags
38+
function parseFeatureFlagValue<T>(flagName: string, rawValue: any, defaultValue: T): T {
39+
// Special handling for JSON-based feature flags
40+
if (flagName === "classic_payg_sunset_enabled") {
41+
try {
42+
if (typeof rawValue === "string") {
43+
return JSON.parse(rawValue) as T;
44+
}
45+
// If it's already an object, return as-is
46+
if (typeof rawValue === "object" && rawValue !== null) {
47+
return rawValue as T;
48+
}
49+
} catch (error) {
50+
console.error(`Failed to parse feature flag ${flagName}:`, error);
51+
return defaultValue;
52+
}
53+
}
54+
return rawValue;
55+
}
56+
3357
export const useFeatureFlag = <K extends keyof FeatureFlags>(featureFlag: K): FeatureFlags[K] | boolean => {
3458
const user = useCurrentUser();
3559
const org = useCurrentOrg().data;
3660

3761
const queryKey = ["featureFlag", featureFlag, user?.id || "", org?.id || ""];
3862

3963
const query = useQuery(queryKey, async () => {
40-
const flagValue = await getExperimentsClient().getValueAsync(featureFlag, featureFlags[featureFlag], {
64+
const defaultValue = featureFlags[featureFlag];
65+
// For JSON flags, send stringified default to ConfigCat
66+
const configCatDefault =
67+
featureFlag === "classic_payg_sunset_enabled" ? JSON.stringify(defaultValue) : defaultValue;
68+
69+
const rawValue = await getExperimentsClient().getValueAsync(featureFlag, configCatDefault, {
4170
user: user && {
4271
id: user.id,
4372
email: getPrimaryEmail(user),
@@ -46,7 +75,8 @@ export const useFeatureFlag = <K extends keyof FeatureFlags>(featureFlag: K): Fe
4675
teamName: org?.name,
4776
gitpodHost: window.location.host,
4877
});
49-
return flagValue;
78+
79+
return parseFeatureFlagValue(featureFlag, rawValue, defaultValue);
5080
});
5181

5282
return query.data !== undefined ? query.data : featureFlags[featureFlag];

components/gitpod-protocol/src/experiments/configcat.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,8 @@ export function attributesToUser(attributes: Attributes): ConfigCatUser {
6060

6161
return new ConfigCatUser(userId, email, "", custom);
6262
}
63+
64+
export interface ClassicPaygSunsetConfig {
65+
enabled: boolean;
66+
exemptedOrganizations: string[];
67+
}

components/server/src/api/workspace-service-api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ import { UserService } from "../user/user-service";
6161
import { ContextParser } from "../workspace/context-parser-service";
6262
import { isWorkspaceId } from "@gitpod/gitpod-protocol/lib/util/parse-workspace-id";
6363
import { SYSTEM_USER, SYSTEM_USER_ID } from "../authorization/authorizer";
64+
import { isWorkspaceStartBlockedBySunset } from "../util/featureflags";
65+
import { User } from "@gitpod/gitpod-protocol";
66+
import { Config } from "../config";
6467

6568
@injectable()
6669
export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceInterface> {
@@ -69,6 +72,16 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
6972
@inject(ContextService) private readonly contextService: ContextService;
7073
@inject(UserService) private readonly userService: UserService;
7174
@inject(ContextParser) private contextParser: ContextParser;
75+
@inject(Config) private readonly config: Config;
76+
77+
private async checkClassicPaygSunset(user: User, organizationId: string): Promise<void> {
78+
if (await isWorkspaceStartBlockedBySunset(user, organizationId, this.config.isDedicatedInstallation)) {
79+
throw new ApplicationError(
80+
ErrorCodes.PERMISSION_DENIED,
81+
"Gitpod Classic PAYG has sunset. Please visit https://app.ona.com/login to continue.",
82+
);
83+
}
84+
}
7285

7386
async getWorkspace(req: GetWorkspaceRequest, _: HandlerContext): Promise<GetWorkspaceResponse> {
7487
if (!isWorkspaceId(req.workspaceId)) {
@@ -198,6 +211,9 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
198211
}
199212
const contextUrl = req.source.value;
200213
const user = await this.userService.findUserById(ctxUserId(), ctxUserId());
214+
215+
// Check if user is blocked by Classic PAYG sunset
216+
await this.checkClassicPaygSunset(user, req.metadata.organizationId);
201217
const { context, project } = await this.contextService.parseContext(user, contextUrl.url, {
202218
projectId: req.metadata.configurationId,
203219
organizationId: req.metadata.organizationId,
@@ -244,6 +260,10 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
244260
ctxUserId(),
245261
req.workspaceId,
246262
);
263+
264+
// Check if user is blocked by Classic PAYG sunset
265+
await this.checkClassicPaygSunset(user, workspace.organizationId);
266+
247267
if (instance && instance.status.phase !== "stopped") {
248268
const info = await this.workspaceService.getWorkspace(ctxUserId(), workspace.id);
249269
const response = new StartWorkspaceResponse();

components/server/src/user/user-controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { UserService } from "./user-service";
4343
import { WorkspaceService } from "../workspace/workspace-service";
4444
import { runWithSubjectId } from "../util/request-context";
4545
import { SubjectId } from "../auth/subject-id";
46+
import { isUserLoginBlockedBySunset } from "../util/featureflags";
4647

4748
export const ServerFactory = Symbol("ServerFactory");
4849
export type ServerFactory = () => GitpodServerImpl;
@@ -69,6 +70,19 @@ export class UserController {
6970
router.get("/login", async (req: express.Request, res: express.Response, next: express.NextFunction) => {
7071
if (req.isAuthenticated()) {
7172
log.info("(Auth) User is already authenticated.", { "login-flow": true });
73+
74+
// Check if authenticated user is blocked by sunset
75+
const user = req.user as User;
76+
if (await isUserLoginBlockedBySunset(user, this.config.isDedicatedInstallation)) {
77+
log.info("(Auth) User blocked by Classic PAYG sunset", {
78+
userId: user.id,
79+
organizationId: user.organizationId,
80+
"login-flow": true,
81+
});
82+
res.redirect(302, "https://app.ona.com/login");
83+
return;
84+
}
85+
7286
// redirect immediately
7387
const redirectTo = this.ensureSafeReturnToParam(req) || this.config.hostUrl.asDashboard().toString();
7488
safeFragmentRedirect(res, redirectTo);

components/server/src/util/featureflags.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,83 @@
55
*/
66

77
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
8+
import { ClassicPaygSunsetConfig } from "@gitpod/gitpod-protocol/lib/experiments/configcat";
9+
import { User } from "@gitpod/gitpod-protocol";
810

911
export async function getFeatureFlagEnableExperimentalJBTB(userId: string): Promise<boolean> {
1012
return getExperimentsClientForBackend().getValueAsync("enable_experimental_jbtb", false, {
1113
user: { id: userId },
1214
});
1315
}
16+
17+
export async function getClassicPaygSunsetConfig(userId: string): Promise<ClassicPaygSunsetConfig> {
18+
const defaultConfig: ClassicPaygSunsetConfig = { enabled: false, exemptedOrganizations: [] };
19+
const rawValue = await getExperimentsClientForBackend().getValueAsync(
20+
"classic_payg_sunset_enabled",
21+
JSON.stringify(defaultConfig),
22+
{ user: { id: userId } },
23+
);
24+
25+
// Parse JSON string from ConfigCat
26+
try {
27+
if (typeof rawValue === "string") {
28+
return JSON.parse(rawValue) as ClassicPaygSunsetConfig;
29+
}
30+
// Fallback if somehow we get an object (shouldn't happen with ConfigCat text flags)
31+
return rawValue as ClassicPaygSunsetConfig;
32+
} catch (error) {
33+
console.error("Failed to parse classic_payg_sunset_enabled feature flag:", error);
34+
return defaultConfig;
35+
}
36+
}
37+
38+
export async function isWorkspaceStartBlockedBySunset(
39+
user: User,
40+
organizationId: string,
41+
isDedicatedInstallation: boolean,
42+
): Promise<boolean> {
43+
// Dedicated installations are never blocked
44+
if (isDedicatedInstallation) {
45+
return false;
46+
}
47+
48+
const config = await getClassicPaygSunsetConfig(user.id);
49+
50+
if (!config.enabled) {
51+
return false;
52+
}
53+
54+
// If user has an org, check if it's exempted
55+
if (organizationId) {
56+
return !config.exemptedOrganizations.includes(organizationId);
57+
}
58+
59+
// Installation-owned users (no organizationId) are blocked
60+
return true;
61+
}
62+
63+
export async function isUserLoginBlockedBySunset(user: User, isDedicatedInstallation: boolean): Promise<boolean> {
64+
// Dedicated installations are never blocked
65+
if (isDedicatedInstallation) {
66+
return false;
67+
}
68+
69+
const config = await getClassicPaygSunsetConfig(user.id);
70+
71+
if (!config.enabled) {
72+
return false;
73+
}
74+
75+
// Users with roles/permissions are exempted (admins, etc.)
76+
if (user.rolesOrPermissions && user.rolesOrPermissions.length > 0) {
77+
return false;
78+
}
79+
80+
// If user has an org, check if it's exempted
81+
if (user.organizationId) {
82+
return !config.exemptedOrganizations.includes(user.organizationId);
83+
}
84+
85+
// Installation-owned users (no organizationId) are blocked
86+
return true;
87+
}

0 commit comments

Comments
 (0)