mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -103,7 +103,7 @@ The BookOrbit plugin for KOReader adds progress sync, two-way annotation sync, a
|
||||
|
||||

|
||||
|
||||
1. In BookOrbit, go to **Settings > Integrations > KOReader** and click **Download Plugin**.
|
||||
1. In BookOrbit, go to **Settings > KOReader**, create credentials if prompted, and click **Download Plugin**.
|
||||
2. Unzip `bookorbit.koplugin.zip`.
|
||||
3. Copy `bookorbit.koplugin` to `koreader/plugins/` on the device.
|
||||
4. Restart KOReader and open a book.
|
||||
|
||||
@@ -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'))!
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user