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 (
+
+ );
+});
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";
}