sn: 공통 모듈 기반 로그인 변경

This commit is contained in:
dongsilove
2026-07-15 22:04:26 +09:00
parent 52a79eca60
commit 6acacbfcf4
32 changed files with 1831 additions and 348 deletions
+30 -4
View File
@@ -1,9 +1,6 @@
import { PROXY_HOST } from "./env";
// 브라우저에서는 동일 오리진으로 상대경로 호출 → CORS/localhost 고정 문제 방지
// (Next middleware가 /server/* 요청을 백엔드로 rewrite)
// 서버 환경(SSR 등)에서는 절대 URL이 필요할 수 있어 기존 PROXY_HOST 기반 유지
export const BASE_URL = `${PROXY_HOST}/server`;
export const BASE_URL = `${PROXY_HOST}`;
export const ENDPOINT = {
ATTACH_SERVICE: {
@@ -17,13 +14,21 @@ export const ENDPOINT = {
},
USER_SERVICE: {
LOGIN: "/user-service/login",
LOGIN_2FA: "/user-service/api/v1/auth/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
ROLES: "/user-service/api/v1/roles",
LOGINLOG: "/user-service/api/v1/login-logs",
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify",
TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup",
TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable",
USER_JOIN: "/user-service/api/v1/anonymouse/users/join",
},
CMS_SERVICE: {
DASHBOARD: "/cms-service/api/v1/dashboard",
SITES: "/cms-service/api/v1/sites",
IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns",
@@ -32,6 +37,8 @@ export const ENDPOINT = {
MENUS: "/cms-service/api/v1/menus",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
CONTENTS: "/cms-service/api/v1/contents",
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
@@ -47,13 +54,32 @@ export const ENDPOINT = {
SURVEY: "/cms-service/api/v1/srvy",
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
ZIPS: "/cms-service/api/v1/zips",
GROUPS: "/cms-service/api/v1/groups",
GROUP_USERS: "/cms-service/api/v1/group-users",
MANAGER_IP: "/cms-service/api/v1/mgrip",
APILOG: "/cms-service/api/v1/api-logs",
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
},
SEWERAGE_SERVICE: {
PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts",
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
},
IC_SERVICE: {
GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics",
},
DC_SERVICE: {
DATA_STATUS: "/dc-service/api/v1/dc/dataStatus",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+17
View File
@@ -0,0 +1,17 @@
import type { AuthConfig } from "@common/auth";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
} from "./env";
export const authConfig: AuthConfig = {
authUserId: AUTH_USER_ID || "token-id",
claimName: CLAIM_NAME || "Authorization",
cookieDomain: COOKIE_DOMAIN,
accessToken: ACCESS_TOKEN || "access-token",
refreshToken: REFRESH_TOKEN || "refresh-token",
};
+13 -4
View File
@@ -1,8 +1,17 @@
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const SERVER_SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL;
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
+2
View File
@@ -1,2 +1,4 @@
export * from "./api";
export * from "./env";
export const DEFAULT_LIST_SIZE = 10;
+12
View File
@@ -0,0 +1,12 @@
import type { MenuItem } from "@/src/entities/menu";
export const BBS_NOTICE_MENU_MARKER = "bbsnotice";
export function isBbsNoticeMenu(item: MenuItem): boolean {
const marker = BBS_NOTICE_MENU_MARKER;
return (
item.urlPath?.toLowerCase().includes(marker) ||
item.menuEngNm?.toLowerCase().includes(marker) ||
item.menuPath?.toLowerCase().includes(marker)
);
}
+2
View File
@@ -1,5 +1,7 @@
import Cookies from "js-cookie";
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
+7 -1
View File
@@ -2,6 +2,12 @@ export { useMutationWrapper } from "./use-mutation-wrapper";
export { useAlertStore } from "./use-alert-store";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { useDataStore } from "./use-data-store";
export { usePrompt } from "./use-prompt";
export { getCookie, setCookie, removeCookie } from "./cookie";
export {
buildStatisticsComponentImportPath,
menuMatchesStatisticsTabId,
parseStatisticsMenuUrlPath,
resolveStatisticsCategory,
resolveStatisticsTabId,
} from "./statistics-menu-url";
@@ -0,0 +1,84 @@
import type { MenuItem } from "@/src/entities/menu";
export type ParsedStatisticsMenuUrl = {
categoryFromPath: string | null;
componentName: string | null;
rawUrlPath: string;
};
/** 메뉴 `urlPath`에서 통계 화면 카테고리·컴포넌트명을 파싱합니다. */
export function parseStatisticsMenuUrlPath(
urlPath: string,
): ParsedStatisticsMenuUrl {
const trimmed = (urlPath ?? "").trim();
if (!trimmed) {
return { categoryFromPath: null, componentName: null, rawUrlPath: trimmed };
}
let pathPart = trimmed;
let queryString = "";
const queryIndex = trimmed.indexOf("?");
if (queryIndex >= 0) {
pathPart = trimmed.slice(0, queryIndex);
queryString = trimmed.slice(queryIndex + 1);
}
const tabParam = new URLSearchParams(queryString).get("tab")?.trim() || null;
const segments = pathPart.replace(/^\/+/, "").split("/").filter(Boolean);
let categoryFromPath: string | null = null;
if (segments[0] === "statistics" && segments.length >= 2) {
categoryFromPath = segments[1] ?? null;
}
let componentName = tabParam;
if (!componentName) {
if (!pathPart.includes("/") && !queryString) {
componentName = trimmed;
} else if (segments.length === 1 && segments[0] !== "statistics") {
componentName = segments[0] ?? null;
} else if (segments[0] === "statistics" && segments.length >= 3) {
componentName = segments[segments.length - 1] ?? null;
}
}
return { categoryFromPath, componentName, rawUrlPath: trimmed };
}
/** 동적 import용 카테고리 폴더명 (`input`, `review` 등) */
export function resolveStatisticsCategory(
urlPath: string,
parentEngNm: string,
): string {
const { categoryFromPath } = parseStatisticsMenuUrlPath(urlPath);
return categoryFromPath || parentEngNm || "";
}
/** 탭 ID·URL `?tab=` 값 - 컴포넌트 파일명(확장자 제외) */
export function resolveStatisticsTabId(urlPath: string): string {
const { componentName, rawUrlPath } = parseStatisticsMenuUrlPath(urlPath);
return componentName || rawUrlPath;
}
/** `@/src/components/statistics/{category}/{Component}` 또는 루트 컴포넌트 경로 */
export function buildStatisticsComponentImportPath(
urlPath: string,
parentEngNm: string,
): string {
const { componentName } = parseStatisticsMenuUrlPath(urlPath);
const category = resolveStatisticsCategory(urlPath, parentEngNm);
const name = componentName || urlPath;
if (!category) {
return `@/src/components/statistics/${name}`;
}
return `@/src/components/statistics/${category}/${name}`;
}
export function menuMatchesStatisticsTabId(
menu: MenuItem,
tabId: string,
): boolean {
if (!tabId) return false;
if (menu.urlPath === tabId) return true;
return resolveStatisticsTabId(menu.urlPath) === tabId;
}
+175
View File
@@ -0,0 +1,175 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn, Label } from "@common/components";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[1rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};