feat(tauri): offload OAuth login flow to a separate window
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
This commit is contained in:
parent
419da80e37
commit
0095fc566e
|
@ -1,9 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { BackendError } from '~/types'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { onBeforeRouteLeave, type RouteLocationRaw, useRouter } from 'vue-router'
|
||||
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
|
@ -26,6 +26,15 @@ const { t } = useI18n()
|
|||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
// TODO (wvffle): Move to store logic when migrated to pinia
|
||||
useEventListener(window, 'beforeunload', () => {
|
||||
store.dispatch('auth/tryFinishOAuthFlow')
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
store.dispatch('auth/tryFinishOAuthFlow')
|
||||
})
|
||||
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
|
@ -53,13 +62,13 @@ const submit = async () => {
|
|||
} else {
|
||||
await store.dispatch('auth/oauthLogin', props.next)
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const backendError = error as BackendError
|
||||
|
||||
if (backendError.response?.status === 400) {
|
||||
errors.value = ['invalid_credentials']
|
||||
} else {
|
||||
errors.value = backendError.backendErrors
|
||||
errors.value = backendError.backendErrors ?? error.message ?? error
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,20 +77,14 @@ const submit = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="ui form"
|
||||
@submit.prevent="submit()"
|
||||
>
|
||||
<div
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="errors.length > 0" role="alert" class="ui negative message">
|
||||
<h4 class="header">
|
||||
{{ $t('components.auth.LoginForm.header.loginFailure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
|
||||
<li
|
||||
v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
|
||||
{{ $t('components.auth.LoginForm.help.approvalRequired') }}
|
||||
</li>
|
||||
<li v-else-if="errors[0] == 'invalid_credentials'">
|
||||
|
@ -103,33 +106,18 @@ const submit = async () => {
|
|||
</router-link>
|
||||
</template>
|
||||
</label>
|
||||
<input
|
||||
id="username-field"
|
||||
ref="username"
|
||||
v-model="credentials.username"
|
||||
required
|
||||
name="username"
|
||||
type="text"
|
||||
autofocus
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
>
|
||||
<input id="username-field" ref="username" v-model="credentials.username" required name="username" type="text"
|
||||
autofocus :placeholder="labels.usernamePlaceholder">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password-field">
|
||||
{{ $t('components.auth.LoginForm.label.password') }}
|
||||
<span class="middle pipe symbol" />
|
||||
<router-link
|
||||
tabindex="1"
|
||||
:to="{name: 'auth.password-reset', query: {email: credentials.username}}"
|
||||
>
|
||||
<router-link tabindex="1" :to="{ name: 'auth.password-reset', query: { email: credentials.username } }">
|
||||
{{ $t('components.auth.LoginForm.link.resetPassword') }}
|
||||
</router-link>
|
||||
</label>
|
||||
<password-input
|
||||
v-model="credentials.password"
|
||||
field-id="password-field"
|
||||
required
|
||||
/>
|
||||
<password-input v-model="credentials.password" field-id="password-field" required />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
@ -137,10 +125,7 @@ const submit = async () => {
|
|||
{{ $t('components.auth.LoginForm.message.redirect', { domain: $store.getters['instance/domain'] }) }}
|
||||
</p>
|
||||
</template>
|
||||
<button
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']"
|
||||
type="submit"
|
||||
>
|
||||
<button :class="['ui', { 'loading': isLoading }, 'right', 'floated', buttonClasses, 'button']" type="submit">
|
||||
{{ $t('components.auth.LoginForm.button.login') }}
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { BackendError, User } from '~/types'
|
|||
import type { Module } from 'vuex'
|
||||
import type { RootState } from '~/store/index'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { WebviewWindow } from '@tauri-apps/api/webview'
|
||||
|
||||
import axios from 'axios'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
|
@ -9,6 +10,7 @@ import useFormData from '~/composables/useFormData'
|
|||
|
||||
import { clear as clearIDB } from 'idb-keyval'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { isTauri } from '~/composables/tauri'
|
||||
|
||||
export type Permission = 'settings' | 'library' | 'moderation'
|
||||
export interface State {
|
||||
|
@ -21,6 +23,8 @@ export interface State {
|
|||
scopedTokens: ScopedTokens
|
||||
|
||||
applicationSecret: string | undefined
|
||||
|
||||
oauthWindow: WebviewWindow | undefined
|
||||
}
|
||||
|
||||
interface ScopedTokens {
|
||||
|
@ -55,7 +59,7 @@ function getDefaultOauth (): OAuthTokens {
|
|||
|
||||
async function createOauthApp () {
|
||||
const payload = {
|
||||
name: `Funkwhale web client at ${window.location.hostname}`,
|
||||
name: `Funkwhale web client at ${location.hostname}`,
|
||||
website: location.origin,
|
||||
scopes: NEEDED_SCOPES,
|
||||
redirect_uris: `${location.origin}/auth/callback`
|
||||
|
@ -78,7 +82,9 @@ const store: Module<State, RootState> = {
|
|||
oauth: getDefaultOauth(),
|
||||
scopedTokens: getDefaultScopedTokens(),
|
||||
|
||||
applicationSecret: undefined
|
||||
applicationSecret: undefined,
|
||||
|
||||
oauthWindow: undefined
|
||||
},
|
||||
getters: {
|
||||
header: state => {
|
||||
|
@ -243,14 +249,41 @@ const store: Module<State, RootState> = {
|
|||
commit('permission', { key: permission, status: hasPermission })
|
||||
}
|
||||
},
|
||||
async oauthLogin ({ state, rootState, commit }, next: RouteLocationRaw) {
|
||||
async tryFinishOAuthFlow ({ state }) {
|
||||
if (isTauri()) {
|
||||
return state.oauthWindow?.close().catch(() => {
|
||||
// Ignore the error in case of window being already closed
|
||||
})
|
||||
}
|
||||
},
|
||||
async oauthLogin ({ state, rootState, commit, dispatch }, next: RouteLocationRaw) {
|
||||
const app = await createOauthApp()
|
||||
commit('oauthApp', app)
|
||||
const redirectUri = encodeURIComponent(`${location.origin}/auth/callback`)
|
||||
const params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUri}&state=${next}&client_id=${state.oauth.clientId}`
|
||||
const redirectUrl = encodeURIComponent(`${location.origin}/auth/callback`)
|
||||
const params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUrl}&state=${next}&client_id=${state.oauth.clientId}`
|
||||
const authorizeUrl = `${rootState.instance.instanceUrl}authorize?${params}`
|
||||
|
||||
if (isTauri()) {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webview')
|
||||
|
||||
state.oauthWindow = new WebviewWindow('oauth', {
|
||||
title: `Login to ${rootState.instance.settings.instance.name}`,
|
||||
parent: 'main',
|
||||
url: authorizeUrl
|
||||
})
|
||||
|
||||
const token = await new Promise((resolve, reject) => {
|
||||
state.oauthWindow?.once('tauri://error', reject)
|
||||
state.oauthWindow?.once('tauri://destroyed', () => reject(new Error('Aborted by user')))
|
||||
state.oauthWindow?.once('oauthToken', async (event) => resolve(event.payload))
|
||||
}).finally(() => dispatch('tryFinishOAuthFlow'))
|
||||
|
||||
commit('oauthToken', token)
|
||||
await dispatch('fetchUser')
|
||||
} else {
|
||||
logger.log('Redirecting user...', authorizeUrl)
|
||||
window.location.href = authorizeUrl
|
||||
location.href = authorizeUrl
|
||||
}
|
||||
},
|
||||
async handleOauthCallback ({ state, commit, dispatch }, authorizationCode) {
|
||||
logger.log('Fetching token...')
|
||||
|
@ -266,6 +299,18 @@ const store: Module<State, RootState> = {
|
|||
useFormData(payload),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
|
||||
if (isTauri()) {
|
||||
const { getCurrent } = await import('@tauri-apps/api/window')
|
||||
const currentWindow = getCurrent()
|
||||
|
||||
// If the current window is the oauth window, pass the event to the main window
|
||||
if (currentWindow.label === 'oauth') {
|
||||
await currentWindow.emit('oauthToken', response.data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
commit('oauthToken', response.data)
|
||||
await dispatch('fetchUser')
|
||||
},
|
||||
|
|
|
@ -567,6 +567,33 @@ dependencies = [
|
|||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-eyre"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"color-spantrace",
|
||||
"eyre",
|
||||
"indenter",
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-spantrace"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"tracing-core",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
|
@ -1009,6 +1036,16 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
|
||||
dependencies = [
|
||||
"indenter",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
|
@ -1099,6 +1136,7 @@ dependencies = [
|
|||
name = "funkwhale"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"color-eyre",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
@ -1714,6 +1752,12 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indenter"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
|
@ -2288,6 +2332,12 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
|
@ -3908,6 +3958,16 @@ dependencies = [
|
|||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-error"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
|
|
|
@ -22,6 +22,7 @@ tauri-build = { version = "2.0.0-beta", features = [] }
|
|||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "2.0.0-beta", features = [] }
|
||||
color-eyre = "0.6.2"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"identifier": "migrated",
|
||||
"description": "permissions that were migrated from v1",
|
||||
"context": "local",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "oauth"],
|
||||
"permissions": [
|
||||
"path:default",
|
||||
"event:default",
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"identifier": "oauth2",
|
||||
"description": "permissions that required for OAuth2 login window",
|
||||
"context": "local",
|
||||
"windows": ["main"],
|
||||
"permissions": ["webview:allow-create-webview-window", "window:allow-close"],
|
||||
"platforms": ["linux", "macOS", "windows", "android", "iOS"]
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
[toolchain]
|
||||
profile = "minimal"
|
||||
channel = "1.71.0"
|
||||
components = ["rust-src", "rust-analyzer", "clippy"]
|
|
@ -1,6 +1,12 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
use color_eyre::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
funkwhale_lib::run();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -10,11 +10,12 @@
|
|||
},
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"fullscreen": false,
|
||||
"height": 600,
|
||||
"width": 800,
|
||||
"resizable": true,
|
||||
"title": "Funkwhale",
|
||||
"width": 800
|
||||
"title": "Funkwhale"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue