aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-03-05 18:27:38 +0000
committerMohamedBassem <me@mbassem.com>2024-03-05 18:44:15 +0000
commite6570dd7ec5d7aea3c3d0c0235476a1227bbe71f (patch)
tree69ee48d5dc6a5e5b95a1ff7f91ea90c8a66e97e4
parent56c5236245359987e7a729979de3892bbee70852 (diff)
downloadkarakeep-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.tsx66
-rw-r--r--packages/browser-extension/src/Layout.tsx2
-rw-r--r--packages/browser-extension/src/NotConfiguredPage.tsx35
-rw-r--r--packages/browser-extension/src/OptionsPage.tsx71
-rw-r--r--packages/browser-extension/src/SignInPage.tsx87
-rw-r--r--packages/browser-extension/src/main.tsx13
-rw-r--r--packages/browser-extension/src/utils/providers.tsx47
-rw-r--r--packages/browser-extension/src/utils/settings.ts17
-rw-r--r--packages/trpc/routers/apiKeys.ts41
-rw-r--r--packages/web/next.config.mjs6
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",
+ },
],
},
];