mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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' })
|
||||
|
||||
+150
@@ -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')
|
||||
})
|
||||
})
|
||||
+4
-8
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user