Compare commits

...

1 Commits

Author SHA1 Message Date
openhands
e2ca4662d8 Add TOS acceptance functionality to frontend 2025-04-15 20:30:35 +00:00
10 changed files with 212 additions and 11 deletions

View File

@@ -8,6 +8,7 @@ import {
Conversation,
ResultSet,
GetTrajectoryResponse,
AcceptTosResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
@@ -277,6 +278,15 @@ class OpenHands {
appMode === "saas" ? "/api/logout" : "/api/unset-settings-tokens";
await openHands.post(endpoint);
}
/**
* Accept the Terms of Service
* @returns Promise<boolean> - True if successful
*/
static async acceptTos(): Promise<boolean> {
const response = await openHands.post<AcceptTosResponse>("/api/accept_tos");
return response.status === 200;
}
}
export default OpenHands;

View File

@@ -83,3 +83,8 @@ export interface ResultSet<T> {
results: T[];
next_page_id: string | null;
}
export interface AcceptTosResponse {
message: string;
accepted_tos: string;
}

View File

@@ -0,0 +1,58 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router";
import { checkTosAcceptance } from "#/utils/check-tos-acceptance";
interface TOSRedirectProps {
children: React.ReactNode;
}
/**
* Component that checks if the user has accepted the TOS and redirects to the TOS page if not
*/
export function TOSRedirect({ children }: TOSRedirectProps) {
const navigate = useNavigate();
const location = useLocation();
const [isLoading, setIsLoading] = useState(true);
const [hasAcceptedTos, setHasAcceptedTos] = useState(false);
useEffect(() => {
const checkTos = async () => {
try {
// Skip the check if we're already on the TOS page or login page
if (location.pathname === "/accept-tos") {
setIsLoading(false);
return;
}
const hasAccepted = await checkTosAcceptance();
setHasAcceptedTos(hasAccepted);
if (!hasAccepted) {
navigate("/accept-tos");
}
} catch (error) {
console.error("Failed to check TOS acceptance:", error);
} finally {
setIsLoading(false);
}
};
checkTos();
}, [navigate, location.pathname]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
// If we're on the TOS page or the user has accepted the TOS, render the children
if (location.pathname === "/accept-tos" || hasAcceptedTos) {
return <>{children}</>;
}
// This should not happen as we redirect to the TOS page if the user hasn't accepted it
return null;
}

View File

@@ -4,7 +4,6 @@ import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { TOSCheckbox } from "./tos-checkbox";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
@@ -15,7 +14,6 @@ interface AuthModalProps {
export function AuthModal({ githubAuthUrl }: AuthModalProps) {
const { t } = useTranslation();
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
const handleGitHubAuth = () => {
if (githubAuthUrl) {
@@ -34,10 +32,19 @@ export function AuthModal({ githubAuthUrl }: AuthModalProps) {
</h1>
</div>
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
<div className="text-sm text-muted-foreground mb-2 text-center">
By signing in, you agree to our{" "}
<a
href="https://www.all-hands.dev/tos"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 text-blue-500 hover:text-blue-700"
>
{t(I18nKey.TOS$TERMS)}
</a>
</div>
<BrandButton
isDisabled={!isTosAccepted}
type="button"
variant="primary"
onClick={handleGitHubAuth}

View File

@@ -8,6 +8,7 @@ import {
export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("accept-tos", "routes/accept-tos.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
route("billing", "routes/billing.tsx"),

View File

@@ -0,0 +1,85 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useNavigate } from "react-router";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { BrandButton } from "#/components/features/settings/brand-button";
import OpenHands from "#/api/open-hands";
import toast from "react-hot-toast";
export default function AcceptTOS() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleAcceptTOS = async () => {
try {
setIsSubmitting(true);
const success = await OpenHands.acceptTos();
if (success) {
toast.success("Terms of Service accepted");
navigate("/");
} else {
toast.error("Failed to accept Terms of Service");
}
} catch (error) {
console.error("Failed to accept TOS:", error);
toast.error("Failed to accept Terms of Service");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-4">
<div className="w-full max-w-md p-6 bg-card rounded-lg shadow-lg border border-tertiary">
<div className="flex flex-col items-center gap-6">
<AllHandsLogo width={68} height={46} />
<h1 className="text-2xl font-bold text-center">
Terms of Service
</h1>
<div className="text-sm text-muted-foreground mb-4">
<p className="mb-4">
Please read and accept our Terms of Service to continue using OpenHands.
</p>
<div className="border border-tertiary rounded-md p-4 max-h-60 overflow-y-auto mb-4">
<p className="mb-2">
By using OpenHands, you agree to the following terms:
</p>
<ul className="list-disc pl-5 space-y-2">
<li>You will use the service responsibly and ethically</li>
<li>You will not use the service for illegal activities</li>
<li>You will not attempt to circumvent security measures</li>
<li>You understand that your usage may be monitored</li>
<li>You acknowledge that the service is provided as-is</li>
</ul>
<p className="mt-4">
For the complete terms, please visit{" "}
<a
href="https://www.all-hands.dev/tos"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 text-blue-500 hover:text-blue-700"
>
{t(I18nKey.TOS$TERMS)}
</a>
</p>
</div>
</div>
<BrandButton
type="button"
variant="primary"
onClick={handleAcceptTOS}
className="w-full"
isDisabled={isSubmitting}
>
{isSubmitting ? "Processing..." : `${t(I18nKey.TOS$ACCEPT)} ${t(I18nKey.TOS$TERMS)}`}
</BrandButton>
</div>
</div>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { useBalance } from "#/hooks/query/use-balance";
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { TOSRedirect } from "#/components/features/tos/tos-redirect";
export function ErrorBoundary() {
const error = useRouteError();
@@ -120,14 +121,29 @@ export default function MainApp() {
data-testid="root-layout"
className="bg-base p-3 h-screen md:min-w-[1024px] overflow-x-hidden flex flex-col md:flex-row gap-3"
>
<Sidebar />
{userIsAuthed ? (
<TOSRedirect>
<Sidebar />
<div
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative"
>
<Outlet />
</div>
<div
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative"
>
<Outlet />
</div>
</TOSRedirect>
) : (
<>
<Sidebar />
<div
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative"
>
<Outlet />
</div>
</>
)}
{renderAuthModal && <AuthModal githubAuthUrl={gitHubAuthUrl} />}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (

View File

@@ -20,6 +20,7 @@ export type Settings = {
USER_CONSENTS_TO_ANALYTICS: boolean | null;
PROVIDER_TOKENS: Record<Provider, string>;
IS_NEW_USER?: boolean;
ACCEPTED_TOS?: string | null;
};
export type ApiSettings = {
@@ -37,6 +38,7 @@ export type ApiSettings = {
user_consents_to_analytics: boolean | null;
provider_tokens: Record<Provider, string>;
provider_tokens_set: Record<Provider, boolean>;
accepted_tos: string | null;
};
export type PostSettings = Settings & {

View File

@@ -0,0 +1,15 @@
import OpenHands from "#/api/open-hands";
/**
* Checks if the user has accepted the Terms of Service
* @returns Promise<boolean> - True if the user has accepted the TOS, false otherwise
*/
export async function checkTosAcceptance(): Promise<boolean> {
try {
const settings = await OpenHands.getSettings();
return !!settings.accepted_tos;
} catch (error) {
console.error("Failed to check TOS acceptance:", error);
return false;
}
}

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from pydantic import (
BaseModel,
Field,
@@ -35,6 +36,7 @@ class Settings(BaseModel):
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
accepted_tos: datetime | None = None
model_config = {
'validate_assignment': True,