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
6 changes: 3 additions & 3 deletions docs/api-data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ Docker 테스트는 네 자료 중 하나가 느리거나 실패한 경우, 병
## 계산 전 사람 인증

운영 화면은 Cloudflare Turnstile 인증을 완료해야 계산 버튼이 활성화된다.
Managed 위젯의 `appearance: interaction-only`를 사용해 직접 확인이 필요할 때만
인증창을 표시한다. 평소에는 실행 버튼 옆에 작은 인증 상태만 표시하며, 인증창은
최대 300px로 제한한다. 모바일에서는 실행 버튼 위에 배치한다.
Cloudflare 기본 Managed 위젯을 실행 버튼 옆에 최대 300px로 표시한다.
로고와 인증 상태는 기본 위젯이 표시하며 별도의 완료 문구는 덧붙이지 않는다.
모바일에서는 실행 버튼 위에 배치한다.
공개 sitekey는 GitHub Actions Variable `VITE_TURNSTILE_SITE_KEY` → Docker 빌드 인자
→ Vite 번들로 전달한다. 비밀키는 프론트엔드·GitHub에 넣지 않는다.

Expand Down
2 changes: 1 addition & 1 deletion src/features/backtest/components/PortfolioBacktestForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ const PortfolioBacktestForm: React.FC<PortfolioBacktestFormProps> = ({ onSubmit,
{/* 제출 버튼 */}
<div className="flex flex-col items-end justify-end gap-3 pt-4 pb-6 sm:flex-row sm:items-center sm:gap-4 sm:pb-8">
{verificationRequired && <TurnstileVerification siteKey={siteKey}
resetKey={verificationAttempt} onToken={setTurnstileToken} verified={!!turnstileToken} />}
resetKey={verificationAttempt} onToken={setTurnstileToken} />}
<Button
type="submit"
disabled={loading || state.ui.isLoading || (verificationRequired && !turnstileToken)}
Expand Down
13 changes: 3 additions & 10 deletions src/features/backtest/components/TurnstileVerification.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, ShieldCheck, ShieldAlert } from 'lucide-react';
import { Button } from '@/shared/ui/button';

interface WidgetOptions {
sitekey: string;
action: string;
size: 'flexible';
appearance: 'interaction-only';
appearance: 'always';
'response-field': false;
callback: (token: string) => void;
'expired-callback': () => void;
Expand Down Expand Up @@ -51,10 +50,9 @@ interface Props {
siteKey: string;
resetKey: number;
onToken: (token: string) => void;
verified?: boolean;
}

export function TurnstileVerification({ siteKey, resetKey, onToken, verified = false }: Props) {
export function TurnstileVerification({ siteKey, resetKey, onToken }: Props) {
const container = useRef<HTMLDivElement>(null);
const [error, setError] = useState(false);
const [retry, setRetry] = useState(0);
Expand All @@ -70,7 +68,7 @@ export function TurnstileVerification({ siteKey, resetKey, onToken, verified = f
const invalidate = () => { if (active) onToken(''); };
widget = api.render(container.current, {
sitekey: siteKey, action: 'backtest', size: 'flexible',
appearance: 'interaction-only', 'response-field': false,
appearance: 'always', 'response-field': false,
callback: token => { if (active) { setError(false); onToken(token); } },
'expired-callback': invalidate,
'error-callback': () => { if (active) { invalidate(); setError(true); } },
Expand All @@ -86,11 +84,6 @@ export function TurnstileVerification({ siteKey, resetKey, onToken, verified = f

return (
<div className="w-full max-w-[300px] space-y-2" aria-label="사람 인증">
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
{verified ? <><ShieldCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />사람 인증 완료</>
: (!siteKey || error) ? <><ShieldAlert className="h-4 w-4" aria-hidden="true" />사람 인증 필요</>
: <><Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />사람 인증 확인 중…</>}
</div>
<div ref={container} className="w-full" />
{(!siteKey || error) && (
<div role="alert" className="text-sm text-destructive">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,12 @@ describe('Turnstile calculation gate', () => {
expect(button).toBeDisabled();
await waitFor(() => expect(options).toHaveLength(1));
expect(options[0]!).toMatchObject({ sitekey: 'public-sitekey', action: 'backtest',
appearance: 'interaction-only', 'response-field': false });
expect(screen.getByRole('status')).toHaveTextContent('사람 인증 확인 중');
appearance: 'always', 'response-field': false });
for (const callback of ['expired-callback', 'error-callback', 'timeout-callback'] as const) {
act(() => options[0]!.callback('fresh-token'));
expect(button).toBeEnabled();
expect(screen.getByRole('status')).toHaveTextContent('사람 인증 완료');
act(() => options[0]![callback]());
expect(button).toBeDisabled();
expect(screen.getByRole('status')).not.toHaveTextContent('사람 인증 완료');
}
});

Expand Down