213 lines
6.7 KiB
Vue
213 lines
6.7 KiB
Vue
<template>
|
|
<AuthLayout>
|
|
<div class="space-y-6">
|
|
<div class="text-center">
|
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{{ t('auth.linuxdo.callbackTitle') }}
|
|
</h2>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
|
{{ isProcessing ? t('auth.linuxdo.callbackProcessing') : t('auth.linuxdo.callbackHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<transition name="fade">
|
|
<div v-if="needsInvitation" class="space-y-4">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
|
{{ t('auth.linuxdo.invitationRequired') }}
|
|
</p>
|
|
<div>
|
|
<input
|
|
v-model="invitationCode"
|
|
type="text"
|
|
class="input w-full"
|
|
:placeholder="t('auth.invitationCodePlaceholder')"
|
|
:disabled="isSubmitting"
|
|
@keyup.enter="handleSubmitInvitation"
|
|
/>
|
|
</div>
|
|
<transition name="fade">
|
|
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
|
{{ invitationError }}
|
|
</p>
|
|
</transition>
|
|
<button
|
|
class="btn btn-primary w-full"
|
|
:disabled="isSubmitting || !invitationCode.trim()"
|
|
@click="handleSubmitInvitation"
|
|
>
|
|
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
|
|
</button>
|
|
</div>
|
|
</transition>
|
|
|
|
<transition name="fade">
|
|
<div
|
|
v-if="errorMessage"
|
|
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex-shrink-0">
|
|
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<p class="text-sm text-red-700 dark:text-red-400">
|
|
{{ errorMessage }}
|
|
</p>
|
|
<router-link to="/login" class="btn btn-primary">
|
|
{{ t('auth.linuxdo.backToLogin') }}
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</AuthLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { AuthLayout } from '@/components/layout'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import { useAuthStore, useAppStore } from '@/stores'
|
|
import { completeLinuxDoOAuthRegistration } from '@/api/auth'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { t } = useI18n()
|
|
|
|
const authStore = useAuthStore()
|
|
const appStore = useAppStore()
|
|
|
|
const isProcessing = ref(true)
|
|
const errorMessage = ref('')
|
|
|
|
// Invitation code flow state
|
|
const needsInvitation = ref(false)
|
|
const pendingOAuthToken = ref('')
|
|
const invitationCode = ref('')
|
|
const isSubmitting = ref(false)
|
|
const invitationError = ref('')
|
|
const redirectTo = ref('/dashboard')
|
|
|
|
function parseFragmentParams(): URLSearchParams {
|
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
|
return new URLSearchParams(hash)
|
|
}
|
|
|
|
function sanitizeRedirectPath(path: string | null | undefined): string {
|
|
if (!path) return '/dashboard'
|
|
if (!path.startsWith('/')) return '/dashboard'
|
|
if (path.startsWith('//')) return '/dashboard'
|
|
if (path.includes('://')) return '/dashboard'
|
|
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
|
return path
|
|
}
|
|
|
|
async function handleSubmitInvitation() {
|
|
invitationError.value = ''
|
|
if (!invitationCode.value.trim()) return
|
|
|
|
isSubmitting.value = true
|
|
try {
|
|
const tokenData = await completeLinuxDoOAuthRegistration(
|
|
pendingOAuthToken.value,
|
|
invitationCode.value.trim()
|
|
)
|
|
if (tokenData.refresh_token) {
|
|
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
|
}
|
|
if (tokenData.expires_in) {
|
|
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
|
}
|
|
await authStore.setToken(tokenData.access_token)
|
|
appStore.showSuccess(t('auth.loginSuccess'))
|
|
await router.replace(redirectTo.value)
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
|
invitationError.value =
|
|
err.response?.data?.message || err.message || t('auth.linuxdo.completeRegistrationFailed')
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const params = parseFragmentParams()
|
|
|
|
const token = params.get('access_token') || ''
|
|
const refreshToken = params.get('refresh_token') || ''
|
|
const expiresInStr = params.get('expires_in') || ''
|
|
const redirect = sanitizeRedirectPath(
|
|
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
|
)
|
|
const error = params.get('error')
|
|
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
|
|
|
if (error) {
|
|
if (error === 'invitation_required') {
|
|
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
|
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
|
if (!pendingOAuthToken.value) {
|
|
errorMessage.value = t('auth.linuxdo.invalidPendingToken')
|
|
appStore.showError(errorMessage.value)
|
|
isProcessing.value = false
|
|
return
|
|
}
|
|
needsInvitation.value = true
|
|
isProcessing.value = false
|
|
return
|
|
}
|
|
errorMessage.value = errorDesc || error
|
|
appStore.showError(errorMessage.value)
|
|
isProcessing.value = false
|
|
return
|
|
}
|
|
|
|
if (!token) {
|
|
errorMessage.value = t('auth.linuxdo.callbackMissingToken')
|
|
appStore.showError(errorMessage.value)
|
|
isProcessing.value = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Store refresh token and expires_at (convert to timestamp) if provided
|
|
if (refreshToken) {
|
|
localStorage.setItem('refresh_token', refreshToken)
|
|
}
|
|
if (expiresInStr) {
|
|
const expiresIn = parseInt(expiresInStr, 10)
|
|
if (!isNaN(expiresIn)) {
|
|
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
|
}
|
|
}
|
|
|
|
await authStore.setToken(token)
|
|
appStore.showSuccess(t('auth.loginSuccess'))
|
|
await router.replace(redirect)
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
|
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
|
appStore.showError(errorMessage.value)
|
|
isProcessing.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
</style>
|
|
|