登录入口原先会切到主页并打开内嵌网页。改为弹出启动器自己的登录弹窗: - SkinSiteLoginModal:账号密码校验规则与皮肤站一致,登录前走验证码, 成功后保存 JWT 并刷新会话。 - SkinSiteCaptchaModal:拉取旋转图片验证码,用滑块调整角度后提交。 - AccountsCard 的登录按钮改为打开该弹窗,登录成功后重新拉取玩家列表。 - 修正 verifyCaptcha 的参数:服务端校验的是旋转角度 degree,不是文字答案。
This commit is contained in:
1 parent
0edf96fc2c
commit
00f0f111d8
4 files changed
+312
-13
No files matched your search
@@ -244,6 +244,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
||||||
|
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="onSkinSiteSignedIn" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -269,12 +270,12 @@ import { useQueryClient } from '@tanstack/vue-query'
|
|||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import axolotlLogo from '@/assets/netherstar.png'
|
import axolotlLogo from '@/assets/netherstar.png'
|
||||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||||
|
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
|
||||||
import {
|
import {
|
||||||
openSkinSiteLogin,
|
|
||||||
requestSkinSitePlayers,
|
requestSkinSitePlayers,
|
||||||
selectedSkinSitePlayerId,
|
selectedSkinSitePlayerId,
|
||||||
selectSkinSitePlayer,
|
selectSkinSitePlayer,
|
||||||
@@ -298,21 +299,20 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|||||||
import type { Skin } from '@/helpers/skins'
|
import type { Skin } from '@/helpers/skins'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
import { useTheming } from '@/store/state'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
|
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
||||||
const themeStore = useTheming()
|
|
||||||
|
|
||||||
async function goToSkinSiteLogin() {
|
async function goToSkinSiteLogin() {
|
||||||
openSkinSiteLogin()
|
skinSiteLoginModal.value?.show()
|
||||||
// The login action must reveal the iframe even if Minimal Home was selected.
|
}
|
||||||
themeStore.homeLayout = 'standard'
|
|
||||||
await router.push('/').catch(handleError)
|
function onSkinSiteSignedIn() {
|
||||||
|
void requestSkinSitePlayers().catch(() => {})
|
||||||
}
|
}
|
||||||
const refreshingNetwork = ref(false)
|
const refreshingNetwork = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
injectNotificationManager,
|
||||||
|
NewModal,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { requestCaptcha, verifyCaptcha } from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.skin-site.captcha.title', defaultMessage: 'Let us check you are human' },
|
||||||
|
subtitle: {
|
||||||
|
id: 'app.skin-site.captcha.subtitle',
|
||||||
|
defaultMessage: 'Drag the slider to rotate the image upright.',
|
||||||
|
},
|
||||||
|
submit: { id: 'app.skin-site.captcha.submit', defaultMessage: 'Submit' },
|
||||||
|
loading: { id: 'app.skin-site.captcha.loading', defaultMessage: 'Loading captcha…' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'verified', clientId: string): void; (e: 'cancel'): void }>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const image = ref('')
|
||||||
|
const challengeClientId = ref('')
|
||||||
|
const degree = ref(0)
|
||||||
|
|
||||||
|
/** The image is rotated by the server; the slider counter-rotates the preview. */
|
||||||
|
const previewRotation = computed(() => `${degree.value}deg`)
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
degree.value = 0
|
||||||
|
image.value = ''
|
||||||
|
challengeClientId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaptcha() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await requestCaptcha()
|
||||||
|
if (!result.success) throw new Error(result.errorMessage)
|
||||||
|
image.value = result.payload.image ?? ''
|
||||||
|
challengeClientId.value = result.payload.clientId
|
||||||
|
degree.value = 0
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
modal.value?.hide()
|
||||||
|
emit('cancel')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
resetState()
|
||||||
|
modal.value?.show()
|
||||||
|
await loadCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (submitting.value || !challengeClientId.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await verifyCaptcha({
|
||||||
|
clientId: challengeClientId.value,
|
||||||
|
degree: degree.value,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
handleError(new Error(result.errorMessage))
|
||||||
|
await loadCaptcha()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal.value?.hide()
|
||||||
|
emit('verified', result.payload)
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
modal.value?.hide()
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="resetState">
|
||||||
|
<div class="flex flex-col items-center gap-6">
|
||||||
|
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="size-44 overflow-hidden rounded-full border-4 border-solid border-bg-raised bg-button-bg"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="image"
|
||||||
|
:src="image"
|
||||||
|
alt=""
|
||||||
|
class="size-full object-cover transition-transform duration-75"
|
||||||
|
:style="{ transform: `rotate(${previewRotation})` }"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<input
|
||||||
|
v-model.number="degree"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
class="w-full accent-brand"
|
||||||
|
:disabled="loading || submitting"
|
||||||
|
/>
|
||||||
|
<div class="mt-1 flex justify-between text-xs text-secondary">
|
||||||
|
<span>0°</span>
|
||||||
|
<span>90°</span>
|
||||||
|
<span>180°</span>
|
||||||
|
<span>270°</span>
|
||||||
|
<span>360°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast"
|
||||||
|
:disabled="loading || submitting || !image"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ formatMessage(loading ? messages.loading : messages.submit) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
injectNotificationManager,
|
||||||
|
NewModal,
|
||||||
|
StyledInput,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session'
|
||||||
|
|
||||||
|
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError, addNotification } = injectNotificationManager()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.skin-site.login.title', defaultMessage: 'Sign in to StarLight' },
|
||||||
|
username: { id: 'app.skin-site.login.username', defaultMessage: 'Username or email' },
|
||||||
|
password: { id: 'app.skin-site.login.password', defaultMessage: 'Password' },
|
||||||
|
signIn: { id: 'app.skin-site.login.sign-in', defaultMessage: 'Sign in' },
|
||||||
|
signingIn: { id: 'app.skin-site.login.signing-in', defaultMessage: 'Signing in…' },
|
||||||
|
usernameHint: {
|
||||||
|
id: 'app.skin-site.login.username-hint',
|
||||||
|
defaultMessage: '3–16 characters, or a registered email address.',
|
||||||
|
},
|
||||||
|
passwordHint: {
|
||||||
|
id: 'app.skin-site.login.password-hint',
|
||||||
|
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
|
||||||
|
},
|
||||||
|
success: { id: 'app.skin-site.login.success', defaultMessage: 'Signed in to StarLight.' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const working = ref(false)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'signed-in' | 'cancel'): void }>()
|
||||||
|
|
||||||
|
const usernameValid = computed(() => {
|
||||||
|
const value = username.value.trim()
|
||||||
|
if (!value) return false
|
||||||
|
if (value.includes('@')) return value.length < 64
|
||||||
|
return value.length >= 3 && value.length <= 16
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordValid = computed(() => PASSWORD_RE.test(password.value))
|
||||||
|
const canSubmit = computed(() => usernameValid.value && passwordValid.value && !working.value)
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
username.value = ''
|
||||||
|
password.value = ''
|
||||||
|
working.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
reset()
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginSignIn() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
captchaModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCaptchaVerified(clientId: string) {
|
||||||
|
working.value = true
|
||||||
|
try {
|
||||||
|
const result = await signInSkinSite({
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
clientId,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
handleError(new Error(result.errorMessage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await reloadSkinSiteSession()
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.success) })
|
||||||
|
modal.value?.hide()
|
||||||
|
reset()
|
||||||
|
emit('signed-in')
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
working.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
modal.value?.hide()
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="font-semibold text-contrast">{{ formatMessage(messages.username) }}</span>
|
||||||
|
<StyledInput v-model="username" type="text" autocomplete="username" />
|
||||||
|
<span class="text-xs text-secondary">{{ formatMessage(messages.usernameHint) }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="font-semibold text-contrast">{{ formatMessage(messages.password) }}</span>
|
||||||
|
<StyledInput v-model="password" type="password" autocomplete="current-password" />
|
||||||
|
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="beginSignIn"
|
||||||
|
>
|
||||||
|
{{ formatMessage(working ? messages.signingIn : messages.signIn) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
|
||||||
|
<SkinSiteCaptchaModal
|
||||||
|
ref="captchaModal"
|
||||||
|
@verified="onCaptchaVerified"
|
||||||
|
@cancel="working = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
|
|
||||||
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
||||||
|
|
||||||
/** Where the skin site stores the JWT for this launcher. Kept separate from
|
/** Where the skin site stores the JWT for this launcher. Kept separate from
|
||||||
@@ -204,11 +203,10 @@ export function requestCaptcha(): Promise<SkinSiteResponse<SkinSiteCaptchaRequir
|
|||||||
return skinSiteRequest<SkinSiteCaptchaRequire>('captcha', { auth: false })
|
return skinSiteRequest<SkinSiteCaptchaRequire>('captcha', { auth: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Submit the captcha answer; resolves to the clientId to use for login/register. */
|
/** Submit the captcha rotation angle; resolves to the clientId used for login/register. */
|
||||||
export function verifyCaptcha(payload: {
|
export function verifyCaptcha(payload: {
|
||||||
clientId: string
|
clientId: string
|
||||||
answer: string
|
degree: number
|
||||||
[key: string]: unknown
|
|
||||||
}): Promise<SkinSiteResponse<string>> {
|
}): Promise<SkinSiteResponse<string>> {
|
||||||
return skinSiteRequest<string>('verifyCaptcha', { method: 'POST', body: payload, auth: false })
|
return skinSiteRequest<string>('verifyCaptcha', { method: 'POST', body: payload, auth: false })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user