Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 60 additions & 14 deletions components/dashboard/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -217,13 +217,22 @@ 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<string | undefined>(undefined);

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({});
Expand Down Expand Up @@ -292,18 +301,49 @@ const LoginContent = ({
<Heading2>Open a cloud development environment</Heading2>
<Subheading>for the repository {repoPathname?.slice(1)}</Subheading>
</>
) : showSunsetUI ? (
<>
<Heading1>Start building with Ona</Heading1>
<Subheading>What do you want to get done today?</Subheading>
</>
) : !isGitpodIoUser ? (
<Heading1>Log in to Gitpod</Heading1>
) : (
<>
<Heading1>Log in to Gitpod Classic</Heading1>
<Subheading>Hosted by us</Subheading>
<Heading1>Start building with Ona</Heading1>
<Subheading>What do you want to get done today?</Subheading>
</>
)}
</div>

<div className="w-56 mx-auto flex flex-col space-y-3 items-center">
{providerFromContext ? (
{showSunsetUI ? (
<>
<Button
className="w-full"
onClick={() => {
window.location.href = "https://app.ona.com/login";
}}
>
Continue with Ona
</Button>
<div className="mt-4 text-center text-sm">
<p className="text-gray-500 dark:text-gray-400">
Need to access your organization?{" "}
<a
href={`${window.location.pathname}?oldLogin=true${
searchParams.get("returnToPath")
? `&returnToPath=${encodeURIComponent(searchParams.get("returnToPath")!)}`
: ""
}`}
className="gp-link hover:text-gray-600"
>
Show all login options
</a>
</p>
</div>
</>
) : providerFromContext ? (
<LoginButton
key={"button" + providerFromContext.host}
onClick={() => openLogin(providerFromContext!.host)}
Expand All @@ -323,22 +363,32 @@ const LoginContent = ({
</LoginButton>
))
)}
<SSOLoginForm onSuccess={authorizeSuccessful} />
{!showSunsetUI && <SSOLoginForm onSuccess={authorizeSuccessful} />}
</div>
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}

{/* Gitpod Classic sunset notice - only show for non-enterprise */}
{!enterprise && (
<div className="mt-6 text-center text-sm">
<p className="text-pk-content-primary">
Gitpod Classic sunsets Oct 15.{" "}
Gitpod Classic has been sunset on Oct 15.{" "}
<a
href="https://app.gitpod.io"
href="https://ona.com/stories/gitpod-is-now-ona"
target="_blank"
rel="noopener noreferrer"
className="gp-link hover:text-gray-600"
>
Start here for free
{" "}
Gitpod is now Ona
</a>
,{" "}
<a
href="https://app.ona.com"
target="_blank"
rel="noopener noreferrer"
className="gp-link hover:text-gray-600"
>
start for free
</a>{" "}
and get $100 credits.
</p>
Expand Down Expand Up @@ -378,9 +428,9 @@ const RightProductDescriptionPanel = () => {
>
Start for free
</a>{" "}
and get $100 credits. <br />
and get $100 in credits. <br />
<br />
Gitpod Classic sunsets Oct 15 |{" "}
Gitpod Classic has been sunset on Oct 15 |{" "}
<a
href="https://ona.com/stories/gitpod-classic-payg-sunset"
target="_blank"
Expand All @@ -396,10 +446,6 @@ const RightProductDescriptionPanel = () => {
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.
</p>
<p className="text-white/70 text-base mt-2">
Ona runs inside your infrastructure (VPC), with full audit trails, zero data exposure, and
support for any LLM.
</p>
</div>

<div className="mt-4">
Expand Down
34 changes: 32 additions & 2 deletions components/dashboard/src/data/featureflag-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,18 +29,44 @@ 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<T>(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 = <K extends keyof FeatureFlags>(featureFlag: K): FeatureFlags[K] | boolean => {
const user = useCurrentUser();
const org = useCurrentOrg().data;

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),
Expand All @@ -46,7 +75,8 @@ export const useFeatureFlag = <K extends keyof FeatureFlags>(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];
Expand Down
5 changes: 5 additions & 0 deletions components/gitpod-protocol/src/experiments/configcat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ export function attributesToUser(attributes: Attributes): ConfigCatUser {

return new ConfigCatUser(userId, email, "", custom);
}

export interface ClassicPaygSunsetConfig {
enabled: boolean;
exemptedOrganizations: string[];
}
20 changes: 20 additions & 0 deletions components/server/src/api/workspace-service-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof WorkspaceServiceInterface> {
Expand All @@ -69,6 +72,16 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
@inject(ContextService) private readonly contextService: ContextService;
@inject(UserService) private readonly userService: UserService;
@inject(ContextParser) private contextParser: ContextParser;
@inject(Config) private readonly config: Config;

private async checkClassicPaygSunset(user: User, organizationId: string): Promise<void> {
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<GetWorkspaceResponse> {
if (!isWorkspaceId(req.workspaceId)) {
Expand Down Expand Up @@ -198,6 +211,9 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
}
const contextUrl = req.source.value;
const user = await this.userService.findUserById(ctxUserId(), ctxUserId());

// Check if user is blocked by Classic PAYG sunset
await this.checkClassicPaygSunset(user, req.metadata.organizationId);
const { context, project } = await this.contextService.parseContext(user, contextUrl.url, {
projectId: req.metadata.configurationId,
organizationId: req.metadata.organizationId,
Expand Down Expand Up @@ -244,6 +260,10 @@ export class WorkspaceServiceAPI implements ServiceImpl<typeof WorkspaceServiceI
ctxUserId(),
req.workspaceId,
);

// Check if user is blocked by Classic PAYG sunset
await this.checkClassicPaygSunset(user, workspace.organizationId);

if (instance && instance.status.phase !== "stopped") {
const info = await this.workspaceService.getWorkspace(ctxUserId(), workspace.id);
const response = new StartWorkspaceResponse();
Expand Down
14 changes: 14 additions & 0 deletions components/server/src/user/user-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { UserService } from "./user-service";
import { WorkspaceService } from "../workspace/workspace-service";
import { runWithSubjectId } from "../util/request-context";
import { SubjectId } from "../auth/subject-id";
import { isUserLoginBlockedBySunset } from "../util/featureflags";

export const ServerFactory = Symbol("ServerFactory");
export type ServerFactory = () => GitpodServerImpl;
Expand All @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions components/server/src/util/featureflags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
return getExperimentsClientForBackend().getValueAsync("enable_experimental_jbtb", false, {
user: { id: userId },
});
}

export async function getClassicPaygSunsetConfig(userId: string): Promise<ClassicPaygSunsetConfig> {
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<boolean> {
// 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<boolean> {
// 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;
}
Loading