diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts index 71eb0aa..0d3f363 100644 --- a/__mocks__/obsidian.ts +++ b/__mocks__/obsidian.ts @@ -5,11 +5,22 @@ import { PluginSettingTabMock } from "./obsidian/PluginSettingTab"; import { NoticeMock } from "./obsidian/Notice"; import { setIconMock } from "./obsidian/setIcon"; +class SecretComponentMock { + constructor(_app: unknown, _el: unknown) {} + setValue(_value: string) { + return this; + } + onChange(_cb: (value: string) => unknown) { + return this; + } +} + module.exports = { App: AppMock, Plugin: PluginMock, Modal: ModalMock, PluginSettingTab: PluginSettingTabMock, Notice: NoticeMock, + SecretComponent: SecretComponentMock, setIcon: setIconMock, }; diff --git a/src/keychain.ts b/src/keychain.ts index fd382fa..0686963 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -2,44 +2,59 @@ import type { SecretStorage } from "obsidian"; import type { GithubAccount } from "./settings/types"; import { logger } from "./plugin"; -const KEY_PREFIX = "gh-link-"; - let storage: SecretStorage; -function keyFor(accountId: string): string { - return `${KEY_PREFIX}${accountId}`; -} - export function initKeychain(secretStorage: SecretStorage): void { storage = secretStorage; } +/** + * Read the token value from SecretStorage using the account's secret name. + */ export function getToken(account: GithubAccount): string | null { - return storage.getSecret(keyFor(account.id)); -} - -export function setToken(account: GithubAccount, token: string): void { - storage.setSecret(keyFor(account.id), token); -} - -export function clearToken(account: GithubAccount): void { - storage.setSecret(keyFor(account.id), ""); + if (!account.tokenSecret) { + return null; + } + return storage.getSecret(account.tokenSecret); } /** - * Migrate plain-text tokens from data.json into the keychain. + * Store a token under a named secret and update the account's tokenSecret reference. + */ +export function setToken(account: GithubAccount, token: string, secretName?: string): void { + if (!secretName && !account.tokenSecret) { + secretName = `github-link-${account.name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`; + } + if (secretName) { + account.tokenSecret = secretName; + } + storage.setSecret(account.tokenSecret, token); +} + +/** + * Clear the token value from SecretStorage (the entry remains with an empty value). + */ +export function clearToken(account: GithubAccount): void { + if (account.tokenSecret) { + storage.setSecret(account.tokenSecret, ""); + } +} + +/** + * Migrate plain-text tokens from data.json into named secrets. + * Called once on data version upgrade. */ export function migrateTokens(accounts: GithubAccount[]): void { for (const account of accounts) { - if (account.token) { + if (account.token && !account.tokenSecret) { setToken(account, account.token); - logger.info(`Migrated token for account "${account.name}" to Obsidian Keychain.`); + logger.info(`Migrated token for account "${account.name}" to Obsidian Keychain as "${account.tokenSecret}".`); } } } /** - * Populate in-memory account tokens from the keychain. + * Populate in-memory account tokens from SecretStorage on startup. */ export function loadTokens(accounts: GithubAccount[]): void { for (const account of accounts) { diff --git a/src/settings/account.ts b/src/settings/account.ts index f55e33c..140d68d 100644 --- a/src/settings/account.ts +++ b/src/settings/account.ts @@ -1,5 +1,5 @@ import type { App } from "obsidian"; -import { Setting } from "obsidian"; +import { SecretComponent, Setting } from "obsidian"; import type { Verification } from "@octokit/auth-oauth-device/dist-types/types"; import { AuthModal } from "../auth-modal"; import { auth } from "../github/auth"; @@ -29,7 +29,7 @@ export class AccountSettings { saveNewAccountCallback: (account: GithubAccount) => Promise, ): void { if (!this.newAccount) { - this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "", customOAuth: false }; + this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "", tokenSecret: "", customOAuth: false }; } // TODO: Combine the new account and existing account rendering to reduce duplication const accountContainer = container.createDiv(); @@ -99,7 +99,7 @@ export class AccountSettings { new Setting(accountContainer) .setName("Token") .setDesc( - "A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required.", + "A GitHub token stored in Obsidian's keychain. Generate one automatically (recommended) or link an existing secret.", ) .addButton((button) => { button.setButtonText("Generate Token"); @@ -118,14 +118,15 @@ export class AccountSettings { this.displayCallback(); }); }) - .addText((text) => { - text.setPlaceholder("Personal Access Token / OAuth Token"); - text.setValue(this.newAccount!.token); - text.onChange((value) => { - this.newAccount!.token = value.trim(); - setToken(this.newAccount!, value.trim()); - }); - }); + .addComponent((el) => + new SecretComponent(this.app, el) + .setValue(this.newAccount!.tokenSecret) + .onChange((secretName) => { + this.newAccount!.tokenSecret = secretName; + const token = this.app.secretStorage.getSecret(secretName); + this.newAccount!.token = token ?? ""; + }), + ); new Setting(accountContainer).addButton((button) => { button.setButtonText("Save account"); @@ -207,7 +208,7 @@ export class AccountSettings { new Setting(accountContainer) .setName("Token") .setDesc( - "A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens).", + "A GitHub token stored in Obsidian's keychain. Generate one automatically (recommended) or link an existing secret.", ) .addButton((button) => { button.setButtonText("Generate Token"); @@ -227,15 +228,16 @@ export class AccountSettings { this.displayCallback(); }); }) - .addText((text) => { - text.setPlaceholder("Personal Access Token / OAuth Token"); - text.setValue(account.token); - text.onChange((value) => { - account.token = value.trim(); - setToken(account, value.trim()); - void this.saveCallback(); - }); - }); + .addComponent((el) => + new SecretComponent(this.app, el) + .setValue(account.tokenSecret) + .onChange(async (secretName) => { + account.tokenSecret = secretName; + const token = this.app.secretStorage.getSecret(secretName); + account.token = token ?? ""; + await this.saveCallback(); + }), + ); } private tokenVerification(verification: Verification) { diff --git a/src/settings/types.ts b/src/settings/types.ts index 14086f4..cde4725 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -6,7 +6,10 @@ export interface GithubAccount { id: string; name: string; orgs: string[]; + /** Runtime-only: populated from secretStorage on load. Not persisted. */ token: string; + /** Name of the secret in Obsidian's SecretStorage. Persisted in data.json. */ + tokenSecret: string; customOAuth?: boolean; clientId?: string; }