From e6570dd7ec5d7aea3c3d0c0235476a1227bbe71f Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Tue, 5 Mar 2024 18:27:38 +0000 Subject: extension: Instead of manually creating api keys, let users exchange their username passwords for one --- .../browser-extension/src/BookmarkSavedPage.tsx | 66 ++++++++-------- packages/browser-extension/src/Layout.tsx | 2 +- .../browser-extension/src/NotConfiguredPage.tsx | 35 ++++++++- packages/browser-extension/src/OptionsPage.tsx | 71 +++--------------- packages/browser-extension/src/SignInPage.tsx | 87 ++++++++++++++++++++++ packages/browser-extension/src/main.tsx | 13 ++-- packages/browser-extension/src/utils/providers.tsx | 47 ++++++------ packages/browser-extension/src/utils/settings.ts | 17 ++++- packages/trpc/routers/apiKeys.ts | 41 +++++++--- packages/web/next.config.mjs | 6 +- 10 files changed, 247 insertions(+), 138 deletions(-) create mode 100644 packages/browser-extension/src/SignInPage.tsx diff --git a/packages/browser-extension/src/BookmarkSavedPage.tsx b/packages/browser-extension/src/BookmarkSavedPage.tsx index 52c09b2c..f25a83ba 100644 --- a/packages/browser-extension/src/BookmarkSavedPage.tsx +++ b/packages/browser-extension/src/BookmarkSavedPage.tsx @@ -1,55 +1,61 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { api } from "./utils/trpc"; import usePluginSettings from "./utils/settings"; import { ArrowUpRightFromSquare, Trash } from "lucide-react"; import Spinner from "./Spinner"; +import { useState } from "react"; export default function BookmarkSavedPage() { const { bookmarkId } = useParams(); const navigate = useNavigate(); + const [error, setError] = useState(""); const { mutate: deleteBookmark, isPending } = api.bookmarks.deleteBookmark.useMutation({ onSuccess: () => { navigate("/bookmarkdeleted"); }, - onError: () => {}, + onError: (e) => { + setError(e.message); + }, }); - const [settings] = usePluginSettings(); + const { settings } = usePluginSettings(); if (!bookmarkId) { return
NOT FOUND
; } return ( -
-

Bookmarked!

-
- - -

Open

-
- deleteBookmark({ bookmarkId: bookmarkId })} - className="flex gap-2 text-red-500 hover:text-red-500" - href="#" - > - {!isPending ? ( - <> - -

Delete

- - ) : ( - - - - )} -
+
+ {error &&

{error}

} +
+

Bookmarked!

+
+ + +

Open

+ + +
); diff --git a/packages/browser-extension/src/Layout.tsx b/packages/browser-extension/src/Layout.tsx index b7768d1a..f8279a18 100644 --- a/packages/browser-extension/src/Layout.tsx +++ b/packages/browser-extension/src/Layout.tsx @@ -5,7 +5,7 @@ import usePluginSettings from "./utils/settings"; export default function Layout() { const navigate = useNavigate(); - const [settings, _1, _2, _3, isInit] = usePluginSettings(); + const { settings, isPending: isInit } = usePluginSettings(); if (!isInit) { return
Loading ...
; } diff --git a/packages/browser-extension/src/NotConfiguredPage.tsx b/packages/browser-extension/src/NotConfiguredPage.tsx index ef73f149..a5b9c734 100644 --- a/packages/browser-extension/src/NotConfiguredPage.tsx +++ b/packages/browser-extension/src/NotConfiguredPage.tsx @@ -1,14 +1,41 @@ +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import usePluginSettings from "./utils/settings"; export default function NotConfiguredPage() { const navigate = useNavigate(); + + const { settings, setSettings } = usePluginSettings(); + + const [error, setError] = useState(""); + const [serverAddress, setServerAddress] = useState(settings.address); + useEffect(() => { + setServerAddress(settings.address); + }, [settings.address]); + + const onSave = () => { + if (serverAddress == "") { + setError("Server address is required"); + return; + } + setSettings((s) => ({ ...s, address: serverAddress })); + navigate("/signin"); + }; + return (
To use the plugin, you need to configure it first. -
diff --git a/packages/browser-extension/src/OptionsPage.tsx b/packages/browser-extension/src/OptionsPage.tsx index 5f0f479a..9a490995 100644 --- a/packages/browser-extension/src/OptionsPage.tsx +++ b/packages/browser-extension/src/OptionsPage.tsx @@ -1,22 +1,12 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect } from "react"; import usePluginSettings from "./utils/settings"; import { api } from "./utils/trpc"; import Spinner from "./Spinner"; +import { useNavigate } from "react-router-dom"; export default function OptionsPage() { - const [settings, setSettings, _1, _2, _3] = usePluginSettings(); - - const apiKeyRef = useRef(null); - const addressRef = useRef(null); - - const [settingsInput, setSettingsInput] = useState(settings); - - useEffect(() => { - setSettingsInput(settings); - }, [settings]); - - const [isSaved, setIsSaved] = useState(false); - const [error, setError] = useState(null); + const navigate = useNavigate(); + const { settings, setSettings } = usePluginSettings(); const { data: whoami, @@ -24,7 +14,11 @@ export default function OptionsPage() { error: whoAmIError, } = api.users.whoami.useQuery(); - const invalidateWhoami = api.useUtils().users.whoami.invalidate; + const invalidateWhoami = api.useUtils().users.whoami.refetch; + + useEffect(() => { + invalidateWhoami(); + }, [settings, invalidateWhoami]); let loggedInMessage: React.ReactNode; if (whoAmIError) { @@ -43,65 +37,20 @@ export default function OptionsPage() { loggedInMessage = {whoami.name}; } - const onSave = () => { - if (apiKeyRef.current?.value == "") { - setError("API Key can't be empty"); - return; - } - if (addressRef.current?.value == "") { - setError("Server addres can't be empty"); - return; - } - setSettings({ - apiKey: apiKeyRef.current?.value || "", - address: addressRef.current?.value || "https://demo.hoarder.app", - }); - setTimeout(() => { - setIsSaved(false); - }, 2000); - setIsSaved(true); - invalidateWhoami(); - }; - const onLogout = () => { setSettings((s) => ({ ...s, apiKey: "" })); invalidateWhoami(); + navigate("/notconfigured"); }; return (
Settings
-

{error}

Logged in as: {loggedInMessage}
-
- - - setSettingsInput((s) => ({ ...s, address: e.target.value })) - } - className="h-8 flex-1 rounded-lg border border-gray-300 p-2" - /> -
-
- - - setSettingsInput((s) => ({ ...s, apiKey: e.target.value })) - } - className="h-8 flex-1 rounded-lg border border-gray-300 p-2" - /> -
- diff --git a/packages/browser-extension/src/SignInPage.tsx b/packages/browser-extension/src/SignInPage.tsx new file mode 100644 index 00000000..203480f7 --- /dev/null +++ b/packages/browser-extension/src/SignInPage.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { api } from "./utils/trpc"; +import usePluginSettings from "./utils/settings"; +import { useNavigate } from "react-router-dom"; + +export default function SignInPage() { + const navigate = useNavigate(); + const { setSettings } = usePluginSettings(); + + const { + mutate: login, + error, + isPending, + } = api.apiKeys.exchange.useMutation({ + onSuccess: (resp) => { + setSettings((s) => ({ ...s, apiKey: resp.key })); + navigate("/options"); + }, + onError: () => {}, + }); + + const [formData, setFormData] = useState<{ + email: string; + password: string; + }>({ + email: "", + password: "", + }); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const randStr = (Math.random() + 1).toString(36).substring(5); + login({ ...formData, keyName: `Browser extension: (${randStr})` }); + }; + + let errorMessage = ""; + if (error) { + if (error.data?.code == "UNAUTHORIZED") { + errorMessage = "Wrong username or password"; + } else { + errorMessage = error.message; + } + } + + return ( +
+

Login

+

{errorMessage}

+
+
+ + + setFormData((f) => ({ ...f, email: e.target.value })) + } + type="text" + name="email" + className="h-8 flex-1 rounded-lg border border-gray-300 p-2" + /> +
+
+ + + setFormData((f) => ({ + ...f, + password: e.target.value, + })) + } + type="password" + name="password" + className="h-8 flex-1 rounded-lg border border-gray-300 p-2" + /> +
+ +
+
+ ); +} diff --git a/packages/browser-extension/src/main.tsx b/packages/browser-extension/src/main.tsx index cebbde34..085a5a69 100644 --- a/packages/browser-extension/src/main.tsx +++ b/packages/browser-extension/src/main.tsx @@ -1,4 +1,3 @@ -import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; import OptionsPage from "./OptionsPage.tsx"; @@ -9,9 +8,10 @@ import { HashRouter, Routes, Route } from "react-router-dom"; import Layout from "./Layout.tsx"; import SavePage from "./SavePage.tsx"; import BookmarkDeletedPage from "./BookmarkDeletedPage.tsx"; +import SignInPage from "./SignInPage.tsx"; -ReactDOM.createRoot(document.getElementById("root")!).render( - +function App() { + return (
@@ -29,9 +29,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> + } />
-
, -); + ); +} + +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/packages/browser-extension/src/utils/providers.tsx b/packages/browser-extension/src/utils/providers.tsx index cf3ca804..d21714b6 100644 --- a/packages/browser-extension/src/utils/providers.tsx +++ b/packages/browser-extension/src/utils/providers.tsx @@ -1,35 +1,38 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { api } from "./trpc"; -import usePluginSettings from "./settings"; +import usePluginSettings, { getPluginSettings } from "./settings"; import superjson from "superjson"; +function getTRPCClient(address: string) { + return api.createClient({ + links: [ + httpBatchLink({ + url: `${address}/api/trpc`, + async headers() { + const settings = await getPluginSettings(); + return { + Authorization: `Bearer ${settings.apiKey}`, + }; + }, + transformer: superjson, + }), + ], + }); +} + export function Providers({ children }: { children: React.ReactNode }) { - const [settings] = usePluginSettings(); + const { settings } = usePluginSettings(); const [queryClient] = useState(() => new QueryClient()); - const getTrpcClient = useCallback(() => { - return api.createClient({ - links: [ - httpBatchLink({ - url: `${settings.address}/api/trpc`, - headers() { - return { - Authorization: `Bearer ${settings.apiKey}`, - }; - }, - transformer: superjson, - }), - ], - }); - }, [settings]); - - const [trpcClient, setTrpcClient] = useState(getTrpcClient()); + const [trpcClient, setTrpcClient] = useState< + ReturnType + >(getTRPCClient(settings.address)); useEffect(() => { - setTrpcClient(getTrpcClient()); - }, [getTrpcClient]); + setTrpcClient(getTRPCClient(settings.address)); + }, [settings.address]); return ( diff --git a/packages/browser-extension/src/utils/settings.ts b/packages/browser-extension/src/utils/settings.ts index ee7f0722..37f474c0 100644 --- a/packages/browser-extension/src/utils/settings.ts +++ b/packages/browser-extension/src/utils/settings.ts @@ -6,8 +6,17 @@ export type Settings = { }; export default function usePluginSettings() { - return useChromeStorageSync("settings", { - apiKey: "", - address: "", - } as Settings); + const [settings, setSettings, _1, _2, isInit] = useChromeStorageSync( + "settings", + { + apiKey: "", + address: "", + } as Settings, + ); + + return { settings, setSettings, isPending: isInit }; +} + +export async function getPluginSettings() { + return (await chrome.storage.sync.get("settings")).settings as Settings; } diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts index d13f87fb..3093b433 100644 --- a/packages/trpc/routers/apiKeys.ts +++ b/packages/trpc/routers/apiKeys.ts @@ -1,8 +1,16 @@ -import { generateApiKey } from "../auth"; -import { authedProcedure, router } from "../index"; +import { generateApiKey, validatePassword } from "../auth"; +import { authedProcedure, publicProcedure, router } from "../index"; import { z } from "zod"; import { apiKeys } from "@hoarder/db/schema"; import { eq, and } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; + +const zApiKeySchema = z.object({ + id: z.string(), + name: z.string(), + key: z.string(), + createdAt: z.date(), +}); export const apiKeysAppRouter = router({ create: authedProcedure @@ -11,14 +19,7 @@ export const apiKeysAppRouter = router({ name: z.string(), }), ) - .output( - z.object({ - id: z.string(), - name: z.string(), - key: z.string(), - createdAt: z.date(), - }), - ) + .output(zApiKeySchema) .mutation(async ({ input, ctx }) => { return await generateApiKey(input.name, ctx.user.id); }), @@ -58,4 +59,24 @@ export const apiKeysAppRouter = router({ }); return { keys: resp }; }), + // Exchange the username and password with an API key. + // Homemade oAuth. This is used by the extension. + exchange: publicProcedure + .input( + z.object({ + keyName: z.string(), + email: z.string(), + password: z.string(), + }), + ) + .output(zApiKeySchema) + .mutation(async ({ input }) => { + let user; + try { + user = await validatePassword(input.email, input.password); + } catch (e) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return await generateApiKey(input.keyName, user.id); + }), }); diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 0aa37287..bda43a58 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -18,7 +18,7 @@ const nextConfig = withPWA({ // Allow for specific domains to have access or * for all { key: "Access-Control-Allow-Origin", - value: "*", + value: "chrome-extension://olmdabfolepgfmjhmikngmfekcdgjinp", }, // Allows for specific methods accepted { @@ -30,6 +30,10 @@ const nextConfig = withPWA({ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization", }, + { + key: "Access-Control-Allow-Credentials", + value: "true", + }, ], }, ]; -- cgit v1.2.3-70-g09d2