Files
auto-trade/features/settings/components/KisAuthForm.tsx

314 lines
14 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 { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/settings/apis/kis-auth.api";
import {
KeyRound,
Shield,
CheckCircle2,
XCircle,
Lock,
Sparkles,
Zap,
Activity,
} from "lucide-react";
import { InlineSpinner } from "@/components/ui/loading-spinner";
/**
* @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();
// 입력 필드 Focus 상태 관리를 위한 State
const [focusedField, setFocusedField] = useState<"appKey" | "appSecret" | null>(null);
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 (
<div className="group relative w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10">
{/* Inner Content Container - Compact spacing */}
<div className="flex flex-col gap-4">
{/* Header Section */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
<KeyRound className="h-4.5 w-4.5" />
</div>
<div>
<h2 className="flex items-center gap-2 text-base font-bold tracking-tight text-zinc-800 dark:text-zinc-100">
{isKisVerified && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-1.5 py-0.5 text-[10px] font-medium text-green-600 ring-1 ring-green-600/10 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</h2>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
Open API에서 /릿 .
</p>
</div>
</div>
{/* Trading Mode Switch (Segmented Control - Compact) */}
<div className="flex shrink-0 items-center rounded-md bg-zinc-100 p-0.5 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700">
<button
onClick={() => setKisTradingEnvInput("real")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "real"
? "bg-white text-brand-600 shadow-sm ring-1 ring-black/5 dark:bg-brand-500 dark:text-white dark:ring-brand-400"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Zap
className={cn(
"h-3 w-3",
kisTradingEnvInput === "real"
? "text-brand-500 dark:text-white"
: "text-zinc-400",
)}
/>
</button>
<button
onClick={() => setKisTradingEnvInput("mock")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "mock"
? "bg-white text-zinc-800 shadow-sm ring-1 ring-black/5 dark:bg-zinc-600 dark:text-white dark:ring-zinc-500"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Activity
className={cn(
"h-3 w-3",
kisTradingEnvInput === "mock"
? "text-zinc-800 dark:text-zinc-200"
: "text-zinc-400",
)}
/>
</button>
</div>
</div>
{/* Input Fields Section (Compact Stacked Layout) */}
<div className="flex flex-col gap-2">
{/* ========== APP KEY INPUT ========== */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appKey"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Shield className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
</div>
<Input
type="password"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
onFocus={() => setFocusedField("appKey")}
onBlur={() => setFocusedField(null)}
placeholder="한국투자증권 앱키 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
{/* ========== APP SECRET INPUT ========== */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appSecret"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Lock className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
릿
</div>
<Input
type="password"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
onFocus={() => setFocusedField("appSecret")}
onBlur={() => setFocusedField(null)}
placeholder="한국투자증권 앱시크릿키 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
</div>
{/* Action & Status Section */}
<div className="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800/50">
<div className="flex 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="text-white h-3 w-3" />
</span>
) : (
<span className="flex items-center gap-1.5">
<Sparkles className="h-3 w-3 fill-brand-200 text-brand-200" />
</span>
)}
</Button>
{isKisVerified && (
<Button
variant="outline"
onClick={handleRevoke}
disabled={isRevoking}
className="h-9 rounded-lg border-zinc-200 bg-white 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 ? "해제 중" : "연결 해제"}
</Button>
)}
</div>
{/* Status Messages - Compact */}
<div className="flex-1 text-right">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] 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 justify-end gap-1.5 text-[11px] 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 justify-end gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 translate-y-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
</div>
</div>
</div>
</div>
);
}