rcgit

/ karakeep

Commit 26521b70

SHA 26521b70a79c42442f44c8053590bbb8c5e5f1b1
Author kamtschatka <simon.schatka at gmx dot at>
Author Date 2024-09-21 21:17 +0200
Committer GitHub <noreply at github dot com>
Commit Date 2024-09-21 20:17 +0100
Parent(s) 9dd6f216ad18 (diff)
Tree bdcf9cf835c1

patch snapshot

feature(extension): Allow login directly with an API key
* [Feature request] NextAuth Providers for OAuth/SSO #92
Added API key based authentication to the extension to make the extension usable when OAuth is in use

* Minor UI tweak

---------

Co-authored-by: MohamedBassem <me@mbassem.com>
File + - Graph
M apps/browser-extension/src/SignInPage.tsx +86 -8
M packages/trpc/routers/apiKeys.ts +10 -1
2 file(s) changed, 96 insertions(+), 9 deletions(-)

apps/browser-extension/src/SignInPage.tsx

diff --git a/apps/browser-extension/src/SignInPage.tsx b/apps/browser-extension/src/SignInPage.tsx
index 4e846070..f1899d5a 100644
--- a/apps/browser-extension/src/SignInPage.tsx
+++ b/apps/browser-extension/src/SignInPage.tsx
@@ -7,14 +7,20 @@ import Logo from "./Logo";
 import usePluginSettings from "./utils/settings";
 import { api } from "./utils/trpc";
 
+const enum LoginState {
+  NONE = "NONE",
+  USERNAME_PASSWORD = "USERNAME/PASSWORD",
+  API_KEY = "API_KEY",
+}
+
 export default function SignInPage() {
   const navigate = useNavigate();
   const { setSettings } = usePluginSettings();
 
   const {
     mutate: login,
-    error,
-    isPending,
+    error: usernamePasswordError,
+    isPending: userNamePasswordRequestIsPending,
   } = api.apiKeys.exchange.useMutation({
     onSuccess: (resp) => {
       setSettings((s) => ({ ...s, apiKey: resp.key, apiKeyId: resp.id }));
@@ -22,6 +28,20 @@ export default function SignInPage() {
     },
   });
 
+  const {
+    mutate: validateApiKey,
+    error: apiKeyValidationError,
+    isPending: apiKeyValueRequestIsPending,
+  } = api.apiKeys.validate.useMutation({
+    onSuccess: () => {
+      setSettings((s) => ({ ...s, apiKey: apiKeyFormData.apiKey }));
+      navigate("/options");
+    },
+  });
+
+  const [lastLoginAttemptSource, setLastLoginAttemptSource] =
+    useState<LoginState>(LoginState.NONE);
+
   const [formData, setFormData] = useState<{
     email: string;
     password: string;
@@ -30,18 +50,40 @@ export default function SignInPage() {
     password: "",
   });
 
-  const onSubmit = (e: React.FormEvent) => {
+  const [apiKeyFormData, setApiKeyFormData] = useState<{
+    apiKey: string;
+  }>({
+    apiKey: "",
+  });
+
+  const onUserNamePasswordSubmit = (e: React.FormEvent) => {
     e.preventDefault();
+    setLastLoginAttemptSource(LoginState.USERNAME_PASSWORD);
     const randStr = (Math.random() + 1).toString(36).substring(5);
     login({ ...formData, keyName: `Browser extension: (${randStr})` });
   };
 
+  const onApiKeySubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setLastLoginAttemptSource(LoginState.API_KEY);
+    validateApiKey({ ...apiKeyFormData });
+  };
+
   let errorMessage = "";
-  if (error) {
-    if (error.data?.code == "UNAUTHORIZED") {
+  let loginError;
+  switch (lastLoginAttemptSource) {
+    case LoginState.USERNAME_PASSWORD:
+      loginError = usernamePasswordError;
+      break;
+    case LoginState.API_KEY:
+      loginError = apiKeyValidationError;
+      break;
+  }
+  if (loginError) {
+    if (loginError.data?.code == "UNAUTHORIZED") {
       errorMessage = "Wrong username or password";
     } else {
-      errorMessage = error.message;
+      errorMessage = loginError.message;
     }
   }
 
@@ -50,7 +92,10 @@ export default function SignInPage() {
       <Logo />
       <p className="text-lg">Login</p>
       <p className="text-red-500">{errorMessage}</p>
-      <form className="flex flex-col gap-y-2" onSubmit={onSubmit}>
+      <form
+        className="flex flex-col gap-y-2"
+        onSubmit={onUserNamePasswordSubmit}
+      >
         <div className="flex flex-col gap-y-1">
           <label className="my-auto font-bold">Email</label>
           <Input
@@ -78,10 +123,43 @@ export default function SignInPage() {
             className="h-8 flex-1 rounded-lg border border-gray-300 p-2"
           />
         </div>
-        <Button type="submit" disabled={isPending}>
+        <Button
+          type="submit"
+          disabled={
+            userNamePasswordRequestIsPending || apiKeyValueRequestIsPending
+          }
+        >
           Login
         </Button>
       </form>
+      <div className="flex w-full flex-row items-center gap-3">
+        <hr className="flex-1" />
+        Or
+        <hr className="flex-1" />
+      </div>
+
+      <form className="flex flex-col gap-y-2" onSubmit={onApiKeySubmit}>
+        <div className="flex flex-col gap-y-1">
+          <label className="my-auto font-bold">API Key</label>
+          <Input
+            value={apiKeyFormData.apiKey}
+            onChange={(e) =>
+              setApiKeyFormData((f) => ({ ...f, apiKey: e.target.value }))
+            }
+            type="text"
+            name="apiKey"
+            className="h-8 flex-1 rounded-lg border border-gray-300 p-2"
+          />
+        </div>
+        <Button
+          type="submit"
+          disabled={
+            userNamePasswordRequestIsPending || apiKeyValueRequestIsPending
+          }
+        >
+          Login with API key
+        </Button>
+      </form>
     </div>
   );
 }

packages/trpc/routers/apiKeys.ts

diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
index deeb108f..81e3bb2b 100644
--- a/packages/trpc/routers/apiKeys.ts
+++ b/packages/trpc/routers/apiKeys.ts
@@ -4,7 +4,7 @@ import { z } from "zod";
 
 import { apiKeys } from "@hoarder/db/schema";
 
-import { generateApiKey, validatePassword } from "../auth";
+import { authenticateApiKey, generateApiKey, validatePassword } from "../auth";
 import { authedProcedure, publicProcedure, router } from "../index";
 
 const zApiKeySchema = z.object({
@@ -81,4 +81,13 @@ export const apiKeysAppRouter = router({
       }
       return await generateApiKey(input.keyName, user.id);
     }),
+  validate: publicProcedure
+    .input(z.object({ apiKey: z.string() }))
+    .output(z.object({ success: z.boolean() }))
+    .mutation(async ({ input }) => {
+      await authenticateApiKey(input.apiKey); // Throws if the key is invalid
+      return {
+        success: true,
+      };
+    }),
 });