237 lines
8.8 KiB
TypeScript
237 lines
8.8 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 {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
|
import {
|
|
revokeKisCredentials,
|
|
validateKisCredentials,
|
|
} from "@/features/dashboard/apis/kis-auth.api";
|
|
|
|
/**
|
|
* @description KIS 인증 입력 폼
|
|
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
|
|
*/
|
|
export function KisAuthForm() {
|
|
const {
|
|
kisTradingEnvInput,
|
|
kisAppKeyInput,
|
|
kisAppSecretInput,
|
|
verifiedCredentials,
|
|
isKisVerified,
|
|
setKisTradingEnvInput,
|
|
setKisAppKeyInput,
|
|
setKisAppSecretInput,
|
|
setVerifiedKisSession,
|
|
invalidateKisVerification,
|
|
clearKisRuntimeSession,
|
|
} = useKisRuntimeStore(
|
|
useShallow((state) => ({
|
|
kisTradingEnvInput: state.kisTradingEnvInput,
|
|
kisAppKeyInput: state.kisAppKeyInput,
|
|
kisAppSecretInput: state.kisAppSecretInput,
|
|
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("App Key와 App Secret을 모두 입력해 주세요.");
|
|
}
|
|
|
|
// 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다.
|
|
const credentials = {
|
|
appKey,
|
|
appSecret,
|
|
tradingEnv: kisTradingEnvInput,
|
|
accountNo: verifiedCredentials?.accountNo ?? "",
|
|
};
|
|
|
|
const result = await validateKisCredentials(credentials);
|
|
setVerifiedKisSession(credentials, result.tradingEnv);
|
|
setStatusMessage(
|
|
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
|
);
|
|
} catch (err) {
|
|
invalidateKisVerification();
|
|
setErrorMessage(
|
|
err instanceof Error
|
|
? err.message
|
|
: "API 키 검증 중 오류가 발생했습니다.",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
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 (
|
|
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background dark:border-brand-700/50 dark:from-brand-900/35 dark:to-background">
|
|
<CardHeader>
|
|
<CardTitle className="text-foreground dark:text-brand-50">KIS API 키 연결</CardTitle>
|
|
<CardDescription className="text-muted-foreground dark:text-brand-100/80">
|
|
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요.
|
|
검증에 성공해야 시세 조회가 동작합니다.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{/* ========== CREDENTIAL INPUTS ========== */}
|
|
<div className="grid gap-3 md:grid-cols-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground dark:text-brand-100/72">
|
|
거래 모드
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
|
className={cn(
|
|
"flex-1 border transition-all",
|
|
"dark:border-brand-700/70 dark:bg-black/20 dark:text-brand-100/80 dark:hover:bg-brand-900/45",
|
|
kisTradingEnvInput === "real"
|
|
? "bg-brand-600 text-white shadow-sm ring-2 ring-brand-300/45 hover:bg-brand-500 dark:bg-brand-500 dark:text-white dark:ring-brand-300/55"
|
|
: "text-brand-700 hover:text-brand-800 dark:text-brand-100/80",
|
|
)}
|
|
onClick={() => setKisTradingEnvInput("real")}
|
|
>
|
|
실전
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
|
className={cn(
|
|
"flex-1 border transition-all",
|
|
"dark:border-brand-700/70 dark:bg-black/20 dark:text-brand-100/80 dark:hover:bg-brand-900/45",
|
|
kisTradingEnvInput === "mock"
|
|
? "bg-brand-600 text-white shadow-sm ring-2 ring-brand-300/45 hover:bg-brand-500 dark:bg-brand-500 dark:text-white dark:ring-brand-300/55"
|
|
: "text-brand-700 hover:text-brand-800 dark:text-brand-100/80",
|
|
)}
|
|
onClick={() => setKisTradingEnvInput("mock")}
|
|
>
|
|
모의
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">
|
|
KIS App Key
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
|
|
value={kisAppKeyInput}
|
|
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
|
placeholder="App Key 입력"
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">
|
|
KIS App Secret
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
|
|
value={kisAppSecretInput}
|
|
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
|
placeholder="App Secret 입력"
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ========== ACTIONS ========== */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
onClick={handleValidate}
|
|
disabled={
|
|
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
|
|
}
|
|
className="bg-brand-600 hover:bg-brand-700"
|
|
>
|
|
{isValidating ? "검증 중..." : "API 키 검증"}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleRevoke}
|
|
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
|
|
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800 dark:border-brand-700/60 dark:text-brand-200 dark:hover:bg-brand-900/35 dark:hover:text-brand-100"
|
|
>
|
|
{isRevoking ? "해제 중..." : "연결 끊기"}
|
|
</Button>
|
|
|
|
{isKisVerified ? (
|
|
<span className="flex items-center text-sm font-medium text-brand-700 dark:text-brand-200">
|
|
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
|
|
연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">미연결</span>
|
|
)}
|
|
</div>
|
|
|
|
{errorMessage && (
|
|
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
|
|
)}
|
|
{statusMessage && (
|
|
<div className="text-sm text-brand-700 dark:text-brand-200">{statusMessage}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|