Browse Source

[ALL-543] feat(frontend): Setup auth route, replace loading spinner, add new route (#4448)

sp.wack 1 year ago
parent
commit
cf793582a7

+ 17 - 0
frontend/src/api/open-hands.ts

@@ -210,6 +210,23 @@ class OpenHands {
 
     return response.json();
   }
+
+  /**
+   * Check if the user is authenticated
+   * @param login The user's GitHub login handle
+   * @returns Whether the user is authenticated
+   */
+  static async isAuthenticated(login: string): Promise<boolean> {
+    const response = await fetch(`${OpenHands.BASE_URL}/authenticate`, {
+      method: "POST",
+      body: JSON.stringify({ login }),
+      headers: {
+        "Content-Type": "application/json",
+      },
+    });
+
+    return response.status === 200;
+  }
 }
 
 export default OpenHands;

+ 0 - 8
frontend/src/routes/_oh._index/route.tsx

@@ -4,7 +4,6 @@ import {
   json,
   redirect,
   useLoaderData,
-  useNavigation,
   useRouteLoaderData,
 } from "@remix-run/react";
 import React from "react";
@@ -21,7 +20,6 @@ import ModalButton from "#/components/buttons/ModalButton";
 import GitHubLogo from "#/assets/branding/github-logo.svg?react";
 import { ConnectToGitHubModal } from "#/components/modals/connect-to-github-modal";
 import { ModalBackdrop } from "#/components/modals/modal-backdrop";
-import { LoadingSpinner } from "#/components/modals/LoadingProject";
 import store, { RootState } from "#/store";
 import { removeFile, setInitialQuery } from "#/state/initial-query-slice";
 import { clientLoader as rootClientLoader } from "#/routes/_oh";
@@ -102,7 +100,6 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
 
 function Home() {
   const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
-  const navigation = useNavigation();
   const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
   const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
     React.useState(false);
@@ -124,11 +121,6 @@ function Home() {
 
   return (
     <div className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto">
-      {navigation.state === "loading" && (
-        <div className="absolute top-8 right-8">
-          <LoadingSpinner size="small" />
-        </div>
-      )}
       <HeroHeading />
       <div className="flex flex-col gap-16 w-[600px] items-center">
         <div className="flex flex-col gap-2 w-full">

+ 11 - 1
frontend/src/routes/_oh.app.tsx

@@ -7,6 +7,7 @@ import {
   json,
   ClientActionFunctionArgs,
   useRouteLoaderData,
+  redirect,
 } from "@remix-run/react";
 import { useDispatch, useSelector } from "react-redux";
 import WebSocket from "ws";
@@ -42,6 +43,8 @@ import { base64ToBlob } from "#/utils/base64-to-blob";
 import { clientLoader as rootClientLoader } from "#/routes/_oh";
 import { clearJupyter } from "#/state/jupyterSlice";
 import { FilesProvider } from "#/context/files";
+import { clearSession } from "#/utils/clear-session";
+import { userIsAuthenticated } from "#/utils/user-is-authenticated";
 
 const isAgentStateChange = (
   data: object,
@@ -51,6 +54,14 @@ const isAgentStateChange = (
   "agent_state" in data.extras;
 
 export const clientLoader = async () => {
+  const ghToken = localStorage.getItem("ghToken");
+
+  const isAuthed = await userIsAuthenticated(ghToken);
+  if (!isAuthed) {
+    clearSession();
+    return redirect("/waitlist");
+  }
+
   const q = store.getState().initalQuery.initialQuery;
   const repo =
     store.getState().initalQuery.selectedRepository ||
@@ -59,7 +70,6 @@ export const clientLoader = async () => {
 
   const settings = getSettings();
   const token = localStorage.getItem("token");
-  const ghToken = localStorage.getItem("ghToken");
 
   if (token && importedProject) {
     const blob = base64ToBlob(importedProject);

+ 17 - 22
frontend/src/routes/_oh.tsx

@@ -15,7 +15,7 @@ import CogTooth from "#/assets/cog-tooth";
 import { SettingsForm } from "#/components/form/settings-form";
 import AccountSettingsModal from "#/components/modals/AccountSettingsModal";
 import { DangerModal } from "#/components/modals/confirmation-modals/danger-modal";
-import LoadingProjectModal from "#/components/modals/LoadingProject";
+import { LoadingSpinner } from "#/components/modals/LoadingProject";
 import { ModalBackdrop } from "#/components/modals/modal-backdrop";
 import { UserAvatar } from "#/components/user-avatar";
 import { useSocket } from "#/context/socket";
@@ -173,16 +173,22 @@ export default function MainApp() {
 
   return (
     <div className="bg-root-primary p-3 h-screen min-w-[1024px] overflow-x-hidden flex gap-3">
-      <aside className="px-1 flex flex-col gap-[15px]">
-        <button
-          type="button"
-          aria-label="All Hands Logo"
-          onClick={() => {
-            if (location.pathname !== "/") setStartNewProjectModalIsOpen(true);
-          }}
-        >
-          <AllHandsLogo width={34} height={23} />
-        </button>
+      <aside className="px-1 flex flex-col gap-1">
+        <div className="w-[34px] h-[34px] flex items-center justify-center">
+          {navigation.state === "loading" && <LoadingSpinner size="small" />}
+          {navigation.state !== "loading" && (
+            <button
+              type="button"
+              aria-label="All Hands Logo"
+              onClick={() => {
+                if (location.pathname !== "/")
+                  setStartNewProjectModalIsOpen(true);
+              }}
+            >
+              <AllHandsLogo width={34} height={23} />
+            </button>
+          )}
+        </div>
         <nav className="py-[18px] flex flex-col items-center gap-[18px]">
           <UserAvatar
             user={user}
@@ -222,17 +228,6 @@ export default function MainApp() {
       </aside>
       <div className="h-full w-full relative">
         <Outlet />
-        {navigation.state === "loading" && location.pathname !== "/" && (
-          <ModalBackdrop>
-            <LoadingProjectModal
-              message={
-                endSessionFetcher.state === "loading"
-                  ? "Ending session, please wait..."
-                  : undefined
-              }
-            />
-          </ModalBackdrop>
-        )}
         {(!settingsIsUpdated || settingsModalIsOpen) && (
           <ModalBackdrop onClose={() => setSettingsModalIsOpen(false)}>
             <div className="bg-root-primary w-[384px] p-6 rounded-xl flex flex-col gap-2">

+ 39 - 0
frontend/src/routes/_oh.waitlist.tsx

@@ -0,0 +1,39 @@
+import { Link } from "@remix-run/react";
+import Clipboard from "#/assets/clipboard.svg?react";
+
+function Waitlist() {
+  return (
+    <div className="bg-neutral-800 h-full flex items-center justify-center rounded-xl">
+      <div className="w-[384px] flex flex-col gap-6 bg-neutral-900 rounded-xl p-6">
+        <Clipboard className="w-14 self-center" />
+
+        <div className="flex flex-col gap-2">
+          <h1 className="text-[20px] leading-6 -tracking-[0.01em] font-semibold">
+            You&apos;re not in the waitlist yet!
+          </h1>
+          <p className="text-neutral-400 text-xs">
+            Please click{" "}
+            <a
+              href="https://www.all-hands.dev/join-waitlist"
+              target="_blank"
+              rel="noreferrer noopener"
+              className="text-blue-500"
+            >
+              here
+            </a>{" "}
+            to join the waitlist.
+          </p>
+        </div>
+
+        <Link
+          to="/"
+          className="text-white text-sm py-[10px] bg-neutral-500 rounded text-center"
+        >
+          Go back to home
+        </Link>
+      </div>
+    </div>
+  );
+}
+
+export default Waitlist;

+ 16 - 0
frontend/src/utils/user-is-authenticated.ts

@@ -0,0 +1,16 @@
+import { retrieveGitHubUser, isGitHubErrorReponse } from "#/api/github";
+import OpenHands from "#/api/open-hands";
+
+export const userIsAuthenticated = async (ghToken: string | null) => {
+  if (window.__APP_MODE__ === "oss") return true;
+
+  let user: GitHubUser | GitHubErrorReponse | null = null;
+  if (ghToken) user = await retrieveGitHubUser(ghToken);
+
+  if (user && !isGitHubErrorReponse(user)) {
+    const isAuthed = await OpenHands.isAuthenticated(user.login);
+    return isAuthed;
+  }
+
+  return false;
+};

+ 29 - 0
openhands/server/listen.py

@@ -798,4 +798,33 @@ def github_callback(auth_code: AuthCode):
     )
 
 
+class User(BaseModel):
+    login: str  # GitHub login handle
+
+
+@app.post('/authenticate')
+def authenticate(user: User | None = None):
+    waitlist = os.getenv('GITHUB_USER_LIST_FILE')
+
+    # Only check if waitlist is provided
+    if waitlist is not None:
+        try:
+            with open(waitlist, 'r') as f:
+                users = f.read().splitlines()
+                if user is None or user.login not in users:
+                    return JSONResponse(
+                        status_code=status.HTTP_403_FORBIDDEN,
+                        content={'error': 'User not on waitlist'},
+                    )
+        except FileNotFoundError:
+            return JSONResponse(
+                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                content={'error': 'Waitlist file not found'},
+            )
+
+    return JSONResponse(
+        status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
+    )
+
+
 app.mount('/', StaticFiles(directory='./frontend/build', html=True), name='dist')