311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { useState, useTransition } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
|
import {
|
|
revokeKisCredentials,
|
|
validateKisCredentials,
|
|
} from "@/features/settings/apis/kis-auth.api";
|
|
import {
|
|
KeyRound,
|
|
ShieldCheck,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Lock,
|
|
Link2,
|
|
Unlink2,
|
|
Activity,
|
|
Zap,
|
|
KeySquare,
|
|
} from "lucide-react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { InlineSpinner } from "@/components/ui/loading-spinner";
|
|
import { SettingsCard } from "./SettingsCard";
|
|
|
|
/**
|
|
* @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다.
|
|
* @remarks UI 흐름: /settings -> 앱키/앱시크릿키 입력 -> 연결 확인 버튼 -> /api/kis/validate -> 연결 상태 반영
|
|
* @see app/api/kis/validate/route.ts 앱키 검증 API
|
|
* @see features/settings/store/use-kis-runtime-store.ts 인증 상태 저장소
|
|
*/
|
|
export function KisAuthForm() {
|
|
const {
|
|
kisTradingEnvInput,
|
|
kisAppKeyInput,
|
|
kisAppSecretInput,
|
|
verifiedAccountNo,
|
|
verifiedCredentials,
|
|
isKisVerified,
|
|
setKisTradingEnvInput,
|
|
setKisAppKeyInput,
|
|
setKisAppSecretInput,
|
|
setVerifiedKisSession,
|
|
invalidateKisVerification,
|
|
clearKisRuntimeSession,
|
|
} = useKisRuntimeStore(
|
|
useShallow((state) => ({
|
|
kisTradingEnvInput: state.kisTradingEnvInput,
|
|
kisAppKeyInput: state.kisAppKeyInput,
|
|
kisAppSecretInput: state.kisAppSecretInput,
|
|
verifiedAccountNo: state.verifiedAccountNo,
|
|
verifiedCredentials: state.verifiedCredentials,
|
|
isKisVerified: state.isKisVerified,
|
|
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
|
setKisAppKeyInput: state.setKisAppKeyInput,
|
|
setKisAppSecretInput: state.setKisAppSecretInput,
|
|
setVerifiedKisSession: state.setVerifiedKisSession,
|
|
invalidateKisVerification: state.invalidateKisVerification,
|
|
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
|
})),
|
|
);
|
|
|
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [isValidating, startValidateTransition] = useTransition();
|
|
const [isRevoking, startRevokeTransition] = useTransition();
|
|
|
|
function handleValidate() {
|
|
startValidateTransition(async () => {
|
|
try {
|
|
setErrorMessage(null);
|
|
setStatusMessage(null);
|
|
|
|
const appKey = kisAppKeyInput.trim();
|
|
const appSecret = kisAppSecretInput.trim();
|
|
if (!appKey || !appSecret) {
|
|
throw new Error("앱키와 앱시크릿키를 모두 입력해 주세요.");
|
|
}
|
|
|
|
const credentials = {
|
|
appKey,
|
|
appSecret,
|
|
tradingEnv: kisTradingEnvInput,
|
|
accountNo: verifiedAccountNo ?? "",
|
|
};
|
|
|
|
const result = await validateKisCredentials(credentials);
|
|
setVerifiedKisSession(credentials, result.tradingEnv);
|
|
setStatusMessage(
|
|
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
|
);
|
|
} catch (err) {
|
|
invalidateKisVerification();
|
|
setErrorMessage(
|
|
err instanceof Error
|
|
? err.message
|
|
: "앱키 확인 중 오류가 발생했습니다.",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleRevoke() {
|
|
if (!verifiedCredentials) return;
|
|
|
|
startRevokeTransition(async () => {
|
|
try {
|
|
setErrorMessage(null);
|
|
setStatusMessage(null);
|
|
const result = await revokeKisCredentials(verifiedCredentials);
|
|
clearKisRuntimeSession(result.tradingEnv);
|
|
setStatusMessage(
|
|
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
|
);
|
|
} catch (err) {
|
|
setErrorMessage(
|
|
err instanceof Error
|
|
? err.message
|
|
: "연결 해제 중 오류가 발생했습니다.",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<SettingsCard
|
|
icon={KeyRound}
|
|
title="한국투자증권 앱키 연결"
|
|
description="Open API에서 발급받은 앱키와 앱시크릿키를 입력해 연결을 완료하세요."
|
|
badge={
|
|
isKisVerified ? (
|
|
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 ring-1 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
연결됨
|
|
</span>
|
|
) : undefined
|
|
}
|
|
footer={{
|
|
actions: (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
onClick={handleValidate}
|
|
disabled={
|
|
isValidating ||
|
|
!kisAppKeyInput.trim() ||
|
|
!kisAppSecretInput.trim()
|
|
}
|
|
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
|
|
>
|
|
{isValidating ? (
|
|
<span className="flex items-center gap-1.5">
|
|
<InlineSpinner className="h-3 w-3 text-white" />
|
|
검증 중
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5">
|
|
<Link2 className="h-3.5 w-3.5 text-brand-100" />
|
|
앱키 연결 확인
|
|
</span>
|
|
)}
|
|
</Button>
|
|
|
|
{isKisVerified && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleRevoke}
|
|
disabled={isRevoking}
|
|
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
>
|
|
{isRevoking ? (
|
|
"해제 중"
|
|
) : (
|
|
<span className="flex items-center gap-1.5">
|
|
<Unlink2 className="h-3.5 w-3.5" />
|
|
연결 해제
|
|
</span>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
),
|
|
status: (
|
|
<div className="flex min-h-5 items-center justify-start sm:justify-end">
|
|
{errorMessage && (
|
|
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
{errorMessage}
|
|
</p>
|
|
)}
|
|
{statusMessage && (
|
|
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-brand-600 dark:text-brand-400">
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
{statusMessage}
|
|
</p>
|
|
)}
|
|
{!errorMessage && !statusMessage && !isKisVerified && (
|
|
<p className="flex items-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-600">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
|
미연결 상태입니다
|
|
</p>
|
|
)}
|
|
</div>
|
|
),
|
|
}}
|
|
className="h-full"
|
|
>
|
|
<div className="space-y-4">
|
|
{/* ========== TRADING MODE ========== */}
|
|
<section className="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/30">
|
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
|
|
<ShieldCheck className="h-3.5 w-3.5 text-brand-500" />
|
|
투자 모드 선택
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setKisTradingEnvInput("real")}
|
|
className={cn(
|
|
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
|
|
kisTradingEnvInput === "real"
|
|
? "border-brand-500 bg-brand-600 text-white shadow-sm"
|
|
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
|
|
)}
|
|
>
|
|
<Zap className="h-3.5 w-3.5" />
|
|
실전 투자
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setKisTradingEnvInput("mock")}
|
|
className={cn(
|
|
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
|
|
kisTradingEnvInput === "mock"
|
|
? "border-zinc-700 bg-zinc-800 text-white shadow-sm dark:border-zinc-500 dark:bg-zinc-700"
|
|
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
|
|
)}
|
|
>
|
|
<Activity className="h-3.5 w-3.5" />
|
|
모의 투자
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ========== APP KEY INPUTS ========== */}
|
|
<div className="space-y-3">
|
|
<CredentialInput
|
|
id="kis-app-key"
|
|
label="앱키"
|
|
placeholder="한국투자증권 앱키 입력"
|
|
value={kisAppKeyInput}
|
|
onChange={setKisAppKeyInput}
|
|
icon={KeySquare}
|
|
/>
|
|
<CredentialInput
|
|
id="kis-app-secret"
|
|
label="앱시크릿키"
|
|
placeholder="한국투자증권 앱시크릿키 입력"
|
|
value={kisAppSecretInput}
|
|
onChange={setKisAppSecretInput}
|
|
icon={Lock}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SettingsCard>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 앱키/시크릿키 입력 전용 필드 블록입니다.
|
|
* @see features/settings/components/KisAuthForm.tsx 입력 UI 렌더링
|
|
*/
|
|
function CredentialInput({
|
|
id,
|
|
label,
|
|
value,
|
|
placeholder,
|
|
onChange,
|
|
icon: Icon,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
placeholder: string;
|
|
onChange: (value: string) => void;
|
|
icon: LucideIcon;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={id} className="text-xs font-semibold text-zinc-600">
|
|
{label}
|
|
</Label>
|
|
<div className="group/input flex items-center overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 dark:border-zinc-700 dark:bg-zinc-900/20">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/40">
|
|
<Icon className="h-4 w-4" />
|
|
</div>
|
|
<Input
|
|
id={id}
|
|
type="password"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0"
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|