Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### ✨ Features

- You can now select where the plugin should store your API token.
- By default, this is Obsidian's secret storage as this is the most secure option.
- You may select the old file-based approach that was used prior to v2.5.0

### 🐛 Bug Fixes

- Task descriptions will now correctly be rendered if the `show` parameter of the query is omitted entirely.
Expand Down
9 changes: 7 additions & 2 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ There are a number of options that allow you to configure the behaviour of the p

## General

### Todoist API token
### Token storage

The API token used to connect to Todoist. This is stored in your vault at `.obsidian/todoist-token`. If you synchronize your vault, I recommend that you do _not_ sync this file for security reasons.
Controls where the plugin stores your Todoist API token. There are two options:

- **Obsidian secrets** - Uses Obsidian's built-in secret storage. This is the recommended option as it keeps your token out of your vault files.
- **File-based** - Stores the token in a file at `.obsidian/todoist-token` inside your vault. If you synchronize your vault, you should consider _not_ syncing this file for security reasons. You may want to use this option if you have issues with Obsidian secrets.

Changing this setting will automatically migrate your token to the new storage location.

## Auto-refresh

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Once the plugin is installed, you'll need to enable and do some initial setup.
4. The prompt will verify that the token provided is valid and will present you with a checkmark if it is
5. Select 'Save' to complete the setup

> Your API token is stored securely using Obsidian's built-in secret storage under the ID `swt-todoist-api-token`.
> By default, your API token is stored securely using Obsidian's built-in secret storage. You can change this to file-based storage in the [plugin configuration](./configuration#token-storage).

## What's next?

Expand Down
4 changes: 2 additions & 2 deletions docs/docs/translation-status.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"total": 209,
"total": 215,
"statuses": [
{
"name": "English",
"code": "en",
"completed": 209
"completed": 215
},
{
"name": "Nederlands",
Expand Down
8 changes: 8 additions & 0 deletions plugin/src/i18n/langs/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export const en: Translations = {
description: "The Todoist API token to use when fetching tasks",
buttonLabel: "Setup",
},
tokenStorage: {
label: "Token storage",
description: "Where the plugin should store your Todoist API token",
options: {
secrets: "Obsidian secrets",
file: "File-based",
},
},
},
autoRefresh: {
header: "Auto-refresh",
Expand Down
8 changes: 8 additions & 0 deletions plugin/src/i18n/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export type Translations = {
description: string;
buttonLabel: string;
};
tokenStorage: {
label: string;
description: string;
options: {
secrets: string;
file: string;
};
};
};
autoRefresh: {
header: string;
Expand Down
6 changes: 3 additions & 3 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default class TodoistPlugin extends Plugin {

private async loadApiClient(): Promise<void> {
const accessor = this.services.token;
const token = accessor.read();
const token = await accessor.read();

if (token !== null) {
await this.services.todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher()));
Expand All @@ -65,7 +65,7 @@ export default class TodoistPlugin extends Plugin {

this.services.modals.onboarding({
onTokenSubmit: async (token) => {
accessor.write(token);
await accessor.write(token);
await this.services.todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher()));
},
});
Expand Down Expand Up @@ -95,7 +95,7 @@ export default class TodoistPlugin extends Plugin {
const migrations: Record<number, () => Promise<void>> = {
1: async () => {
// Migration from 0 -> 1: migrate token to secrets
await this.services.token.migrateToSecrets();
await this.services.token.migrateStorage("file", "secrets");
},
};

Expand Down
89 changes: 76 additions & 13 deletions plugin/src/services/tokenAccessor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SecretStorage, Vault } from "obsidian";

import type { TokenStorageSetting } from "@/settings";
import { useSettingsStore } from "@/settings";

export class VaultTokenAccessor {
Expand All @@ -13,26 +14,88 @@ export class VaultTokenAccessor {
this.path = `${vault.configDir}/todoist-token`;
}

read(): string | null {
const secretId = useSettingsStore.getState().apiTokenSecretId;
return this.secrets.getSecret(secretId);
read(): Promise<string | null> {
const settings = useSettingsStore.getState();
return this.readFromStorage(settings.tokenStorage);
}

write(token: string) {
const secretId = useSettingsStore.getState().apiTokenSecretId;
this.secrets.setSecret(secretId, token);
write(token: string): Promise<void> {
const settings = useSettingsStore.getState();
return this.writeToStorage(token, settings.tokenStorage);
}

async migrateToSecrets(): Promise<void> {
const tokenExists = await this.vault.adapter.exists(this.path);
if (!tokenExists) {
async migrateStorage(from: TokenStorageSetting, to: TokenStorageSetting): Promise<void> {
if (from === to) {
return;
}

const token = await this.vault.adapter.read(this.path);
const secretId = useSettingsStore.getState().apiTokenSecretId;
const token = await this.readFromStorage(from);
if (token === null) {
return;
}

await this.writeToStorage(token, to);
await this.cleanupStorage(from);
}

private async readFromStorage(storage: TokenStorageSetting): Promise<string | null> {
switch (storage) {
case "file": {
const exists = await this.vault.adapter.exists(this.path);
if (!exists) {
return null;
}

const value = await this.vault.adapter.read(this.path);
return value === "" ? null : value;
}
case "secrets": {
const secretId = useSettingsStore.getState().apiTokenSecretId;
const value = this.secrets.getSecret(secretId);
return value === "" ? null : value;
}
default: {
const _: never = storage;
throw new Error("Unknown token storage setting");
}
}
}

private async writeToStorage(token: string, storage: TokenStorageSetting): Promise<void> {
switch (storage) {
case "file":
await this.vault.adapter.write(this.path, token);
return;
case "secrets": {
const secretId = useSettingsStore.getState().apiTokenSecretId;
this.secrets.setSecret(secretId, token);
return;
}
default: {
const _: never = storage;
throw new Error("Unknown token storage setting");
}
}
}

this.secrets.setSecret(secretId, token);
await this.vault.adapter.remove(this.path);
private async cleanupStorage(storage: TokenStorageSetting): Promise<void> {
switch (storage) {
case "file": {
const exists = await this.vault.adapter.exists(this.path);
if (exists) {
await this.vault.adapter.remove(this.path);
}
return;
}
case "secrets": {
const secretId = useSettingsStore.getState().apiTokenSecretId;
this.secrets.setSecret(secretId, "");
return;
}
default: {
const _: never = storage;
throw new Error("Unknown token storage setting");
}
}
}
}
4 changes: 4 additions & 0 deletions plugin/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type AddTaskAction = "add" | "add-copy-app" | "add-copy-web";

export type DueDateDefaultSetting = "none" | "today" | "tomorrow";

export type TokenStorageSetting = "secrets" | "file";

export type ProjectDefaultSetting = {
projectId: string;
projectName: string;
Expand All @@ -18,6 +20,7 @@ export type LabelsDefaultSetting = Array<{

const defaultSettings: Settings = {
apiTokenSecretId: "swt-todoist-api-token",
tokenStorage: "secrets",

fadeToggle: true,

Expand Down Expand Up @@ -46,6 +49,7 @@ const defaultSettings: Settings = {

export type Settings = {
apiTokenSecretId: string;
tokenStorage: TokenStorageSetting;

fadeToggle: boolean;
autoRefreshToggle: boolean;
Expand Down
4 changes: 2 additions & 2 deletions plugin/src/ui/settings/TokenChecker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const TokenChecker: React.FC<Props> = ({ tester }) => {
useEffect(() => {
setTokenState({ kind: "in-progress" });
(async () => {
const token = tokenAccessor.read();
const token = await tokenAccessor.read();
if (token === null) {
setTokenState({
kind: "error",
Expand All @@ -45,7 +45,7 @@ export const TokenChecker: React.FC<Props> = ({ tester }) => {
onTokenSubmit: async (token) => {
setTokenValidationCount((old) => old + 1);

tokenAccessor.write(token);
await tokenAccessor.write(token);
await todoist.initialize(new TodoistApiClient(token, new ObsidianFetcher()));
},
});
Expand Down
25 changes: 24 additions & 1 deletion plugin/src/ui/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { t } from "@/i18n";
import { PluginContext } from "@/ui/context";

import type TodoistPlugin from "../..";
import { type Settings, useSettingsStore } from "../../settings";
import { type Settings, type TokenStorageSetting, useSettingsStore } from "../../settings";
import { TokenValidation } from "../../token";
import { AutoRefreshIntervalControl } from "./AutoRefreshIntervalControl";
import { LabelsControl } from "./LabelsControl";
Expand Down Expand Up @@ -105,6 +105,29 @@ const SettingsRoot: React.FC<Props> = ({ plugin }) => {
>
<TokenChecker tester={TokenValidation.DefaultTester} />
</Setting.Root>
<Setting.Root
name={i18n.general.tokenStorage.label}
description={i18n.general.tokenStorage.description}
>
<Setting.DropdownControl
value={settings.tokenStorage}
options={[
{
label: i18n.general.tokenStorage.options.secrets,
value: "secrets",
},
{
label: i18n.general.tokenStorage.options.file,
value: "file",
},
]}
onClick={async (val: TokenStorageSetting) => {
const oldStorage = settings.tokenStorage;
await plugin.services.token.migrateStorage(oldStorage, val);
await plugin.writeOptions({ tokenStorage: val });
}}
/>
</Setting.Root>

<h2>{i18n.autoRefresh.header}</h2>
<Setting.Root
Expand Down