Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/app/(auth)/login/_components/AdminLoginForm/AdminLoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { Button, Icon, InputText } from "@/components";
import { Button, InputText } from "@/components";
import Logo from "@/assets/logo.svg";
import useAdminLoginForm from "../../_hooks/useAdminLoginForm";
import useLoginReasonToast from "../../_hooks/useLoginReasonToast";

Expand All @@ -25,23 +26,31 @@ const LOGIN_INPUTS = [
},
] as const;

interface AdminLoginFormProps {
/** 로그인 화면으로 보내진 이유(`reason` 쿼리) */
reason?: string;
/** 로그인 후 돌아갈 경로(`callbackUrl` 쿼리) */
callbackUrl?: string;
}

/**
* 관리자 로그인 폼입니다.
*
* @remarks
* - 상위에서 `FormProvider`로 감싸야 합니다.
* - 회원가입과 비밀번호 찾기는 운영 앱에서만 제공하므로 링크를 두지 않습니다.
* - 로고는 첫 화면에 바로 보여야 하므로 `Icon`의 지연 로딩을 거치지 않고 정적으로 가져옵니다.
*/

const AdminLoginForm = () => {
const { onSubmitLogin, isPending } = useAdminLoginForm();
useLoginReasonToast();
const AdminLoginForm = ({ reason, callbackUrl }: AdminLoginFormProps) => {
const { onSubmitLogin, isPending } = useAdminLoginForm(callbackUrl);
useLoginReasonToast(reason);

return (
<form onSubmit={onSubmitLogin}>
<div className="flex min-h-dvh w-full gap-12 px-4 py-[64px] flex-col-center tablet:px-[80px] pc:mx-auto pc:max-w-[480px] pc:px-0">
<div className="flex w-full flex-col items-center gap-10">
<Icon name="Logo" size={120} title="찾아줘! 관리자" />
<Logo width={120} height={120} role="img" aria-label="찾아줘! 관리자" />

<fieldset className="flex w-full flex-col gap-3">
<legend className="sr-only">로그인 정보 입력</legend>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";
"use no memo";

import { FormProvider, useForm } from "react-hook-form";
import { AdminLoginFormType } from "../../_types/AdminLoginFormType";
import AdminLoginForm from "../AdminLoginForm/AdminLoginForm";

interface AdminLoginFormProviderProps {
/** 로그인 화면으로 보내진 이유(`reason` 쿼리) */
reason?: string;
/** 로그인 후 돌아갈 경로(`callbackUrl` 쿼리) */
callbackUrl?: string;
}

/**
* 관리자 로그인 폼의 `react-hook-form` 상태를 만들고 폼에 내려줍니다.
*
* @remarks
* - 페이지가 서버 컴포넌트라 폼 상태를 이 클라이언트 컴포넌트에서 만듭니다.
*/

export const AdminLoginFormProvider = ({ reason, callbackUrl }: AdminLoginFormProviderProps) => {
const methods = useForm<AdminLoginFormType>({
mode: "onChange",
reValidateMode: "onChange",
});

return (
<FormProvider {...methods}>
<AdminLoginForm reason={reason} callbackUrl={callbackUrl} />
</FormProvider>
);
};
1 change: 1 addition & 0 deletions src/app/(auth)/login/_components/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as AdminLoginForm } from "./AdminLoginForm/AdminLoginForm";
export { AdminLoginFormProvider } from "./AdminLoginFormProvider/AdminLoginFormProvider";
10 changes: 10 additions & 0 deletions src/app/(auth)/login/_docs/plan.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# login 작업 계획

- [x] 로그인 폼 최소 높이를 헤더 전제의 `h-base`(`calc(100dvh - 56px)`)에서 `min-h-dvh`로 바꿔, body 좌우 테두리가 화면 맨 아래까지 이어지게 한다

## 로고 로딩 개선 (2026-09-27)

로고가 늦게 뜨는 원인은 두 가지다. 폼 훅이 `useSearchParams`를 써서 `<Suspense>` 안쪽 폼 전체가 서버에서 그려지지 않았고, 로고는 `Icon`의 `React.lazy`로 폼 JS 실행 뒤 별도 청크로 한 번 더 받았다.

- [x] A. 로그인 폼 로고를 `Icon` 대신 `@/assets/logo.svg` 정적 import로 바꾼다
- [x] B-1. `useForm`/`FormProvider`를 `_components/AdminLoginFormProvider`로 옮기고 `reason`, `callbackUrl`을 props로 받는다
- [x] B-2. `useLoginReasonToast`, `useAdminLoginForm`에서 `useSearchParams`를 걷어내고 인자로 값을 받는다
- [x] B-3. `page.tsx`를 서버 컴포넌트로 바꿔 `searchParams`를 읽어 넘기고 `<Suspense>`를 없앤다
- [x] B-4. `searchParams` 값이 배열로 올 수 있어 `_utils/getSingleSearchParam`으로 첫 값만 쓰게 한다
10 changes: 5 additions & 5 deletions src/app/(auth)/login/_hooks/useAdminLoginForm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useState, type BaseSyntheticEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useFormContext } from "react-hook-form";
import { AxiosError } from "axios";
import { useQueryClient } from "@tanstack/react-query";
Expand All @@ -20,12 +20,13 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
* - 로그인에 성공하면 `callbackUrl`이 안전한 내부 경로일 때 그곳으로, 아니면 `/`로 이동합니다.
* `/`에서는 미들웨어가 계정 권한에 맞는 영역으로 보냅니다.
* - 요청 중이거나 이동 중일 때는 중복 제출을 막습니다.
*
* @param callbackUrl - 로그인 후 돌아갈 경로(`callbackUrl` 쿼리)
*/

const useAdminLoginForm = () => {
const useAdminLoginForm = (callbackUrl?: string) => {
const { handleSubmit } = useFormContext<AdminLoginFormType>();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { addToast } = useToast();
const { mutateAsync: emailLoginMutateAsync, isPending } = useApiEmailLogin();
Expand All @@ -44,8 +45,7 @@ const useAdminLoginForm = () => {
setIsRedirecting(true);
queryClient.clear();

const rawCallback = searchParams.get("callbackUrl");
router.replace(isValidCallbackUrl(rawCallback) ? rawCallback : "/");
router.replace(isValidCallbackUrl(callbackUrl) ? callbackUrl : "/");
} catch (error) {
const errorCode = (error as AxiosError<ApiBaseResponseType<null>>).response?.data?.code;
const toast = (errorCode && LOGIN_ERROR_TOAST[errorCode]) || LOGIN_ERROR_TOAST.COMMON500;
Expand Down
7 changes: 3 additions & 4 deletions src/app/(auth)/login/_hooks/useLoginReasonToast.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { useToast } from "@/context/ToastContext";
import { LOGIN_REASON_TOAST } from "../_constants/LOGIN_REASON_TOAST";

Expand All @@ -9,13 +8,13 @@ import { LOGIN_REASON_TOAST } from "../_constants/LOGIN_REASON_TOAST";
* @remarks
* - 세션 만료(`session-expired`)와 권한 없음(`forbidden`)을 안내합니다.
* - 쿠키 정리는 미들웨어가 담당합니다.
*
* @param reason - 로그인 화면으로 보내진 이유(`reason` 쿼리)
*/

const useLoginReasonToast = () => {
const useLoginReasonToast = (reason?: string) => {
const { addToast } = useToast();
const searchParams = useSearchParams();
const isShown = useRef(false);
const reason = searchParams.get("reason");

useEffect(() => {
const toast = reason ? LOGIN_REASON_TOAST[reason] : undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getSingleSearchParam } from "./getSingleSearchParam";

describe("getSingleSearchParam", () => {
it("문자열은 그대로 돌려준다", () => {
expect(getSingleSearchParam("forbidden")).toBe("forbidden");
});

it("배열이면 첫 번째 값을 돌려준다", () => {
expect(getSingleSearchParam(["/admin", "/admin/notice"])).toBe("/admin");
});

it("값이 없으면 undefined를 돌려준다", () => {
expect(getSingleSearchParam(undefined)).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* 페이지 `searchParams`의 값 하나를 문자열로 좁힙니다.
*
* @remarks
* - 같은 키가 여러 번 오면 배열이 되므로 첫 번째 값을 씁니다.
*
* @example
* ```ts
* getSingleSearchParam("forbidden"); // "forbidden"
* getSingleSearchParam(["a", "b"]); // "a"
* getSingleSearchParam(undefined); // undefined
* ```
*/

export const getSingleSearchParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
27 changes: 11 additions & 16 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
"use client";
"use no memo";
import { AdminLoginFormProvider } from "./_components";
import { getSingleSearchParam } from "./_utils/getSingleSearchParam/getSingleSearchParam";

import { Suspense } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { AdminLoginForm } from "./_components";
import { AdminLoginFormType } from "./_types/AdminLoginFormType";
interface LoginPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}

const LoginPage = () => {
const methods = useForm<AdminLoginFormType>({
mode: "onChange",
reValidateMode: "onChange",
});
const LoginPage = async ({ searchParams }: LoginPageProps) => {
const { reason, callbackUrl } = await searchParams;

return (
<>
<h1 className="sr-only">관리자 로그인</h1>

<FormProvider {...methods}>
<Suspense>
<AdminLoginForm />
</Suspense>
</FormProvider>
<AdminLoginFormProvider
reason={getSingleSearchParam(reason)}
callbackUrl={getSingleSearchParam(callbackUrl)}
/>
</>
);
};
Expand Down
Loading