feat: add server selection UI for desktop OAuth login

Add a server selection screen matching the mobile app UX with
Vikunja Cloud, Try the Demo, and Custom Server URL options.
Extract all desktop login logic into a dedicated DesktopLogin
component. Use the existing ApiConfig component for custom server
URL input. Skip loading server config on startup to avoid showing
motd/demo popups on the login screen.
This commit is contained in:
kolaente 2026-03-30 19:28:21 +02:00 committed by kolaente
parent dd7532a57a
commit a12002de6d
5 changed files with 157 additions and 6 deletions

View File

@ -25,7 +25,7 @@
>
{{ title }}
</h2>
<ApiConfig v-if="showApiConfig" />
<ApiConfig v-if="shouldShowApiConfig" />
<Message
v-if="motd !== ''"
class="is-hidden-tablet mbe-4"
@ -52,8 +52,9 @@ import ApiConfig from '@/components/misc/ApiConfig.vue'
import { useTitle } from '@/composables/useTitle'
import { useConfigStore } from '@/stores/config'
import { isDesktopApp } from '@/helpers/desktopAuth'
withDefaults(
const props = withDefaults(
defineProps<{
showApiConfig?: boolean;
}>(),
@ -61,6 +62,11 @@ withDefaults(
showApiConfig: false,
},
)
const isDesktop = isDesktopApp()
const hasStoredApiUrl = isDesktop && localStorage.getItem('API_URL') !== null
const shouldShowApiConfig = computed(() => props.showApiConfig && (!isDesktop || hasStoredApiUrl))
const configStore = useConfigStore()
const motd = computed(() => configStore.motd)

View File

@ -55,6 +55,12 @@
"openIdStateError": "State does not match, refusing to continue!",
"openIdGeneralError": "An error occurred while authenticating against the third party.",
"oauthMissingParams": "Missing required OAuth parameters: {params}",
"oauthRedirectedToApp": "You have been redirected to the app. You can close this tab now.",
"desktopTryDemo": "Try the Demo",
"desktopCustomServer": "Custom Server URL",
"desktopCustomServerDescription": "Enter the URL of your Vikunja server to get started.",
"desktopWaitingForAuth": "Waiting for authentication…",
"desktopOAuthError": "Authentication failed: {error}",
"logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
@ -156,8 +162,7 @@
"tokenCreated": "Here is your new token: {token}",
"wontSeeItAgain": "Write it down or save it securely — you will not be able to see it again.",
"mustUseToken": "You need to create a CalDAV token to use CalDAV with any third-party client. Enter the token in the password field of your client.",
"usernameIs": "Your username for CalDAV is: {0}",
"apiTokenHint": "You can also use an API token with CalDAV permission. Create one in {link}."
"usernameIs": "Your username for CalDAV is: {0}"
},
"avatar": {
"title": "Avatar",

View File

@ -7,6 +7,7 @@ import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import ProjectModel from '@/models/project'
import ProjectService from '@/services/project'
import {checkAndSetApiUrl, ERROR_NO_API_URL, InvalidApiUrlProvidedError, NoApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
import {isDesktopApp} from '@/helpers/desktopAuth'
import {useMenuActive} from '@/composables/useMenuActive'
@ -146,6 +147,19 @@ export const useBaseStore = defineStore('base', () => {
async function loadApp() {
try {
if (isDesktopApp()) {
// On desktop, ignore the default window.API_URL (set by index.html)
// and only use a previously stored API URL from localStorage.
const storedApiUrl = localStorage.getItem('API_URL')
if (storedApiUrl) {
window.API_URL = storedApiUrl
await authStore.checkAuth()
}
await router.isReady()
ready.value = true
return
}
await checkAndSetApiUrl(window.API_URL)
await authStore.checkAuth()
await router.isReady()

View File

@ -0,0 +1,119 @@
<template>
<div>
<Message
v-if="errorMessage"
variant="danger"
class="mbe-4"
>
{{ errorMessage }}
</Message>
<Message
v-if="waitingForAuth"
class="mbe-4"
>
{{ $t('user.auth.desktopWaitingForAuth') }}
</Message>
<template v-if="hasStoredServer">
<XButton
:loading="waitingForAuth"
class="is-fullwidth"
@click="loginWithServer(window.API_URL)"
>
{{ $t('user.auth.login') }}
</XButton>
</template>
<template v-else-if="showCustomServerInput">
<p class="mbe-4">
{{ $t('user.auth.desktopCustomServerDescription') }}
</p>
<ApiConfig
:configure-open="true"
@foundApi="loginWithServer"
/>
<div class="has-text-centered mbs-2">
<a
role="button"
@click="showCustomServerInput = false"
>
{{ $t('misc.cancel') }}
</a>
</div>
</template>
<template v-else>
<XButton
:loading="waitingForAuth"
class="is-fullwidth mbe-2"
@click="loginWithServer('https://app.vikunja.cloud')"
>
Vikunja Cloud
</XButton>
<XButton
:loading="waitingForAuth"
variant="secondary"
class="is-fullwidth mbe-2"
@click="loginWithServer('https://try.vikunja.io')"
>
{{ $t('user.auth.desktopTryDemo') }}
</XButton>
<XButton
variant="secondary"
class="is-fullwidth"
@click="showCustomServerInput = true"
>
{{ $t('user.auth.desktopCustomServer') }}
</XButton>
</template>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {useI18n} from 'vue-i18n'
import Message from '@/components/misc/Message.vue'
import ApiConfig from '@/components/misc/ApiConfig.vue'
import {getErrorText} from '@/message'
import {startDesktopOAuthLogin, listenForDesktopOAuthTokens, listenForDesktopOAuthError} from '@/helpers/desktopAuth'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const {redirectIfSaved} = useRedirectToLastVisited()
const waitingForAuth = ref(false)
const errorMessage = ref('')
const hasStoredServer = localStorage.getItem('API_URL') !== null
const showCustomServerInput = ref(false)
listenForDesktopOAuthTokens(async (tokens) => {
waitingForAuth.value = false
try {
await authStore.handleDesktopOAuthTokens(tokens)
redirectIfSaved()
} catch (e) {
errorMessage.value = getErrorText(e)
}
})
listenForDesktopOAuthError((error) => {
waitingForAuth.value = false
errorMessage.value = t('user.auth.desktopOAuthError', {error})
})
async function loginWithServer(serverUrl: string) {
errorMessage.value = ''
waitingForAuth.value = true
try {
await checkAndSetApiUrl(serverUrl)
await startDesktopOAuthLogin(window.API_URL)
} catch (e) {
waitingForAuth.value = false
errorMessage.value = getErrorText(e)
}
}
</script>

View File

@ -15,8 +15,11 @@
>
{{ errorMessage }}
</Message>
<DesktopLogin v-if="isDesktop" />
<form
v-if="localAuthEnabled || ldapAuthEnabled"
v-if="!isDesktop && (localAuthEnabled || ldapAuthEnabled)"
id="loginform"
@submit.prevent="submit"
>
@ -106,7 +109,7 @@
</form>
<div
v-if="hasOpenIdProviders"
v-if="!isDesktop && hasOpenIdProviders"
class="mbs-4"
>
<XButton
@ -131,10 +134,12 @@ import {useDebounceFn} from '@vueuse/core'
import Message from '@/components/misc/Message.vue'
import Password from '@/components/input/Password.vue'
import FormField from '@/components/input/FormField.vue'
import DesktopLogin from '@/views/user/DesktopLogin.vue'
import {getErrorText} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {isDesktopApp} from '@/helpers/desktopAuth'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
@ -157,6 +162,7 @@ const openidConnect = computed(() => configStore.auth.openidConnect)
const hasOpenIdProviders = computed(() => openidConnect.value.enabled && openidConnect.value.providers?.length > 0)
const isLoading = computed(() => authStore.isLoading)
const isDesktop = isDesktopApp()
const confirmedEmailSuccess = ref(false)
const errorMessage = ref('')
@ -189,6 +195,7 @@ const validateUsernameField = useDebounceFn(() => {
usernameValid.value = usernameRef.value?.value !== ''
}, 100)
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
const totpPasscode = ref<HTMLInputElement | null>(null)