fix(settings): stop Chrome offering to save provider secrets

Non-credential secrets rendered as input[type="password"], so every
settings tab click unmounted a populated password form during a
pushState navigation, matching Chrome's heuristic for a submitted login
form and popping the "Update password?" bubble.

Render them as masked text inputs instead, and guard the boundary with a
static scan that restricts input[type="password"] to BookOrbit login
forms.
This commit is contained in:
neon
2026-08-11 05:43:59 +00:00
parent 4c46a4fc2c
commit a5385633b0
18 changed files with 354 additions and 36 deletions
+8
View File
@@ -408,6 +408,14 @@
disabled:opacity-50 transition-colors;
}
/* Masks a secret that is not a BookOrbit login credential (API keys, sync
tokens, SMTP passwords). Pair with type="text" and SECRET_INPUT_ATTRS from
@/lib/secret-input so Chrome's password manager ignores the field; see that
module for why type="password" cannot be used here. */
.input-secret {
-webkit-text-security: disc;
}
/* ── Settings action buttons ─────────────────────────────────
Use these on <button> elements in settings panels (headers,
card rows, inline forms). Matches the Kobo "Add device" size.
@@ -9,6 +9,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useAuth } from '@/features/auth/composables/useAuth'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useMediaQuery } from '@vueuse/core'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const { t } = useI18n()
const {
@@ -259,9 +260,9 @@ watch(
</label>
<input
v-model="form.password"
type="password"
autocomplete="new-password"
class="w-full h-9 px-3 text-sm border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
v-bind="SECRET_INPUT_ATTRS"
type="text"
class="input-secret w-full h-9 px-3 text-sm border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { Link, Save, CheckCircle2, AlertCircle, Info, Loader2, Unlink } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
import { useHardcoverSettings } from '../composables/useHardcoverSettings'
const { t } = useI18n()
@@ -116,10 +117,11 @@ function selectBookSyncMode(mode: 'all_eligible' | 'selected_only') {
<div class="flex gap-2">
<input
v-model="tokenInput"
:type="tokenVisible ? 'text' : 'password'"
v-bind="SECRET_INPUT_ATTRS"
type="text"
:placeholder="t('hardcover.connection.tokenPlaceholder')"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
autocomplete="off"
:class="{ 'input-secret': !tokenVisible }"
/>
<button
type="button"
@@ -170,9 +170,11 @@ describe('HardcoverConnectionCard', () => {
const disconnectButton = wrapper.findAll('button').find((button) => button.text().includes('Disconnect'))!
const saveButton = wrapper.findAll('button').find((button) => button.text().includes('Save'))!
expect(tokenInput.attributes('type')).toBe('password')
expect(tokenInput.attributes('type')).toBe('text')
expect(tokenInput.classes()).toContain('input-secret')
await showButton.trigger('click')
expect(tokenInput.attributes('type')).toBe('text')
expect(tokenInput.classes()).not.toContain('input-secret')
await validateButton.trigger('click')
expect(toastError).toHaveBeenCalledWith('Enter your Hardcover API token first')
@@ -39,6 +39,7 @@ import {
import { useMigrationPolling } from '@/features/migration/composables/useMigrationPolling'
import { useMigrationProgress } from '@/features/migration/composables/useMigrationProgress'
import { useMigrationSetupReset } from '@/features/migration/composables/useMigrationSetupReset'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
interface StepDefinition {
label: string
@@ -1440,8 +1441,10 @@ const sourceTypeCompatibility = computed<SourceTypeCompatibility | null>(() => {
<div class="relative mt-1">
<input
v-model="sourceDraft.password"
v-bind="SECRET_INPUT_ATTRS"
class="input-field w-full pr-9"
:type="showPassword ? 'text' : 'password'"
:class="{ 'input-secret': !showPassword }"
type="text"
:placeholder="source ? t('migration.source.fields.passwordSaved') : t('migration.source.fields.passwordNotSet')"
:disabled="hasActiveRun"
/>
@@ -3,6 +3,7 @@ import { nextTick, ref } from 'vue'
import { KeyRound, LoaderCircle } from '@lucide/vue'
import type { DocumentState } from '@embedpdf/core'
import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/vue'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const props = defineProps<{ documentState: DocumentState }>()
const emit = defineEmits<{ back: [] }>()
@@ -49,12 +50,12 @@ function handleSubmit() {
<input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="off"
v-bind="SECRET_INPUT_ATTRS"
type="text"
autofocus
:disabled="submitting"
placeholder="Document password"
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
class="input-secret h-10 w-full rounded-md border border-border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
<p v-if="errorMessage" class="mt-2 text-xs text-destructive">{{ errorMessage }}</p>
<div class="mt-4 grid grid-cols-2 gap-2">
@@ -6,6 +6,7 @@ import { toast } from 'vue-sonner'
import type { ReadwiseSyncDisabledReason, ReadwiseTokenValidationResult } from '@bookorbit/types'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
import { useReadwiseSettings } from '../composables/useReadwiseSettings'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
@@ -103,10 +104,11 @@ function toggleTokenVisible() {
<input
:id="tokenInputId"
v-model="tokenInput"
:type="tokenVisible ? 'text' : 'password'"
v-bind="SECRET_INPUT_ATTRS"
type="text"
placeholder="Paste your Readwise access token"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
autocomplete="off"
:class="{ 'input-secret': !tokenVisible }"
/>
<button
type="button"
@@ -178,10 +178,12 @@ describe('ReadwiseSettings', () => {
const wrapper = await mountPage()
const input = wrapper.get('#readwise-access-token')
expect((input.element as HTMLInputElement).type).toBe('password')
await testButton(wrapper, 'Show').trigger('click')
expect((input.element as HTMLInputElement).type).toBe('text')
expect(input.classes()).toContain('input-secret')
await testButton(wrapper, 'Show').trigger('click')
expect(input.classes()).not.toContain('input-secret')
await testButton(wrapper, 'Hide').trigger('click')
expect((input.element as HTMLInputElement).type).toBe('password')
expect(input.classes()).toContain('input-secret')
expect((input.element as HTMLInputElement).type).toBe('text')
})
})
@@ -34,6 +34,7 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
import { useKoreaderSync } from '@/features/koreader/composables/useKoreaderSync'
import { useGlobalSearch } from '@/features/book/composables/useGlobalSearch'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const { t } = useI18n()
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
@@ -674,10 +675,11 @@ async function handleDownloadPlugin() {
<div class="relative">
<input
v-model="newPassword"
:type="showPassword ? 'text' : 'password'"
v-bind="SECRET_INPUT_ATTRS"
type="text"
:placeholder="t('settings.reader.koreader.passwordPlaceholder')"
class="input-field w-full pr-10"
autocomplete="new-password"
:class="{ 'input-secret': !showPassword }"
/>
<button class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="handleTogglePassword">
<EyeOff v-if="showPassword" :size="14" />
@@ -41,6 +41,7 @@ import {
} from '@/features/migration/lib/migration-api'
import { useMigrationPolling } from '@/features/migration/composables/useMigrationPolling'
import { useMigrationProgress } from '@/features/migration/composables/useMigrationProgress'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const { t } = useI18n()
@@ -1352,8 +1353,10 @@ function formatSourceCountLabel(key: string) {
<div class="relative mt-1">
<input
v-model="sourceDraft.password"
v-bind="SECRET_INPUT_ATTRS"
class="input-field w-full pr-9"
:type="showPassword ? 'text' : 'password'"
:class="{ 'input-secret': !showPassword }"
type="text"
:placeholder="
source ? t('settings.admin.migration.passwordSavedPlaceholder') : t('settings.admin.migration.passwordEmptyPlaceholder')
"
@@ -8,6 +8,7 @@ import { Permission, PERMISSION_LABELS } from '@bookorbit/types'
import { ArrowLeft, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
@@ -633,10 +634,10 @@ async function deleteGroupMapping(id: number) {
</p>
<input
v-model="form.clientSecret"
type="password"
v-bind="SECRET_INPUT_ATTRS"
type="text"
:placeholder="viewMode === 'edit' ? t('settings.oidc.form.clientSecretPlaceholder') : ''"
autocomplete="new-password"
class="input-field w-full md:w-72"
class="input-field input-secret w-full md:w-72"
/>
</div>
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
@@ -10,6 +10,7 @@ import { copyToClipboard } from '@/lib/clipboard'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import type { OpdsUser, OpdsSortOrder } from '@bookorbit/types'
import { useMediaQuery } from '@vueuse/core'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const { t } = useI18n()
const { hasPermission } = usePermissions()
@@ -290,7 +291,13 @@ watch(
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1.5">{{ t('settings.reader.opds.password') }}</label>
<input v-model="createPassword" type="password" :placeholder="t('settings.reader.opds.passwordPlaceholder')" class="input-field w-full" />
<input
v-model="createPassword"
v-bind="SECRET_INPUT_ATTRS"
type="text"
:placeholder="t('settings.reader.opds.passwordPlaceholder')"
class="input-field input-secret w-full"
/>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1.5">{{ t('settings.reader.opds.defaultSort') }}</label>
@@ -293,8 +293,10 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).not.toContain('Setup Guide')
await buttonByText(wrapper, 'Create credentials')!.trigger('click')
await wrapper.find('input[type="text"]').setValue('new-reader')
await wrapper.find('input[type="password"]').setValue('secret1')
const [usernameField, passwordField] = wrapper.findAll('input[type="text"]')
expect(passwordField.classes()).toContain('input-secret')
await usernameField.setValue('new-reader')
await passwordField.setValue('secret1')
await buttonByText(wrapper, 'Create')!.trigger('click')
expect(koreaderMock.createCredentials).toHaveBeenCalledWith({ username: 'new-reader', password: 'secret1' })
@@ -0,0 +1,150 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { ProviderConfigurations, ProviderStatus } from '@bookorbit/types'
import ProviderConfigPanel from '../components/ProviderConfigPanel.vue'
/**
* The Metadata tab is the default settings surface that holds saved provider
* secrets. While these rendered as `input[type="password"]`, every settings tab
* click unmounted a populated password form during a `history.pushState`, which
* is exactly Chrome's heuristic for a submitted login form: it popped the
* "Update password?" bubble. These tests pin the field shape that keeps the
* panel out of Chrome's password manager.
*/
function makeConfig(overrides: Partial<ProviderConfigurations> = {}): ProviderConfigurations {
return {
google: { enabled: true, apiKey: 'google-secret-key' },
amazon: { enabled: true, domain: 'amazon.com', cookie: 'amazon-session-cookie' },
goodreads: { enabled: false },
hardcover: { enabled: true, apiKey: 'hardcover-secret-key' },
openLibrary: { enabled: false },
itunes: { enabled: false, coverResolution: 'high' },
audible: { enabled: false, domain: 'audible.com' },
audnexus: { enabled: false },
librofm: { enabled: false },
comicvine: { enabled: true, apiKey: 'comicvine-secret-key' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: true, ttbKey: 'aladin-ttb-key' },
...overrides,
}
}
const SECRET_FIELD_NAMES = [
'metadata-google-apiKey',
'metadata-amazon-cookie',
'metadata-hardcover-apiKey',
'metadata-comicvine-apiKey',
'metadata-aladin-ttbKey',
]
function mountPanel(config: ProviderConfigurations = makeConfig(), statuses: ProviderStatus[] = []) {
return mount(ProviderConfigPanel, { props: { config, statuses, saving: false } })
}
function secretField(wrapper: VueWrapper, name: string) {
return wrapper.get(`input[name="${name}"]`)
}
describe('ProviderConfigPanel secret fields', () => {
it('renders no password input at all', () => {
const wrapper = mountPanel()
expect(wrapper.findAll('input[type="password"]')).toHaveLength(0)
})
it('renders every provider secret as a masked text input', () => {
const wrapper = mountPanel()
const rendered = SECRET_FIELD_NAMES.map((name) => {
const field = secretField(wrapper, name)
return { name, type: field.attributes('type'), masked: field.classes().includes('input-secret') }
})
expect(rendered).toEqual(SECRET_FIELD_NAMES.map((name) => ({ name, type: 'text', masked: true })))
})
it('opts every provider secret out of browser and manager autofill', () => {
const wrapper = mountPanel()
const rendered = SECRET_FIELD_NAMES.map((name) => {
const attrs = secretField(wrapper, name).attributes()
return {
name,
autocomplete: attrs.autocomplete,
autocorrect: attrs.autocorrect,
autocapitalize: attrs.autocapitalize,
spellcheck: attrs.spellcheck,
lastPass: attrs['data-lpignore'],
onePassword: attrs['data-1p-ignore'],
formType: attrs['data-form-type'],
}
})
expect(rendered).toEqual(
SECRET_FIELD_NAMES.map((name) => ({
name,
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: 'false',
lastPass: 'true',
onePassword: 'true',
formType: 'other',
})),
)
})
it('still populates each secret from the saved configuration', () => {
const wrapper = mountPanel()
expect((secretField(wrapper, 'metadata-google-apiKey').element as HTMLInputElement).value).toBe('google-secret-key')
expect((secretField(wrapper, 'metadata-amazon-cookie').element as HTMLInputElement).value).toBe('amazon-session-cookie')
expect((secretField(wrapper, 'metadata-aladin-ttbKey').element as HTMLInputElement).value).toBe('aladin-ttb-key')
})
it('leaves non-secret fields unmasked', () => {
const wrapper = mountPanel()
const maskedNonSecrets = wrapper
.findAll('input')
.filter((field) => !SECRET_FIELD_NAMES.includes(field.attributes('name') ?? ''))
.filter((field) => field.classes().includes('input-secret'))
.map((field) => field.attributes('name') ?? '(unnamed)')
expect(maskedNonSecrets).toEqual([])
})
it('keeps the secret editable and saves the edited value', async () => {
const wrapper = mountPanel()
await secretField(wrapper, 'metadata-google-apiKey').setValue('rotated-google-key')
await wrapper.get('form').trigger('submit')
const saved = wrapper.emitted('save')?.[0]?.[0] as ProviderConfigurations
expect(saved.google.apiKey).toBe('rotated-google-key')
expect(saved.hardcover.apiKey).toBe('hardcover-secret-key')
})
it('reflects a configuration reload back into the masked field', async () => {
const wrapper = mountPanel()
await wrapper.setProps({ config: makeConfig({ google: { enabled: true, apiKey: 'reloaded-key' } }) })
const field = secretField(wrapper, 'metadata-google-apiKey')
expect((field.element as HTMLInputElement).value).toBe('reloaded-key')
expect(field.attributes('type')).toBe('text')
expect(field.classes()).toContain('input-secret')
})
it('disables a secret field the row cannot edit without unmasking it', () => {
const wrapper = mountPanel(makeConfig({ amazon: { enabled: false, domain: 'amazon.com', cookie: 'amazon-session-cookie' } }))
const field = secretField(wrapper, 'metadata-amazon-cookie')
expect(field.attributes('disabled')).toBeDefined()
expect(field.classes()).toContain('input-secret')
expect(field.attributes('type')).toBe('text')
})
})
@@ -6,6 +6,7 @@ import { MetadataProviderKey } from '@bookorbit/types'
import type { ProviderConfigurations, ProviderConnectionTestResult, ProviderStatus, ProviderThrottleRuntimeState } from '@bookorbit/types'
import { toast } from 'vue-sonner'
import { Badge } from '@/components/ui/badge'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
import { stripBearerPrefix } from '../lib/provider-token'
const { t } = useI18n()
@@ -443,18 +444,13 @@ const visibleRows = computed(() => {
<input
v-else
v-model="(draft[row.key] as unknown as Record<string, string>)[field.key]"
:type="field.type === 'password' ? 'password' : field.type"
v-bind="SECRET_INPUT_ATTRS"
:type="field.type === 'password' ? 'text' : field.type"
:name="`metadata-${row.key}-${field.key}`"
:placeholder="field.placeholder ?? field.label"
:disabled="!canEditField(row, field)"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
:data-lpignore="field.type === 'password' ? 'true' : undefined"
:data-1p-ignore="field.type === 'password' ? 'true' : undefined"
:data-form-type="field.type === 'password' ? 'other' : undefined"
class="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 text-xs font-medium placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all md:w-72 lg:w-80"
:class="{ 'input-secret': field.type === 'password' }"
/>
<p v-if="field.helper" class="mt-0.5 max-w-72 text-[10px] leading-snug text-muted-foreground md:text-right">
{{ field.helper }}
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
import { Link, Save, CheckCircle2, AlertCircle, Info, Loader2, Unlink } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
import { useStorygraphSettings } from '../composables/useStorygraphSettings'
const { settings, saving, validating, error, fetchSettings, saveSettings, disconnect, validateCookies } = useStorygraphSettings()
@@ -138,10 +139,11 @@ function selectBookSyncMode(mode: 'all_eligible' | 'selected_only') {
<div class="flex gap-2">
<input
v-model="sessionCookieInput"
:type="cookiesVisible ? 'text' : 'password'"
v-bind="SECRET_INPUT_ATTRS"
type="text"
placeholder="Paste the _storygraph_session cookie value"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
autocomplete="off"
:class="{ 'input-secret': !cookiesVisible }"
/>
</div>
</div>
@@ -151,10 +153,11 @@ function selectBookSyncMode(mode: 'all_eligible' | 'selected_only') {
<div class="flex gap-2">
<input
v-model="rememberTokenInput"
:type="cookiesVisible ? 'text' : 'password'"
v-bind="SECRET_INPUT_ATTRS"
type="text"
placeholder="Paste the remember_user_token cookie value"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
autocomplete="off"
:class="{ 'input-secret': !cookiesVisible }"
/>
<button
type="button"
@@ -0,0 +1,108 @@
// @vitest-environment node
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { SECRET_INPUT_ATTRS } from '../secret-input'
const SRC_ROOT = fileURLToPath(new URL('../..', import.meta.url))
/**
* `input[type="password"]` is only legitimate for a BookOrbit login credential.
* Chrome's password manager tracks every password input on the origin, so one
* holding a provider API key or an external client credential makes each
* client-side route change look like a submitted login form and pops the
* "Save/Update password?" bubble. Secrets that are not BookOrbit credentials
* must use a masked text input instead; see `@/lib/secret-input`.
*
* Only add a file here when the field really does hold the password of the
* BookOrbit account the browser is signed in as.
*/
const BOOKORBIT_CREDENTIAL_FILES = [
'features/auth/ChangePasswordDialog.vue',
'features/auth/LoginPage.vue',
'features/auth/RegisterPage.vue',
'features/auth/ResetPasswordPage.vue',
'features/auth/SetupPage.vue',
'features/settings/AccountSettings.vue',
]
/** Matches a literal `type="password"` and a binding whose branch yields 'password'. */
const STATIC_PASSWORD_TYPE = /type="password"/
const BOUND_PASSWORD_TYPE = /:type="[^"]*[?:]\s*'password'/
function rendersPasswordInput(source: string): boolean {
return STATIC_PASSWORD_TYPE.test(source) || BOUND_PASSWORD_TYPE.test(source)
}
function collectVueFiles(dir: string): string[] {
const found: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
found.push(...collectVueFiles(full))
continue
}
if (entry.name.endsWith('.vue')) found.push(full)
}
return found
}
function toRelativePath(file: string): string {
return relative(SRC_ROOT, file).split(sep).join('/')
}
describe('secret input policy', () => {
const vueFiles = collectVueFiles(SRC_ROOT).map((file) => ({ path: toRelativePath(file), source: readFileSync(file, 'utf8') }))
it('walks the whole component tree', () => {
expect(vueFiles.length).toBeGreaterThan(100)
})
it('restricts input[type=password] to BookOrbit credential forms', () => {
const offenders = vueFiles.filter((file) => rendersPasswordInput(file.source)).map((file) => file.path)
expect([...offenders].sort()).toEqual(BOOKORBIT_CREDENTIAL_FILES)
})
it('masks every secret input it renders with the input-secret class', () => {
const missingMask = vueFiles.filter((file) => file.source.includes('SECRET_INPUT_ATTRS') && !file.source.includes('input-secret'))
expect(missingMask.map((file) => file.path)).toEqual([])
})
it('covers the settings surfaces that triggered the Chrome password bubble', () => {
const converted = vueFiles.filter((file) => file.source.includes('SECRET_INPUT_ATTRS')).map((file) => file.path)
expect(converted).toEqual(
expect.arrayContaining([
'features/settings/metadata-preferences/components/ProviderConfigPanel.vue',
'features/hardcover/components/HardcoverConnectionCard.vue',
'features/readwise/components/ReadwiseSettings.vue',
'features/storygraph/components/StorygraphConnectionCard.vue',
'features/settings/MigrationSettings.vue',
'features/settings/OpdsSettings.vue',
'features/settings/KoreaderSettings.vue',
'features/settings/OidcSettings.vue',
'features/email/components/ProvidersTab.vue',
]),
)
})
it('exposes anti-autofill attributes and never a password type', () => {
expect(SECRET_INPUT_ATTRS).toMatchObject({
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: 'false',
})
expect(Object.values(SECRET_INPUT_ATTRS)).not.toContain('password')
expect(SECRET_INPUT_ATTRS).not.toHaveProperty('type')
})
it('defines the mask with a supported CSS property', () => {
const css = readFileSync(join(SRC_ROOT, 'assets/main.css'), 'utf8')
expect(css).toMatch(/\.input-secret\s*\{[^}]*-webkit-text-security:\s*disc/)
})
})
+25
View File
@@ -0,0 +1,25 @@
/**
* Attributes for inputs holding a secret that is not a BookOrbit login credential:
* provider API keys, sync tokens, SMTP passwords, and credentials minted for
* external clients such as OPDS or KOSync.
*
* Such fields must render as `type="text"` masked with the `input-secret` class
* rather than `type="password"`. Chrome's password manager only tracks
* `input[type="password"]`, and it deliberately ignores `autocomplete` when
* deciding whether to offer to save one. A password input that Vue unmounts on a
* router navigation matches Chrome's heuristic for a submitted login form, so
* every settings tab click pops the "Save/Update password?" bubble.
*
* The `data-*` hints below are for third-party managers (1Password, LastPass,
* Dashlane); Chrome does not read them, which is why the type change carries the
* actual fix.
*/
export const SECRET_INPUT_ATTRS = {
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: 'false',
'data-lpignore': 'true',
'data-1p-ignore': 'true',
'data-form-type': 'other',
} as const