Add account admission tickets and nickname feedback for launcher 0.1.2
This commit is contained in:
+8
-1
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
||||
import { Library } from './components/Library'
|
||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
@@ -18,6 +19,7 @@ export function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [windowError, setWindowError] = useState<string | null>(null)
|
||||
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
@@ -35,6 +37,9 @@ export function App() {
|
||||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
|
||||
const repairDisabled = !desktop || busy || checking
|
||||
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
||||
useEffect(() => {
|
||||
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
|
||||
}, [error])
|
||||
|
||||
let label = 'Играть'
|
||||
if (!desktop) label = 'В приложении'
|
||||
@@ -73,6 +78,8 @@ export function App() {
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
|
||||
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
|
||||
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
||||
</form>
|
||||
)}
|
||||
{account && !linkedNickname && (
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
||||
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface Feedback {
|
||||
kind: 'info' | 'success' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const visible = feedback !== null
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
if (!visible || !element) return
|
||||
const previous = document.activeElement
|
||||
element.showModal()
|
||||
return () => {
|
||||
element.close()
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||
}
|
||||
}, [visible])
|
||||
if (!feedback) return null
|
||||
return (
|
||||
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
|
||||
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
|
||||
<h2 id="feedback-title">{feedback.title}</h2>
|
||||
<p id="feedback-message">{feedback.message}</p>
|
||||
</div>
|
||||
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { useAccount } from '../hooks/useAccount'
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
return {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
feedback: null, dismissFeedback: () => {},
|
||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||
acknowledgeRecoveryCodes: () => {},
|
||||
|
||||
+35
-59
@@ -1,24 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Feedback } from '../components/FeedbackDialog'
|
||||
import { createRequestScope, errorMessage } from '../services/async'
|
||||
import { isNative, native } from '../services/native'
|
||||
import { linkedNickname, validCredentials } from '../state/account'
|
||||
import { isValidNickname } from '../state/settings'
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
|
||||
interface PendingLink {
|
||||
challengeId: number
|
||||
expiresAt: number
|
||||
isCurrent: () => boolean
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
// undefined = restoring saved account; null = signed out.
|
||||
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [challenge, setChallenge] = useState<PendingLink | null>(null)
|
||||
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null)
|
||||
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
|
||||
setLinkMessage(message)
|
||||
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
|
||||
}, [])
|
||||
const pending = useRef(false)
|
||||
const requests = useRef(createRequestScope())
|
||||
|
||||
@@ -35,44 +34,6 @@ export function useAccount() {
|
||||
return () => { active = false; requests.current.invalidate() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!challenge) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const current = () => active && challenge.isCurrent()
|
||||
const poll = async () => {
|
||||
if (!current()) return
|
||||
if (Date.now() >= challenge.expiresAt) {
|
||||
setChallenge(null)
|
||||
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await native.linkStatus(challenge.challengeId)
|
||||
if (!current()) return
|
||||
if (result.status === 'verified') {
|
||||
const refreshed = await native.getAccount()
|
||||
if (!current()) return
|
||||
setAccount(refreshed)
|
||||
setChallenge(null)
|
||||
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
|
||||
} else if (result.status === 'expired' || result.status === 'conflict') {
|
||||
setChallenge(null)
|
||||
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
|
||||
} else {
|
||||
// One request at a time; dispose and logout cancel future polling.
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setChallenge(null)
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
|
||||
}
|
||||
}
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [challenge])
|
||||
|
||||
const authenticate = async (username: string, password: string, register: boolean) => {
|
||||
if (pending.current || account === undefined) return false
|
||||
if (!validCredentials(username, password)) {
|
||||
@@ -92,7 +53,6 @@ export function useAccount() {
|
||||
const result = await native.authenticate(username, password, register)
|
||||
if (!currentRequest()) return false
|
||||
setAccount(result.account)
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
return true
|
||||
@@ -109,7 +69,6 @@ export function useAccount() {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
@@ -126,26 +85,42 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
const startLink = async (nickname: string) => {
|
||||
if (!isNative() || pending.current || challenge || !account) return
|
||||
if (pending.current) return
|
||||
if (!isNative()) {
|
||||
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
||||
return
|
||||
}
|
||||
if (!account) {
|
||||
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
||||
return
|
||||
}
|
||||
nickname = nickname.trim()
|
||||
if (!isValidNickname(nickname)) {
|
||||
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
||||
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
||||
return
|
||||
}
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setLinkMessage('Создаём проверку…')
|
||||
setError(null)
|
||||
reportLink('Проверяем аккаунт и закрепляем ник…')
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
try {
|
||||
const started = await native.startLink(nickname)
|
||||
const refreshed = await native.getAccount()
|
||||
if (!currentRequest()) return
|
||||
setLinkMessage(started.registered_on_server
|
||||
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
|
||||
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
|
||||
setChallenge({ challengeId: started.challenge_id,
|
||||
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
|
||||
setAccount(refreshed)
|
||||
if (!refreshed) {
|
||||
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
|
||||
return
|
||||
}
|
||||
const linkedAccount = await native.claimNickname(nickname)
|
||||
if (!currentRequest()) return
|
||||
setAccount(linkedAccount)
|
||||
const confirmed = linkedNickname(linkedAccount)
|
||||
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
|
||||
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
|
||||
} catch (reason) {
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
@@ -153,7 +128,8 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
||||
feedback, dismissFeedback: () => setFeedback(null),
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
clearError: () => setError(null),
|
||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||
|
||||
@@ -26,6 +26,7 @@ export const native = {
|
||||
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
|
||||
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
|
||||
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
|
||||
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { nickname })),
|
||||
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
|
||||
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
|
||||
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
|
||||
|
||||
@@ -166,6 +166,14 @@ button:disabled { cursor: default; }
|
||||
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
|
||||
|
||||
.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; }
|
||||
.feedback-dialog::backdrop { background: #050a07b8; }
|
||||
.feedback-dialog.error { border-color: #a46c54; }
|
||||
.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; }
|
||||
.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; }
|
||||
.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.workspace { grid-template-columns: 58px 224px 1fr; }
|
||||
.brand { width: 240px; }
|
||||
|
||||
Reference in New Issue
Block a user