Files
Starlight_Lancher/apps/app-frontend/src/components/ui/MinecraftLoginModal.vue

165 lines
4.6 KiB
Vue

<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onUnmounted, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { begin_device_login, poll_device_login } from '@/helpers/auth'
type MinecraftCredential = {
account_type: 'microsoft'
profile: {
id: string
name: string
}
}
type DeviceLoginFlow = {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
type DeviceLoginPoll =
| { status: 'pending'; slow_down: boolean }
| { status: 'complete'; credentials: MinecraftCredential }
const emit = defineEmits<{
complete: [credentials: MinecraftCredential]
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const busy = ref(false)
const deviceFlow = ref<DeviceLoginFlow | null>(null)
const deviceError = ref<string | null>(null)
let devicePollTimer: ReturnType<typeof setTimeout> | undefined
let deviceExpiresAt = 0
const messages = defineMessages({
title: { id: 'minecraft-login.title', defaultMessage: 'Sign in to Minecraft' },
deviceStarting: {
id: 'minecraft-login.device-starting',
defaultMessage: 'Requesting a device code...',
},
deviceDescription: {
id: 'minecraft-login.device-description',
defaultMessage: 'Open the page below on any device, then enter this code.',
},
deviceExpired: {
id: 'minecraft-login.device-expired',
defaultMessage: 'This device code expired. Start again to receive a new code.',
},
openVerification: {
id: 'minecraft-login.open-verification',
defaultMessage: 'Open verification page',
},
})
const verificationUrl = computed(() => deviceFlow.value?.verification_uri ?? '')
function clearDevicePolling() {
if (devicePollTimer !== undefined) {
clearTimeout(devicePollTimer)
devicePollTimer = undefined
}
}
function resetDeviceLogin() {
clearDevicePolling()
deviceFlow.value = null
deviceError.value = null
deviceExpiresAt = 0
}
function hide() {
resetDeviceLogin()
modal.value?.hide()
}
function showDeviceLogin() {
resetDeviceLogin()
modal.value?.show()
void startDeviceLogin()
}
async function finishLogin(credentials: MinecraftCredential) {
emit('complete', credentials)
hide()
}
async function pollDeviceLogin(delay: number) {
const deviceCode = deviceFlow.value?.device_code
if (!deviceCode || Date.now() >= deviceExpiresAt) {
deviceError.value = formatMessage(messages.deviceExpired)
return
}
try {
const result = (await poll_device_login(deviceCode)) as DeviceLoginPoll
if (deviceFlow.value?.device_code !== deviceCode) return
if (result.status === 'complete') {
await finishLogin(result.credentials)
return
}
const nextDelay = result.slow_down ? delay + 5000 : delay
devicePollTimer = setTimeout(() => void pollDeviceLogin(nextDelay), nextDelay)
} catch (error) {
deviceError.value = error instanceof Error ? error.message : String(error)
}
}
async function openDeviceVerification() {
if (!verificationUrl.value) return
await openUrl(verificationUrl.value)
}
async function startDeviceLogin() {
if (busy.value) return
busy.value = true
deviceError.value = null
try {
const flow = (await begin_device_login()) as DeviceLoginFlow
deviceFlow.value = flow
deviceExpiresAt = Date.now() + flow.expires_in * 1000
void pollDeviceLogin(Math.max(flow.interval, 1) * 1000)
} catch (error) {
deviceError.value = error instanceof Error ? error.message : String(error)
} finally {
busy.value = false
}
}
onUnmounted(clearDevicePolling)
defineExpose({ showDeviceLogin, hide })
</script>
<template>
<ModalWrapper ref="modal" :header="formatMessage(messages.title)" :on-hide="resetDeviceLogin">
<div class="flex min-w-[24rem] flex-col gap-4">
<template v-if="deviceFlow">
<p class="m-0 text-secondary">{{ formatMessage(messages.deviceDescription) }}</p>
<ButtonStyled>
<button @click="openDeviceVerification">
<ExternalIcon /> {{ formatMessage(messages.openVerification) }}
</button>
</ButtonStyled>
<code
class="rounded-xl bg-surface-3 px-4 py-3 text-center text-xl font-bold tracking-[0.18em] text-contrast"
>
{{ deviceFlow.user_code }}
</code>
<p v-if="deviceError" class="m-0 text-sm text-red">{{ deviceError }}</p>
</template>
<template v-else>
<p v-if="busy" class="m-0 text-secondary">{{ formatMessage(messages.deviceStarting) }}</p>
<p v-if="deviceError" class="m-0 text-sm text-red">{{ deviceError }}</p>
</template>
</div>
</ModalWrapper>
</template>