add claim support

This commit is contained in:
Gauthier Roebroeck 2025-08-06 14:12:52 +08:00
parent 584418eb8a
commit 7a4ecb14f7
6 changed files with 304 additions and 41 deletions

View file

@ -0,0 +1,38 @@
import { defineMutation, defineQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
export const QUERY_KEYS_CLAIM = {
root: ['claim'] as const,
}
export const useClaimStatus = defineQuery(() => {
return useQuery({
key: () => QUERY_KEYS_CLAIM.root,
query: () =>
komgaClient
.GET('/api/v1/claim')
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
// forever
staleTime: 0,
gcTime: false,
})
})
export const useClaimServer = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: ({ username, password }: { username: string; password: string }) =>
komgaClient.POST('/api/v1/claim', {
params: {
header: {
'X-Komga-Email': username,
'X-Komga-Password': password,
},
},
}),
onSuccess: () => {
queryCache.cancelQueries({ key: QUERY_KEYS_CLAIM.root })
},
})
})

View file

@ -52,6 +52,36 @@ export const useCurrentUser = defineQuery(() => {
}
})
export const useLogin = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: ({
username,
password,
rememberMe,
}: {
username: string
password: string
rememberMe?: boolean
}) =>
komgaClient.GET('/api/v2/users/me', {
headers: {
authorization: 'Basic ' + btoa(username + ':' + password),
'X-Requested-With': 'XMLHttpRequest',
},
params: {
query: {
'remember-me': rememberMe,
},
},
}),
onSuccess: ({ data }) => {
queryCache.setQueryData(QUERY_KEYS_USERS.currentUser, data)
queryCache.cancelQueries({ key: QUERY_KEYS_USERS.currentUser })
},
})
})
export const useLogout = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({

192
next-ui/src/pages/claim.vue Normal file
View file

@ -0,0 +1,192 @@
<template>
<v-form
ref="form"
:disabled="isLoading"
@submit.prevent="submitForm()"
>
<v-container max-width="400px">
<v-row justify="center">
<v-col
cols="7"
sm="10"
>
<v-img src="@/assets/logo.svg" />
</v-col>
</v-row>
<v-row>
<v-col>
<v-alert
color="info"
variant="tonal"
icon="i-mdi:info"
:text="
$formatMessage({
description: 'Claim server screen: information banner',
defaultMessage:
'This Komga server is not yet active, you need to create a user account to be able to access it.',
id: '2p+JVw',
})
"
>
</v-alert>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
v-model="username"
:label="
$formatMessage({
description: 'Claim server screen: email field label',
defaultMessage: 'Email',
id: 'aDFZOW',
})
"
autofocus
:rules="['required', 'email']"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
v-model="password"
:label="
$formatMessage({
description: 'Claim server screen: password field label',
defaultMessage: 'Password',
id: 'U3Uo3q',
})
"
:rules="['required']"
autocomplete="off"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'i-mdi:eye' : 'i-mdi:eye-off'"
@click:append-inner="showPassword = !showPassword"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
:label="
$formatMessage({
description: 'Claim server screen: Confirm password field label',
defaultMessage: 'Confirm password',
id: 'ezgnXr',
})
"
:rules="[
[
'sameAs',
password,
$formatMessage({
description: 'Claim server screen:: Error message if passwords differ',
defaultMessage: 'Passwords must be identical',
id: 'ZWFPAg',
}),
],
]"
autocomplete="off"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'i-mdi:eye' : 'i-mdi:eye-off'"
@click:append-inner="showPassword = !showPassword"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn
:text="
$formatMessage({
description: 'Claim server screen:: Create User Account button',
defaultMessage: 'Create User Account',
id: 'd8lMZe',
})
"
:loading="isLoading"
type="submit"
block
/>
</v-col>
</v-row>
<v-divider class="my-4" />
<v-row justify="center">
<v-col cols="auto">
<div class="d-flex ga-4">
<FragmentLocaleSelector />
<FragmentThemeSelector />
</div>
</v-col>
</v-row>
</v-container>
</v-form>
</template>
<script lang="ts" setup>
import { type ErrorCause } from '@/api/komga-client'
import { useMessagesStore } from '@/stores/messages'
import { useIntl } from 'vue-intl'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useClaimServer, useClaimStatus } from '@/colada/claim'
import { useLogin } from '@/colada/users'
const messagesStore = useMessagesStore()
const intl = useIntl()
const form = ref()
const username = ref('')
const password = ref('')
const showPassword = ref<boolean>(false)
const router = useRouter()
const route = useRoute()
const { mutateAsync: login, isLoading: isLoadingLogin } = useLogin()
const { mutateAsync: claimServer, isLoading: isLoadingClaim } = useClaimServer()
const isLoading = computed(() => isLoadingClaim.value || isLoadingLogin.value)
async function submitForm() {
const { valid } = await form.value.validate()
if (valid) {
const credentials = {
username: username.value,
password: password.value,
}
claimServer(credentials)
.then(() => {
void login(credentials).then(() => {
if (route.query.redirect) void router.push({ path: route.query.redirect.toString() })
else void router.push('/')
})
})
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message ||
intl.formatMessage(commonMessages.networkError),
})
})
}
}
const { refresh: claimStatus } = useClaimStatus()
void claimStatus().then(({ data }) => {
if (data?.isClaimed) void router.push('/')
})
</script>
<route lang="yaml">
meta:
layout: single
noAuth: true
</route>

View file

@ -1,6 +1,6 @@
<template>
<v-form
v-model="formValid"
ref="form"
:disabled="isLoading"
@submit.prevent="submitForm()"
>
@ -116,18 +116,19 @@
</template>
<script lang="ts" setup>
import { type ErrorCause, komgaClient } from '@/api/komga-client'
import { useMutation, useQueryCache } from '@pinia/colada'
import { type ErrorCause } from '@/api/komga-client'
import { useMessagesStore } from '@/stores/messages'
import { useIntl } from 'vue-intl'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useAppStore } from '@/stores/app'
import { useLogin } from '@/colada/users'
import { useClaimStatus } from '@/colada/claim'
const messagesStore = useMessagesStore()
const intl = useIntl()
const appStore = useAppStore()
const formValid = ref<boolean>(false)
const form = ref()
const username = ref('')
const password = ref('')
const loginError = ref<string>('')
@ -135,44 +136,40 @@ const loginError = ref<string>('')
const router = useRouter()
const route = useRoute()
const queryCache = useQueryCache()
const { mutate: performLogin, isLoading } = useMutation({
mutation: () =>
komgaClient.GET('/api/v2/users/me', {
headers: {
authorization: 'Basic ' + btoa(username.value + ':' + password.value),
'X-Requested-With': 'XMLHttpRequest',
},
params: {
query: {
'remember-me': appStore.rememberMe,
},
},
}),
onSuccess: ({ data }) => {
queryCache.setQueryData(['current-user'], data)
queryCache.cancelQueries({ key: ['current-user'] })
if (route.query.redirect) void router.push({ path: route.query.redirect.toString() })
else void router.push('/')
},
onError: (error) => {
if ((error?.cause as ErrorCause)?.status === 401)
loginError.value = intl.formatMessage({
description: 'Login screen: error message displayed when login failed',
defaultMessage: 'Invalid login or password',
id: 'AjWlka',
})
else
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
})
},
})
const { mutateAsync: performLogin, isLoading } = useLogin()
function submitForm() {
if (formValid.value) performLogin()
async function submitForm() {
const { valid } = await form.value.validate()
if (valid)
performLogin({
username: username.value,
password: password.value,
rememberMe: appStore.rememberMe,
})
.then(() => {
if (route.query.redirect) void router.push({ path: route.query.redirect.toString() })
else void router.push('/')
})
.catch((error) => {
if ((error?.cause as ErrorCause)?.status === 401)
loginError.value = intl.formatMessage({
description: 'Login screen: error message displayed when login failed',
defaultMessage: 'Invalid login or password',
id: 'AjWlka',
})
else
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message ||
intl.formatMessage(commonMessages.networkError),
})
})
}
const { refresh: claimStatus } = useClaimStatus()
void claimStatus().then(({ data }) => {
if (data?.isClaimed == false) void router.push('/')
})
</script>
<route lang="yaml">

View file

@ -10,19 +10,24 @@
<script lang="ts" setup>
import { useCurrentUser } from '@/colada/users'
import { useClaimStatus } from '@/colada/claim'
async function checkAuthenticated() {
const router = useRouter()
const route = useRoute()
const { data, error, refresh } = useCurrentUser()
const { data: claimData, refresh: claimRefresh } = useClaimStatus()
await refresh()
await claimRefresh()
if (data.value) {
if (route.query.redirect) await router.push({ path: route.query.redirect.toString() })
else await router.push('/')
}
if (error.value) {
await router.push({ name: '/login', query: { redirect: route.query.redirect } })
if (claimData.value?.isClaimed)
await router.push({ name: '/login', query: { redirect: route.query.redirect } })
else await router.push({ name: '/claim', query: { redirect: route.query.redirect } })
}
}

View file

@ -23,6 +23,7 @@ declare module 'vue-router/auto-routes' {
'/account/api-keys': RouteRecordInfo<'/account/api-keys', '/account/api-keys', Record<never, never>, Record<never, never>>,
'/account/details': RouteRecordInfo<'/account/details', '/account/details', Record<never, never>, Record<never, never>>,
'/account/ui': RouteRecordInfo<'/account/ui', '/account/ui', Record<never, never>, Record<never, never>>,
'/claim': RouteRecordInfo<'/claim', '/claim', Record<never, never>, Record<never, never>>,
'/history': RouteRecordInfo<'/history', '/history', Record<never, never>, Record<never, never>>,
'/import/books': RouteRecordInfo<'/import/books', '/import/books', Record<never, never>, Record<never, never>>,
'/import/readlist': RouteRecordInfo<'/import/readlist', '/import/readlist', Record<never, never>, Record<never, never>>,