diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 2c276112..f723f962 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -730,6 +730,55 @@ describe("PodNotes persistence integration", () => { expect(persisted).not.toHaveProperty("diarizationApiKey"); }); + it("names exactly the legacy keys it moved into SecretStorage", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + const migrate = async (legacy: Record) => { + notice.mockClear(); + const { storage } = memorySecretStorage(); + const plugin = makePlugin(); + Object.assign(plugin, { + app: { ...plugin.app, secretStorage: storage }, + credentials: new CredentialRepository(storage), + loadData: vi.fn().mockResolvedValue({ schemaVersion: 1, ...legacy }), + }); + await plugin.loadSettings(); + return notice.mock.calls; + }; + + expect(await migrate({ openAIApiKey: "sk", diarizationApiKey: "dg" })).toEqual([ + ["Moved your OpenAI and Deepgram API keys into Obsidian SecretStorage."], + ]); + expect(await migrate({ diarizationApiKey: "dg" })).toEqual([ + ["Moved your Deepgram API key into Obsidian SecretStorage."], + ]); + }); + + it("says its API keys could not move when only a Deepgram key fails to migrate", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + const storage = { + getSecret: vi.fn(() => null), + setSecret: vi.fn(() => { + throw new Error("keychain unavailable"); + }), + listSecrets: vi.fn(() => []), + } as unknown as SecretStorage; + const plugin = makePlugin(); + Object.assign(plugin, { + app: { ...plugin.app, secretStorage: storage }, + credentials: new CredentialRepository(storage), + loadData: vi.fn().mockResolvedValue({ schemaVersion: 1, diarizationApiKey: "dg" }), + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(plugin.loadSettings()).rejects.toThrow("keychain unavailable"); + expect(notice.mock.calls).toEqual([ + [ + "PodNotes could not securely migrate its API keys. Your existing data was kept unchanged. Restart PodNotes to retry.", + 0, + ], + ]); + }); + it("leaves legacy data untouched when SecretStorage migration fails", async () => { const saveData = vi.fn().mockResolvedValue(undefined); const raw = { schemaVersion: 1, openAIApiKey: "sk", defaultVolume: 0.3 }; diff --git a/src/main.ts b/src/main.ts index b8179770..c75b45f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,6 +54,7 @@ import { WriteRefusedError, } from "./persistence/writer"; import { CredentialRepository } from "./services/CredentialRepository"; +import { CREDENTIAL_KINDS, CREDENTIALS } from "./types/Credentials"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { privateFeedAddresses, protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds"; import { redactPrivateFeedAddresses } from "./utility/privateFeedUrl"; @@ -370,6 +371,9 @@ export default class PodNotes extends Plugin implements IPodNotes { decoded.retiredPlaintextPresent || movedFeeds ) { + const migratedProviders = CREDENTIAL_KINDS.filter( + (kind) => decoded.legacySecrets[CREDENTIALS[kind].exportKey], + ).map((kind) => CREDENTIALS[kind].label); // SecretStorage writes are synchronous. Store every legacy value and // verify it first, then write the v2 snapshot directly because the normal // save queue intentionally stays dormant until onload finishes. A v2 file @@ -388,15 +392,8 @@ export default class PodNotes extends Plugin implements IPodNotes { ); if (written.kind !== "verified") throw this.writeFailure(); } catch (error) { - throw new PodNotesSchemaMigrationError( - error, - Boolean(decoded.legacySecrets.openAI || decoded.legacySecrets.deepgram), - ); + throw new PodNotesSchemaMigrationError(error, migratedProviders.length > 0); } - const migratedProviders = [ - decoded.legacySecrets.openAI ? "OpenAI" : null, - decoded.legacySecrets.deepgram ? "Deepgram" : null, - ].filter((provider): provider is string => provider !== null); if (migratedProviders.length > 0) { new Notice( `Moved your ${migratedProviders.join(" and ")} API ${migratedProviders.length === 1 ? "key" : "keys"} into Obsidian SecretStorage.`, diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index d4a3a152..72351737 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -467,6 +467,25 @@ describe("PodNotes data schema", () => { ); }); + it("keeps every numbered SecretStorage ID that released versions accept", () => { + const accepted = decodePodNotesData({ + schemaVersion: 2, + openAISecretId: "podnotes-openai-api-key-1", + deepgramSecretId: "podnotes-deepgram-api-key-10", + }); + const rejected = decodePodNotesData({ + schemaVersion: 2, + openAISecretId: "podnotes-openai-api-key-0", + deepgramSecretId: "podnotes-deepgram-api-key-01", + }); + + expect(accepted.settings.openAISecretId).toBe("podnotes-openai-api-key-1"); + expect(accepted.settings.deepgramSecretId).toBe("podnotes-deepgram-api-key-10"); + expect(accepted.warnings).toEqual([]); + expect(rejected.settings.openAISecretId).toBe(""); + expect(rejected.settings.deepgramSecretId).toBe(""); + }); + it("salvages valid fields and only drops individually invalid collection entries", () => { const validEpisode = { ...episode("Valid"), diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 78487091..b1da3c84 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -13,7 +13,12 @@ import { } from "src/settingsMigrations"; import { NEXT_TRACK_ACTIONS, PREVIOUS_TRACK_ACTIONS } from "src/types/Headset"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; -import { isPodNotesSecretId, type CredentialKind } from "src/types/Credentials"; +import { + CREDENTIAL_KINDS, + CREDENTIALS, + isPodNotesSecretId, + type CredentialReferenceSettings, +} from "src/types/Credentials"; import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; import { normalizePlaybackRate } from "src/utility/playbackRate"; import { @@ -119,23 +124,27 @@ export function decodeSettings( feedNote: decodeFeedNote(root.feedNote, warnings), download: decodeDownload(root.download, warnings), downloadedEpisodes: decodeDownloadedEpisodes(root.downloadedEpisodes, warnings), - openAISecretId: decodeSecretId(root, "openAISecretId", warnings), - deepgramSecretId: decodeSecretId(root, "deepgramSecretId", warnings), + ...decodeSecretIds(root, warnings), transcript: decodeTranscript(root.transcript, warnings), }; } -function decodeSecretId( - root: UnknownRecord, - key: "openAISecretId" | "deepgramSecretId", - warnings: Set, -): string { - const value = readString(root, key, DEFAULT_SETTINGS[key], warnings); - const kind: CredentialKind = key === "openAISecretId" ? "openai" : "deepgram"; - if (!value || isPodNotesSecretId(kind, value)) return value; - - warn(warnings, key, "foreign or wrong-provider SecretStorage ID; reference was removed"); - return ""; +function decodeSecretIds(root: UnknownRecord, warnings: Set): CredentialReferenceSettings { + const ids = {} as CredentialReferenceSettings; + for (const kind of CREDENTIAL_KINDS) { + const key = CREDENTIALS[kind].settingsKey; + const value = readString(root, key, DEFAULT_SETTINGS[key], warnings); + const owned = !value || isPodNotesSecretId(kind, value); + if (!owned) { + warn( + warnings, + key, + "foreign or wrong-provider SecretStorage ID; reference was removed", + ); + } + ids[key] = owned ? value : ""; + } + return ids; } /** diff --git a/src/services/CredentialRepository.test.ts b/src/services/CredentialRepository.test.ts index 3b4df3d9..d381a2ce 100644 --- a/src/services/CredentialRepository.test.ts +++ b/src/services/CredentialRepository.test.ts @@ -178,4 +178,21 @@ describe("CredentialRepository", () => { }), ).toThrow("not available in SecretStorage on this device"); }); + + it("names the provider whose configured key is missing from an explicit export", () => { + const { api } = storage({ "podnotes-openai-api-key": "sk" }); + const repository = new CredentialRepository(api); + + expect(() => + repository.exportValues( + settings({ + openAISecretId: "podnotes-openai-api-key", + deepgramSecretId: "podnotes-deepgram-api-key", + }), + { requireConfigured: true }, + ), + ).toThrow( + "The selected Deepgram API key is not available in SecretStorage on this device.", + ); + }); }); diff --git a/src/services/CredentialRepository.ts b/src/services/CredentialRepository.ts index 95f86c05..d17e19cd 100644 --- a/src/services/CredentialRepository.ts +++ b/src/services/CredentialRepository.ts @@ -1,34 +1,18 @@ import type { SecretStorage } from "obsidian"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { + CREDENTIAL_KINDS, + CREDENTIALS, isPodNotesSecretId, isValidSecretId, + secretBaseId, type CredentialKind, type CredentialReferences, type CredentialValues, } from "src/types/Credentials"; -export type { CredentialKind, CredentialReferences, CredentialValues } from "src/types/Credentials"; - export type CredentialStatus = "unconfigured" | "available" | "missing"; -const CREDENTIALS: Record< - CredentialKind, - { - baseId: string; - settingsKey: keyof Pick; - } -> = { - openai: { - baseId: "podnotes-openai-api-key", - settingsKey: "openAISecretId", - }, - deepgram: { - baseId: "podnotes-deepgram-api-key", - settingsKey: "deepgramSecretId", - }, -}; - /** * Narrow boundary around Obsidian's vault-local SecretStorage. * @@ -64,25 +48,18 @@ export class CredentialRepository { settings: IPodNotesSettings, options: { requireConfigured?: boolean } = {}, ): CredentialValues { - const openAI = this.get(settings, "openai"); - const deepgram = this.get(settings, "deepgram"); - if (options.requireConfigured) { - if (settings.openAISecretId.trim() && !openAI) { - throw new Error( - "The selected OpenAI API key is not available in SecretStorage on this device.", - ); - } - if (settings.deepgramSecretId.trim() && !deepgram) { + const values: CredentialValues = {}; + for (const kind of CREDENTIAL_KINDS) { + const { settingsKey, exportKey, label } = CREDENTIALS[kind]; + const value = this.get(settings, kind); + if (options.requireConfigured && settings[settingsKey].trim() && !value) { throw new Error( - "The selected Deepgram API key is not available in SecretStorage on this device.", + `The selected ${label} API key is not available in SecretStorage on this device.`, ); } + if (value) values[exportKey] = value; } - - return { - ...(openAI ? { openAI } : {}), - ...(deepgram ? { deepgram } : {}), - }; + return values; } /** @@ -92,12 +69,11 @@ export class CredentialRepository { */ storeValues(values: CredentialValues): CredentialReferences { const references: CredentialReferences = {}; - const openAI = values.openAI?.trim(); - const deepgram = values.deepgram?.trim(); - - if (openAI) references.openAISecretId = this.store("openai", openAI); - if (deepgram) references.deepgramSecretId = this.store("deepgram", deepgram); - + for (const kind of CREDENTIAL_KINDS) { + const { settingsKey, exportKey } = CREDENTIALS[kind]; + const value = values[exportKey]?.trim(); + if (value) references[settingsKey] = this.store(kind, value); + } return references; } @@ -122,7 +98,7 @@ export class CredentialRepository { } private store(kind: CredentialKind, secret: string): string { - const { baseId } = CREDENTIALS[kind]; + const baseId = secretBaseId(kind); for (let suffix = 1; suffix <= 10_000; suffix++) { const id = suffix === 1 ? baseId : `${baseId}-${suffix}`; diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index c04d7bc4..45e13fc7 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -7,7 +7,7 @@ import { type PersistedPodNotesSettings, PodNotesDataError, } from "./persistence/podNotesData"; -import type { CredentialValues } from "./types/Credentials"; +import { CREDENTIAL_KINDS, CREDENTIALS, type CredentialValues } from "./types/Credentials"; import type { Episode } from "./types/Episode"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; import { ownRecord } from "./utility/ownRecord"; @@ -35,23 +35,16 @@ export const VAULT_LOCAL_KEYS: readonly (keyof IPodNotesSettings)[] = [ ]; /** Provider API-key references are device-local, not settings transfer data. */ -const SECRET_REFERENCE_KEYS: ReadonlySet = new Set([ - "openAISecretId", - "deepgramSecretId", -]); +const SECRET_REFERENCE_KEYS: ReadonlySet = new Set( + CREDENTIAL_KINDS.map((kind) => CREDENTIALS[kind].settingsKey), +); +/** Plaintext fields that versions before 2.19.0 wrote. No new kind joins them. */ const LEGACY_SECRET_KEYS = { openAIApiKey: "openAI", diarizationApiKey: "deepgram", } as const; -/** Human-facing names for each secret, so export/import copy can name exactly - * which keys leave or enter the vault instead of hard-coding "OpenAI". */ -const SECRET_KEY_LABELS: Record = { - openAI: "OpenAI API key", - deepgram: "Deepgram API key", -}; - /** * The human-facing labels for the secrets actually present (non-empty) in a * settings object. Used to keep the export toggle, the export notice, and the @@ -59,9 +52,9 @@ const SECRET_KEY_LABELS: Record = { * user is never told only "OpenAI API key" (and vice versa). See issue #168. */ export function describeSecrets(secrets: CredentialValues): string[] { - return (Object.keys(SECRET_KEY_LABELS) as (keyof CredentialValues)[]) - .filter((key) => Boolean(secrets[key]?.trim())) - .map((key) => SECRET_KEY_LABELS[key]); + return CREDENTIAL_KINDS.filter((kind) => + Boolean(secrets[CREDENTIALS[kind].exportKey]?.trim()), + ).map((kind) => `${CREDENTIALS[kind].label} API key`); } /** Known top-level setting keys that are safe to import (excludes vault-local state). */ @@ -382,9 +375,10 @@ function parseSecretsPayload(value: unknown): { values: CredentialValues } | { e return { error: "Export file has an invalid secrets payload." }; } - for (const key of ["openAI", "deepgram"] as const) { - if (value[key] !== undefined && typeof value[key] !== "string") { - return { error: `Export file has an invalid ${SECRET_KEY_LABELS[key]}.` }; + for (const kind of CREDENTIAL_KINDS) { + const { exportKey, label } = CREDENTIALS[kind]; + if (value[exportKey] !== undefined && typeof value[exportKey] !== "string") { + return { error: `Export file has an invalid ${label} API key.` }; } } @@ -392,12 +386,13 @@ function parseSecretsPayload(value: unknown): { values: CredentialValues } | { e } function normalizeSecrets(values: CredentialValues): CredentialValues { - const openAI = values.openAI?.trim(); - const deepgram = values.deepgram?.trim(); - return { - ...(openAI ? { openAI } : {}), - ...(deepgram ? { deepgram } : {}), - }; + const normalized: CredentialValues = {}; + for (const kind of CREDENTIAL_KINDS) { + const key = CREDENTIALS[kind].exportKey; + const value = values[key]?.trim(); + if (value) normalized[key] = value; + } + return normalized; } /** Drop any playlist entry that isn't a plain object with an `episodes` array. */ diff --git a/src/types/Credentials.ts b/src/types/Credentials.ts index f14030c1..1c18c7da 100644 --- a/src/types/Credentials.ts +++ b/src/types/Credentials.ts @@ -1,29 +1,40 @@ -export type CredentialKind = "openai" | "deepgram"; +/** + * One row per API key PodNotes can hold. Released versions store a kind's key + * under `podnotes--api-key` and read its settingsKey and exportKey from + * synced data and export files, so none of them may change. + */ +export const CREDENTIALS = { + openai: { settingsKey: "openAISecretId", exportKey: "openAI", label: "OpenAI" }, + deepgram: { settingsKey: "deepgramSecretId", exportKey: "deepgram", label: "Deepgram" }, +} as const; -export interface CredentialValues { - openAI?: string; - deepgram?: string; -} +export type CredentialKind = keyof typeof CREDENTIALS; +export const CREDENTIAL_KINDS = Object.keys(CREDENTIALS) as readonly CredentialKind[]; -export interface CredentialReferences { - openAISecretId?: string; - deepgramSecretId?: string; -} +type Credential = (typeof CREDENTIALS)[CredentialKind]; +export type CredentialSettingsKey = Credential["settingsKey"]; + +/** Plaintext keys, as a settings export carries them under `secrets`. */ +export type CredentialValues = Partial>; + +export type CredentialReferences = Partial>; + +/** SecretStorage IDs of the API keys. They never contain a key itself. */ +export type CredentialReferenceSettings = Record; /** Obsidian SecretStorage IDs are lowercase alphanumeric strings with dashes. */ export function isValidSecretId(value: string): boolean { return /^(?=.*[a-z0-9])[a-z0-9-]+$/.test(value); } -const PODNOTES_SECRET_ID_PATTERNS: Record = { - openai: /^podnotes-openai-api-key(?:-[1-9]\d*)?$/, - deepgram: /^podnotes-deepgram-api-key(?:-[1-9]\d*)?$/, -}; +export function secretBaseId(kind: CredentialKind): string { + return `podnotes-${kind}-api-key`; +} /** * Persisted references are capabilities, not arbitrary SecretStorage lookups. * Keep each provider confined to the IDs PodNotes owns for that provider. */ export function isPodNotesSecretId(kind: CredentialKind, value: string): boolean { - return PODNOTES_SECRET_ID_PATTERNS[kind].test(value); + return new RegExp(`^${secretBaseId(kind)}(?:-[1-9]\\d*)?$`).test(value); } diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 246e1011..01f1c2a7 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -6,8 +6,9 @@ import type { Episode } from "./Episode"; import type DownloadedEpisode from "./DownloadedEpisode"; import type { DiarizationProviderId } from "src/services/diarization/types"; import type { NextTrackAction, PreviousTrackAction } from "./Headset"; +import type { CredentialReferenceSettings } from "./Credentials"; -export interface IPodNotesSettings { +export interface IPodNotesSettings extends CredentialReferenceSettings { savedFeeds: { [podcastName: string]: PodcastFeed }; podNotes: { [episodeName: string]: PodNote }; defaultPlaybackRate: number; @@ -59,10 +60,6 @@ export interface IPodNotesSettings { path: string; }; downloadedEpisodes: { [podcastName: string]: DownloadedEpisode[] }; - /** SecretStorage ID for the OpenAI API key. Never contains the key itself. */ - openAISecretId: string; - /** SecretStorage ID for the Deepgram API key. Never contains the key itself. */ - deepgramSecretId: string; transcript: { path: string; template: string; diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index d3c66c33..dc352602 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_SETTINGS } from "src/constants"; import { decodePodNotesData, PODNOTES_DATA_SCHEMA_VERSION } from "src/persistence/podNotesData"; import PodNotes from "src/main"; import { PlayerController } from "src/player/PlayerController"; +import { CredentialRepository } from "src/services/CredentialRepository"; import { episodeListLimit, hidePlayedEpisodes, savedFeeds } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; import type { Episode } from "src/types/Episode"; @@ -380,6 +381,36 @@ describe("PodNotesSettingsTab settings import", () => { ]); }); + it("copies a secret selected in each key row into that row's provider ID", async () => { + const storage = new obsidian.SecretStorage(); + storage.setSecret("shared-api-key", "shared-value"); + const plugin = { + settings: structuredClone(DEFAULT_SETTINGS), + credentials: new CredentialRepository(storage), + saveSettingsStrict: vi.fn().mockResolvedValue(undefined), + } as unknown as PodNotes; + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); + const secret = { setValue: vi.fn() }; + const select = ( + tab as unknown as { + handleSecretSelection: ( + key: "openAISecretId" | "deepgramSecretId", + value: string, + component: typeof secret, + onSettled: () => void, + ) => Promise; + } + ).handleSecretSelection.bind(tab); + + await select("openAISecretId", "shared-api-key", secret, vi.fn()); + await select("deepgramSecretId", "shared-api-key", secret, vi.fn()); + + expect(plugin.settings.openAISecretId).toBe("podnotes-openai-api-key"); + expect(plugin.settings.deepgramSecretId).toBe("podnotes-deepgram-api-key"); + expect(storage.getSecret("podnotes-openai-api-key")).toBe("shared-value"); + expect(storage.getSecret("podnotes-deepgram-api-key")).toBe("shared-value"); + }); + it("keeps a newer selection authoritative when an older save fails", async () => { const firstSave = deferred(); let disk = "podnotes-openai-api-key"; diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index ad738ec1..95e2b5ef 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -51,11 +51,16 @@ import { parseImport, serializeSettings, } from "src/settingsTransfer"; -import type { CredentialKind, CredentialValues } from "src/types/Credentials"; +import { + CREDENTIAL_KINDS, + CREDENTIALS, + type CredentialKind, + type CredentialSettingsKey, + type CredentialValues, +} from "src/types/Credentials"; import { observePersistedStoreChanges } from "src/store/persistence"; import { PLAYBACK_RATES } from "src/utility/playbackRate"; -type SecretReferenceKey = "openAISecretId" | "deepgramSecretId"; type SettingsControl = | HTMLButtonElement | HTMLInputElement @@ -225,10 +230,9 @@ function restoreFailedImportChanges( } } -const CREDENTIAL_KIND_BY_REFERENCE: Record = { - openAISecretId: "openai", - deepgramSecretId: "deepgram", -}; +const CREDENTIAL_KIND_BY_REFERENCE = Object.fromEntries( + CREDENTIAL_KINDS.map((kind) => [CREDENTIALS[kind].settingsKey, kind]), +) as Record; function stackSettingVertically(setting: Setting): void { setting.settingEl.setCssStyles({ @@ -242,10 +246,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { plugin: PodNotes; private settingsMutationTail: Promise = Promise.resolve(); - private credentialSaveGenerations: Record = { - openAISecretId: 0, - deepgramSecretId: 0, - }; + private readonly credentialSaveGenerations = new Map(); private settingsInteractionLockCount = 0; private settingsControlDisabledStates = new Map(); private readonly drafts = new Set<() => void>(); @@ -348,7 +349,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }), ]), group("Transcripts", [ - this.secretRow("OpenAI API key", "openAISecretId"), + this.secretRow("openai"), this.textRow("Transcript path", { ...this.text((settings) => settings.transcript, "path"), preview: pathPreview(FilePathTemplateEngine), @@ -381,7 +382,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { ({ enabled }) => enabled, ), this.whenDiarizing( - this.secretRow("Deepgram API key", "deepgramSecretId"), + this.secretRow("deepgram"), ({ enabled, provider }) => enabled && provider === "deepgram", ), this.whenDiarizing( @@ -693,16 +694,14 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } - private secretRow(name: string, key: SecretReferenceKey): SettingDefinitionRender { + private secretRow(kind: CredentialKind): SettingDefinitionRender { + const { settingsKey: key, label } = CREDENTIALS[kind]; return { - name, + name: `${label} API key`, render: (setting) => { const describe = () => setting.setDesc( - this.plugin.credentials.status( - this.plugin.settings, - CREDENTIAL_KIND_BY_REFERENCE[key], - ) === "missing" + this.plugin.credentials.status(this.plugin.settings, kind) === "missing" ? "The selected secret is not available on this device. Select an existing secret or create one." : "", ); @@ -1123,22 +1122,23 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private async saveSecretReference( - key: SecretReferenceKey, + key: CredentialSettingsKey, selectedId: string | null, ): Promise { - const generation = ++this.credentialSaveGenerations[key]; + const generation = (this.credentialSaveGenerations.get(key) ?? 0) + 1; + this.credentialSaveGenerations.set(key, generation); const operation = this.enqueueSettingsMutation(() => this.persistSecretReference(key, selectedId), ); const result = await operation; return { ...result, - isLatest: generation === this.credentialSaveGenerations[key], + isLatest: generation === this.credentialSaveGenerations.get(key), }; } private async persistSecretReference( - key: SecretReferenceKey, + key: CredentialSettingsKey, selectedId: string | null, ): Promise> { const previous = this.plugin.settings[key]; @@ -1189,7 +1189,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private async handleSecretSelection( - key: SecretReferenceKey, + key: CredentialSettingsKey, selectedId: string | null, secret: Pick, onLatestSettled: () => void,