diff --git a/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx b/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx new file mode 100644 index 000000000..faa51b975 --- /dev/null +++ b/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx @@ -0,0 +1,419 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { useRef, useState, useEffect } from "react"; +import { Box, Button, Flex, Input, Text, Select } from "@theme-ui/components"; +import { formatDate, InboxApiKey } from "@notesnook/core"; +import { db } from "../../../common/db"; +import { showToast } from "../../../utils/toast"; +import { + Loading, + Copy, + Trash, + Check, + PasswordInvisible +} from "../../../components/icons"; +import Field from "../../../components/field"; +import { BaseDialogProps, DialogManager } from "../../../common/dialog-manager"; +import Dialog from "../../../components/dialog"; +import { usePromise } from "@notesnook/common"; +import { ConfirmDialog } from "../../confirm"; +import { PromptDialog } from "../../prompt"; +import { showPasswordDialog } from "../../password-dialog"; +import { strings } from "@notesnook/intl"; + +export function InboxApiKeys() { + const apiKeysPromise = usePromise(() => db.inboxApiKeys.get(), []); + + if (apiKeysPromise.status === "pending") { + return ( + + + Loading API keys... + + ); + } + + if (apiKeysPromise.status === "rejected") { + return ( + + + Failed to load API keys. Please try again. + + + + ); + } + + const apiKeys = apiKeysPromise.value || []; + + return ( + + + + + API Keys + + + + + {apiKeys.length === 0 ? ( + + + Create your first api key to get started. + + + ) : ( + + {apiKeys.map((key, i) => ( + apiKeysPromise.refresh()} + isAtEnd={i === apiKeys.length - 1} + /> + ))} + + )} + + + ); +} + +type ApiKeyItemProps = { + apiKey: InboxApiKey; + onRevoke: () => void; + isAtEnd: boolean; +}; + +const VIEW_KEY_TIMEOUT = 15; + +function ApiKeyItem({ apiKey, onRevoke, isAtEnd }: ApiKeyItemProps) { + const [copied, setCopied] = useState(false); + const [viewing, setViewing] = useState(false); + const [isRevoking, setIsRevoking] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(VIEW_KEY_TIMEOUT); + + async function viewKey() { + const result = await showPasswordDialog({ + title: "Authenticate to view API key", + inputs: { + password: { + label: strings.accountPassword(), + autoComplete: "current-password" + } + }, + validate: ({ password }) => { + return db.user.verifyPassword(password); + } + }); + if (!result) return; + + setViewing(true); + } + + async function copyToClipboard() { + if (!viewing) return; + try { + await navigator.clipboard.writeText(apiKey.key); + setCopied(true); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + showToast("error", "Failed to copy to clipboard"); + } + } + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => { + setCopied(false); + }, 1000); + return () => clearTimeout(timer); + } + }, [copied]); + + useEffect(() => { + if (viewing) { + setSecondsLeft(VIEW_KEY_TIMEOUT); + const interval = setInterval(() => { + setSecondsLeft((prev) => { + if (prev <= 1) { + setViewing(false); + return VIEW_KEY_TIMEOUT; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(interval); + } + }, [viewing]); + + const isApiKeyExpired = Date.now() > apiKey.expiryDate; + + return ( + + + + + + {apiKey.name} + + {isApiKeyExpired && ( + + EXPIRED + + )} + + + + + {apiKey.lastUsedAt + ? `Last used on ${formatDate(apiKey.lastUsedAt)}` + : "Never used"} + + + Created on {formatDate(apiKey.dateCreated)} + + + {apiKey.expiryDate === -1 + ? "Never expires" + : `${isApiKeyExpired ? "Expired" : "Expires"} on + ${formatDate(apiKey.expiryDate)}`} + + + + + {!viewing && ( + + )} + {viewing && ( + <> + + {secondsLeft}s + + + + )} + + + + ); +} + +type AddApiKeyDialogProps = BaseDialogProps & { + onAdd: () => void; +}; + +const EXPIRY_OPTIONS = [ + { label: "1 day", value: 24 * 60 * 60 * 1000 }, + { label: "1 week", value: 7 * 24 * 60 * 60 * 1000 }, + { label: "1 month", value: 30 * 24 * 60 * 60 * 1000 }, + { label: "1 year", value: 365 * 24 * 60 * 60 * 1000 }, + { label: "Never", value: -1 } +]; + +const AddApiKeyDialog = DialogManager.register(function AddApiKeyDialog( + props: AddApiKeyDialogProps +) { + const { onClose, onAdd } = props; + const inputRef = useRef(null); + const [isCreating, setIsCreating] = useState(false); + const [selectedExpiry, setSelectedExpiry] = useState(EXPIRY_OPTIONS[2].value); + + async function onSubmit() { + try { + setIsCreating(true); + if (!inputRef.current || !inputRef.current.value.trim()) { + showToast("error", "Please enter a key name"); + return; + } + await db.inboxApiKeys.create(inputRef.current.value, selectedExpiry); + onAdd(); + onClose(true); + } catch (error) { + console.error("Failed to create inbox API key:", error); + const message = error instanceof Error ? error.message : ""; + showToast( + "error", + `Failed to create API key${message ? `: ${message}` : ""}` + ); + } finally { + setIsCreating(false); + } + } + + return ( + onClose(false)} + positiveButton={{ + text: isCreating ? "Creating..." : "Create", + onClick: onSubmit, + disabled: isCreating + }} + negativeButton={{ + text: "Cancel", + onClick: () => onClose(false) + }} + > + + { + if (e.key === "Enter") { + await onSubmit(); + } + }} + required + /> + + + Expires in + + + + + + ); +}); diff --git a/apps/web/src/dialogs/settings/inbox-settings.ts b/apps/web/src/dialogs/settings/inbox-settings.ts index e6834feb9..2743972a7 100644 --- a/apps/web/src/dialogs/settings/inbox-settings.ts +++ b/apps/web/src/dialogs/settings/inbox-settings.ts @@ -19,6 +19,7 @@ along with this program. If not, see . import { SettingsGroup } from "./types"; import { useStore as useSettingStore } from "../../stores/setting-store"; +import { InboxApiKeys } from "./components/inbox-api-keys"; export const InboxSettings: SettingsGroup[] = [ { @@ -40,6 +41,19 @@ export const InboxSettings: SettingsGroup[] = [ toggle: () => useSettingStore.getState().toggleInbox() } ] + }, + { + key: "inbox-api-keys", + title: "", + onStateChange: (listener) => + useSettingStore.subscribe((s) => s.isInboxEnabled, listener), + isHidden: () => !useSettingStore.getState().isInboxEnabled, + components: [ + { + type: "custom", + component: InboxApiKeys + } + ] } ] } diff --git a/apps/web/src/stores/setting-store.ts b/apps/web/src/stores/setting-store.ts index 05250e614..eec7a1bc0 100644 --- a/apps/web/src/stores/setting-store.ts +++ b/apps/web/src/stores/setting-store.ts @@ -28,6 +28,7 @@ import { setDocumentTitle } from "../utils/dom"; import { TimeFormat } from "@notesnook/core"; import { Profile, TrashCleanupInterval } from "@notesnook/core"; import { showToast } from "../utils/toast"; +import { ConfirmDialog } from "../dialogs/confirm"; export const HostIds = [ "API_HOST", @@ -277,16 +278,29 @@ class SettingStore extends BaseStore { toggleInbox = async () => { const { isInboxEnabled } = this.get(); - const newState = !isInboxEnabled; try { - if (newState) { - await db.user.getInboxKeys(); - } else { + if (isInboxEnabled) { + const inboxTokens = await db.inboxApiKeys.get(); + if (inboxTokens && inboxTokens.length > 0) { + const ok = await ConfirmDialog.show({ + title: "Disable Inbox API", + message: + "Disabling will revoke all existing API keys, they will no longer work. Are you sure?", + positiveButtonText: "Yes", + negativeButtonText: "No" + }); + if (!ok) return; + } + await db.user.discardInboxKeys(); + this.set({ isInboxEnabled: false }); + + return; } - this.set((state) => (state.isInboxEnabled = newState)); + await db.user.getInboxKeys(); + this.set({ isInboxEnabled: true }); } catch (e) { if (e instanceof Error) { showToast("error", e.message); diff --git a/packages/core/src/api/inbox-api-keys.ts b/packages/core/src/api/inbox-api-keys.ts new file mode 100644 index 000000000..5a13e9bfb --- /dev/null +++ b/packages/core/src/api/inbox-api-keys.ts @@ -0,0 +1,80 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { InboxApiKey } from "../types.js"; +import http from "../utils/http.js"; +import constants from "../utils/constants.js"; +import TokenManager from "./token-manager.js"; +import Database from "./index.js"; + +const ENDPOINTS = { + inboxApiKeys: "/inbox/api-keys" +}; + +export class InboxApiKeys { + constructor( + private readonly db: Database, + private readonly tokenManager: TokenManager + ) {} + + async get() { + const user = await this.db.user.getUser(); + if (!user) return; + + const token = await this.tokenManager.getAccessToken(); + if (!token) return; + + const inboxApiKeys = await http.get( + `${constants.API_HOST}${ENDPOINTS.inboxApiKeys}`, + token + ); + return inboxApiKeys as InboxApiKey[]; + } + + async revoke(key: string) { + const user = await this.db.user.getUser(); + if (!user) return; + + const token = await this.tokenManager.getAccessToken(); + if (!token) return; + + await http.delete( + `${constants.API_HOST}${ENDPOINTS.inboxApiKeys}/${key}`, + token + ); + } + + async create(name: string, expiryDuration: number) { + const user = await this.db.user.getUser(); + if (!user) return; + + const token = await this.tokenManager.getAccessToken(); + if (!token) return; + + const payload: Omit = { + name, + expiryDate: expiryDuration === -1 ? -1 : Date.now() + expiryDuration + }; + await http.post.json( + `${constants.API_HOST}${ENDPOINTS.inboxApiKeys}`, + payload, + token + ); + } +} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 5325ddca5..e081b4f11 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -81,6 +81,7 @@ import { createTriggers, dropTriggers } from "../database/triggers.js"; import { NNMigrationProvider } from "../database/migrations.js"; import { ConfigStorage } from "../database/config.js"; import { LazyPromise } from "../utils/lazy-promise.js"; +import { InboxApiKeys } from "./inbox-api-keys.js"; type EventSourceConstructor = new ( uri: string, @@ -218,6 +219,8 @@ class Database { vaults = new Vaults(this); settings = new Settings(this); + inboxApiKeys = new InboxApiKeys(this, this.tokenManager); + /** * @deprecated only kept here for migration purposes */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 827656fcb..c992528bb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -582,6 +582,14 @@ export type Profile = { profilePicture?: string; }; +export type InboxApiKey = { + name: string; + key: string; + dateCreated: number; + expiryDate: number; + lastUsedAt: number; +}; + export function isDeleted(item: any): item is DeletedItem { return !!item.deleted && item.type !== "trash"; }