166 lines
6.4 KiB
TypeScript
166 lines
6.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
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, LinkChallenge } 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 pending = useRef(false)
|
|
const requests = useRef(createRequestScope())
|
|
|
|
useEffect(() => {
|
|
if (!isNative()) return
|
|
let active = true
|
|
void native.getAccount().then((value) => {
|
|
if (active) setAccount(value)
|
|
}).catch((reason: unknown) => {
|
|
if (!active) return
|
|
setAccount(null)
|
|
setError(errorMessage(reason, 'Не удалось восстановить аккаунт ShaCraft'))
|
|
})
|
|
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)) {
|
|
setError('Логин: 3–32 латинских буквы, цифры или _; пароль: 3–128 символов')
|
|
return false
|
|
}
|
|
if (!isNative()) {
|
|
setError('Вход в ShaCraft доступен в приложении лаунчера')
|
|
return false
|
|
}
|
|
pending.current = true
|
|
requests.current.invalidate()
|
|
const currentRequest = requests.current.capture()
|
|
setBusy(true)
|
|
setError(null)
|
|
try {
|
|
const result = await native.authenticate(username, password, register)
|
|
if (!currentRequest()) return false
|
|
setAccount(result.account)
|
|
setChallenge(null)
|
|
setLinkMessage(null)
|
|
setRecoveryCodes(result.recoveryCodes)
|
|
return true
|
|
} catch (reason) {
|
|
setError(errorMessage(reason, register ? 'Не удалось зарегистрироваться' : 'Не удалось войти'))
|
|
return false
|
|
} finally {
|
|
pending.current = false
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const logout = async () => {
|
|
if (!isNative() || pending.current) return
|
|
pending.current = true
|
|
requests.current.invalidate()
|
|
setChallenge(null)
|
|
setLinkMessage(null)
|
|
setBusy(true)
|
|
setError(null)
|
|
try {
|
|
await native.logout()
|
|
setAccount(null)
|
|
setRecoveryCodes([])
|
|
} catch (reason) {
|
|
setError(errorMessage(reason, 'Не удалось выйти из ShaCraft'))
|
|
} finally {
|
|
pending.current = false
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const acceptChallenge = (started: LinkChallenge) => {
|
|
requests.current.invalidate()
|
|
const current = requests.current.capture()
|
|
setLinkMessage(`${started.registered_on_server ? 'Войдите на Aeronautics: /login <пароль>.' : 'Войдите на Aeronautics: /register <пароль> <пароль>.'} Затем подтвердите свой аккаунт командой /shacraft link ${started.challenge_id} ${started.proof_code}`)
|
|
setChallenge({ challengeId: started.challenge_id,
|
|
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: current })
|
|
}
|
|
|
|
const startLink = async (nickname: string) => {
|
|
if (!isNative() || pending.current || challenge || !account) return
|
|
if (!isValidNickname(nickname)) {
|
|
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
|
return
|
|
}
|
|
pending.current = true
|
|
setBusy(true)
|
|
setLinkMessage('Создаём проверку…')
|
|
requests.current.invalidate()
|
|
const currentRequest = requests.current.capture()
|
|
try {
|
|
const started = await native.startLink(nickname)
|
|
if (!currentRequest()) return
|
|
acceptChallenge(started)
|
|
} catch (reason) {
|
|
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
|
} finally {
|
|
pending.current = false
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return {
|
|
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
|
linkedNickname: linkedNickname(account), authenticate, logout, startLink, acceptChallenge,
|
|
clearError: () => setError(null),
|
|
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
|
}
|
|
}
|