diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-05 18:27:38 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-03-05 18:44:15 +0000 |
| commit | e6570dd7ec5d7aea3c3d0c0235476a1227bbe71f (patch) | |
| tree | 69ee48d5dc6a5e5b95a1ff7f91ea90c8a66e97e4 | |
| parent | 56c5236245359987e7a729979de3892bbee70852 (diff) | |
| download | karakeep-e6570dd7ec5d7aea3c3d0c0235476a1227bbe71f.tar.zst | |
extension: Instead of manually creating api keys, let users exchange their username passwords for one
| -rw-r--r-- | packages/browser-extension/src/BookmarkSavedPage.tsx | 66 | ||||
| -rw-r--r-- | packages/browser-extension/src/Layout.tsx | 2 | ||||
| -rw-r--r-- | packages/browser-extension/src/NotConfiguredPage.tsx | 35 | ||||
| -rw-r--r-- | packages/browser-extension/src/OptionsPage.tsx | 71 | ||||
| -rw-r--r-- | packages/browser-extension/src/SignInPage.tsx | 87 | ||||
| -rw-r--r-- | packages/browser-extension/src/main.tsx | 13 | ||||
| -rw-r--r-- | packages/browser-extension/src/utils/providers.tsx | 47 | ||||
| -rw-r--r-- | packages/browser-extension/src/utils/settings.ts | 17 | ||||
| -rw-r--r-- | packages/trpc/routers/apiKeys.ts | 41 | ||||
| -rw-r--r-- | packages/web/next.config.mjs | 6 |
10 files changed, 247 insertions, 138 deletions
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 <div>NOT FOUND</div>; } return ( - <div className="flex items-center justify-between gap-2"> - <p className="text-lg">Bookmarked!</p> - <div className="flex gap-2"> - <a - className="flex gap-2 rounded-md p-3 text-black hover:text-black" - target="_blank" - href={`${settings.address}/dashboard/preview/${bookmarkId}`} - > - <ArrowUpRightFromSquare className="my-auto" size="20" /> - <p className="my-auto">Open</p> - </a> - <a - onClick={() => deleteBookmark({ bookmarkId: bookmarkId })} - className="flex gap-2 text-red-500 hover:text-red-500" - href="#" - > - {!isPending ? ( - <> - <Trash className="my-auto" size="20" /> - <p className="my-auto">Delete</p> - </> - ) : ( - <span className="m-auto"> - <Spinner /> - </span> - )} - </a> + <div className="flex flex-col gap-2"> + {error && <p className="text-red-500">{error}</p>} + <div className="flex items-center justify-between gap-2"> + <p className="text-lg">Bookmarked!</p> + <div className="flex gap-2"> + <Link + className="flex gap-2 rounded-md p-3 text-black hover:text-black" + target="_blank" + to={`${settings.address}/dashboard/preview/${bookmarkId}`} + > + <ArrowUpRightFromSquare className="my-auto" size="20" /> + <p className="my-auto">Open</p> + </Link> + <button + onClick={() => deleteBookmark({ bookmarkId: bookmarkId })} + className="flex gap-2 bg-transparent text-red-500 hover:text-red-500" + > + {!isPending ? ( + <> + <Trash className="my-auto" size="20" /> + <p className="my-auto">Delete</p> + </> + ) : ( + <span className="m-auto"> + <Spinner /> + </span> + )} + </button> + </div> </div> </div> ); 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 <div className="p-4">Loading ... </div>; } 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 ( <div className="flex flex-col space-y-2"> <span>To use the plugin, you need to configure it first.</span> - <button - className="bg-black text-white" - onClick={() => navigate("/options")} - > + <p className="text-red-500">{error}</p> + <div className="flex gap-2"> + <label className="my-auto">Server Address</label> + <input + name="address" + value={serverAddress} + className="h-8 flex-1 rounded-lg border border-gray-300 p-2" + onChange={(e) => setServerAddress(e.target.value)} + /> + </div> + <button className="bg-black text-white" onClick={onSave}> Configure </button> </div> 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<HTMLInputElement>(null); - const addressRef = useRef<HTMLInputElement>(null); - - const [settingsInput, setSettingsInput] = useState<typeof settings>(settings); - - useEffect(() => { - setSettingsInput(settings); - }, [settings]); - - const [isSaved, setIsSaved] = useState(false); - const [error, setError] = useState<string | null>(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 = <span>{whoami.name}</span>; } - 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 ( <div className="flex flex-col space-y-2"> <span className="text-lg">Settings</span> <hr /> - <p className="text-red-500">{error}</p> <div className="flex gap-2"> <span className="my-auto">Logged in as:</span> {loggedInMessage} </div> - <div className="flex space-x-2"> - <label className="m-auto h-full">Server Address</label> - <input - ref={addressRef} - value={settingsInput.address} - onChange={(e) => - setSettingsInput((s) => ({ ...s, address: e.target.value })) - } - className="h-8 flex-1 rounded-lg border border-gray-300 p-2" - /> - </div> - <div className="flex space-x-2"> - <label className="m-auto h-full">API Key</label> - <input - ref={apiKeyRef} - value={settingsInput.apiKey} - onChange={(e) => - setSettingsInput((s) => ({ ...s, apiKey: e.target.value })) - } - className="h-8 flex-1 rounded-lg border border-gray-300 p-2" - /> - </div> - <button className="rounded-lg border border-gray-200" onClick={onSave}> - {isSaved ? "Saved!" : "Save"} - </button> <button className="rounded-lg border border-gray-200" onClick={onLogout}> Logout </button> 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 ( + <div className="flex flex-col space-y-2"> + <p className="text-lg">Login</p> + <p className="text-red-500">{errorMessage}</p> + <form className="flex flex-col gap-y-2" onSubmit={onSubmit}> + <div className="flex flex-col gap-y-1"> + <label className="my-auto font-bold">Email</label> + <input + value={formData.email} + onChange={(e) => + setFormData((f) => ({ ...f, email: e.target.value })) + } + type="text" + name="email" + className="h-8 flex-1 rounded-lg border border-gray-300 p-2" + /> + </div> + <div className="flex flex-col gap-y-1"> + <label className="my-auto font-bold">Password</label> + <input + value={formData.password} + onChange={(e) => + setFormData((f) => ({ + ...f, + password: e.target.value, + })) + } + type="password" + name="password" + className="h-8 flex-1 rounded-lg border border-gray-300 p-2" + /> + </div> + <button + className="bg-black text-white" + type="submit" + disabled={isPending} + > + Login + </button> + </form> + </div> + ); +} 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( - <React.StrictMode> +function App() { + return ( <div className="w-96 p-4"> <Providers> <HashRouter> @@ -29,9 +29,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render( </Route> <Route path="/notconfigured" element={<NotConfiguredPage />} /> <Route path="/options" element={<OptionsPage />} /> + <Route path="/signin" element={<SignInPage />} /> </Routes> </HashRouter> </Providers> </div> - </React.StrictMode>, -); + ); +} + +ReactDOM.createRoot(document.getElementById("root")!).render(<App />); 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<typeof getTRPCClient> + >(getTRPCClient(settings.address)); useEffect(() => { - setTrpcClient(getTrpcClient()); - }, [getTrpcClient]); + setTrpcClient(getTRPCClient(settings.address)); + }, [settings.address]); return ( <api.Provider client={trpcClient} queryClient={queryClient}> 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", + }, ], }, ]; |
