chore: retire access token field in extension

This commit is contained in:
johnnyjoy
2024-08-22 16:46:42 +08:00
parent 966e1d9ce3
commit 080929faed
16 changed files with 26 additions and 889 deletions

View File

@@ -1,187 +0,0 @@
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useStorageContext } from "@/context";
import { useShortcutStore } from "@/stores";
import type { Visibility } from "@/types/proto/api/v1/common";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon";
interface State {
name: string;
title: string;
link: string;
}
const CreateShortcutButton = () => {
const context = useStorageContext();
const shortcutStore = useShortcutStore();
const [state, setState] = useState<State>({
name: "",
title: "",
link: "",
});
const [tag, setTag] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
if (showModal) {
document.body.style.height = "384px";
} else {
document.body.style.height = "auto";
}
}, [showModal]);
const handleCreateShortcutButtonClick = async () => {
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
if (tabs.length === 0) {
toast.error("No active tab found");
return;
}
const tab = tabs[0];
setState((state) => ({
...state,
name: "",
title: tab.title || "",
link: tab.url || "",
}));
setShowModal(true);
});
};
const generateRandomName = () => {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let name = "";
for (let i = 0; i < 8; i++) {
name += chars.charAt(Math.floor(Math.random() * chars.length));
}
setState((state) => ({
...state,
name,
}));
};
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => ({
...state,
name: e.target.value,
}));
};
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => ({
...state,
title: e.target.value,
}));
};
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => ({
...state,
link: e.target.value,
}));
};
const handleTagsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setTag(text);
};
const handleSaveBtnClick = async () => {
if (isLoading) {
return;
}
if (!state.name) {
toast.error("Name is required");
return;
}
setIsLoading(true);
try {
const tags = tag.split(" ").filter(Boolean);
await shortcutStore.createShortcut(
context.instanceUrl,
context.accessToken,
Shortcut.fromPartial({
name: state.name,
title: state.title,
link: state.link,
tags,
visibility: context.defaultVisibility as Visibility,
}),
);
toast.success("Shortcut created successfully");
setShowModal(false);
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
}
setIsLoading(false);
};
return (
<>
<IconButton color="primary" variant="solid" size="sm" onClick={() => handleCreateShortcutButtonClick()}>
<Icon.Plus className="w-5 h-auto" />
</IconButton>
<Modal container={() => document.body} open={showModal} onClose={() => setShowModal(false)}>
<ModalDialog className="w-3/4">
<div className="w-full flex flex-row justify-between items-center mb-2">
<span className="text-base font-medium">Create Shortcut</span>
<Button size="sm" variant="plain" onClick={() => setShowModal(false)}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
<div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Name</span>
<Input
className="grow"
type="text"
placeholder="Unique shortcut name"
value={state.name}
onChange={handleNameInputChange}
endDecorator={
<IconButton size="sm" onClick={generateRandomName}>
<Icon.RefreshCcw className="w-4 h-auto cursor-pointer" />
</IconButton>
}
/>
</div>
<div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Title</span>
<Input className="grow" type="text" placeholder="Shortcut title" value={state.title} onChange={handleTitleInputChange} />
</div>
<div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Link</span>
<Input
className="grow"
type="text"
placeholder="e.g., https://github.com/yourselfhosted/slash"
value={state.link}
onChange={handleLinkInputChange}
/>
</div>
<div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Tags</span>
<Input className="grow" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
</div>
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
Cancel
</Button>
<Button color="primary" disabled={isLoading} loading={isLoading} onClick={handleSaveBtnClick}>
Save
</Button>
</div>
</div>
</ModalDialog>
</Modal>
</>
);
};
export default CreateShortcutButton;

View File

@@ -1,36 +0,0 @@
import { IconButton } from "@mui/joy";
import { useEffect } from "react";
import { toast } from "react-hot-toast";
import { useStorageContext } from "@/context";
import { useShortcutStore } from "@/stores";
import Icon from "./Icon";
const PullShortcutsButton = () => {
const context = useStorageContext();
const shortcutStore = useShortcutStore();
useEffect(() => {
if (context.instanceUrl && context.accessToken) {
handlePullShortcuts(true);
}
}, [context]);
const handlePullShortcuts = async (silence = false) => {
try {
await shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
if (!silence) {
toast.success("Shortcuts pulled");
}
} catch (error) {
toast.error("Failed to pull shortcuts, error: " + error.message);
}
};
return (
<IconButton color="neutral" variant="plain" size="sm" onClick={() => handlePullShortcuts()}>
<Icon.RefreshCcw className="w-4 h-auto" />
</IconButton>
);
};
export default PullShortcutsButton;

View File

@@ -1,66 +0,0 @@
import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames";
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon";
interface Props {
shortcut: Shortcut;
}
const ShortcutView = (props: Props) => {
const { shortcut } = props;
const [domain] = useStorage<string>("instance_url", "");
const favicon = getFaviconWithGoogleS2(shortcut.link);
const handleShortcutLinkClick = () => {
const shortcutLink = `${domain}/s/${shortcut.name}`;
chrome.tabs.create({ url: shortcutLink });
};
return (
<>
<div
className={classNames(
"group w-auto px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800",
)}
>
<div className="w-full flex flex-row justify-start items-center">
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
{favicon ? (
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
) : (
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
)}
</span>
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
<div className="w-full flex flex-row justify-start items-center">
<button
className={classNames(
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline",
)}
onClick={handleShortcutLinkClick}
>
<div className="truncate">
<span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? (
<span className="text-gray-500">({shortcut.name})</span>
) : (
<>
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
</>
)}
</div>
<span className="ml-1 cursor-pointer shrink-0 opacity-80">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span>
</button>
</div>
</div>
</div>
</div>
</>
);
};
export default ShortcutView;

View File

@@ -1,26 +0,0 @@
import classNames from "classnames";
import { useShortcutStore } from "@/stores";
import Icon from "./Icon";
import ShortcutView from "./ShortcutView";
const ShortcutsContainer = () => {
const shortcuts = useShortcutStore().getShortcutList();
return (
<div>
<div className="w-full flex flex-row justify-start items-center mb-4">
<a className="bg-blue-100 dark:bg-blue-500 dark:opacity-70 py-2 px-3 rounded-full border dark:border-blue-600 flex flex-row justify-start items-center cursor-pointer shadow">
<Icon.AlertCircle className="w-4 h-auto" />
<span className="mx-1 text-sm">Please make sure you have signed in your instance.</span>
</a>
</div>
<div className={classNames("w-full flex flex-row justify-start items-start flex-wrap gap-2")}>
{shortcuts.map((shortcut) => {
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
})}
</div>
</div>
);
};
export default ShortcutsContainer;

View File

@@ -1,22 +1,13 @@
import { createContext, useContext } from "react";
import { Visibility } from "@/types/proto/api/v1/common";
interface Context {
instanceUrl?: string;
accessToken?: string;
defaultVisibility: string;
setInstanceUrl: (instanceUrl: string) => void;
setAccessToken: (accessToken: string) => void;
setDefaultVisibility: (visibility: string) => void;
}
export const StorageContext = createContext<Context>({
instanceUrl: undefined,
accessToken: undefined,
defaultVisibility: Visibility.PRIVATE,
setInstanceUrl: () => {},
setAccessToken: () => {},
setDefaultVisibility: () => {},
});
const useStorageContext = () => {

View File

@@ -1,6 +1,5 @@
import { Storage } from "@plasmohq/storage";
import { useEffect, useState } from "react";
import { Visibility } from "@/types/proto/api/v1/common";
import { StorageContext } from "./context";
interface Props {
@@ -10,27 +9,13 @@ interface Props {
const StorageContextProvider = ({ children }: Props) => {
const storage = new Storage();
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
const [defaultVisibility, setDefaultVisibility] = useState<Visibility>(Visibility.PRIVATE);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
(async () => {
let instanceUrl = await storage.get("instance_url");
const accessToken = await storage.get("access_token");
const defaultVisibility = (await storage.get("default_visibility")) as Visibility;
// Migrate domain to instance_url.
const domain = await storage.get("domain");
if (domain) {
instanceUrl = domain;
await storage.remove("domain");
await storage.set("instance_url", instanceUrl);
}
const instanceUrl = await storage.get("instance_url");
setInstanceUrl(instanceUrl);
setAccessToken(accessToken);
setDefaultVisibility(defaultVisibility);
setIsInitialized(true);
})();
@@ -38,12 +23,6 @@ const StorageContextProvider = ({ children }: Props) => {
instance_url: (c) => {
setInstanceUrl(c.newValue);
},
access_token: (c) => {
setAccessToken(c.newValue);
},
default_visibility: (c) => {
setDefaultVisibility(c.newValue as Visibility);
},
});
}, []);
@@ -51,11 +30,7 @@ const StorageContextProvider = ({ children }: Props) => {
<StorageContext.Provider
value={{
instanceUrl,
accessToken,
defaultVisibility,
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
setAccessToken: (accessToken: string) => storage.set("access_token", accessToken),
setDefaultVisibility: (visibility: Visibility) => storage.set("default_visibility", visibility),
}}
>
{isInitialized && children}

View File

@@ -1,8 +0,0 @@
export const getFaviconWithGoogleS2 = (url: string) => {
try {
const urlObject = new URL(url);
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
} catch (error) {
return undefined;
}
};

View File

@@ -1,43 +0,0 @@
import { useColorScheme } from "@mui/joy";
import { useEffect } from "react";
const useColorTheme = () => {
const { mode: colorTheme, setMode: setColorTheme } = useColorScheme();
useEffect(() => {
const root = document.documentElement;
if (colorTheme === "light") {
root.classList.remove("dark");
} else if (colorTheme === "dark") {
root.classList.add("dark");
} else {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
if (darkMediaQuery.matches) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
if (e.matches) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
};
try {
darkMediaQuery.addEventListener("change", handleColorSchemeChange);
} catch (error) {
console.error("failed to initial color scheme listener", error);
}
return () => {
darkMediaQuery.removeEventListener("change", handleColorSchemeChange);
};
}
}, [colorTheme]);
return { colorTheme, setColorTheme };
};
export default useColorTheme;

View File

@@ -1,51 +1,24 @@
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
import { Button, CssVarsProvider, Input } from "@mui/joy";
import { useEffect, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import { useShortcutStore } from "@/stores";
import Icon from "./components/Icon";
import Logo from "./components/Logo";
import PullShortcutsButton from "./components/PullShortcutsButton";
import ShortcutsContainer from "./components/ShortcutsContainer";
import { StorageContextProvider, useStorageContext } from "./context";
import useColorTheme from "./hooks/useColorTheme";
import "./style.css";
import { Visibility } from "./types/proto/api/v1/common";
interface SettingState {
domain: string;
accessToken: string;
instanceUrl: string;
}
const colorThemeOptions = [
{
value: "system",
label: "System",
},
{
value: "light",
label: "Light",
},
{
value: "dark",
label: "Dark",
},
];
const IndexOptions = () => {
const { colorTheme, setColorTheme } = useColorTheme();
const context = useStorageContext();
const [settingState, setSettingState] = useState<SettingState>({
domain: context.instanceUrl || "",
accessToken: context.accessToken || "",
instanceUrl: context.instanceUrl || "",
});
const shortcutStore = useShortcutStore();
const shortcuts = shortcutStore.getShortcutList();
const isInitialized = context.instanceUrl && context.accessToken;
useEffect(() => {
setSettingState({
domain: context.instanceUrl || "",
accessToken: context.accessToken || "",
instanceUrl: context.instanceUrl || "",
});
}, [context]);
@@ -57,19 +30,10 @@ const IndexOptions = () => {
};
const handleSaveSetting = () => {
context.setInstanceUrl(settingState.domain);
context.setAccessToken(settingState.accessToken);
context.setInstanceUrl(settingState.instanceUrl);
toast.success("Setting saved");
};
const handleSelectColorTheme = async (colorTheme: string) => {
setColorTheme(colorTheme as any);
};
const handleDefaultVisibilitySelect = (value: Visibility) => {
context.setDefaultVisibility(value);
};
return (
<div className="w-full px-4">
<div className="w-full flex flex-row justify-center items-center">
@@ -112,21 +76,8 @@ const IndexOptions = () => {
className="w-full"
type="text"
placeholder="The url of your Slash instance. e.g., https://slash.example.com"
value={settingState.domain}
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="mb-2 text-base dark:text-gray-400">Access Token</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="An available access token of your account."
value={settingState.accessToken}
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
value={settingState.instanceUrl}
onChange={(e) => setPartialSettingState({ instanceUrl: e.target.value })}
/>
</div>
</div>
@@ -134,50 +85,7 @@ const IndexOptions = () => {
<div className="w-full mt-6 flex flex-row justify-end">
<Button onClick={handleSaveSetting}>Save</Button>
</div>
<Divider className="!my-6" />
<p className="text-base font-semibold leading-6 mb-2 text-gray-900 dark:text-gray-500">Preference</p>
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center gap-x-1">
<span className="dark:text-gray-400">Color Theme</span>
</div>
<Select defaultValue={colorTheme} onChange={(_, value) => handleSelectColorTheme(value)}>
{colorThemeOptions.map((option) => {
return (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
);
})}
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center gap-x-1">
<span className="dark:text-gray-400">Default Visibility</span>
</div>
<Select defaultValue={context.defaultVisibility} onChange={(_, value) => handleDefaultVisibilitySelect(value as Visibility)}>
<Option value={Visibility.PRIVATE}>Private</Option>
<Option value={Visibility.WORKSPACE}>Workspace</Option>
<Option value={Visibility.PUBLIC}>Public</Option>
</Select>
</div>
</div>
{isInitialized && (
<>
<Divider className="!my-6" />
<h2 className="flex flex-row justify-start items-center mb-4">
<span className="text-lg dark:text-gray-400">Shortcuts</span>
<span className="text-gray-500 mr-1">({shortcuts.length})</span>
<PullShortcutsButton />
</h2>
<ShortcutsContainer />
</>
)}
</div>
</div>
);

View File

@@ -1,30 +1,13 @@
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
import { useEffect } from "react";
import { Button, CssVarsProvider, IconButton } from "@mui/joy";
import { Toaster } from "react-hot-toast";
import CreateShortcutButton from "@/components/CreateShortcutButton";
import Icon from "@/components/Icon";
import Logo from "@/components/Logo";
import PullShortcutsButton from "@/components/PullShortcutsButton";
import ShortcutsContainer from "@/components/ShortcutsContainer";
import { useShortcutStore } from "@/stores";
import { StorageContextProvider, useStorageContext } from "./context";
import useColorTheme from "./hooks/useColorTheme";
import "./style.css";
const IndexPopup = () => {
useColorTheme();
const context = useStorageContext();
const shortcutStore = useShortcutStore();
const shortcuts = shortcutStore.getShortcutList();
const isInitialized = context.instanceUrl && context.accessToken;
useEffect(() => {
if (!isInitialized) {
return;
}
shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
}, [isInitialized]);
const isInitialized = context.instanceUrl;
const handleSettingButtonClick = () => {
chrome.runtime.openOptionsPage();
@@ -41,31 +24,23 @@ const IndexPopup = () => {
<div className="flex flex-row justify-start items-center dark:text-gray-400">
<Logo className="w-6 h-auto mr-1" />
<span className="">Slash</span>
{isInitialized && (
<>
<span className="mx-1 text-gray-400">/</span>
<span>Shortcuts</span>
<span className="text-gray-500 mr-0.5">({shortcuts.length})</span>
<PullShortcutsButton />
</>
)}
</div>
<div>{isInitialized && <CreateShortcutButton />}</div>
</div>
<div className="w-full mt-4">
{isInitialized ? (
<>
{shortcuts.length !== 0 ? (
<ShortcutsContainer />
) : (
<div className="w-full flex flex-col justify-center items-center">
<p>No shortcut found.</p>
</div>
)}
<Divider className="!mt-4 !mb-2 opacity-40" />
<p className="w-full mb-2">
<span>Your instance URL is </span>
<a
className="inline-flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
href={context.instanceUrl}
target="_blank"
>
<span className="mr-1">{context.instanceUrl}</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
</p>
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center">
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
@@ -82,22 +57,12 @@ const IndexPopup = () => {
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" />
</IconButton>
</div>
<div className="flex flex-row justify-end items-center">
<a
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
href={context.instanceUrl}
target="_blank"
>
<span className="mr-1">Go to my Slash</span>
<Icon.ExternalLink className="w-4 h-auto" />
</a>
</div>
</div>
</>
) : (
<div className="w-full flex flex-col justify-start items-center">
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
<p className="dark:text-gray-400">Please set your instance URL and access token first.</p>
<p className="dark:text-gray-400">Please set your instance URL first.</p>
<div className="w-full flex flex-row justify-center items-center py-4">
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto mr-1" /> Go to Setting

View File

@@ -1,3 +0,0 @@
import useShortcutStore from "./shortcut";
export { useShortcutStore };

View File

@@ -1,50 +0,0 @@
import axios from "axios";
import { create } from "zustand";
import { combine } from "zustand/middleware";
import { ListShortcutsResponse, Shortcut } from "@/types/proto/api/v1/shortcut_service";
interface State {
shortcutMapById: Record<number, Shortcut>;
}
const getDefaultState = (): State => {
return {
shortcutMapById: {},
};
};
const useShortcutStore = create(
combine(getDefaultState(), (set, get) => ({
fetchShortcutList: async (instanceUrl: string, accessToken: string) => {
const {
data: { shortcuts },
} = await axios.get<ListShortcutsResponse>(`${instanceUrl}/api/v1/shortcuts`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const shortcutMap = get().shortcutMapById;
shortcuts.forEach((shortcut) => {
shortcutMap[shortcut.id] = shortcut;
});
set({ shortcutMapById: shortcutMap });
return shortcuts;
},
getShortcutList: () => {
return Object.values(get().shortcutMapById);
},
createShortcut: async (instanceUrl: string, accessToken: string, create: Shortcut) => {
const { data: shortcut } = await axios.post<Shortcut>(`${instanceUrl}/api/v1/shortcuts`, create, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const shortcutMap = get().shortcutMapById;
shortcutMap[shortcut.id] = shortcut;
set({ shortcutMapById: shortcutMap });
return shortcut;
},
})),
);
export default useShortcutStore;