2022-04-03 14:24:17 -05:00
|
|
|
<template>
|
2022-07-01 16:33:39 -05:00
|
|
|
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
|
|
|
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
2023-11-12 10:30:39 -06:00
|
|
|
<!-- list of server connection configs -->
|
2022-04-03 14:24:17 -05:00
|
|
|
<template v-if="!showForm">
|
2025-01-18 13:44:16 -06:00
|
|
|
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
|
|
|
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
2025-03-30 23:26:14 -07:00
|
|
|
<span class="material-symbols text-xl text-fg-muted">dns</span>
|
2025-01-18 13:44:16 -06:00
|
|
|
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2025-01-18 13:44:16 -06:00
|
|
|
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
2025-03-30 23:26:14 -07:00
|
|
|
<span class="material-symbols text-lg text-fg-muted">more_vert</span>
|
2025-01-18 13:44:16 -06:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- warning message if server connection config is using an old user id -->
|
|
|
|
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
|
|
|
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
|
|
|
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="my-1 py-4 w-full">
|
2023-12-03 17:37:01 -06:00
|
|
|
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
</template>
|
2023-11-12 10:30:39 -06:00
|
|
|
<!-- form to add a new server connection config -->
|
2022-04-17 16:59:49 -05:00
|
|
|
<div v-else class="w-full">
|
2023-11-12 10:30:39 -06:00
|
|
|
<!-- server address input -->
|
|
|
|
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
2022-07-19 17:32:49 -05:00
|
|
|
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
2025-03-30 23:26:14 -07:00
|
|
|
<span class="material-symbols text-fg-muted">arrow_back</span>
|
2022-07-19 17:32:49 -05:00
|
|
|
</div>
|
2023-12-03 17:37:01 -06:00
|
|
|
<h2 class="text-lg leading-7 mb-2">{{ $strings.LabelServerAddress }}</h2>
|
2022-07-04 12:15:08 -05:00
|
|
|
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
2022-07-01 16:33:39 -05:00
|
|
|
<div class="flex justify-end items-center mt-6">
|
2023-12-03 17:37:01 -06:00
|
|
|
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
</form>
|
2023-11-12 10:30:39 -06:00
|
|
|
<!-- username/password and auth methods -->
|
|
|
|
<template v-else>
|
2022-04-03 14:24:17 -05:00
|
|
|
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
2025-03-30 23:26:14 -07:00
|
|
|
<span class="material-symbols text-fg-muted">arrow_back</span>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="flex items-center">
|
2023-12-10 17:53:27 -06:00
|
|
|
<p class="text-fg-muted">{{ serverConfig.address }}</p>
|
2022-04-03 14:24:17 -05:00
|
|
|
<div class="flex-grow" />
|
2025-03-30 23:26:14 -07:00
|
|
|
<span v-if="!serverConfig.id" class="material-symbols" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
2023-12-10 17:53:27 -06:00
|
|
|
<div class="w-full h-px bg-fg/10 my-2" />
|
2023-09-25 17:30:39 -05:00
|
|
|
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
|
2023-12-03 17:37:01 -06:00
|
|
|
<ui-text-input v-model="serverConfig.username" :disabled="processing" :placeholder="$strings.LabelUsername" class="w-full mb-2 text-lg" />
|
|
|
|
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
2022-04-03 14:24:17 -05:00
|
|
|
|
|
|
|
<div class="flex items-center pt-2">
|
2024-07-08 17:21:31 -05:00
|
|
|
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
2022-04-03 14:24:17 -05:00
|
|
|
<div class="flex-grow" />
|
2023-12-03 17:37:01 -06:00
|
|
|
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
</form>
|
2023-12-10 17:53:27 -06:00
|
|
|
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-fg/10 my-4" />
|
2023-11-11 14:00:48 -06:00
|
|
|
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
|
2022-04-03 14:24:17 -05:00
|
|
|
</template>
|
2022-04-17 16:59:49 -05:00
|
|
|
</div>
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2023-11-12 10:30:39 -06:00
|
|
|
<!-- auth error message -->
|
2022-04-17 16:59:49 -05:00
|
|
|
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
2025-03-30 23:26:14 -07:00
|
|
|
<span class="material-symbols mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
2022-04-17 16:59:49 -05:00
|
|
|
<p class="text-error">{{ error }}</p>
|
|
|
|
</div>
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
|
2023-12-10 17:53:27 -06:00
|
|
|
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
2022-04-03 14:24:17 -05:00
|
|
|
<div>
|
|
|
|
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
|
|
|
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
|
|
|
</div>
|
|
|
|
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
|
|
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
|
|
|
</svg>
|
|
|
|
</div>
|
|
|
|
</div>
|
2022-07-01 16:33:39 -05:00
|
|
|
|
2023-12-03 17:37:01 -06:00
|
|
|
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error" v-html="$strings.MessageAudiobookshelfServerRequired" />
|
2023-11-12 10:30:39 -06:00
|
|
|
|
2022-07-01 16:33:39 -05:00
|
|
|
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
2022-04-03 14:24:17 -05:00
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script>
|
2023-11-01 09:30:52 -05:00
|
|
|
import { Browser } from '@capacitor/browser'
|
2023-10-13 23:14:52 +02:00
|
|
|
import { CapacitorHttp } from '@capacitor/core'
|
|
|
|
import { Dialog } from '@capacitor/dialog'
|
2023-10-08 18:26:29 +02:00
|
|
|
|
2023-11-11 21:30:10 +01:00
|
|
|
// TODO: when backend ready. See validateLoginFormResponse()
|
|
|
|
//const requiredServerVersion = '2.5.0'
|
2023-11-05 20:32:14 +01:00
|
|
|
|
2022-04-03 14:24:17 -05:00
|
|
|
export default {
|
|
|
|
data() {
|
|
|
|
return {
|
|
|
|
loggedIn: false,
|
|
|
|
showAuth: false,
|
|
|
|
processing: false,
|
|
|
|
serverConfig: {
|
|
|
|
address: null,
|
2023-12-07 11:07:37 -06:00
|
|
|
version: null,
|
2022-07-01 16:33:39 -05:00
|
|
|
username: null,
|
|
|
|
customHeaders: null
|
2022-04-03 14:24:17 -05:00
|
|
|
},
|
|
|
|
password: null,
|
|
|
|
error: null,
|
2022-07-01 16:33:39 -05:00
|
|
|
showForm: false,
|
2023-09-25 17:30:39 -05:00
|
|
|
showAddCustomHeaders: false,
|
2023-11-03 21:29:37 +01:00
|
|
|
authMethods: [],
|
|
|
|
oauth: {
|
2023-11-05 17:27:04 +01:00
|
|
|
state: null,
|
2023-11-05 21:34:28 +01:00
|
|
|
verifier: null,
|
2023-11-10 22:30:55 +01:00
|
|
|
challenge: null,
|
2023-12-04 22:43:14 +01:00
|
|
|
buttonText: 'Login with OpenID',
|
|
|
|
enforceHTTPs: true // RFC 6749, Section 10.9 requires https
|
2023-11-03 21:29:37 +01:00
|
|
|
}
|
2022-04-03 14:24:17 -05:00
|
|
|
}
|
|
|
|
},
|
|
|
|
computed: {
|
2022-07-04 12:15:08 -05:00
|
|
|
deviceData() {
|
|
|
|
return this.$store.state.deviceData || {}
|
|
|
|
},
|
2023-12-12 17:50:04 -06:00
|
|
|
deviceSettings() {
|
|
|
|
return this.deviceData.deviceSettings || {}
|
|
|
|
},
|
2022-04-03 14:24:17 -05:00
|
|
|
networkConnected() {
|
|
|
|
return this.$store.state.networkConnected
|
|
|
|
},
|
2022-04-03 14:52:30 -05:00
|
|
|
serverConnectionConfigs() {
|
2023-11-12 10:30:39 -06:00
|
|
|
return this.deviceData?.serverConnectionConfigs || []
|
2022-04-03 14:52:30 -05:00
|
|
|
},
|
2022-04-03 14:24:17 -05:00
|
|
|
lastServerConnectionConfigId() {
|
2023-11-12 10:30:39 -06:00
|
|
|
return this.deviceData?.lastServerConnectionConfigId || null
|
2022-04-03 14:24:17 -05:00
|
|
|
},
|
|
|
|
lastServerConnectionConfig() {
|
|
|
|
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
|
|
|
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
2022-07-01 16:33:39 -05:00
|
|
|
},
|
2023-09-25 17:30:39 -05:00
|
|
|
isLocalAuthEnabled() {
|
|
|
|
return this.authMethods.includes('local') || !this.authMethods.length
|
|
|
|
},
|
|
|
|
isOpenIDAuthEnabled() {
|
|
|
|
return this.authMethods.includes('openid')
|
2022-04-03 14:24:17 -05:00
|
|
|
}
|
|
|
|
},
|
|
|
|
methods: {
|
2025-01-18 13:44:16 -06:00
|
|
|
showOldUserIdWarningDialog() {
|
|
|
|
Dialog.alert({
|
|
|
|
title: 'Old Server Connection Warning',
|
|
|
|
message: this.$strings.MessageOldServerConnectionWarningHelp,
|
2025-01-18 14:46:12 -06:00
|
|
|
cancelText: this.$strings.ButtonOk
|
2025-01-18 13:44:16 -06:00
|
|
|
})
|
|
|
|
},
|
|
|
|
checkIdUuid(userId) {
|
|
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
|
|
|
},
|
2023-11-05 21:01:51 +01:00
|
|
|
/**
|
|
|
|
* Initiates the login process using OpenID via OAuth2.0.
|
|
|
|
* 1. Verifying the server's address
|
|
|
|
* 2. Calling oauthRequest() to obtain the special OpenID redirect URL
|
|
|
|
* including a challenge and specying audiobookshelf://oauth as redirect URL
|
|
|
|
* 3. Open this redirect URL in browser (which is a website of the SSO provider)
|
|
|
|
*
|
|
|
|
* When the browser is open, the following flow is expected:
|
|
|
|
* a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth
|
|
|
|
* b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called
|
|
|
|
* b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter
|
|
|
|
* c. oauthExchangeCodeForToken() exchanges the authorization code for an access token
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* @async
|
|
|
|
* @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user.
|
|
|
|
*/
|
2023-09-25 17:30:39 -05:00
|
|
|
async clickLoginWithOpenId() {
|
2023-11-03 21:29:37 +01:00
|
|
|
// oauth standard requires https explicitly
|
2023-12-04 22:43:14 +01:00
|
|
|
if (!this.serverConfig.address.startsWith('https') && this.oauth.enforceHTTPs) {
|
2023-11-03 21:29:37 +01:00
|
|
|
console.warn(`[SSO] Oauth2 requires HTTPS`)
|
|
|
|
this.$toast.error(`SSO: The URL to the server must be https:// secured`)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-10-08 18:26:29 +02:00
|
|
|
// First request that we want to do oauth/openid and get the URL which a browser window should open
|
|
|
|
const redirectUrl = await this.oauthRequest(this.serverConfig.address)
|
2023-11-03 21:29:37 +01:00
|
|
|
if (!redirectUrl) {
|
|
|
|
// error message handled by oauthRequest
|
|
|
|
return
|
|
|
|
}
|
2023-10-08 18:26:29 +02:00
|
|
|
|
|
|
|
// Actually we should be able to use the redirectUrl directly for Browser.open below
|
|
|
|
// However it seems that when directly using it there is a malformation and leads to the error
|
|
|
|
// Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
|
|
|
|
// (On calling Browser.open)
|
|
|
|
// Which is hard to debug
|
|
|
|
// So we simply extract the important elements and build the required URL ourselves
|
|
|
|
// which also has the advantage that we can replace the callbackurl with the app url
|
|
|
|
|
|
|
|
const client_id = redirectUrl.searchParams.get('client_id')
|
|
|
|
const scope = redirectUrl.searchParams.get('scope')
|
|
|
|
const state = redirectUrl.searchParams.get('state')
|
2023-12-07 11:07:37 -06:00
|
|
|
let redirect_uri_param = redirectUrl.searchParams.get('redirect_uri')
|
|
|
|
// Backwards compatability with 2.6.0
|
|
|
|
if (this.serverConfig.version === '2.6.0') {
|
|
|
|
redirect_uri_param = 'audiobookshelf://oauth'
|
|
|
|
}
|
2023-10-08 18:26:29 +02:00
|
|
|
|
2023-12-04 22:43:14 +01:00
|
|
|
if (!client_id || !scope || !state || !redirect_uri_param) {
|
|
|
|
console.warn(`[SSO] Invalid OpenID URL - client_id scope state or redirect_uri missing: ${redirectUrl}`)
|
2023-10-08 18:26:29 +02:00
|
|
|
this.$toast.error(`SSO: Invalid answer`)
|
|
|
|
return
|
2023-09-25 17:30:39 -05:00
|
|
|
}
|
2023-10-08 18:26:29 +02:00
|
|
|
|
2023-12-04 22:43:14 +01:00
|
|
|
if (redirectUrl.protocol !== 'https:' && this.oauth.enforceHTTPs) {
|
2023-11-03 21:29:37 +01:00
|
|
|
console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`)
|
|
|
|
this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// We need to verify if the state is the same later
|
|
|
|
this.oauth.state = state
|
|
|
|
|
2023-12-04 22:43:14 +01:00
|
|
|
const host = `${redirectUrl.protocol}//${redirectUrl.host}`
|
|
|
|
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent(redirect_uri_param)}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
|
2023-10-08 18:26:29 +02:00
|
|
|
|
|
|
|
// example url for authentik
|
2023-11-01 09:30:52 -05:00
|
|
|
// const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..."
|
2023-10-08 18:26:29 +02:00
|
|
|
|
|
|
|
// Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
|
|
|
|
try {
|
2023-11-01 09:30:52 -05:00
|
|
|
await Browser.open({ url: buildUrl })
|
2023-10-08 18:26:29 +02:00
|
|
|
} catch (error) {
|
2023-11-01 09:30:52 -05:00
|
|
|
console.error('Error opening browser', error)
|
2023-10-08 18:26:29 +02:00
|
|
|
}
|
|
|
|
},
|
2023-11-05 21:01:51 +01:00
|
|
|
/**
|
|
|
|
* Requests the OAuth/OpenID URL from the backend server to open in browser
|
|
|
|
*
|
|
|
|
* @async
|
|
|
|
* @param {string} url - The base URL of the server to append the OAuth request parameters to.
|
|
|
|
* @return {Promise<URL|null>} OAuth URL which should be opened in a browser
|
|
|
|
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
|
|
|
*/
|
2023-10-08 18:26:29 +02:00
|
|
|
async oauthRequest(url) {
|
2023-11-05 17:27:04 +01:00
|
|
|
// Generate oauth2 PKCE challenge
|
|
|
|
// In accordance to RFC 7636 Section 4
|
|
|
|
function base64URLEncode(arrayBuffer) {
|
|
|
|
let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
|
2023-11-11 13:55:32 -06:00
|
|
|
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
2023-11-05 17:27:04 +01:00
|
|
|
}
|
2023-11-10 22:30:55 +01:00
|
|
|
|
|
|
|
async function sha256(plain) {
|
|
|
|
const encoder = new TextEncoder()
|
|
|
|
const data = encoder.encode(plain)
|
|
|
|
return await window.crypto.subtle.digest('SHA-256', data)
|
|
|
|
}
|
|
|
|
|
|
|
|
function generateRandomString() {
|
|
|
|
var array = new Uint32Array(42)
|
|
|
|
window.crypto.getRandomValues(array)
|
2023-11-11 13:55:32 -06:00
|
|
|
return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex
|
2023-11-05 17:27:04 +01:00
|
|
|
}
|
|
|
|
|
2023-11-10 22:30:55 +01:00
|
|
|
const verifier = generateRandomString()
|
2023-11-05 17:27:04 +01:00
|
|
|
|
2023-11-10 22:30:55 +01:00
|
|
|
const challenge = base64URLEncode(await sha256(verifier))
|
2023-11-05 17:27:04 +01:00
|
|
|
|
|
|
|
this.oauth.verifier = verifier
|
2023-11-10 22:30:55 +01:00
|
|
|
this.oauth.challenge = challenge
|
2023-11-05 17:27:04 +01:00
|
|
|
|
2023-12-07 11:07:37 -06:00
|
|
|
let backendEndpoint = `${url}/auth/openid?code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}&client_id=${encodeURIComponent('Audiobookshelf-App')}&response_type=code`
|
|
|
|
// Backwards compatability with 2.6.0
|
|
|
|
if (this.serverConfig.version === '2.6.0') {
|
|
|
|
backendEndpoint += '&isRest=true'
|
|
|
|
}
|
2023-10-08 18:26:29 +02:00
|
|
|
|
|
|
|
try {
|
|
|
|
const response = await CapacitorHttp.get({
|
|
|
|
url: backendEndpoint,
|
|
|
|
disableRedirects: true,
|
|
|
|
webFetchExtra: {
|
2023-11-01 09:30:52 -05:00
|
|
|
redirect: 'manual'
|
|
|
|
}
|
2023-09-25 17:30:39 -05:00
|
|
|
})
|
2023-10-08 18:26:29 +02:00
|
|
|
|
2023-11-03 21:29:37 +01:00
|
|
|
// Every kind of redirection is allowed [RFC6749 - 1.7]
|
|
|
|
if (!(response.status >= 300 && response.status < 400)) {
|
|
|
|
throw new Error(`Unexpected response from server: ${response.status}`)
|
|
|
|
}
|
|
|
|
|
2023-10-13 23:14:52 +02:00
|
|
|
// Depending on iOS or Android, it can be location or Location...
|
2023-11-01 09:30:52 -05:00
|
|
|
const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')]
|
2023-11-03 21:29:37 +01:00
|
|
|
if (!locationHeader) {
|
|
|
|
throw new Error(`No location header in SSO answer`)
|
2023-10-08 18:26:29 +02:00
|
|
|
}
|
2023-11-03 21:29:37 +01:00
|
|
|
|
|
|
|
const url = new URL(locationHeader)
|
|
|
|
return url
|
2023-10-08 18:26:29 +02:00
|
|
|
} catch (error) {
|
2023-11-03 21:29:37 +01:00
|
|
|
console.error(`[SSO] ${error.message}`)
|
|
|
|
this.$toast.error(`SSO Error: ${error.message}`)
|
2023-10-08 18:26:29 +02:00
|
|
|
}
|
|
|
|
},
|
2023-11-05 21:01:51 +01:00
|
|
|
/**
|
|
|
|
* Handles the callback received from the OAuth/OpenID provider.
|
|
|
|
*
|
|
|
|
* @async
|
|
|
|
* @function appUrlOpen
|
|
|
|
* @param {string} url - The callback URL received from the OAuth/OpenID provider.
|
|
|
|
* @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match.
|
|
|
|
*/
|
|
|
|
async appUrlOpen(url) {
|
|
|
|
if (!url) return
|
|
|
|
|
|
|
|
// Handle the OAuth callback
|
|
|
|
const urlObj = new URL(url)
|
|
|
|
|
|
|
|
// audiobookshelf://oauth?code...
|
|
|
|
// urlObj.hostname for iOS and urlObj.pathname for android
|
|
|
|
if (url.startsWith('audiobookshelf://oauth')) {
|
|
|
|
// Extract possible errors thrown by the SSO provider
|
|
|
|
const authError = urlObj.searchParams.get('error')
|
|
|
|
if (authError) {
|
|
|
|
console.warn(`[SSO] Received the following error: ${authError}`)
|
|
|
|
this.$toast.error(`SSO: Received the following error: ${authError}`)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Extract oauth2 code to be exchanged for a token
|
|
|
|
const authCode = urlObj.searchParams.get('code')
|
|
|
|
// Extract the state variable
|
|
|
|
const state = urlObj.searchParams.get('state')
|
|
|
|
|
|
|
|
if (this.oauth.state !== state) {
|
|
|
|
console.warn(`[SSO] Wrong state returned by SSO Provider`)
|
|
|
|
this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clear the state variable from the component config
|
|
|
|
this.oauth.state = null
|
|
|
|
|
|
|
|
if (authCode) {
|
|
|
|
await this.oauthExchangeCodeForToken(authCode, state)
|
2023-11-11 21:30:10 +01:00
|
|
|
} else {
|
|
|
|
console.warn(`[SSO] No code received`)
|
|
|
|
this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`)
|
2023-11-05 21:01:51 +01:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
|
|
* Exchanges an oauth2 authorization code for a JWT token.
|
|
|
|
* And uses that token to finalise the log in process using authenticateToken()
|
|
|
|
*
|
|
|
|
* @async
|
|
|
|
* @function oauthExchangeCodeForToken
|
|
|
|
* @param {string} code - The authorization code provided by the OpenID provider.
|
|
|
|
* @param {string} state - The state value used to associate a client session with an ID token.
|
|
|
|
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
|
|
|
*/
|
2023-10-08 18:26:29 +02:00
|
|
|
async oauthExchangeCodeForToken(code, state) {
|
|
|
|
// We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info
|
2023-11-13 15:22:15 -06:00
|
|
|
const backendEndpoint = `${this.serverConfig.address}/auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}`
|
2023-10-08 18:26:29 +02:00
|
|
|
|
|
|
|
try {
|
2023-10-13 23:14:52 +02:00
|
|
|
// We can close the browser at this point (does not work on Android)
|
2023-11-01 10:16:31 -05:00
|
|
|
if (this.$platform === 'ios' || this.$platform === 'web') {
|
2023-10-13 23:14:52 +02:00
|
|
|
await Browser.close()
|
|
|
|
}
|
2023-11-11 13:55:32 -06:00
|
|
|
} catch (error) {} // No Error handling needed
|
2023-10-08 18:26:29 +02:00
|
|
|
|
2023-11-03 21:29:37 +01:00
|
|
|
try {
|
2023-10-08 18:26:29 +02:00
|
|
|
const response = await CapacitorHttp.get({
|
|
|
|
url: backendEndpoint
|
2023-11-01 09:30:52 -05:00
|
|
|
})
|
2023-10-08 18:26:29 +02:00
|
|
|
|
2023-11-03 21:29:37 +01:00
|
|
|
if (!response.data || !response.data.user || !response.data.user.token) {
|
|
|
|
throw new Error('Token data is missing in the response.')
|
|
|
|
}
|
|
|
|
|
2023-11-01 10:16:31 -05:00
|
|
|
this.serverConfig.token = response.data.user.token
|
|
|
|
const payload = await this.authenticateToken()
|
2023-10-08 18:26:29 +02:00
|
|
|
|
|
|
|
if (!payload) {
|
2023-11-03 21:29:37 +01:00
|
|
|
throw new Error('Authentication failed with the provided token.')
|
2023-10-08 18:26:29 +02:00
|
|
|
}
|
|
|
|
|
2023-11-11 13:55:32 -06:00
|
|
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
|
|
|
if (duplicateConfig) {
|
|
|
|
throw new Error('Config already exists for this address and username.')
|
|
|
|
}
|
|
|
|
|
2023-11-01 10:16:31 -05:00
|
|
|
this.setUserAndConnection(payload)
|
2023-10-08 18:26:29 +02:00
|
|
|
} catch (error) {
|
2023-11-03 21:29:37 +01:00
|
|
|
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
|
|
|
this.$toast.error(`SSO error: ${error.message || error}`)
|
2023-11-05 21:01:51 +01:00
|
|
|
} finally {
|
|
|
|
// We don't need the oauth verifier any more
|
|
|
|
this.oauth.verifier = null
|
2023-11-10 22:30:55 +01:00
|
|
|
this.oauth.challenge = null
|
2023-10-08 18:26:29 +02:00
|
|
|
}
|
2023-09-25 17:30:39 -05:00
|
|
|
},
|
2022-07-01 16:33:39 -05:00
|
|
|
addCustomHeaders() {
|
|
|
|
this.showAddCustomHeaders = true
|
|
|
|
},
|
2022-04-03 14:24:17 -05:00
|
|
|
showServerList() {
|
|
|
|
this.showForm = false
|
|
|
|
this.showAuth = false
|
|
|
|
this.error = null
|
|
|
|
this.serverConfig = {
|
|
|
|
address: null,
|
2022-04-09 12:03:37 -05:00
|
|
|
userId: null,
|
2022-04-03 14:24:17 -05:00
|
|
|
username: null
|
|
|
|
}
|
|
|
|
},
|
|
|
|
async connectToServer(config) {
|
2023-01-08 15:32:15 -06:00
|
|
|
await this.$hapticsImpact()
|
2022-04-17 16:59:49 -05:00
|
|
|
console.log('[ServerConnectForm] connectToServer', config.address)
|
2022-04-03 14:24:17 -05:00
|
|
|
this.processing = true
|
|
|
|
this.serverConfig = {
|
|
|
|
...config
|
|
|
|
}
|
|
|
|
this.showForm = true
|
|
|
|
var success = await this.pingServerAddress(config.address)
|
2022-04-17 16:59:49 -05:00
|
|
|
this.processing = false
|
|
|
|
console.log(`[ServerConnectForm] pingServer result ${success}`)
|
2022-04-03 14:24:17 -05:00
|
|
|
if (!success) {
|
2022-04-17 16:59:49 -05:00
|
|
|
this.showForm = false
|
|
|
|
this.showAuth = false
|
|
|
|
console.log(`[ServerConnectForm] showForm ${this.showForm}`)
|
2022-04-03 14:24:17 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
this.error = null
|
|
|
|
var payload = await this.authenticateToken()
|
|
|
|
|
|
|
|
if (payload) {
|
2022-07-19 18:50:14 -05:00
|
|
|
this.setUserAndConnection(payload)
|
2022-04-03 14:24:17 -05:00
|
|
|
} else {
|
|
|
|
this.showAuth = true
|
|
|
|
}
|
|
|
|
},
|
|
|
|
async removeServerConfigClick() {
|
|
|
|
if (!this.serverConfig.id) return
|
2023-01-08 15:32:15 -06:00
|
|
|
await this.$hapticsImpact()
|
2022-04-03 14:24:17 -05:00
|
|
|
|
|
|
|
const { value } = await Dialog.confirm({
|
|
|
|
title: 'Confirm',
|
|
|
|
message: `Remove this server config?`
|
|
|
|
})
|
|
|
|
if (value) {
|
|
|
|
this.processing = true
|
|
|
|
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
2022-07-04 12:15:08 -05:00
|
|
|
const updatedDeviceData = { ...this.deviceData }
|
|
|
|
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
|
|
|
this.$store.commit('setDeviceData', updatedDeviceData)
|
|
|
|
|
2022-04-03 14:24:17 -05:00
|
|
|
this.serverConfig = {
|
|
|
|
address: null,
|
2022-04-09 12:03:37 -05:00
|
|
|
userId: null,
|
2022-04-03 14:24:17 -05:00
|
|
|
username: null
|
|
|
|
}
|
|
|
|
this.password = null
|
|
|
|
this.processing = false
|
|
|
|
this.showAuth = false
|
|
|
|
this.showForm = !this.serverConnectionConfigs.length
|
|
|
|
}
|
|
|
|
},
|
|
|
|
editServerConfig(serverConfig) {
|
|
|
|
this.serverConfig = {
|
|
|
|
...serverConfig
|
|
|
|
}
|
|
|
|
this.showForm = true
|
|
|
|
this.showAuth = true
|
|
|
|
},
|
2022-12-08 00:28:28 -05:00
|
|
|
async newServerConfigClick() {
|
2023-01-08 15:32:15 -06:00
|
|
|
await this.$hapticsImpact()
|
2022-04-17 16:59:49 -05:00
|
|
|
this.serverConfig = {
|
|
|
|
address: '',
|
|
|
|
userId: '',
|
|
|
|
username: ''
|
|
|
|
}
|
2022-04-03 14:24:17 -05:00
|
|
|
this.showForm = true
|
|
|
|
this.showAuth = false
|
2022-04-17 16:59:49 -05:00
|
|
|
this.error = null
|
2022-04-03 14:24:17 -05:00
|
|
|
},
|
|
|
|
editServerAddress() {
|
|
|
|
this.error = null
|
|
|
|
this.showAuth = false
|
|
|
|
},
|
2023-11-04 15:25:03 +01:00
|
|
|
/**
|
|
|
|
* Validates a URL and reconstructs it with an optional protocol override.
|
|
|
|
* If the URL is invalid, null is returned.
|
|
|
|
*
|
|
|
|
* @param {string} url - The URL to validate.
|
|
|
|
* @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol.
|
|
|
|
* @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid.
|
|
|
|
*/
|
|
|
|
validateServerUrl(url, protocolOverride = null) {
|
2022-04-03 14:24:17 -05:00
|
|
|
try {
|
|
|
|
var urlObject = new URL(url)
|
2024-12-23 20:26:23 +02:00
|
|
|
if (protocolOverride) urlObject.protocol = protocolOverride
|
|
|
|
return urlObject.href
|
2022-04-03 14:24:17 -05:00
|
|
|
} catch (error) {
|
|
|
|
console.error('Invalid URL', error)
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
},
|
2023-11-04 15:25:03 +01:00
|
|
|
/**
|
|
|
|
* Sends a GET request to the specified URL with the provided headers and timeout.
|
|
|
|
* If the response is successful (HTTP 200), the response object is returned.
|
|
|
|
* Otherwise, throws an error object containing code.
|
|
|
|
* code can be either a number, which is then a HTTP status code or
|
|
|
|
* a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established.
|
|
|
|
* When code is a string, error.message contains the human readable error by the OS or
|
|
|
|
* the http body of the non-200 answer.
|
|
|
|
*
|
|
|
|
* @async
|
|
|
|
* @param {string} url - The URL to which the GET request will be sent.
|
|
|
|
* @param {Object} headers - HTTP headers to be included in the request.
|
|
|
|
* @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds.
|
|
|
|
* @returns {Promise<HttpResponse>} The HTTP response object if the request is successful.
|
|
|
|
* @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful.
|
|
|
|
* @throws {Error} An error with 'code' property set to the error code if the request fails.
|
|
|
|
*/
|
2023-09-15 17:35:44 -05:00
|
|
|
async getRequest(url, headers, connectTimeout = 6000) {
|
|
|
|
const options = {
|
|
|
|
url,
|
|
|
|
headers,
|
|
|
|
connectTimeout
|
|
|
|
}
|
2023-11-04 15:25:03 +01:00
|
|
|
try {
|
|
|
|
const response = await CapacitorHttp.get(options)
|
|
|
|
console.log('[ServerConnectForm] GET request response', response)
|
|
|
|
if (response.status == 200) {
|
|
|
|
return response
|
|
|
|
} else {
|
|
|
|
// Put the HTTP error code inside the cause
|
|
|
|
let errorObj = new Error(response.data)
|
|
|
|
errorObj.code = response.status
|
|
|
|
throw errorObj
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
// Put the error name inside the cause (a string)
|
|
|
|
let errorObj = new Error(error.message)
|
|
|
|
errorObj.code = error.code
|
|
|
|
throw errorObj
|
2023-09-15 17:35:44 -05:00
|
|
|
}
|
|
|
|
},
|
|
|
|
async postRequest(url, data, headers, connectTimeout = 6000) {
|
|
|
|
if (!headers) headers = {}
|
|
|
|
if (!headers['Content-Type'] && data) {
|
|
|
|
headers['Content-Type'] = 'application/json'
|
2022-07-01 16:33:39 -05:00
|
|
|
}
|
2023-09-15 17:35:44 -05:00
|
|
|
const options = {
|
|
|
|
url,
|
|
|
|
headers,
|
|
|
|
data,
|
|
|
|
connectTimeout
|
|
|
|
}
|
|
|
|
const response = await CapacitorHttp.post(options)
|
|
|
|
console.log('[ServerConnectForm] POST request response', response)
|
|
|
|
if (response.status >= 400) {
|
|
|
|
throw new Error(response.data)
|
|
|
|
} else {
|
|
|
|
return response.data
|
|
|
|
}
|
|
|
|
},
|
2023-09-25 17:30:39 -05:00
|
|
|
/**
|
|
|
|
* Get request to server /status api endpoint
|
|
|
|
*
|
|
|
|
* @param {string} address
|
2023-11-04 15:25:03 +01:00
|
|
|
* @returns {Promise<HttpResponse>}
|
|
|
|
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
2023-09-25 17:30:39 -05:00
|
|
|
*/
|
2023-11-04 15:25:03 +01:00
|
|
|
async getServerAddressStatus(address) {
|
|
|
|
return this.getRequest(`${address}/status`)
|
2023-09-25 17:30:39 -05:00
|
|
|
},
|
2023-09-15 17:35:44 -05:00
|
|
|
pingServerAddress(address, customHeaders) {
|
|
|
|
return this.getRequest(`${address}/ping`, customHeaders)
|
2023-11-04 15:25:03 +01:00
|
|
|
.then((response) => {
|
|
|
|
return response.data.success
|
2023-09-15 17:35:44 -05:00
|
|
|
})
|
2022-04-03 14:24:17 -05:00
|
|
|
.catch((error) => {
|
2023-09-15 17:35:44 -05:00
|
|
|
console.error('Server ping failed', error)
|
|
|
|
const errorMsg = error.message || error
|
2022-04-03 14:24:17 -05:00
|
|
|
this.error = 'Failed to ping server'
|
2023-09-15 17:35:44 -05:00
|
|
|
if (typeof errorMsg === 'string') {
|
|
|
|
this.error += ` (${errorMsg})`
|
|
|
|
}
|
|
|
|
|
2022-04-03 14:24:17 -05:00
|
|
|
return false
|
|
|
|
})
|
|
|
|
},
|
|
|
|
requestServerLogin() {
|
2023-11-25 11:17:35 -06:00
|
|
|
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
2022-04-03 14:24:17 -05:00
|
|
|
.then((data) => {
|
|
|
|
if (!data.user) {
|
|
|
|
console.error(data.error)
|
|
|
|
this.error = data.error || 'Unknown Error'
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return data
|
|
|
|
})
|
|
|
|
.catch((error) => {
|
|
|
|
console.error('Server auth failed', error)
|
2023-09-15 17:35:44 -05:00
|
|
|
const errorMsg = error.message || error
|
|
|
|
this.error = 'Failed to login'
|
|
|
|
if (typeof errorMsg === 'string') {
|
|
|
|
this.error += ` (${errorMsg})`
|
|
|
|
}
|
2022-04-03 14:24:17 -05:00
|
|
|
return false
|
|
|
|
})
|
|
|
|
},
|
|
|
|
async submit() {
|
|
|
|
if (!this.networkConnected) return
|
|
|
|
if (!this.serverConfig.address) return
|
|
|
|
|
2023-11-04 15:25:03 +01:00
|
|
|
const initialAddress = this.serverConfig.address
|
|
|
|
// Did the user specify a protocol?
|
|
|
|
const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://')
|
|
|
|
// Add https:// if not provided
|
|
|
|
this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress)
|
|
|
|
|
2022-04-03 14:24:17 -05:00
|
|
|
this.processing = true
|
|
|
|
this.error = null
|
2023-09-25 17:30:39 -05:00
|
|
|
this.authMethods = []
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2023-11-04 15:25:03 +01:00
|
|
|
try {
|
|
|
|
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
|
|
|
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
|
|
|
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
2023-09-25 17:30:39 -05:00
|
|
|
this.showAuth = true
|
2023-11-04 15:25:03 +01:00
|
|
|
this.authMethods = statusData.data.authMethods || []
|
2023-11-05 21:34:28 +01:00
|
|
|
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
2023-12-07 11:07:37 -06:00
|
|
|
this.serverConfig.version = statusData.data.serverVersion
|
2023-11-05 21:34:28 +01:00
|
|
|
|
|
|
|
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
|
|
|
this.clickLoginWithOpenId()
|
|
|
|
}
|
2023-09-25 17:30:39 -05:00
|
|
|
}
|
2023-11-04 15:25:03 +01:00
|
|
|
} catch (error) {
|
|
|
|
this.handleLoginFormError(error)
|
|
|
|
} finally {
|
|
|
|
this.processing = false
|
2023-09-25 17:30:39 -05:00
|
|
|
}
|
2022-04-03 14:24:17 -05:00
|
|
|
},
|
2023-11-04 15:25:03 +01:00
|
|
|
/** Validates the login form response from the server.
|
|
|
|
*
|
|
|
|
* Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf
|
|
|
|
*
|
|
|
|
* @param {object} statusData - The data received from the server's response, including data and url.
|
|
|
|
* @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request.
|
|
|
|
* @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address.
|
|
|
|
*
|
|
|
|
* @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error.
|
|
|
|
*/
|
|
|
|
validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) {
|
|
|
|
// We have a 200 status code at this point
|
|
|
|
|
|
|
|
// Check if we got redirected to a different hostname, we don't allow this
|
|
|
|
const initialAddressUrl = new URL(initialAddressWithProtocol)
|
|
|
|
const currentAddressUrl = new URL(statusData.url)
|
|
|
|
if (initialAddressUrl.hostname !== currentAddressUrl.hostname) {
|
|
|
|
this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})`
|
|
|
|
console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`)
|
|
|
|
return false
|
|
|
|
} // We don't allow a redirection back from https to http if the user used https:// explicitly
|
2023-11-11 13:55:32 -06:00
|
|
|
else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') {
|
2023-11-04 15:25:03 +01:00
|
|
|
this.error = `You specified https:// but the Server redirected back to plain http`
|
|
|
|
console.error(`[ServerConnectForm] User specified https:// but server redirected to http`)
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check content of response now
|
|
|
|
if (!statusData || !statusData.data || Object.keys(statusData).length === 0) {
|
|
|
|
this.error = 'Response from server was empty' // Usually some kind of config error on server side
|
|
|
|
console.error('[ServerConnectForm] Received empty response')
|
|
|
|
return false
|
2023-11-11 21:30:10 +01:00
|
|
|
} else if (!('isInit' in statusData.data) || !('language' in statusData.data)) {
|
2023-11-04 15:25:03 +01:00
|
|
|
this.error = 'This does not seem to be a Audiobookshelf server'
|
|
|
|
console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
|
|
|
return false
|
2023-11-12 10:30:39 -06:00
|
|
|
// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check
|
|
|
|
// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') {
|
|
|
|
// this.error = 'This does not seem to be a Audiobookshelf server'
|
|
|
|
// console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
|
|
|
// return false
|
|
|
|
// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) {
|
|
|
|
// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`
|
|
|
|
// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion)
|
|
|
|
// return false
|
2023-11-04 15:25:03 +01:00
|
|
|
} else if (!statusData.data.isInit) {
|
|
|
|
this.error = 'Server is not initialized'
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we got redirected from http to https, we allow this
|
|
|
|
// Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull
|
|
|
|
// So set the correct protocol for the config
|
|
|
|
const configUrl = new URL(this.serverConfig.address)
|
|
|
|
configUrl.protocol = currentAddressUrl.protocol
|
2023-11-13 15:22:15 -06:00
|
|
|
// Remove trailing slash
|
|
|
|
this.serverConfig.address = configUrl.toString().replace(/\/$/, '')
|
2023-11-04 15:25:03 +01:00
|
|
|
|
|
|
|
return true
|
|
|
|
},
|
|
|
|
/**
|
|
|
|
* Handles errors received during the login form process, providing user-friendly error messages.
|
|
|
|
*
|
|
|
|
* @param {Object} error - The error object received from a failed login attempt.
|
|
|
|
*/
|
|
|
|
handleLoginFormError(error) {
|
|
|
|
console.error('[ServerConnectForm] Received invalid status', error)
|
|
|
|
|
|
|
|
if (error.code === 404) {
|
2023-11-11 21:30:10 +01:00
|
|
|
this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)`
|
2023-11-11 13:55:32 -06:00
|
|
|
} else if (typeof error.code === 'number') {
|
|
|
|
// Error with HTTP Code
|
2023-11-04 15:25:03 +01:00
|
|
|
this.error = `Failed to retrieve status of server: ${error.code}`
|
2023-11-11 13:55:32 -06:00
|
|
|
} else {
|
|
|
|
// error is usually a meaningful error like "Server timed out"
|
2023-11-04 15:25:03 +01:00
|
|
|
this.error = `Failed to contact server. (${error})`
|
|
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
|
|
* Attempts to retrieve the server address status for the given URL.
|
|
|
|
* If the initial attempt fails, it retries with HTTP if allowed.
|
|
|
|
*
|
|
|
|
* @param {string} address - The URL address to validate and check.
|
|
|
|
* @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure.
|
|
|
|
* @returns {Promise<HttpResponse>}
|
|
|
|
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
|
|
|
* @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail.
|
|
|
|
*/
|
|
|
|
async tryServerUrl(address, shouldRetryWithHttp) {
|
|
|
|
const validatedUrl = this.validateServerUrl(address)
|
|
|
|
if (!validatedUrl) {
|
|
|
|
throw new Error('URL has wrong format')
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
return await this.getServerAddressStatus(validatedUrl)
|
|
|
|
} catch (error) {
|
|
|
|
// We only retry when the user did not specify a protocol
|
|
|
|
// Also for security reasons, we only retry when the https request did not
|
|
|
|
// return a http status code (so only retry when the TCP connection could not be established)
|
2023-11-11 13:55:32 -06:00
|
|
|
if (shouldRetryWithHttp && typeof error.code !== 'number') {
|
|
|
|
console.log('[ServerConnectForm] https failed, trying to connect with http...')
|
2023-11-04 15:25:03 +01:00
|
|
|
const validatedHttpUrl = this.validateServerUrl(address, 'http:')
|
|
|
|
if (validatedHttpUrl) {
|
|
|
|
return await this.getServerAddressStatus(validatedHttpUrl)
|
|
|
|
}
|
|
|
|
// else if validatedHttpUrl is false return the original error below
|
|
|
|
}
|
|
|
|
// rethrow original error
|
|
|
|
throw error
|
|
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
|
|
* Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://.
|
|
|
|
*
|
|
|
|
* @param {string} address - The server address that may or may not have a protocol.
|
|
|
|
* @returns {string} The address with a protocol prepended if it was missing.
|
|
|
|
*/
|
|
|
|
prependProtocolIfNeeded(address) {
|
2023-11-11 13:55:32 -06:00
|
|
|
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
2023-11-04 15:25:03 +01:00
|
|
|
},
|
2023-11-05 20:32:14 +01:00
|
|
|
/**
|
|
|
|
* Compares two semantic versioning strings to determine if the current version meets
|
|
|
|
* or exceeds the minimum version requirement.
|
|
|
|
*
|
|
|
|
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
|
|
|
|
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
|
|
|
|
* @returns {boolean} - Returns true if the current version is greater than or equal
|
|
|
|
* to the minimum version, false otherwise.
|
|
|
|
*/
|
|
|
|
isValidVersion(currentVersion, minVersion) {
|
2023-11-10 22:30:55 +01:00
|
|
|
const currentParts = currentVersion.split('.').map(Number)
|
|
|
|
const minParts = minVersion.split('.').map(Number)
|
2023-11-05 20:32:14 +01:00
|
|
|
|
|
|
|
for (let i = 0; i < minParts.length; i++) {
|
2023-11-10 22:30:55 +01:00
|
|
|
if (currentParts[i] > minParts[i]) return true
|
|
|
|
if (currentParts[i] < minParts[i]) return false
|
2023-11-05 20:32:14 +01:00
|
|
|
}
|
|
|
|
|
2023-11-10 22:30:55 +01:00
|
|
|
return true
|
2023-11-05 20:32:14 +01:00
|
|
|
},
|
2022-04-03 14:24:17 -05:00
|
|
|
async submitAuth() {
|
|
|
|
if (!this.networkConnected) return
|
|
|
|
if (!this.serverConfig.username) {
|
|
|
|
this.error = 'Invalid username'
|
|
|
|
return
|
|
|
|
}
|
2022-07-19 17:32:49 -05:00
|
|
|
|
|
|
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username && this.serverConfig.id !== scc.id)
|
2022-07-04 12:15:08 -05:00
|
|
|
if (duplicateConfig) {
|
|
|
|
this.error = 'Config already exists for this address and username'
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-04-03 14:24:17 -05:00
|
|
|
this.error = null
|
|
|
|
this.processing = true
|
|
|
|
|
|
|
|
var payload = await this.requestServerLogin()
|
|
|
|
this.processing = false
|
|
|
|
if (payload) {
|
2022-07-19 16:40:50 -05:00
|
|
|
this.setUserAndConnection(payload)
|
2022-04-03 14:24:17 -05:00
|
|
|
}
|
|
|
|
},
|
2023-11-02 16:10:55 -05:00
|
|
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
2022-04-10 20:31:47 -05:00
|
|
|
if (!user) return
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2022-04-10 20:31:47 -05:00
|
|
|
console.log('Successfully logged in', JSON.stringify(user))
|
|
|
|
|
2022-07-19 17:32:49 -05:00
|
|
|
this.$store.commit('setServerSettings', serverSettings)
|
2023-11-02 16:10:55 -05:00
|
|
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
2023-12-12 17:50:04 -06:00
|
|
|
this.$setServerLanguageCode(serverSettings.language)
|
2022-07-19 16:40:50 -05:00
|
|
|
|
2022-04-10 20:31:47 -05:00
|
|
|
// Set library - Use last library if set and available fallback to default user library
|
|
|
|
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
|
|
|
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
|
|
|
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
|
|
|
} else if (userDefaultLibraryId) {
|
|
|
|
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
|
|
|
}
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2022-04-10 20:31:47 -05:00
|
|
|
this.serverConfig.userId = user.id
|
|
|
|
this.serverConfig.token = user.token
|
2023-09-25 17:30:39 -05:00
|
|
|
this.serverConfig.username = user.username
|
2023-12-07 17:02:50 -06:00
|
|
|
delete this.serverConfig.version
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2022-04-10 20:31:47 -05:00
|
|
|
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2023-12-12 17:50:04 -06:00
|
|
|
// Set the device language to match the servers if this is the first server connection
|
|
|
|
if (!this.serverConnectionConfigs.length && serverSettings.language !== 'en-us') {
|
|
|
|
const deviceSettings = {
|
|
|
|
...this.deviceSettings,
|
|
|
|
languageCode: serverSettings.language
|
|
|
|
}
|
|
|
|
const updatedDeviceData = await this.$db.updateDeviceSettings(deviceSettings)
|
|
|
|
if (updatedDeviceData) {
|
|
|
|
this.$store.commit('setDeviceData', updatedDeviceData)
|
|
|
|
this.$setLanguageCode(updatedDeviceData.deviceSettings?.languageCode || 'en-us')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-10 20:31:47 -05:00
|
|
|
this.$store.commit('user/setUser', user)
|
|
|
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
2022-04-03 14:24:17 -05:00
|
|
|
|
2022-04-10 20:31:47 -05:00
|
|
|
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
|
|
|
this.$router.replace('/bookshelf')
|
2022-04-03 14:24:17 -05:00
|
|
|
},
|
|
|
|
async authenticateToken() {
|
|
|
|
if (!this.networkConnected) return
|
|
|
|
if (!this.serverConfig.token) {
|
|
|
|
this.error = 'No token'
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
this.error = null
|
|
|
|
this.processing = true
|
2023-09-15 17:35:44 -05:00
|
|
|
|
2023-11-13 15:22:15 -06:00
|
|
|
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
2023-09-15 17:35:44 -05:00
|
|
|
console.error('[ServerConnectForm] Server auth failed', error)
|
|
|
|
const errorMsg = error.message || error
|
|
|
|
this.error = 'Failed to authorize'
|
|
|
|
if (typeof errorMsg === 'string') {
|
|
|
|
this.error += ` (${errorMsg})`
|
|
|
|
}
|
2022-04-03 14:24:17 -05:00
|
|
|
return false
|
|
|
|
})
|
2023-09-15 17:35:44 -05:00
|
|
|
|
|
|
|
console.log('[ServerConnectForm] authRes=', authRes)
|
|
|
|
|
2022-04-03 14:24:17 -05:00
|
|
|
this.processing = false
|
|
|
|
return authRes
|
|
|
|
},
|
2023-11-01 10:16:31 -05:00
|
|
|
init() {
|
2022-04-03 14:24:17 -05:00
|
|
|
if (this.lastServerConnectionConfig) {
|
|
|
|
this.connectToServer(this.lastServerConnectionConfig)
|
|
|
|
} else {
|
|
|
|
this.showForm = !this.serverConnectionConfigs.length
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
mounted() {
|
2023-11-01 10:16:31 -05:00
|
|
|
this.$eventBus.$on('url-open', this.appUrlOpen)
|
2022-04-03 14:24:17 -05:00
|
|
|
this.init()
|
2023-11-01 10:16:31 -05:00
|
|
|
},
|
|
|
|
beforeDestroy() {
|
|
|
|
this.$eventBus.$off('url-open', this.appUrlOpen)
|
2022-04-03 14:24:17 -05:00
|
|
|
}
|
|
|
|
}
|
2022-12-08 00:28:28 -05:00
|
|
|
</script>
|