Compare commits

...

2 Commits

4 changed files with 110 additions and 5 deletions
@@ -2,7 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Save } from "lucide-react"; import { KeyRound, Save } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -41,6 +41,7 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [name, setName] = useState(maestro.name); const [name, setName] = useState(maestro.name);
const [email, setEmail] = useState(maestro.email); const [email, setEmail] = useState(maestro.email);
@@ -56,7 +57,7 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [successMessage, setSuccessMessage] = useState(""); const [successMessage, setSuccessMessage] = useState("");
const isDisabled = isSaving || isPending; const isDisabled = isSaving || isPending || isResettingPassword;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -107,6 +108,42 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
} }
} }
async function handleResetPassword() {
if (
!window.confirm(
`${maestro.name} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?`
)
)
return;
setIsResettingPassword(true);
setErrorMessage("");
setSuccessMessage("");
try {
const response = await fetch(
`/api/maestros/${maestro.maestroID}/reset-password`,
{ method: "POST" }
);
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "암호 초기화에 실패했습니다.");
}
setSuccessMessage("암호 초기화 완료");
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "암호 초기화에 실패했습니다."
);
} finally {
setIsResettingPassword(false);
}
}
return ( return (
<form onSubmit={(e) => void handleSubmit(e)} className="mt-4 space-y-5"> <form onSubmit={(e) => void handleSubmit(e)} className="mt-4 space-y-5">
<div className="grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
@@ -216,6 +253,16 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
<Save aria-hidden="true" className="size-4" /> <Save aria-hidden="true" className="size-4" />
</Button> </Button>
<Button
disabled={isDisabled}
onClick={() => void handleResetPassword()}
size="sm"
type="button"
variant="outline"
>
<KeyRound aria-hidden="true" className="size-4" />
</Button>
{successMessage ? ( {successMessage ? (
<p className="text-sm text-green-700 dark:text-green-400"> <p className="text-sm text-green-700 dark:text-green-400">
{successMessage} {successMessage}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { resetMaestroPassword } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
const CTX = "maestros/[id]/reset-password";
export const POST = withApiHandler<{ id: string }>(
CTX,
async (_request, { params, t0 }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const found = await resetMaestroPassword(maestroID);
if (!found) {
throw new ApiError("Not found", 404);
}
logger.info(CTX, "password reset", {
id: maestroID,
duration: Date.now() - t0,
});
return NextResponse.json({ success: true });
}
);
+3 -3
View File
@@ -435,10 +435,10 @@ chocoadmin/
- [x] `docker compose logs -f` 확인 방법 - [x] `docker compose logs -f` 확인 방법
- [x] stage와 production의 권장 `.env` 로그 설정 예시 - [x] stage와 production의 권장 `.env` 로그 설정 예시
- [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차 - [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차
- [ ] 검증 - [x] 검증
- [x] local에서 query문과 결과 전체가 출력되는지 확인 - [x] local에서 query문과 결과 전체가 출력되는지 확인
- [ ] stage에서 query문과 결과 전체가 출력되는지 확인 - [x] stage에서 query문과 결과 전체가 출력되는지 확인
- [ ] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인 - [x] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인
- [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략) - [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략)
### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화 ### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
+25
View File
@@ -255,6 +255,31 @@ export async function updateMaestro(
return { changed: true }; return { changed: true };
} }
export async function resetMaestroPassword(
maestroID: number
): Promise<boolean> {
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: { MaestroID: true },
});
if (!maestro) return false;
await db.$transaction(async (tx) => {
await tx.$executeRaw`UPDATE maestro SET Password = PASSWORD('123456') WHERE MaestroID = ${maestroID}`;
await tx.maestro_log.create({
data: {
Type: "reset_maestro_password",
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: "admin reset to 123456",
},
});
});
return true;
}
const logPageSizeOptions = [10, 20, 50] as const; const logPageSizeOptions = [10, 20, 50] as const;
type LogPageSize = (typeof logPageSizeOptions)[number]; type LogPageSize = (typeof logPageSizeOptions)[number];