Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions src/app/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Image from 'next/image'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { LanguageSwitcherSelect } from '@/components/ui/language-switcher'
import { fetchSetupStatusWithRetry } from '@/lib/setup-status'

type SetupStep = 'form' | 'creating'

Expand All @@ -13,6 +14,9 @@ interface ProgressStep {
status: 'pending' | 'active' | 'done' | 'error'
}

const SETUP_STATUS_TIMEOUT_MS = 5000
const SETUP_STATUS_ATTEMPTS = 3

function getInitialProgress(t: (key: string) => string): ProgressStep[] {
return [
{ label: t('validatingCredentials'), status: 'pending' },
Expand Down Expand Up @@ -72,24 +76,36 @@ export default function SetupPage() {
const [progress, setProgress] = useState<ProgressStep[]>(() => getInitialProgress(t))
const [checking, setChecking] = useState(true)
const [setupAvailable, setSetupAvailable] = useState(false)
const [setupCheckTick, setSetupCheckTick] = useState(0)

useEffect(() => {
fetch('/api/setup')
.then((res) => res.json())
.then((data) => {
if (!data.needsSetup) {
window.location.href = '/login'
return
}
setSetupAvailable(true)
setChecking(false)
})
.catch(() => {
setError(t('failedToCheckSetup'))
setChecking(false)
const checkSetupStatus = useCallback(async () => {
setChecking(true)
setError('')

try {
const data = await fetchSetupStatusWithRetry(fetch, {
attempts: SETUP_STATUS_ATTEMPTS,
timeoutMs: SETUP_STATUS_TIMEOUT_MS,
})

if (!data.needsSetup) {
window.location.href = '/login'
return
}

setSetupAvailable(true)
setChecking(false)
} catch {
setSetupAvailable(false)
setError(t('failedToCheckSetup'))
setChecking(false)
}
}, [t])

useEffect(() => {
checkSetupStatus()
}, [checkSetupStatus, setupCheckTick])

const updateProgress = useCallback((index: number, status: ProgressStep['status']) => {
setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)))
}, [])
Expand Down Expand Up @@ -174,7 +190,21 @@ export default function SetupPage() {
}

if (!setupAvailable) {
return null
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm rounded-lg border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive space-y-3">
<p>{error || t('failedToCheckSetup')}</p>
<Button
type="button"
variant="secondary"
onClick={() => setSetupCheckTick((v) => v + 1)}
className="w-full"
>
{tc('retry')}
</Button>
</div>
</div>
)
}

return (
Expand Down
49 changes: 49 additions & 0 deletions src/lib/__tests__/setup-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { fetchSetupStatusWithRetry } from '@/lib/setup-status'

describe('fetchSetupStatusWithRetry', () => {
it('returns setup status on success', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ needsSetup: true }),
})

const result = await fetchSetupStatusWithRetry(fetchMock as any, { attempts: 2, timeoutMs: 2000 })

expect(result).toEqual({ needsSetup: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('retries on failure and eventually succeeds', async () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce({
ok: true,
json: async () => ({ needsSetup: false }),
})

const result = await fetchSetupStatusWithRetry(fetchMock as any, { attempts: 3, timeoutMs: 2000 })

expect(result).toEqual({ needsSetup: false })
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it('throws after attempts are exhausted', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('still failing'))

await expect(fetchSetupStatusWithRetry(fetchMock as any, { attempts: 2, timeoutMs: 2000 }))
.rejects.toThrow('still failing')

expect(fetchMock).toHaveBeenCalledTimes(2)
})

it('throws on invalid payload shape', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ needsSetup: 'yes' }),
})

await expect(fetchSetupStatusWithRetry(fetchMock as any, { attempts: 1, timeoutMs: 2000 }))
.rejects.toThrow('Invalid setup status response')
})
})
53 changes: 53 additions & 0 deletions src/lib/setup-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export interface FetchSetupStatusOptions {
attempts?: number
timeoutMs?: number
}

export interface SetupStatusResponse {
needsSetup: boolean
}

/**
* Fetch setup status with timeout + bounded retries.
*/
export async function fetchSetupStatusWithRetry(
fetchFn: typeof fetch,
options: FetchSetupStatusOptions = {}
): Promise<SetupStatusResponse> {
const attempts = Math.max(1, options.attempts ?? 3)
const timeoutMs = Math.max(1000, options.timeoutMs ?? 5000)

let lastError: Error | null = null

for (let attempt = 1; attempt <= attempts; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)

try {
const res = await fetchFn('/api/setup', { signal: controller.signal })
if (!res.ok) {
throw new Error(`Setup status check failed (${res.status})`)
}
const data = await res.json() as SetupStatusResponse
if (typeof data?.needsSetup !== 'boolean') {
throw new Error('Invalid setup status response')
}
return data
} catch (error: any) {
if (error?.name === 'AbortError') {
lastError = new Error('Setup status request timed out')
} else {
lastError = error instanceof Error ? error : new Error('Setup status request failed')
}

if (attempt < attempts) {
// tiny backoff to avoid immediate hammering during transient stalls
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
} finally {
clearTimeout(timeoutId)
}
}

throw (lastError ?? new Error('Failed to check setup status'))
}
Loading