feat: Use Obsidian's SecretComponent for token management

Switch to Obsidian's built-in SecretComponent UI widget for token
selection, letting users pick or create named secrets through the
standard Obsidian keychain interface.

- Add tokenSecret field to GithubAccount to store the secret name
- Replace plain text token input with SecretComponent via addComponent
- Auto-generate secret names from account name during migration
- Read token values at runtime via secretStorage.getSecret(tokenSecret)
This commit is contained in:
Jacobo de Vera 2026-04-18 22:43:34 +01:00
parent b477774e1c
commit 2f6ec2637e
4 changed files with 71 additions and 40 deletions

View file

@ -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,
};

View file

@ -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) {

View file

@ -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>,
): 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) {

View file

@ -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;
}