From 26521b70a79c42442f44c8053590bbb8c5e5f1b1 Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sat, 21 Sep 2024 21:17:12 +0200 Subject: [PATCH] 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 --- apps/browser-extension/src/SignInPage.tsx | 94 +++++++++++++++++++++-- packages/trpc/routers/apiKeys.ts | 11 ++- 2 files changed, 96 insertions(+), 9 deletions(-) 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.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() {

Login

{errorMessage}

-
+
-
+
+
+ Or +
+
+ +
+
+ + + setApiKeyFormData((f) => ({ ...f, apiKey: e.target.value })) + } + type="text" + name="apiKey" + className="h-8 flex-1 rounded-lg border border-gray-300 p-2" + /> +
+ +
); } 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, + }; + }), });