From 6d84d6be4ec3c25da660dc032a30eea0e2a60f7e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:44:14 -0400 Subject: [PATCH] feat: show provider metadata in saved profiles --- js/app.js | 19 +++- js/services/authProviders/index.js | 127 ++++++++++++++++++++- js/services/authProviders/nip07.js | 1 + js/services/authService.js | 18 ++- js/state/cache.js | 23 ++++ js/ui/profileModalController.js | 133 +++++++++++++++++++--- tests/profile-modal-controller.test.mjs | 60 ++++++++++ tests/state/cache-saved-profiles.test.mjs | 10 ++ 8 files changed, 362 insertions(+), 29 deletions(-) diff --git a/js/app.js b/js/app.js index 782c5134..2307e3b5 100644 --- a/js/app.js +++ b/js/app.js @@ -2085,6 +2085,20 @@ class Application { return "nip07"; })(); + const resolvedProviderId = (() => { + const candidates = [detailProviderId, providerId, resolvedAuthType]; + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + return trimmed; + } + } + return resolvedAuthType; + })(); + const normalizedPubkey = this.normalizeHexPubkey(pubkey); if (!normalizedPubkey) { throw new Error( @@ -2119,6 +2133,7 @@ class Application { name, picture, authType: resolvedAuthType, + providerId: resolvedProviderId, }); persistSavedProfiles({ persistActive: false }); @@ -2955,12 +2970,12 @@ class Application { } } - async handleProfileSwitchRequest({ pubkey } = {}) { + async handleProfileSwitchRequest({ pubkey, providerId } = {}) { if (!pubkey) { throw new Error("Missing pubkey for profile switch request."); } - const result = await this.authService.switchProfile(pubkey); + const result = await this.authService.switchProfile(pubkey, { providerId }); if (result?.switched) { try { diff --git a/js/services/authProviders/index.js b/js/services/authProviders/index.js index 490fb792..29019061 100644 --- a/js/services/authProviders/index.js +++ b/js/services/authProviders/index.js @@ -1,14 +1,129 @@ import nip07 from "./nip07.js"; -const map = { - [(nip07 && typeof nip07.id === "string" && nip07.id.trim()) || "nip07"]: nip07, -}; +function normalizeProviderId(provider, fallbackId) { + if (provider && typeof provider.id === "string") { + const trimmed = provider.id.trim(); + if (trimmed) { + return trimmed; + } + } -if (!Object.prototype.hasOwnProperty.call(map, "nip07")) { - map.nip07 = nip07; + if (typeof fallbackId === "string") { + const trimmed = fallbackId.trim(); + if (trimmed) { + return trimmed; + } + } + + return "provider"; } -export const providers = Object.freeze({ ...map }); +function normalizeProviderMetadata(provider, fallbackId) { + const id = normalizeProviderId(provider, fallbackId); + const label = + provider && typeof provider.label === "string" && provider.label.trim() + ? provider.label.trim() + : "Saved profile"; + const description = + provider && typeof provider.description === "string" && provider.description.trim() + ? provider.description.trim() + : ""; + const badgeVariant = + provider && typeof provider.badgeVariant === "string" && provider.badgeVariant.trim() + ? provider.badgeVariant.trim() + : "neutral"; + + return Object.freeze({ id, label, description, badgeVariant }); +} + +const providerList = [nip07].filter(Boolean); +const providerMap = {}; +const metadataMap = {}; + +providerList.forEach((provider, index) => { + const fallbackId = `provider-${index}`; + const id = normalizeProviderId(provider, fallbackId); + providerMap[id] = provider; + metadataMap[id] = normalizeProviderMetadata(provider, id); +}); + +if (!Object.prototype.hasOwnProperty.call(providerMap, "nip07")) { + providerMap.nip07 = nip07; + metadataMap.nip07 = normalizeProviderMetadata(nip07, "nip07"); +} else if (!Object.prototype.hasOwnProperty.call(metadataMap, "nip07")) { + metadataMap.nip07 = normalizeProviderMetadata(providerMap.nip07, "nip07"); +} + +const builtInMetadata = [ + { + id: "nsec", + label: "Direct key", + description: "Use a saved nsec key stored locally.", + badgeVariant: "warning", + }, +]; + +builtInMetadata.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + + const id = + typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : null; + if (!id || Object.prototype.hasOwnProperty.call(metadataMap, id)) { + return; + } + + metadataMap[id] = Object.freeze({ + id, + label: + typeof entry.label === "string" && entry.label.trim() + ? entry.label.trim() + : "Saved profile", + description: + typeof entry.description === "string" && entry.description.trim() + ? entry.description.trim() + : "", + badgeVariant: + typeof entry.badgeVariant === "string" && entry.badgeVariant.trim() + ? entry.badgeVariant.trim() + : "neutral", + }); +}); + +export const providers = Object.freeze({ ...providerMap }); +export const providerMetadata = Object.freeze({ ...metadataMap }); + +const unknownMetadataCache = new Map(); + +function getFallbackMetadata(providerId) { + const id = + typeof providerId === "string" && providerId.trim() ? providerId.trim() : "unknown"; + if (!unknownMetadataCache.has(id)) { + unknownMetadataCache.set( + id, + Object.freeze({ + id, + label: "Saved profile", + description: "", + badgeVariant: "neutral", + }), + ); + } + return unknownMetadataCache.get(id); +} + +export function getProviderMetadata(providerId) { + if (typeof providerId === "string" && providerId.trim()) { + const normalizedId = providerId.trim(); + if (Object.prototype.hasOwnProperty.call(providerMetadata, normalizedId)) { + return providerMetadata[normalizedId]; + } + return getFallbackMetadata(normalizedId); + } + + return getFallbackMetadata("saved-profile"); +} export function getProvider(providerId = "nip07") { const id = diff --git a/js/services/authProviders/nip07.js b/js/services/authProviders/nip07.js index cc117c5a..3359b95c 100644 --- a/js/services/authProviders/nip07.js +++ b/js/services/authProviders/nip07.js @@ -47,6 +47,7 @@ export default { id: PROVIDER_ID, label: PROVIDER_LABEL, description: PROVIDER_DESCRIPTION, + badgeVariant: "info", capabilities: PROVIDER_CAPABILITIES, button: PROVIDER_BUTTON, messages: PROVIDER_MESSAGES, diff --git a/js/services/authService.js b/js/services/authService.js index 12a2a09a..8a35a6f8 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -521,6 +521,8 @@ export default class AuthService { providerResult, ); + const entryProviderId = this.normalizeProviderId(providerId) || entryAuthType; + mutateSavedProfiles((profiles) => { const draft = Array.isArray(profiles) ? profiles.slice() : []; const existingIndex = draft.findIndex((entry) => { @@ -537,6 +539,7 @@ export default class AuthService { name: cachedProfile.name || draft[existingIndex]?.name || "", picture: cachedProfile.picture || draft[existingIndex]?.picture || "", authType: entryAuthType, + providerId: entryProviderId, }; if (existingIndex >= 0) { @@ -545,7 +548,8 @@ export default class AuthService { currentEntry.npub !== nextEntry.npub || currentEntry.name !== nextEntry.name || currentEntry.picture !== nextEntry.picture || - currentEntry.authType !== nextEntry.authType; + currentEntry.authType !== nextEntry.authType || + currentEntry.providerId !== nextEntry.providerId; if (changed) { draft[existingIndex] = nextEntry; savedProfilesMutated = true; @@ -791,12 +795,16 @@ export default class AuthService { return { removed: changed }; } - async switchProfile(pubkey) { + async switchProfile(pubkey, options = {}) { const normalizedTarget = this.normalizeHexPubkey(pubkey); if (!normalizedTarget) { throw new Error("Unable to switch profiles: invalid account."); } + const normalizedOptions = + options && typeof options === "object" ? { ...options } : {}; + const providerId = this.normalizeProviderId(normalizedOptions.providerId); + const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey()); if (normalizedActive && normalizedActive === normalizedTarget) { return { switched: false, reason: "already-active" }; @@ -806,6 +814,7 @@ export default class AuthService { allowAccountSelection: true, expectPubkey: normalizedTarget, persistActive: true, + ...(providerId ? { providerId } : {}), }; const autoApply = requestOptions.autoApply !== false; @@ -827,7 +836,10 @@ export default class AuthService { if (!detail && !autoApply) { const fallbackTarget = this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget; - detail = await this.login(fallbackTarget, { persistActive: true }); + detail = await this.login(fallbackTarget, { + persistActive: true, + providerId, + }); } mutateSavedProfiles((profiles) => { diff --git a/js/state/cache.js b/js/state/cache.js index 6df5467a..40db7512 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -364,6 +364,10 @@ export function persistSavedProfiles({ persistActive = true } = {}) { typeof entry.authType === "string" && entry.authType.trim() ? entry.authType.trim() : null, + providerId: + typeof entry.providerId === "string" && entry.providerId.trim() + ? entry.providerId.trim() + : null, })), activePubkey: activePubkeyToPersist, }; @@ -430,6 +434,24 @@ export function loadSavedProfilesFromStorage() { needsRewrite = true; } } + const rawProviderId = + typeof candidate.providerId === "string" ? candidate.providerId : null; + let storedProviderId = null; + if (rawProviderId) { + const trimmedProviderId = rawProviderId.trim(); + if (trimmedProviderId) { + storedProviderId = trimmedProviderId; + if (trimmedProviderId !== rawProviderId) { + needsRewrite = true; + } + } + } + if (!storedProviderId && storedAuthType) { + storedProviderId = storedAuthType; + if (rawProviderId !== storedProviderId) { + needsRewrite = true; + } + } const npub = typeof candidate.npub === "string" && candidate.npub.trim() ? candidate.npub.trim() @@ -441,6 +463,7 @@ export function loadSavedProfilesFromStorage() { name: typeof candidate.name === "string" ? candidate.name : "", picture: typeof candidate.picture === "string" ? candidate.picture : "", authType: storedAuthType, + providerId: storedProviderId, }; if ( diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index feb47257..656f03a4 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -11,6 +11,7 @@ import { getTrustedSpamHideThreshold, RUNTIME_FLAGS, } from "../constants.js"; +import { getProviderMetadata } from "../services/authProviders/index.js"; import { devLogger, userLogger } from "../utils/logger.js"; const noop = () => {}; @@ -21,6 +22,30 @@ const DEFAULT_ADMIN_DM_IMAGE_URL = const DEFAULT_BITVID_WEBSITE_URL = "https://bitvid.network/"; const NWC_URI_SCHEME = "nostr+walletconnect://"; const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; +const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; + +const PROVIDER_BADGE_BASE_CLASS = + "text-3xs font-semibold uppercase tracking-extra-wide"; +const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({ + info: "text-status-info", + success: "text-status-success", + warning: "text-status-warning", + danger: "text-status-danger", + neutral: "text-muted", + accent: "text-accent", + primary: "text-accent", +}); + +function resolveProviderBadgeClass(variant) { + if (typeof variant === "string" && variant.trim()) { + const normalized = variant.trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(PROVIDER_BADGE_VARIANT_CLASS_MAP, normalized)) { + return PROVIDER_BADGE_VARIANT_CLASS_MAP[normalized]; + } + } + + return PROVIDER_BADGE_VARIANT_CLASS_MAP.neutral; +} const DEFAULT_INTERNAL_NWC_SETTINGS = Object.freeze({ nwcUri: "", @@ -1419,8 +1444,8 @@ export class ProfileModalController { ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const activeNameFallback = activeMeta?.npub - ? formatNpub(activeMeta.npub) || "Saved profile" - : "Saved profile"; + ? formatNpub(activeMeta.npub) || DEFAULT_SAVED_PROFILE_LABEL + : DEFAULT_SAVED_PROFILE_LABEL; const activeDisplayName = hasActiveProfile ? activeMeta?.name?.trim() || activeNameFallback : "No active profile"; @@ -1545,7 +1570,9 @@ export class ProfileModalController { avatarImg.src = meta.picture || FALLBACK_PROFILE_AVATAR; const cardDisplayName = meta.name?.trim() || - (meta.npub ? formatNpub(meta.npub) || "Saved profile" : "Saved profile"); + (meta.npub + ? formatNpub(meta.npub) || DEFAULT_SAVED_PROFILE_LABEL + : DEFAULT_SAVED_PROFILE_LABEL); avatarImg.alt = `${cardDisplayName} avatar`; avatarSpan.appendChild(avatarImg); @@ -1555,11 +1582,24 @@ export class ProfileModalController { const topLine = document.createElement("div"); topLine.className = "flex flex-wrap items-center gap-3"; + const providerId = this.getEntryProviderId(entry); + const providerInfo = this.resolveEntryProviderMetadata(entry); + const providerLabel = + (providerInfo && providerInfo.label) || DEFAULT_SAVED_PROFILE_LABEL; + const badgeVariant = resolveProviderBadgeClass( + providerInfo && providerInfo.badgeVariant, + ); + const label = document.createElement("span"); - label.className = - "text-3xs font-semibold uppercase tracking-extra-wide text-status-info"; - label.textContent = - normalizedAuthType === "nsec" ? "Direct key" : "Saved profile"; + label.className = `${PROVIDER_BADGE_BASE_CLASS} ${badgeVariant}`; + label.textContent = providerLabel; + label.dataset.providerVariant = + (providerInfo && providerInfo.badgeVariant) || "neutral"; + if (providerId) { + label.dataset.providerId = providerId; + } else if (providerInfo && providerInfo.id) { + label.dataset.providerId = providerInfo.id; + } const action = document.createElement("span"); action.className = "text-xs font-medium text-muted"; @@ -1589,6 +1629,15 @@ export class ProfileModalController { : `Switch to ${cardDisplayName}`; button.setAttribute("aria-label", ariaLabel); + const datasetProviderId = + providerId || (providerInfo && providerInfo.id) || ""; + + if (datasetProviderId) { + button.dataset.providerId = datasetProviderId; + } else { + delete button.dataset.providerId; + } + const activateProfile = async (event) => { if (event) { event.preventDefault(); @@ -1602,13 +1651,22 @@ export class ProfileModalController { button.dataset.loading = "true"; button.setAttribute("aria-busy", "true"); - try { - await this.switchProfile(entry.pubkey, { entry }); - } catch (error) { - userLogger.error("Failed to switch profile:", error); - } finally { - button.dataset.loading = "false"; - button.setAttribute("aria-busy", "false"); + const logProviderId = + providerId || (providerInfo && providerInfo.id) || normalizedAuthType || "unknown"; + + try { + await this.switchProfile(entry.pubkey, { + entry, + providerId: providerId || null, + }); + } catch (error) { + userLogger.error( + `[ProfileModalController] Failed to switch profile for provider ${logProviderId}:`, + error, + ); + } finally { + button.dataset.loading = "false"; + button.setAttribute("aria-busy", "false"); } }; @@ -4708,16 +4766,20 @@ export class ProfileModalController { ); } - async requestSwitchProfile({ pubkey, entry } = {}) { + async requestSwitchProfile({ pubkey, entry, providerId } = {}) { const callback = this.callbacks.onRequestSwitchProfile; if (callback && callback !== noop) { - return callback({ controller: this, pubkey, entry }); + return callback({ controller: this, pubkey, entry, providerId }); } if (!pubkey) { throw new Error("Missing target pubkey for switch request."); } + if (providerId) { + return this.services.switchProfile(pubkey, { providerId }); + } + return this.services.switchProfile(pubkey); } @@ -5618,14 +5680,14 @@ export class ProfileModalController { } } - async switchProfile(pubkey, { entry } = {}) { + async switchProfile(pubkey, { entry, providerId } = {}) { if (!pubkey) { return { switched: false, reason: "missing-pubkey" }; } let result; try { - result = await this.requestSwitchProfile({ pubkey, entry }); + result = await this.requestSwitchProfile({ pubkey, entry, providerId }); } catch (error) { const message = error && typeof error.message === "string" && error.message.trim() @@ -5661,6 +5723,41 @@ export class ProfileModalController { return this.state.setSavedProfiles(...args); } + normalizeProviderId(providerId) { + if (typeof providerId !== "string") { + return null; + } + + const trimmed = providerId.trim(); + if (!trimmed) { + return null; + } + + return trimmed; + } + + getEntryProviderId(entry) { + if (!entry || typeof entry !== "object") { + return null; + } + + const explicit = this.normalizeProviderId(entry.providerId); + if (explicit) { + return explicit; + } + + return this.normalizeProviderId(entry.authType); + } + + resolveEntryProviderMetadata(entry) { + const providerId = this.getEntryProviderId(entry); + if (providerId) { + return getProviderMetadata(providerId); + } + + return getProviderMetadata(); + } + persistSavedProfiles(...args) { if (typeof this.services.persistSavedProfiles === "function") { try { diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 67971dad..851806ed 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -440,12 +440,16 @@ for (const _ of [0]) { npub: 'npub1abcdefghijkmnopqrstuvwxyz1234567890example', name: '', picture: '', + providerId: 'nip07', + authType: 'nip07', }, { pubkey: 'b'.repeat(64), npub: 'npub1zyxwvutsrqponmlkjihgfedcba1234567890sample', name: '', picture: '', + providerId: 'nsec', + authType: 'nsec', }, ]; @@ -470,6 +474,62 @@ for (const _ of [0]) { const expectedSwitcher = formatShortNpub(sampleProfiles[1].npub); assert.equal(switcherNpubEl.textContent, expectedSwitcher); }); + + test('renderSavedProfiles applies provider metadata', async () => { + const sampleProfiles = [ + { + pubkey: defaultActorHex, + npub: 'npub1abcdefghijkmnopqrstuvwxyz1234567890example', + name: 'Primary Account', + picture: '', + providerId: 'nip07', + authType: 'nip07', + }, + { + pubkey: 'b'.repeat(64), + npub: 'npub1zyxwvutsrqponmlkjihgfedcba1234567890sample', + name: 'Backup', + picture: '', + providerId: 'nsec', + authType: 'nsec', + }, + ]; + + const controller = createController({ + services: { + formatShortNpub, + }, + }); + + await controller.load(); + + controller.state.setSavedProfiles(sampleProfiles); + controller.state.setActivePubkey(null); + + controller.renderSavedProfiles(); + + const buttons = Array.from( + controller.switcherList.querySelectorAll('button[data-provider-id]'), + ); + + assert.equal(buttons.length, 2, 'expected two provider entries in the switcher'); + + const [extensionButton, directKeyButton] = buttons; + + assert.equal(extensionButton.dataset.providerId, 'nip07'); + const extensionLabel = extensionButton.querySelector('[data-provider-variant]'); + assert.ok(extensionLabel, 'extension entry should include a provider badge'); + assert.equal(extensionLabel.textContent, 'Login with Extension'); + assert.equal(extensionLabel.dataset.providerVariant, 'info'); + assert.equal(extensionButton.getAttribute('aria-pressed'), 'false'); + + assert.equal(directKeyButton.dataset.providerId, 'nsec'); + const directKeyLabel = directKeyButton.querySelector('[data-provider-variant]'); + assert.ok(directKeyLabel, 'direct key entry should include a provider badge'); + assert.equal(directKeyLabel.textContent, 'Direct key'); + assert.equal(directKeyLabel.dataset.providerVariant, 'warning'); + assert.equal(directKeyButton.getAttribute('aria-pressed'), 'false'); + }); } test('load() injects markup and caches expected elements', async () => { diff --git a/tests/state/cache-saved-profiles.test.mjs b/tests/state/cache-saved-profiles.test.mjs index acf07fd9..29c935d3 100644 --- a/tests/state/cache-saved-profiles.test.mjs +++ b/tests/state/cache-saved-profiles.test.mjs @@ -42,6 +42,7 @@ test("persistSavedProfiles preserves custom authType strings", () => { name: "", picture: "", authType: "custom-wallet", + providerId: "custom-wallet", }, ], { persist: false, persistActive: false }, @@ -55,6 +56,7 @@ test("persistSavedProfiles preserves custom authType strings", () => { const stored = JSON.parse(storedRaw); assert.equal(stored.entries.length, 1); assert.equal(stored.entries[0].authType, "custom-wallet"); + assert.equal(stored.entries[0].providerId, "custom-wallet"); }); test("loadSavedProfilesFromStorage retains custom provider authType", () => { @@ -71,6 +73,7 @@ test("loadSavedProfilesFromStorage retains custom provider authType", () => { name: "", picture: "", authType: "nostr-wallet-connect", + providerId: "nostr-wallet-connect", }, ], activePubkey: null, @@ -80,9 +83,11 @@ test("loadSavedProfilesFromStorage retains custom provider authType", () => { const { profiles } = loadSavedProfilesFromStorage(); assert.equal(profiles.length, 1); assert.equal(profiles[0].authType, "nostr-wallet-connect"); + assert.equal(profiles[0].providerId, "nostr-wallet-connect"); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); assert.equal(stored.entries[0].authType, "nostr-wallet-connect"); + assert.equal(stored.entries[0].providerId, "nostr-wallet-connect"); }); test("loadSavedProfilesFromStorage migrates missing authType to nip07", () => { @@ -107,9 +112,11 @@ test("loadSavedProfilesFromStorage migrates missing authType to nip07", () => { const { profiles } = loadSavedProfilesFromStorage(); assert.equal(profiles.length, 1); assert.equal(profiles[0].authType, "nip07"); + assert.equal(profiles[0].providerId, "nip07"); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); assert.equal(stored.entries[0].authType, "nip07"); + assert.equal(stored.entries[0].providerId, "nip07"); }); test("loadSavedProfilesFromStorage trims stored authType values", () => { @@ -126,6 +133,7 @@ test("loadSavedProfilesFromStorage trims stored authType values", () => { name: "", picture: "", authType: " extension-provider ", + providerId: " extension-provider ", }, ], activePubkey: null, @@ -135,7 +143,9 @@ test("loadSavedProfilesFromStorage trims stored authType values", () => { const { profiles } = loadSavedProfilesFromStorage(); assert.equal(profiles.length, 1); assert.equal(profiles[0].authType, "extension-provider"); + assert.equal(profiles[0].providerId, "extension-provider"); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); assert.equal(stored.entries[0].authType, "extension-provider"); + assert.equal(stored.entries[0].providerId, "extension-provider"); });