feat(aiturk): one-click sign-in for AITURK IDE (1.0.0-beta.14)
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
This commit is contained in:
1 parent
fe0a45a84f
commit
25d8c18c10
13 files changed
+1938
-15
No files matched your search
@@ -0,0 +1,331 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider'
|
||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.resetAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -17,6 +22,7 @@ function setup() {
|
||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
return { input, onConnected }
|
||||
}
|
||||
|
||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||
|
||||
function setupLogin(polls: unknown[]) {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const bridge = {
|
||||
start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }),
|
||||
poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }),
|
||||
cancel: vi.fn().mockResolvedValue({ ok: true }),
|
||||
overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' })
|
||||
}
|
||||
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal }
|
||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||
render(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
||||
|
||||
return { bridge, onConnected, openExternal }
|
||||
}
|
||||
|
||||
const click = async (name: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const tick = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
})
|
||||
|
||||
describe('TurkServis one-click sign-in', () => {
|
||||
it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => {
|
||||
const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }])
|
||||
await click('Giriş yap')
|
||||
|
||||
expect(bridge.start).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345')
|
||||
expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(1)
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
|
||||
await click('Sayfayı yeniden aç')
|
||||
expect(openExternal).toHaveBeenCalledWith(URI)
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
// No key ever reaches the renderer through this flow.
|
||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||
expect(validateCustomEndpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onConnected once the main process reports the provider connected', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
expect(screen.queryByLabelText('Giriş kodu')).toBeNull()
|
||||
await tick()
|
||||
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', ''],
|
||||
['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'],
|
||||
['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'],
|
||||
['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.']
|
||||
])('connects and explains CLI result %s', async (cli, text) => {
|
||||
const { onConnected } = setupLogin([{ state: 'connected', cli }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')?.textContent ?? '').toBe(text)
|
||||
})
|
||||
|
||||
it('asks before replacing another CLI account, then replaces on confirm', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
|
||||
expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
|
||||
await click('Değiştir')
|
||||
expect(bridge.overwriteCli).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||
})
|
||||
|
||||
it('leaves the other CLI account alone when the user says so', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
await click('Olduğu gibi bırak')
|
||||
|
||||
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(onConnected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels on Vazgeç and returns to the sign-in button', async () => {
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
await click('Vazgeç')
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy()
|
||||
await tick()
|
||||
expect(bridge.poll).not.toHaveBeenCalled()
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an expired code and a failed start without connecting', async () => {
|
||||
const { bridge, onConnected } = setupLogin([{ state: 'expired' }])
|
||||
await click('Giriş yap')
|
||||
await tick()
|
||||
expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.')
|
||||
|
||||
bridge.start.mockRejectedValueOnce(
|
||||
new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited")
|
||||
)
|
||||
await click('Giriş yap')
|
||||
expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.')
|
||||
expect(onConnected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes a pending login when the panel goes away', async () => {
|
||||
const { bridge } = setupLogin([])
|
||||
await click('Giriş yap')
|
||||
cleanup()
|
||||
|
||||
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still connects with a pasted API key', async () => {
|
||||
vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' })
|
||||
vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true })
|
||||
const { bridge, onConnected } = setupLogin([])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce())
|
||||
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' }))
|
||||
expect(bridge.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AiturkLogin } from '@/components/aiturk-login'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||
@@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function connect() {
|
||||
if (busy || !key.trim()) return
|
||||
if (busy || !key.trim()) {return}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Discovery goes through the connected backend's existing credential
|
||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
||||
if (!checked.ok) throw new Error(checked.message || copy.failed)
|
||||
|
||||
if (!checked.ok) {throw new Error(checked.message || copy.failed)}
|
||||
const model = checked.models[0]
|
||||
if (!model) throw new Error(copy.noModels)
|
||||
|
||||
if (!model) {throw new Error(copy.noModels)}
|
||||
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
||||
if (saved.ok === false) throw new Error(copy.failed)
|
||||
|
||||
if (saved.ok === false) {throw new Error(copy.failed)}
|
||||
setKey('')
|
||||
await onConnected()
|
||||
} catch (err) {
|
||||
@@ -45,16 +50,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||
</div>
|
||||
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
||||
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
||||
<label className="grid gap-2 text-sm">
|
||||
{copy.key}
|
||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) void connect() }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) {void connect()} }}
|
||||
placeholder={copy.key} type="password" value={key} />
|
||||
</label>
|
||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()}>
|
||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()} variant="outline">
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user