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

309 lines
11 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
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";
2026-02-13 12:17:35 +09:00
import { Label } from "@/components/ui/label";
2026-02-11 16:31:28 +09:00
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
2026-02-10 11:16:39 +09:00
import {
revokeKisCredentials,
validateKisCredentials,
2026-02-11 16:31:28 +09:00
} from "@/features/settings/apis/kis-auth.api";
2026-02-11 15:27:03 +09:00
import {
KeyRound,
2026-02-13 12:17:35 +09:00
ShieldCheck,
2026-02-11 15:27:03 +09:00
CheckCircle2,
XCircle,
Lock,
2026-02-13 12:17:35 +09:00
Link2,
Unlink2,
2026-02-11 15:27:03 +09:00
Activity,
2026-02-13 12:17:35 +09:00
Zap,
KeySquare,
2026-02-11 15:27:03 +09:00
} from "lucide-react";
2026-02-13 12:17:35 +09:00
import type { LucideIcon } from "lucide-react";
2026-02-11 15:27:03 +09:00
import { InlineSpinner } from "@/components/ui/loading-spinner";
2026-02-13 12:17:35 +09:00
import { SettingsCard } from "./SettingsCard";
2026-02-10 11:16:39 +09:00
/**
* @description /릿 .
* @remarks UI : /settings -> /릿 -> -> /api/kis/validate ->
* @see app/api/kis/validate/route.ts API
* @see features/settings/store/use-kis-runtime-store.ts
2026-02-10 11:16:39 +09:00
*/
export function KisAuthForm() {
const {
kisTradingEnvInput,
kisAppKeyInput,
kisAppSecretInput,
verifiedAccountNo,
2026-02-10 11:16:39 +09:00
verifiedCredentials,
isKisVerified,
setKisTradingEnvInput,
setKisAppKeyInput,
setKisAppSecretInput,
setVerifiedKisSession,
invalidateKisVerification,
clearKisRuntimeSession,
} = useKisRuntimeStore(
useShallow((state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedAccountNo: state.verifiedAccountNo,
2026-02-10 11:16:39 +09:00
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("앱키와 앱시크릿키를 모두 입력해 주세요.");
2026-02-12 14:20:07 +09:00
}
2026-02-10 11:16:39 +09:00
const credentials = {
appKey,
appSecret,
tradingEnv: kisTradingEnvInput,
accountNo: verifiedAccountNo ?? "",
2026-02-10 11:16:39 +09:00
};
const result = await validateKisCredentials(credentials);
setVerifiedKisSession(credentials, result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
invalidateKisVerification();
setErrorMessage(
err instanceof Error
? err.message
: "앱키 확인 중 오류가 발생했습니다.",
2026-02-10 11:16:39 +09:00
);
}
});
}
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 (
2026-02-13 12:17:35 +09:00
<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">
2026-02-11 15:27:03 +09:00
<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">
2026-02-13 12:17:35 +09:00
<InlineSpinner className="h-3 w-3 text-white" />
2026-02-11 15:27:03 +09:00
</span>
2026-02-13 12:17:35 +09:00
) : (
<span className="flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-brand-100" />
</span>
)}
</Button>
2026-02-11 15:27:03 +09:00
<Button
variant="outline"
onClick={handleRevoke}
disabled={isRevoking || !verifiedCredentials}
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 disabled:cursor-not-allowed disabled:opacity-50 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>
2026-02-11 15:27:03 +09:00
</div>
2026-02-13 12:17:35 +09:00
),
status: (
<div className="flex min-h-5 items-center justify-start sm:justify-end">
2026-02-11 15:27:03 +09:00
{errorMessage && (
2026-02-13 12:17:35 +09:00
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
2026-02-11 15:27:03 +09:00
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
2026-02-13 12:17:35 +09:00
<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">
2026-02-11 15:27:03 +09:00
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!errorMessage && !statusMessage && !isKisVerified && (
2026-02-13 12:17:35 +09:00
<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" />
2026-02-11 15:27:03 +09:00
</p>
)}
</div>
2026-02-13 12:17:35 +09:00
),
}}
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" />
2026-02-11 15:27:03 +09:00
</div>
2026-02-13 12:17:35 +09:00
<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"
/>
2026-02-11 15:27:03 +09:00
</div>
</div>
2026-02-10 11:16:39 +09:00
);
}