The onboarding and provider settings get a Sign in button: the IDE shows a short code, opens the turkservis.online approval page, and after approval saves the TurkServis provider itself and hands a separate member key to the AITURK CLI over stdin (aiturk login --key-stdin --keep-other-account). Keys never reach the renderer, logs, process arguments or URLs. Pasting an API key remains as the fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
332 lines
8.8 KiB
TypeScript
332 lines
8.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import type { AiturkCliStatus } from '@/global'
|
|
import { useAiturkCopy } from '@/i18n/aiturk'
|
|
import { aiturkLoginScope } from '@/lib/aiturk-provider'
|
|
import { Loader2 } from '@/lib/icons'
|
|
import { notify } from '@/store/notifications'
|
|
|
|
const POLL_INTERVAL_MS = 3000
|
|
|
|
type Phase = 'idle' | 'starting' | 'waiting' | 'question' | 'replacing'
|
|
|
|
interface Waiting {
|
|
userCode: string
|
|
verificationUri: string
|
|
}
|
|
|
|
type LoginCopy = ReturnType<typeof useAiturkCopy>
|
|
|
|
function errorCode(error: unknown): string {
|
|
// The main process only ever throws fixed codes ("aiturk-login:<code>").
|
|
return /aiturk-login:([a-z-]+)/.exec(error instanceof Error ? error.message : String(error))?.[1] ?? ''
|
|
}
|
|
|
|
function errorText(code: string, copy: LoginCopy): string {
|
|
switch (code) {
|
|
case 'rate-limited':
|
|
return copy.login.rateLimited
|
|
|
|
case 'browser-failed':
|
|
return copy.login.browserFailed
|
|
|
|
case 'no-models':
|
|
return copy.noModels
|
|
|
|
case 'provider-failed':
|
|
|
|
case 'invalid-response':
|
|
return copy.failed
|
|
|
|
default:
|
|
return copy.login.unavailable
|
|
}
|
|
}
|
|
|
|
function cliText(cli: AiturkCliStatus, copy: LoginCopy): string {
|
|
switch (cli) {
|
|
case 'ok':
|
|
return copy.login.cliOk
|
|
|
|
case 'outdated':
|
|
return copy.login.cliOutdated
|
|
|
|
case 'failed':
|
|
|
|
case 'unknown-account':
|
|
return copy.login.cliFailed
|
|
|
|
default:
|
|
// 'missing' stays silent; 'other-account' is asked as a question.
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One-click TurkServis sign-in, shared by onboarding and Settings.
|
|
*
|
|
* The API keys never reach this component: the main process shows us only the
|
|
* short code, then reports `connected` once it has saved the provider and
|
|
* handed the AITURK CLI its own key.
|
|
*/
|
|
export function useAiturkLogin(onConnected: () => Promise<void> | void) {
|
|
const copy = useAiturkCopy()
|
|
const [phase, setPhase] = useState<Phase>('idle')
|
|
const [waiting, setWaiting] = useState<null | Waiting>(null)
|
|
const [error, setError] = useState('')
|
|
const [info, setInfo] = useState('')
|
|
const live = useRef(false)
|
|
const mounted = useRef(true)
|
|
const onConnectedRef = useRef(onConnected)
|
|
const copyRef = useRef(copy)
|
|
|
|
onConnectedRef.current = onConnected
|
|
copyRef.current = copy
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- mount flag + in-flight login flag, not an atom mirror
|
|
useEffect(() => {
|
|
mounted.current = true
|
|
|
|
return () => {
|
|
mounted.current = false
|
|
|
|
// Leaving mid-login: revoke the code and drop anything main still holds.
|
|
if (live.current) {
|
|
live.current = false
|
|
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const finish = useCallback(async (cli: AiturkCliStatus) => {
|
|
live.current = false
|
|
const text = cliText(cli, copyRef.current)
|
|
|
|
if (mounted.current) {
|
|
setWaiting(null)
|
|
setPhase('idle')
|
|
setInfo(text)
|
|
}
|
|
|
|
try {
|
|
await onConnectedRef.current()
|
|
} catch {
|
|
// The provider is saved; a failed refresh is the caller's concern.
|
|
}
|
|
|
|
// Onboarding closes this panel as soon as it is connected; keep the CLI
|
|
// note visible as a toast in that case.
|
|
if (!mounted.current && text) {
|
|
notify({ kind: cli === 'ok' ? 'success' : 'info', message: text })
|
|
}
|
|
}, [])
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- clears the in-flight login flag when polling ends
|
|
useEffect(() => {
|
|
if (phase !== 'waiting') {
|
|
return
|
|
}
|
|
|
|
let stopped = false
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
const tick = async () => {
|
|
const bridge = window.hermesDesktop?.aiturkLogin
|
|
|
|
if (stopped || !bridge) {
|
|
return
|
|
}
|
|
|
|
let result
|
|
|
|
try {
|
|
result = await bridge.poll()
|
|
} catch {
|
|
result = { state: 'pending' as const }
|
|
}
|
|
|
|
if (stopped) {
|
|
return
|
|
}
|
|
|
|
if (result.state === 'pending') {
|
|
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
|
|
|
return
|
|
}
|
|
|
|
if (result.state === 'connected') {
|
|
if (result.cli === 'other-account') {
|
|
setWaiting(null)
|
|
setPhase('question')
|
|
|
|
return
|
|
}
|
|
|
|
await finish(result.cli)
|
|
|
|
return
|
|
}
|
|
|
|
live.current = false
|
|
setWaiting(null)
|
|
setPhase('idle')
|
|
setError(result.state === 'expired' ? copyRef.current.login.expired : errorText(result.error, copyRef.current))
|
|
}
|
|
|
|
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
|
|
|
return () => {
|
|
stopped = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [phase, finish])
|
|
|
|
const start = useCallback(async () => {
|
|
const bridge = window.hermesDesktop?.aiturkLogin
|
|
|
|
setError('')
|
|
setInfo('')
|
|
|
|
if (!bridge) {
|
|
setError(copyRef.current.login.unsupported)
|
|
|
|
return
|
|
}
|
|
|
|
setPhase('starting')
|
|
|
|
try {
|
|
const started = await bridge.start(aiturkLoginScope())
|
|
|
|
if (!mounted.current) {
|
|
void bridge.cancel().catch(() => {})
|
|
|
|
return
|
|
}
|
|
|
|
live.current = true
|
|
setWaiting({ userCode: started.userCode, verificationUri: started.verificationUri })
|
|
setPhase('waiting')
|
|
} catch (err) {
|
|
if (mounted.current) {
|
|
setPhase('idle')
|
|
setError(errorText(errorCode(err), copyRef.current))
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const cancel = useCallback(() => {
|
|
live.current = false
|
|
setWaiting(null)
|
|
setPhase('idle')
|
|
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
|
}, [])
|
|
|
|
const reopen = useCallback(() => {
|
|
if (waiting) {
|
|
void window.hermesDesktop?.openExternal(waiting.verificationUri).catch(() => {})
|
|
}
|
|
}, [waiting])
|
|
|
|
const replaceCli = useCallback(async () => {
|
|
setPhase('replacing')
|
|
|
|
let cli: AiturkCliStatus = 'failed'
|
|
|
|
try {
|
|
cli = (await window.hermesDesktop?.aiturkLogin?.overwriteCli())?.cli ?? 'failed'
|
|
} catch {
|
|
cli = 'failed'
|
|
}
|
|
|
|
await finish(cli === 'other-account' ? 'failed' : cli)
|
|
}, [finish])
|
|
|
|
const keepCli = useCallback(async () => {
|
|
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
|
await finish('missing')
|
|
}, [finish])
|
|
|
|
return { phase, waiting, error, info, start, cancel, reopen, replaceCli, keepCli }
|
|
}
|
|
|
|
interface AiturkLoginProps {
|
|
/** Runs once the TurkServis provider is saved (refresh lists, advance onboarding…). */
|
|
onConnected: () => Promise<void> | void
|
|
/** Idle button label; defaults to "Giriş yap". */
|
|
label?: string
|
|
variant?: 'default' | 'outline'
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function AiturkLogin({ onConnected, label, variant = 'default', disabled }: AiturkLoginProps) {
|
|
const copy = useAiturkCopy()
|
|
const login = useAiturkLogin(onConnected)
|
|
|
|
if (login.phase === 'waiting' && login.waiting) {
|
|
return (
|
|
<div className="grid gap-3" data-testid="aiturk-login-code">
|
|
<div>
|
|
<div className="text-xs text-muted-foreground">{copy.login.codeLabel}</div>
|
|
<output aria-label={copy.login.codeLabel} className="block font-mono text-3xl font-semibold tracking-[0.3em]">
|
|
{login.waiting.userCode}
|
|
</output>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">{copy.login.confirmHint}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button onClick={login.reopen} size="sm" variant="outline">
|
|
{copy.login.reopen}
|
|
</Button>
|
|
<Button onClick={login.cancel} size="sm" variant="ghost">
|
|
{copy.login.cancel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (login.phase === 'question' || login.phase === 'replacing') {
|
|
const replacing = login.phase === 'replacing'
|
|
|
|
return (
|
|
<div className="grid gap-3" role="group">
|
|
<p className="text-sm">{copy.login.cliOtherAccount}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button disabled={replacing} onClick={() => void login.replaceCli()} size="sm">
|
|
{replacing ? <Loader2 className="animate-spin" /> : null}
|
|
{copy.login.cliReplace}
|
|
</Button>
|
|
<Button disabled={replacing} onClick={() => void login.keepCli()} size="sm" variant="outline">
|
|
{copy.login.cliKeep}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const starting = login.phase === 'starting'
|
|
|
|
return (
|
|
<div className="grid gap-2">
|
|
<div>
|
|
<Button disabled={disabled || starting} onClick={() => void login.start()} variant={variant}>
|
|
{starting ? <Loader2 className="animate-spin" /> : null}
|
|
{starting ? copy.login.starting : (label ?? copy.login.signIn)}
|
|
</Button>
|
|
</div>
|
|
{login.error ? (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{login.error}
|
|
</p>
|
|
) : null}
|
|
{login.info ? (
|
|
<p className="text-sm text-muted-foreground" role="status">
|
|
{login.info}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|