fix(settings): expose koreader credential rotation

Expose the existing credential update path so users can rotate the single KOReader login without deleting synced data. Clarify the current settings location and device reconnection requirement.

Closes #990
This commit is contained in:
neon
2026-08-13 19:23:38 +00:00
parent becfb6a72e
commit fddb41fdb9
5 changed files with 323 additions and 6 deletions
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Eye, EyeOff, Loader2 } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import type { UpdateKoreaderCredentialsPayload } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const props = defineProps<{
open: boolean
currentUsername: string
saving: boolean
error: string | null
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
submit: [payload: UpdateKoreaderCredentialsPayload]
}>()
const { t } = useI18n()
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const trimmedUsername = computed(() => username.value.trim())
const usernameValid = computed(() => trimmedUsername.value.length >= 3 && trimmedUsername.value.length <= 100)
const passwordValid = computed(() => password.value.length === 0 || (password.value.length >= 6 && password.value.length <= 128))
const changed = computed(() => trimmedUsername.value !== props.currentUsername || password.value.length > 0)
const canSave = computed(() => !props.saving && usernameValid.value && passwordValid.value && changed.value)
watch(
() => props.open,
(open) => {
if (!open) return
username.value = props.currentUsername
password.value = ''
showPassword.value = false
},
{ immediate: true },
)
function handleOpenChange(open: boolean): void {
if (!open && !props.saving) emit('update:open', false)
}
function handleCancel(): void {
if (!props.saving) emit('update:open', false)
}
function handleTogglePassword(): void {
showPassword.value = !showPassword.value
}
function handleSubmit(): void {
if (!canSave.value) return
const payload: UpdateKoreaderCredentialsPayload = {}
if (trimmedUsername.value !== props.currentUsername) payload.username = trimmedUsername.value
if (password.value) payload.password = password.value
emit('submit', payload)
}
</script>
<template>
<DialogRoot :open="props.open" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-foreground/50" />
<DialogContent
aria-modal="true"
class="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<DialogTitle class="text-lg font-semibold text-foreground">
{{ t('settings.reader.koreader.changeCredentials.title') }}
</DialogTitle>
<DialogDescription class="mt-1 text-sm text-muted-foreground">
{{ t('settings.reader.koreader.changeCredentials.description') }}
</DialogDescription>
<form class="mt-5 space-y-4" @submit.prevent="handleSubmit">
<div>
<label for="koreader-credentials-username" class="mb-1.5 block text-sm font-medium text-foreground">
{{ t('settings.reader.koreader.username') }}
</label>
<input
id="koreader-credentials-username"
v-model="username"
type="text"
minlength="3"
maxlength="100"
autocomplete="off"
class="input-field w-full"
:aria-invalid="username.length > 0 && !usernameValid"
:aria-describedby="username.length > 0 && !usernameValid ? 'koreader-credentials-username-error' : undefined"
/>
<p v-if="username.length > 0 && !usernameValid" id="koreader-credentials-username-error" class="mt-1.5 text-xs text-destructive">
{{ t('settings.reader.koreader.changeCredentials.usernameInvalid') }}
</p>
</div>
<div>
<label for="koreader-credentials-password" class="mb-1.5 block text-sm font-medium text-foreground">
{{ t('settings.reader.koreader.changeCredentials.newPassword') }}
</label>
<div class="relative">
<input
id="koreader-credentials-password"
v-model="password"
v-bind="SECRET_INPUT_ATTRS"
type="text"
minlength="6"
maxlength="128"
:placeholder="t('settings.reader.koreader.changeCredentials.passwordPlaceholder')"
class="input-field w-full pr-10"
:class="{ 'input-secret': !showPassword }"
:aria-invalid="password.length > 0 && !passwordValid"
:aria-describedby="
password.length > 0 && !passwordValid ? 'koreader-credentials-password-error' : 'koreader-credentials-password-hint'
"
/>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="
showPassword
? t('settings.reader.koreader.changeCredentials.hidePassword')
: t('settings.reader.koreader.changeCredentials.showPassword')
"
@click="handleTogglePassword"
>
<EyeOff v-if="showPassword" :size="16" aria-hidden="true" />
<Eye v-else :size="16" aria-hidden="true" />
</button>
</div>
<p id="koreader-credentials-password-hint" class="mt-1.5 text-xs text-muted-foreground">
{{ t('settings.reader.koreader.changeCredentials.passwordHint') }}
</p>
<p v-if="password.length > 0 && !passwordValid" id="koreader-credentials-password-error" class="mt-1.5 text-xs text-destructive">
{{ t('settings.reader.koreader.changeCredentials.passwordInvalid') }}
</p>
</div>
<p class="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{{ t('settings.reader.koreader.changeCredentials.deviceNotice') }}
</p>
<p v-if="props.error" role="alert" class="text-sm text-destructive">
{{ props.error }}
</p>
<div class="flex justify-end gap-2 pt-1">
<Button type="button" variant="outline" :disabled="props.saving" @click="handleCancel">
{{ t('common.cancel') }}
</Button>
<Button type="submit" :disabled="!canSave">
<Loader2 v-if="props.saving" class="animate-spin" aria-hidden="true" />
{{ props.saving ? t('settings.reader.koreader.changeCredentials.saving') : t('settings.reader.koreader.changeCredentials.save') }}
</Button>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -16,6 +16,7 @@ import {
Download,
Eye,
EyeOff,
KeyRound,
Library,
Link2,
RefreshCw,
@@ -26,9 +27,17 @@ import {
X,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import type { BookCard, KoreaderDeviceInfo, KoreaderDeviceSweepInfo, KoreaderManualHashLink, KoreaderUnmatchedBook } from '@bookorbit/types'
import type {
BookCard,
KoreaderDeviceInfo,
KoreaderDeviceSweepInfo,
KoreaderManualHashLink,
KoreaderUnmatchedBook,
UpdateKoreaderCredentialsPayload,
} from '@bookorbit/types'
import SettingsPageHeader from './SettingsPageHeader.vue'
import SettingsTabs from './components/SettingsTabs.vue'
import KoreaderCredentialsDialog from './KoreaderCredentialsDialog.vue'
import KoreaderFileNamingSettings from './KoreaderFileNamingSettings.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
@@ -118,6 +127,9 @@ const newUsername = ref('')
const newPassword = ref('')
const creating = ref(false)
const showPassword = ref(false)
const changeCredentialsOpen = ref(false)
const changingCredentials = ref(false)
const changeCredentialsError = ref<string | null>(null)
const deleteConfirmOpen = ref(false)
const helpOpen = ref(false)
const urlCopied = ref(false)
@@ -349,6 +361,31 @@ function handleTogglePassword() {
showPassword.value = !showPassword.value
}
function handleOpenChangeCredentials() {
changeCredentialsError.value = null
changeCredentialsOpen.value = true
}
function handleChangeCredentialsOpenChange(open: boolean) {
if (changingCredentials.value) return
changeCredentialsOpen.value = open
if (!open) changeCredentialsError.value = null
}
async function handleChangeCredentials(payload: UpdateKoreaderCredentialsPayload) {
changingCredentials.value = true
changeCredentialsError.value = null
try {
await updateCredentials(payload)
changeCredentialsOpen.value = false
toast.success(t('settings.reader.koreader.changeCredentials.updated'))
} catch (e) {
changeCredentialsError.value = e instanceof Error ? e.message : t('settings.reader.koreader.changeCredentials.updateFailed')
} finally {
changingCredentials.value = false
}
}
function handleOpenDeleteConfirm() {
deleteConfirmOpen.value = true
}
@@ -670,9 +707,18 @@ async function handleDownloadPlugin() {
class="input-field w-full pr-10"
: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" />
<Eye v-else :size="14" />
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="
showPassword
? t('settings.reader.koreader.changeCredentials.hidePassword')
: t('settings.reader.koreader.changeCredentials.showPassword')
"
@click="handleTogglePassword"
>
<EyeOff v-if="showPassword" :size="14" aria-hidden="true" />
<Eye v-else :size="14" aria-hidden="true" />
</button>
</div>
</div>
@@ -731,6 +777,14 @@ async function handleDownloadPlugin() {
<p class="settings-hint font-mono truncate">
{{ credentials?.username }}
</p>
<button
type="button"
class="mt-2 inline-flex items-center gap-1.5 rounded-md text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="handleOpenChangeCredentials"
>
<KeyRound :size="12" aria-hidden="true" />
{{ t('settings.reader.koreader.changeCredentials.action') }}
</button>
</div>
<div>
<p class="settings-label">
@@ -1289,6 +1343,15 @@ async function handleDownloadPlugin() {
</div>
</template>
<KoreaderCredentialsDialog
:open="changeCredentialsOpen"
:current-username="credentials?.username ?? ''"
:saving="changingCredentials"
:error="changeCredentialsError"
@update:open="handleChangeCredentialsOpenChange"
@submit="handleChangeCredentials"
/>
<div
v-if="deleteConfirmOpen"
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
@@ -200,6 +200,11 @@ function buttonByText(wrapper: ReturnType<typeof mount>, text: string) {
return wrapper.findAll('button').find((button) => button.text().includes(text))
}
function setInputValue(input: HTMLInputElement, value: string): void {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('KoreaderSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -339,6 +344,71 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).not.toContain('Download the preconfigured plugin above.')
})
it('updates an existing KOReader username and password without deleting credentials', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Change credentials')!.trigger('click')
await flushPromises()
const usernameInput = document.body.querySelector<HTMLInputElement>('#koreader-credentials-username')!
const passwordInput = document.body.querySelector<HTMLInputElement>('#koreader-credentials-password')!
const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('Save credentials'))!
expect(usernameInput.value).toBe('reader-user')
expect(passwordInput.classList.contains('input-secret')).toBe(true)
expect(saveButton.disabled).toBe(true)
setInputValue(passwordInput, 'short')
await flushPromises()
expect(saveButton.disabled).toBe(true)
expect(document.body.textContent).toContain('The password must be between 6 and 128 characters.')
setInputValue(usernameInput, 'replacement-reader')
setInputValue(passwordInput, 'new-secret')
await flushPromises()
expect(saveButton.disabled).toBe(false)
saveButton.click()
await flushPromises()
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({
username: 'replacement-reader',
password: 'new-secret',
})
expect(document.body.textContent).not.toContain('Change KOReader credentials')
wrapper.unmount()
})
it('keeps the credential dialog open when updating credentials fails', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.updateCredentials.mockRejectedValueOnce(new Error('Username already taken'))
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Change credentials')!.trigger('click')
await flushPromises()
const usernameInput = document.body.querySelector<HTMLInputElement>('#koreader-credentials-username')!
setInputValue(usernameInput, 'taken-reader')
await flushPromises()
const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('Save credentials'))!
saveButton.click()
await flushPromises()
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({
username: 'taken-reader',
})
expect(document.body.textContent).toContain('Username already taken')
expect(document.body.textContent).toContain('Change KOReader credentials')
wrapper.unmount()
})
it('expands the setup guide only when requested', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
@@ -670,7 +740,10 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).toContain('Linked KOReader Title')
expect(wrapper.text()).toContain('Linked to Linked BookOrbit Title')
await buttonByText(wrapper, 'Change')!.trigger('click')
await wrapper
.findAll('button')
.find((button) => button.text() === 'Change')!
.trigger('click')
await wrapper
.findAll('button')
.find((button) => button.text().includes('Replacement Book'))!
+17
View File
@@ -2089,6 +2089,23 @@
"usernamePlaceholder": "e.g. myreader",
"password": "Password",
"passwordPlaceholder": "Min 6 characters",
"changeCredentials": {
"action": "Change credentials",
"title": "Change KOReader credentials",
"description": "Each BookOrbit account has one KOReader credential set. Change it here without deleting synced data.",
"newPassword": "New password",
"usernameInvalid": "The username must be between 3 and 100 characters.",
"passwordPlaceholder": "Leave blank to keep the current password",
"passwordHint": "Leave this blank to keep the current password.",
"passwordInvalid": "The password must be between 6 and 128 characters.",
"deviceNotice": "Devices using the previous login must be reconnected or updated with a newly downloaded plugin package.",
"showPassword": "Show new password",
"hidePassword": "Hide new password",
"save": "Save credentials",
"saving": "Saving...",
"updated": "KOReader credentials updated",
"updateFailed": "Failed to update KOReader credentials"
},
"creating": "Creating...",
"create": "Create",
"credentialsCreated": "KOReader sync credentials created",