[feat] negodata: 회원관리 / 최고관리자(OWNER)

- OWNER 권한 신설, 최고관리자가 자기 회사 소속 직원(USER) 계정 생성·수정·삭제 관리
- 회사 사용자 API(/v1/company/user/*), 회원관리 페이지·폼
- 로그인/프로필 흐름 정비, 무인증 계정생성(/auth/create) 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-29 13:31:32 +09:00
parent 7da1925365
commit a40257ddc1
45 changed files with 1928 additions and 150 deletions

View File

@ -35,6 +35,7 @@ class ErrorType(Enum):
INTERNAL_EXCEPTION = auto()
# http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션
HTTP_FORBIDDEN = 403
HTTP_INVALID_CLIENT_REQUEST = 419
HTTP_TO_MANY_REQUEST = 429
HTTP_INVALID_CLIENT_ACCESS = 433
@ -46,6 +47,8 @@ class ErrorType(Enum):
ACCOUNT_INVALID_INFO = 1200
ACCOUNT_ALREADY_EXIST = auto()
ACCOUNT_BLOCKED_USER = auto()
ACCOUNT_NOT_FOUND = auto()
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
# 상품 관련 에러
ITEM_NOT_FOUND = 1300
@ -71,8 +74,13 @@ class ErrorType(Enum):
IMAGE_TOO_LARGE = auto()
IMAGE_UPLOAD_FAILED = auto()
# 초청 메일 발송 관련 에러
EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요)
EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name)
EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name)
@ -105,10 +113,12 @@ class UserStatus(CodeEnum):
class UserRole(CodeEnum):
"""users.role 코드값."""
"""users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) —
의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다.
1=일반, 2=최고관리자(직원 계정 생성·관리)."""
USER = 1
MANAGER = 2
OWNER = 2 # 최고관리자: 자기 회사 유저(직원 계정)를 생성·관리
class CompanyStatus(CodeEnum):

View File

@ -1,7 +1,7 @@
from abc import ABC, abstractmethod
from typing import Tuple
from typing import Optional, Tuple
from sqlalchemy import select, update
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
@ -35,6 +35,18 @@ class IUserCRUD(ABC):
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass
@abstractmethod
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
pass
@abstractmethod
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
pass
class UserCRUD(IUserCRUD):
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
@ -90,3 +102,57 @@ class UserCRUD(IUserCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def list_by_company(
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]:
try:
conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
if search:
conditions.append(
or_(
users.id.ilike(f"%{search}%"),
users.name.ilike(f"%{search}%"),
users.email.ilike(f"%{search}%"),
)
)
where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
list_err, rows = await DB_SESSION_MNG.execute(
cdb,
select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit),
)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
try:
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
try:
if not data:
return ErrorType.SUCCESS
query = update(users).where(users.user_id == user_id).values(**data)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from services.auth_service import AuthService
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
from .protocol import Req_Login, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
security = HTTPBearer()
@ -17,13 +17,6 @@ async def login(request: Request, req: Req_Login, service: AuthService = Depends
return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host))
@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.")
async def create_account(req: Req_CreateAccount, service: AuthService = Depends()):
return RemoveNoneResponse(
await service.create_account(req.id, req.password, req.company_id, req.name, req.email, req.contact_number, req.role)
)
@router.post(
path="/refresh_token",
dependencies=[Depends(IsValidRefreshToken)],
@ -43,3 +36,13 @@ async def refresh_token(service: AuthService = Depends(), credentials: HTTPAutho
)
async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_me(user_info))
@router.patch(
path="/me",
response_model=Res_Me,
summary="내 정보 수정",
description="본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).",
)
async def update_me(req: Req_UpdateMe, service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.update_me(user_info, req))

View File

@ -22,18 +22,12 @@ class Res_Login(Res_WebPacketProtocol):
token_type: str = "bearer"
class Req_CreateAccount(AuthProtocol):
id: str = ""
password: str = ""
company_id: str = ""
name: str = ""
email: str = ""
contact_number: str = ""
role: int = UserRole.USER.value
class Res_CreateAccount(Res_WebPacketProtocol):
user_id: str = ""
class Req_UpdateMe(AuthProtocol):
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지
class Res_RefreshToken(Res_WebPacketProtocol):

View File

@ -0,0 +1,58 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import ConfigDict
from common.enums import UserRole, UserStatus
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
# 최고관리자가 자기 회사 유저를 관리하는 도메인. company_id 는 토큰값으로 강제된다.
class CompanyUserProtocol(WebPacketProtocol):
pass
class Req_CreateCompanyUser(CompanyUserProtocol):
# role 은 받지 않는다 — 최고관리자가 만드는 계정은 항상 일반(USER) 로 서버에서 고정.
id: str = ""
password: str = ""
name: str = ""
email: str = ""
contact_number: str = ""
class Req_UpdateCompanyUser(CompanyUserProtocol):
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
status: Optional[UserStatus] = None # 활성/비활성 전환
password: Optional[str] = None # 비밀번호 초기화(옵션)
class CompanyUserData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
user_id: uuid.UUID
company_id: uuid.UUID
id: str
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
status: UserStatus
role: UserRole
last_accessed_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Res_CompanyUser(Res_WebPacketProtocol):
user: Optional[CompanyUserData] = None
class Res_CompanyUserList(Res_PageProtocol):
users: list[CompanyUserData] = []
class Res_DeleteCompanyUser(Res_WebPacketProtocol):
pass

View File

@ -0,0 +1,51 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import RemoveNoneResponse, RequireOwner
from services.company_user_service import CompanyUserService
from .protocol import (
Req_CreateCompanyUser,
Req_UpdateCompanyUser,
Res_CompanyUser,
Res_CompanyUserList,
Res_DeleteCompanyUser,
)
# 최고관리자(OWNER) 전용. 모든 엔드포인트가 RequireOwner 로 게이트되며 company_id 는 토큰값으로 스코프된다.
router = APIRouter(prefix="/v1/company/user", tags=["CompanyUser"], responses={404: {"description": "Not found"}})
@router.get(path="/list", response_model=Res_CompanyUserList, summary="회사 유저 목록(최고관리자)")
async def list_users(
service: CompanyUserService = Depends(),
owner: UserInfo = Depends(RequireOwner),
search: str | None = Query(None, description="로그인ID/이름/이메일 검색"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_users(owner.company_id, search, pg))
@router.post(path="/create", response_model=Res_CompanyUser, summary="회사 유저 생성(일반 권한 고정)")
async def create_user(
req: Req_CreateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)
):
return RemoveNoneResponse(await service.create_user(owner.company_id, req))
@router.get(path="/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 조회")
async def get_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)):
return RemoveNoneResponse(await service.get_user(owner.company_id, str(user_id)))
@router.patch(path="/update/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 수정")
async def update_user(
user_id: UUID, req: Req_UpdateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)
):
return RemoveNoneResponse(await service.update_user(owner.company_id, str(user_id), req))
@router.delete(path="/delete/{user_id}", response_model=Res_DeleteCompanyUser, summary="회사 유저 삭제")
async def delete_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)):
return RemoveNoneResponse(await service.delete_user(owner.company_id, str(user_id)))

View File

@ -10,8 +10,10 @@ from jose import jwt, JWTError, ExpiredSignatureError
from common.enums import (
EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS,
EXCEPTION_REFRESH_TOKEN_EXPIRED,
UserRole,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
@ -98,6 +100,13 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
return DecodeRefreshToken(credentials.credentials)
# 최고관리자 전용 엔드포인트 게이트. 액세스 토큰 검증 + role==OWNER 가 아니면 403.
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if user_info.role != UserRole.OWNER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# ---- ResponseNone 처리 -----------------------------------------------------
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.

View File

@ -4,11 +4,11 @@ from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, UserStatus, UserRole
from common.enums import DBWRType, ErrorType, UserStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import CompanyData, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
from router.v1.auth.protocol import CompanyData, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
from router.v1.validator.dependencies import (
CreateAccessToken,
CreateRefreshToken,
@ -38,6 +38,7 @@ class AuthService:
user_id=str(user.user_id),
id=user.id,
company_id=str(user.company_id),
role=user.role,
)
async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
@ -82,50 +83,6 @@ class AuthService:
return res
async def create_account(
self, login_id: str, password: str, company_id: str, name: str, email: str, contact_number: str, role: int
) -> Res_CreateAccount:
LOG.i(f"CREATE : id={login_id}, company_id={company_id}")
res = Res_CreateAccount()
# 1) 중복 ID 확인 (Read DB)
err_type = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.is_user(s, login_id),
)
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
return res
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장)
user = users(
company_id=uuid.UUID(company_id),
id=login_id,
password=await GetHashedPW(password),
name=name or None,
email=email or None,
contact_number=contact_number or None,
role=role or UserRole.USER.value,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.add_user(s, user)],
)
if err_type != ErrorType.SUCCESS:
# 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑.
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
else:
res.result.SetResult(err_type)
return res
res.user_id = str(user.user_id)
return res
async def get_me(self, user_info: UserInfo) -> Res_Me:
res = Res_Me()
@ -159,6 +116,32 @@ class AuthService:
res.company = company
return res
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:
res = Res_Me()
data = req.model_dump(exclude_unset=True)
# 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함.
if data.get("password"):
data["password"] = await GetHashedPW(data["password"])
else:
data.pop("password", None)
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
for k in ("name", "email", "contact_number"):
if k in data and data[k] == "":
data[k] = None
if data:
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.update_user(s, uuid.UUID(user_info.user_id), data)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 갱신 후 최신 정보로 응답(프론트가 스토어 갱신에 사용).
return await self.get_me(user_info)
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
res = Res_RefreshToken()
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.

View File

@ -0,0 +1,158 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, UserRole, UserStatus
from common.utils.gtime import GTime
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.company.protocol import (
CompanyUserData,
Req_CreateCompanyUser,
Req_UpdateCompanyUser,
Res_CompanyUser,
Res_CompanyUserList,
Res_DeleteCompanyUser,
)
from router.v1.validator.dependencies import GetHashedPW
class CompanyUserService:
"""최고관리자(OWNER)의 자기 회사 유저 관리 로직.
- 라우터에서 RequireOwner 로 1차 권한을 거른 뒤 호출된다.
- company_id 는 토큰값만 쓴다(요청 body 무시) → 남의 회사 데이터 불가.
- 변경 대상이 OWNER 면 거부한다(최고관리자는 앱에서 수정·삭제 불가).
"""
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
self.user_crud = user_crud
async def _fetch_managed(self, company_uuid: uuid.UUID, user_id: uuid.UUID):
"""대상 유저 조회 + 같은 회사 + 비-OWNER 확인. (ErrorType, user|None) 반환."""
err_type, user = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_by_user_id(s, user_id),
)
if err_type != ErrorType.SUCCESS or user is None:
return ErrorType.ACCOUNT_NOT_FOUND, None
if user.company_id != company_uuid:
return ErrorType.ACCOUNT_NOT_FOUND, None
if user.role == UserRole.OWNER.value:
return ErrorType.ACCOUNT_FORBIDDEN, None
return ErrorType.SUCCESS, user
async def list_users(self, company_id: str, search, pg) -> Res_CompanyUserList:
res = Res_CompanyUserList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.list_by_company(s, company_uuid, search, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.users = [CompanyUserData.model_validate(r) for r in rows]
res.total = total
return res
async def get_user(self, company_id: str, user_id: str) -> Res_CompanyUser:
res = Res_CompanyUser()
err_type, user = await self._fetch_managed(uuid.UUID(company_id), uuid.UUID(user_id))
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.user = CompanyUserData.model_validate(user)
return res
async def create_user(self, company_id: str, req: Req_CreateCompanyUser) -> Res_CompanyUser:
res = Res_CompanyUser()
company_uuid = uuid.UUID(company_id)
# 1) 로그인 ID 중복 확인 (id 는 전역 unique)
err_type = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.is_user(s, req.id),
)
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
return res
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 2) 생성 — 회사는 토큰값, 권한은 항상 USER 로 고정.
user = users(
company_id=company_uuid,
id=req.id,
password=await GetHashedPW(req.password),
name=req.name or None,
email=req.email or None,
contact_number=req.contact_number or None,
role=UserRole.USER.value,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.add_user(s, user)],
)
if err_type != ErrorType.SUCCESS:
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
else:
res.result.SetResult(err_type)
return res
# 서버 기본값(created_at 등)은 insert 후 객체에 안 실리므로 재조회.
return await self.get_user(company_id, str(user.user_id))
async def update_user(self, company_id: str, user_id: str, req: Req_UpdateCompanyUser) -> Res_CompanyUser:
res = Res_CompanyUser()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
err_type, _ = await self._fetch_managed(company_uuid, user_uuid)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
data = req.model_dump(exclude_unset=True)
if data.get("status") is not None:
s = data["status"] # pydantic 은 enum 멤버로 돌려준다 → SMALLINT 값으로 환원
data["status"] = s.value if isinstance(s, UserStatus) else int(s)
if data.get("password"):
data["password"] = await GetHashedPW(data["password"])
else:
data.pop("password", None) # 빈 비밀번호는 변경하지 않음
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.update_user(s, user_uuid, data)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
return await self.get_user(company_id, user_id)
async def delete_user(self, company_id: str, user_id: str) -> Res_DeleteCompanyUser:
res = Res_DeleteCompanyUser()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
err_type, _ = await self._fetch_managed(company_uuid, user_uuid)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.update_user(s, user_uuid, {"deleted": True, "updated_at": GTime.UTC()})],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res

View File

@ -25,9 +25,8 @@ import type {
import type {
HTTPValidationError,
ReqCreateAccount,
ReqLogin,
ResCreateAccount,
ReqUpdateMe,
ResLogin,
ResMe,
ResRefreshToken
@ -106,71 +105,6 @@ export const useLogin = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* 새 계정을 생성한다.
* @summary 계정 생성
*/
export const createAccount = (
reqCreateAccount: ReqCreateAccount,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCreateAccount>(
{url: `/v1/auth/create`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqCreateAccount, signal
},
options);
}
export const getCreateAccountMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext> => {
const mutationKey = ['createAccount'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createAccount>>, {data: ReqCreateAccount}> = (props) => {
const {data} = props ?? {};
return createAccount(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type CreateAccountMutationResult = NonNullable<Awaited<ReturnType<typeof createAccount>>>
export type CreateAccountMutationBody = ReqCreateAccount
export type CreateAccountMutationError = void | HTTPValidationError
/**
* @summary 계정 생성
*/
export const useCreateAccount = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof createAccount>>,
TError,
{data: ReqCreateAccount},
TContext
> => {
const mutationOptions = getCreateAccountMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* refresh 토큰으로 access 토큰을 재발급한다.
* @summary 액세스 토큰 갱신
*/
@ -326,3 +260,68 @@ export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>(
/**
* 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).
* @summary 내 정보 수정
*/
export const updateMe = (
reqUpdateMe: ReqUpdateMe,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResMe>(
{url: `/v1/auth/me`, method: 'PATCH',
headers: {'Content-Type': 'application/json', },
data: reqUpdateMe
},
options);
}
export const getUpdateMeMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext> => {
const mutationKey = ['updateMe'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMe>>, {data: ReqUpdateMe}> = (props) => {
const {data} = props ?? {};
return updateMe(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateMeMutationResult = NonNullable<Awaited<ReturnType<typeof updateMe>>>
export type UpdateMeMutationBody = ReqUpdateMe
export type UpdateMeMutationError = void | HTTPValidationError
/**
* @summary 내 정보 수정
*/
export const useUpdateMe = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateMe>>,
TError,
{data: ReqUpdateMe},
TContext
> => {
const mutationOptions = getUpdateMeMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -0,0 +1,417 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import {
useMutation,
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
HTTPValidationError,
ListUsersParams,
ReqCreateCompanyUser,
ReqUpdateCompanyUser,
ResCompanyUser,
ResCompanyUserList,
ResDeleteCompanyUser
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 회사 유저 목록(최고관리자)
*/
export const listUsers = (
params?: ListUsersParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanyUserList>(
{url: `/v1/company/user/list`, method: 'GET',
params, signal
},
options);
}
export const getListUsersQueryKey = (params?: ListUsersParams,) => {
return [
`/v1/company/user/list`, ...(params ? [params]: [])
] as const;
}
export const getListUsersQueryOptions = <TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listUsers>>> = ({ signal }) => listUsers(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListUsersQueryResult = NonNullable<Awaited<ReturnType<typeof listUsers>>>
export type ListUsersQueryError = void | HTTPValidationError
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params: undefined | ListUsersParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
Awaited<ReturnType<typeof listUsers>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
Awaited<ReturnType<typeof listUsers>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 회사 유저 목록(최고관리자)
*/
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListUsersQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary 회사 유저 생성(일반 권한 고정)
*/
export const createUser = (
reqCreateCompanyUser: ReqCreateCompanyUser,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanyUser>(
{url: `/v1/company/user/create`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqCreateCompanyUser, signal
},
options);
}
export const getCreateUserMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: ReqCreateCompanyUser}, TContext> => {
const mutationKey = ['createUser'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createUser>>, {data: ReqCreateCompanyUser}> = (props) => {
const {data} = props ?? {};
return createUser(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type CreateUserMutationResult = NonNullable<Awaited<ReturnType<typeof createUser>>>
export type CreateUserMutationBody = ReqCreateCompanyUser
export type CreateUserMutationError = void | HTTPValidationError
/**
* @summary 회사 유저 생성(일반 권한 고정)
*/
export const useCreateUser = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof createUser>>,
TError,
{data: ReqCreateCompanyUser},
TContext
> => {
const mutationOptions = getCreateUserMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 회사 유저 조회
*/
export const getUser = (
userId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanyUser>(
{url: `/v1/company/user/${userId}`, method: 'GET', signal
},
options);
}
export const getGetUserQueryKey = (userId?: string,) => {
return [
`/v1/company/user/${userId}`
] as const;
}
export const getGetUserQueryOptions = <TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserQueryKey(userId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUser>>> = ({ signal }) => getUser(userId, requestOptions, signal);
return { queryKey, queryFn, enabled: !!(userId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetUserQueryResult = NonNullable<Awaited<ReturnType<typeof getUser>>>
export type GetUserQueryError = void | HTTPValidationError
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getUser>>,
TError,
Awaited<ReturnType<typeof getUser>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getUser>>,
TError,
Awaited<ReturnType<typeof getUser>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 회사 유저 조회
*/
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetUserQueryOptions(userId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary 회사 유저 수정
*/
export const updateUser = (
userId: string,
reqUpdateCompanyUser: ReqUpdateCompanyUser,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResCompanyUser>(
{url: `/v1/company/user/update/${userId}`, method: 'PATCH',
headers: {'Content-Type': 'application/json', },
data: reqUpdateCompanyUser
},
options);
}
export const getUpdateUserMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext> => {
const mutationKey = ['updateUser'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateUser>>, {userId: string;data: ReqUpdateCompanyUser}> = (props) => {
const {userId,data} = props ?? {};
return updateUser(userId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateUserMutationResult = NonNullable<Awaited<ReturnType<typeof updateUser>>>
export type UpdateUserMutationBody = ReqUpdateCompanyUser
export type UpdateUserMutationError = void | HTTPValidationError
/**
* @summary 회사 유저 수정
*/
export const useUpdateUser = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateUser>>,
TError,
{userId: string;data: ReqUpdateCompanyUser},
TContext
> => {
const mutationOptions = getUpdateUserMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 회사 유저 삭제
*/
export const deleteUser = (
userId: string,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResDeleteCompanyUser>(
{url: `/v1/company/user/delete/${userId}`, method: 'DELETE'
},
options);
}
export const getDeleteUserMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{userId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{userId: string}, TContext> => {
const mutationKey = ['deleteUser'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteUser>>, {userId: string}> = (props) => {
const {userId} = props ?? {};
return deleteUser(userId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteUserMutationResult = NonNullable<Awaited<ReturnType<typeof deleteUser>>>
export type DeleteUserMutationError = void | HTTPValidationError
/**
* @summary 회사 유저 삭제
*/
export const useDeleteUser = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{userId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof deleteUser>>,
TError,
{userId: string},
TContext
> => {
const mutationOptions = getDeleteUserMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -0,0 +1,28 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CompanyUserDataName } from './companyUserDataName';
import type { CompanyUserDataEmail } from './companyUserDataEmail';
import type { CompanyUserDataContactNumber } from './companyUserDataContactNumber';
import type { UserStatus } from './userStatus';
import type { UserRole } from './userRole';
import type { CompanyUserDataLastAccessedAt } from './companyUserDataLastAccessedAt';
import type { CompanyUserDataCreatedAt } from './companyUserDataCreatedAt';
import type { CompanyUserDataUpdatedAt } from './companyUserDataUpdatedAt';
export interface CompanyUserData {
user_id: string;
company_id: string;
id: string;
name?: CompanyUserDataName;
email?: CompanyUserDataEmail;
contact_number?: CompanyUserDataContactNumber;
status: UserStatus;
role: UserRole;
last_accessed_at?: CompanyUserDataLastAccessedAt;
created_at?: CompanyUserDataCreatedAt;
updated_at?: CompanyUserDataUpdatedAt;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataContactNumber = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataLastAccessedAt = string | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResCreateAccountMsg = string | null;
export type CompanyUserDataName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataUpdatedAt = string | null;

View File

@ -5,12 +5,10 @@
* OpenAPI spec version: 0.1.0
*/
export interface ReqCreateAccount {
export interface ReqCreateCompanyUser {
id?: string;
password?: string;
company_id?: string;
name?: string;
email?: string;
contact_number?: string;
role?: number;
}

View File

@ -0,0 +1,19 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqUpdateCompanyUserName } from './reqUpdateCompanyUserName';
import type { ReqUpdateCompanyUserEmail } from './reqUpdateCompanyUserEmail';
import type { ReqUpdateCompanyUserContactNumber } from './reqUpdateCompanyUserContactNumber';
import type { ReqUpdateCompanyUserStatus } from './reqUpdateCompanyUserStatus';
import type { ReqUpdateCompanyUserPassword } from './reqUpdateCompanyUserPassword';
export interface ReqUpdateCompanyUser {
name?: ReqUpdateCompanyUserName;
email?: ReqUpdateCompanyUserEmail;
contact_number?: ReqUpdateCompanyUserContactNumber;
status?: ReqUpdateCompanyUserStatus;
password?: ReqUpdateCompanyUserPassword;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateCompanyUserContactNumber = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateCompanyUserEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateCompanyUserName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateCompanyUserPassword = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { UserStatus } from './userStatus';
export type ReqUpdateCompanyUserStatus = UserStatus | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResCompanyUserMsg } from './resCompanyUserMsg';
import type { ResCompanyUserUser } from './resCompanyUserUser';
export interface ResCompanyUser {
result?: ErrorInfo;
msg?: ResCompanyUserMsg;
user?: ResCompanyUserUser;
}

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResCompanyUserListMsg } from './resCompanyUserListMsg';
import type { CompanyUserData } from './companyUserData';
export interface ResCompanyUserList {
result?: ErrorInfo;
msg?: ResCompanyUserListMsg;
total?: number;
page?: number;
size?: number;
users?: CompanyUserData[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResCompanyUserListMsg = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResCompanyUserMsg = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CompanyUserData } from './companyUserData';
export type ResCompanyUserUser = CompanyUserData | null;

View File

@ -5,10 +5,9 @@
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResCreateAccountMsg } from './resCreateAccountMsg';
import type { ResDeleteCompanyUserMsg } from './resDeleteCompanyUserMsg';
export interface ResCreateAccount {
export interface ResDeleteCompanyUser {
result?: ErrorInfo;
msg?: ResCreateAccountMsg;
user_id?: string;
msg?: ResDeleteCompanyUserMsg;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResDeleteCompanyUserMsg = string | null;

View File

@ -6,7 +6,9 @@
*/
/**
* users.role 코드값.
* users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) —
의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다.
1=일반, 2=최고관리자(직원 계정 생성·관리).
*/
export type UserRole = typeof UserRole[keyof typeof UserRole];
@ -14,5 +16,5 @@ export type UserRole = typeof UserRole[keyof typeof UserRole];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const UserRole = {
USER: 1,
MANAGER: 2,
OWNER: 2,
} as const;

View File

@ -1,6 +1,6 @@
import {createBrowserRouter, redirect} from 'react-router';
import {initAuth} from '../features/auth/service';
import {isLoggedIn} from '../stores/auth';
import {isLoggedIn, hasRole} from '../stores/auth';
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
import LoginPage from '../pages/login';
import ForbiddenPage from '../pages/forbidden';
@ -9,6 +9,7 @@ import ProductsPage from '../pages/products';
import PartnersPage from '../pages/partners';
import QuotationPage from '../pages/quotation';
import CardsPage from '../pages/cards';
import MembersPage from '../pages/members';
export const router = createBrowserRouter([
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
@ -56,6 +57,12 @@ export const router = createBrowserRouter([
{path: 'partners', Component: PartnersPage},
{path: 'quotation', Component: QuotationPage},
{path: 'cards', Component: CardsPage},
{
// 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다.
path: 'members',
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
Component: MembersPage,
},
],
},
{

View File

@ -9,6 +9,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
PARTNERS: '/partners',
QUOTATION: '/quotation',
CARDS: '/cards',
MEMBERS: '/members',
};
export default function AuthenticatedLayout() {

View File

@ -0,0 +1,143 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { useAuth } from '../useAuth';
import { updateMe } from '../service';
const schema = z.object({
name: z.string().trim(),
email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')),
contactNumber: z.string().trim(),
password: z.string(),
passwordConfirm: z.string(),
});
type FormValues = z.infer<typeof schema>;
const inputClass = 'text-foreground text-xs';
const blank = (v: string) => (v.trim() ? v.trim() : null);
export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { user } = useAuth();
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
contactNumber: user?.contact ?? '',
password: '',
passwordConfirm: '',
},
});
const onValid = async (v: FormValues) => {
if (v.password && v.password.length < 4) {
setError('password', { message: '비밀번호는 4자 이상으로 설정해 주십시오.' });
return;
}
if (v.password && v.password !== v.passwordConfirm) {
setError('passwordConfirm', { message: '비밀번호가 일치하지 않습니다.' });
return;
}
try {
await updateMe({
name: blank(v.name),
email: blank(v.email),
contact_number: blank(v.contactNumber),
...(v.password ? { password: v.password } : {}),
});
showToast('내 정보가 수정되었습니다.', 'success');
onClose();
} catch (err) {
showToast(err instanceof Error ? err.message : '내 정보 수정 실패', 'error');
}
};
return (
<Sheet open={open} title="내 정보 수정" onClose={onClose}>
<form onSubmit={handleSubmit(onValid)} className="mt-6 space-y-4 text-xs font-mono">
{/* 읽기 전용: 회사 / 로그인ID / 권한 */}
<div className="rounded-md border border-border bg-muted/40 p-3 space-y-1.5">
<div className="flex justify-between">
<Typography variant="caption">회사</Typography>
<Typography variant="caption" className="font-bold text-foreground">{user?.company}</Typography>
</div>
<div className="flex justify-between">
<Typography variant="caption">로그인 ID</Typography>
<Typography variant="caption" className="font-mono font-bold text-foreground">{user?.loginId}</Typography>
</div>
<div className="flex justify-between">
<Typography variant="caption">권한등급</Typography>
<Typography variant="caption" className="font-bold text-foreground">{user?.role}</Typography>
</div>
</div>
{/* 이름 */}
<div className="space-y-1">
<Typography as="label" variant="label">이름</Typography>
<Input id="profile-name" type="text" {...register('name')} className={inputClass} placeholder="이름" />
</div>
{/* 이메일 */}
<div className="space-y-1">
<Typography as="label" variant="label">이메일</Typography>
<Input id="profile-email" type="email" {...register('email')} className={inputClass} placeholder="me@company.co.kr" />
{errors.email && <p className="text-[10px] text-rose-500">{errors.email.message}</p>}
</div>
{/* 연락처 */}
<div className="space-y-1">
<Typography as="label" variant="label">연락처</Typography>
<Input id="profile-phone" type="text" {...register('contactNumber')} className={inputClass} placeholder="010-XXXX-XXXX" />
</div>
{/* 비밀번호 변경(옵션) */}
<div className="space-y-1">
<Typography as="label" variant="label">비밀번호 변경 (변경 시에만 입력)</Typography>
<Input
id="profile-password"
type="password"
{...register('password')}
className={inputClass}
placeholder="비워두면 기존 비밀번호 유지"
autoComplete="new-password"
/>
{errors.password && <p className="text-[10px] text-rose-500">{errors.password.message}</p>}
</div>
{/* 비밀번호 확인 */}
<div className="space-y-1">
<Typography as="label" variant="label">비밀번호 확인</Typography>
<Input
id="profile-password-confirm"
type="password"
{...register('passwordConfirm')}
className={inputClass}
placeholder="비밀번호를 한 번 더 입력"
autoComplete="new-password"
/>
{errors.passwordConfirm && <p className="text-[10px] text-rose-500">{errors.passwordConfirm.message}</p>}
</div>
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-end">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
취소
</Button>
<Button type="submit" size="sm" disabled={isSubmitting}>
변경사항 저장
</Button>
</div>
</form>
</Sheet>
);
}

View File

@ -1,4 +1,4 @@
import {setAccessToken} from '../../api/mutator/custom-fetch';
import {setAccessToken, customFetch} from '../../api/mutator/custom-fetch';
import {
login as loginRequest,
refreshToken as refreshRequest,
@ -9,6 +9,7 @@ import type {ErrorInfo} from '../../api/generated/model/errorInfo';
import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
import {UserRole as UserRoleCode} from '../../api/generated/model';
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
import {toMessage, resultMessage} from '../../lib/apiError';
const ACCESS_KEY = 'negodata.accessToken';
const REFRESH_KEY = 'negodata.refreshToken';
@ -23,6 +24,7 @@ function ensureOk<T extends {result?: ErrorInfo}>(res: T): T {
function toAuthUser(me: ResMe): AuthUser {
return {
userId: me.user_id ?? '',
company: me.company?.name ?? '',
name: me.name ?? me.id ?? '',
loginId: me.id ?? '',
@ -75,6 +77,28 @@ export async function login(loginId: string, password: string): Promise<ResMe> {
return me;
}
export interface UpdateMePayload {
name?: string | null;
email?: string | null;
contact_number?: string | null;
password?: string | null;
}
// 본인 정보 수정(PATCH /v1/auth/me). 성공 시 최신 정보로 스토어 갱신.
// HTTP 에러(ApiError)·응답봉투 모두 toMessage/resultMessage 로 한글 통일.
export async function updateMe(payload: UpdateMePayload): Promise<ResMe> {
let me: ResMe;
try {
me = await customFetch<ResMe>({url: '/v1/auth/me', method: 'PATCH', data: payload});
} catch (err) {
throw new Error(toMessage(err));
}
const msg = resultMessage(me.result);
if (msg) throw new Error(msg);
useAuthStore.getState().setUser(toAuthUser(me));
return me;
}
// 백엔드에 logout 엔드포인트가 없으므로 클라이언트 상태만 비운다.
export async function logout(): Promise<void> {
localStorage.removeItem(ACCESS_KEY);

View File

@ -0,0 +1,51 @@
import { customFetch } from '@/api/mutator/custom-fetch';
import type { ErrorInfo } from '@/api/generated/model/errorInfo';
import type {
CompanyUserData,
ReqCreateCompanyUser,
ReqUpdateCompanyUser,
} from './types';
// 최고관리자 전용 /v1/company/user 엔드포인트 클라이언트.
// 생성 클라이언트(orval)가 아직 없어 공통 customFetch mutator 로 직접 호출한다.
// (백엔드 라우터가 OpenAPI 에 노출된 뒤 `npm run orval` 하면 동일 시그니처의 생성 클라이언트가 만들어진다)
export interface ListCompanyUsersParams {
search?: string;
page?: number;
size?: number;
}
export interface ResCompanyUserList {
result?: ErrorInfo;
total?: number;
page?: number;
size?: number;
users?: CompanyUserData[];
}
export interface ResCompanyUser {
result?: ErrorInfo;
user?: CompanyUserData;
}
export interface ResDeleteCompanyUser {
result?: ErrorInfo;
}
export const listCompanyUsers = (params: ListCompanyUsersParams, signal?: AbortSignal) =>
customFetch<ResCompanyUserList>({
url: '/v1/company/user/list',
method: 'GET',
params: params as Record<string, unknown>,
signal,
});
export const createCompanyUser = (data: ReqCreateCompanyUser) =>
customFetch<ResCompanyUser>({ url: '/v1/company/user/create', method: 'POST', data });
export const updateCompanyUser = (userId: string, data: ReqUpdateCompanyUser) =>
customFetch<ResCompanyUser>({ url: `/v1/company/user/update/${userId}`, method: 'PATCH', data });
export const deleteCompanyUser = (userId: string) =>
customFetch<ResDeleteCompanyUser>({ url: `/v1/company/user/delete/${userId}`, method: 'DELETE' });

View File

@ -0,0 +1,256 @@
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Trash2 } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
UserStatus,
USER_STATUS_LABEL,
type Member,
type ReqCreateCompanyUser,
type ReqUpdateCompanyUser,
} from '../types';
// 기본 검증은 zod(이메일 형식 등)로, create/edit 별 id·password 규칙은 onValid 에서 setError 로 처리한다.
const schema = z.object({
id: z.string(),
password: z.string(),
passwordConfirm: z.string(),
name: z.string().trim(),
email: z.string().trim().email('이메일 형식을 확인해 주십시오.').or(z.literal('')),
contactNumber: z.string().trim(),
status: z.number(),
});
type FormValues = z.infer<typeof schema>;
type MemberFormSheetProps = {
open: boolean;
mode: 'create' | 'edit';
member: Member | null;
onCreate: (data: ReqCreateCompanyUser) => Promise<void>;
onUpdate: (userId: string, data: ReqUpdateCompanyUser) => Promise<void>;
onDelete: (userId: string, label: string) => void;
onClose: () => void;
};
function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValues {
if (mode === 'edit' && member) {
return {
id: member.id,
password: '',
passwordConfirm: '',
name: member.name ?? '',
email: member.email ?? '',
contactNumber: member.contact_number ?? '',
status: member.status,
};
}
return { id: '', password: '', passwordConfirm: '', name: '', email: '', contactNumber: '', status: UserStatus.ACTIVE };
}
const inputClass = 'text-foreground text-xs';
const blank = (v: string) => (v.trim() ? v.trim() : null);
export function MemberFormSheet({
open,
mode,
member,
onCreate,
onUpdate,
onDelete,
onClose,
}: MemberFormSheetProps) {
const {
register,
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: buildDefaults(mode, member),
});
const onValid = async (v: FormValues) => {
// 비밀번호는 입력했을 때(또는 생성 시)만 4자 이상 + 확인 일치.
if ((mode === 'create' || v.password) && v.password.length < 4) {
setError('password', { message: '비밀번호는 4자 이상으로 설정해 주십시오.' });
return;
}
if ((mode === 'create' || v.password) && v.password !== v.passwordConfirm) {
setError('passwordConfirm', { message: '비밀번호가 일치하지 않습니다.' });
return;
}
if (mode === 'create') {
if (!v.id.trim()) {
setError('id', { message: '로그인 ID를 입력해 주십시오.' });
return;
}
try {
await onCreate({
id: v.id ?? '',
password: v.password,
name: v.name.trim() || undefined,
email: v.email.trim() || undefined,
contact_number: v.contactNumber.trim() || undefined,
});
showToast('신규 계정이 생성되었습니다.', 'success');
onClose();
} catch (err) {
showToast(err instanceof Error ? err.message : '계정 생성 실패', 'error');
}
} else if (member) {
try {
await onUpdate(member.user_id, {
name: blank(v.name),
email: blank(v.email),
contact_number: blank(v.contactNumber),
status: v.status as Member['status'],
...(v.password ? { password: v.password } : {}),
});
showToast('계정 정보가 수정되었습니다.', 'success');
onClose();
} catch (err) {
showToast(err instanceof Error ? err.message : '계정 수정 실패', 'error');
}
}
};
return (
<Sheet open={open} title={mode === 'create' ? '신규 계정 생성' : '계정 정보 관리'} onClose={onClose}>
<form onSubmit={handleSubmit(onValid)} className="mt-6 space-y-4 text-xs font-mono">
{/* 로그인 ID */}
<div className="space-y-1">
<Typography as="label" variant="label">로그인 ID</Typography>
{mode === 'create' ? (
<Input id="form-member-id" type="text" {...register('id')} className={inputClass} placeholder="예: kim.staff" />
) : (
<Input id="form-member-id" type="text" value={member?.id ?? ''} disabled className={inputClass} />
)}
{errors.id && <p className="text-[10px] text-rose-500">{errors.id.message}</p>}
</div>
{/* 비밀번호 */}
<div className="space-y-1">
<Typography as="label" variant="label">
{mode === 'create' ? '비밀번호' : '비밀번호 초기화 (변경 시에만 입력)'}
</Typography>
<Input
id="form-member-password"
type="password"
{...register('password')}
className={inputClass}
placeholder={mode === 'create' ? '4자 이상' : '비워두면 기존 비밀번호 유지'}
autoComplete="new-password"
/>
{errors.password && <p className="text-[10px] text-rose-500">{errors.password.message}</p>}
</div>
{/* 비밀번호 확인 */}
<div className="space-y-1">
<Typography as="label" variant="label">비밀번호 확인</Typography>
<Input
id="form-member-password-confirm"
type="password"
{...register('passwordConfirm')}
className={inputClass}
placeholder="비밀번호를 한 번 더 입력"
autoComplete="new-password"
/>
{errors.passwordConfirm && <p className="text-[10px] text-rose-500">{errors.passwordConfirm.message}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
{/* 이름 */}
<div className="space-y-1">
<Typography as="label" variant="label">이름</Typography>
<Input id="form-member-name" type="text" {...register('name')} className={inputClass} placeholder="김직원" />
</div>
{/* 상태 (edit 전용) */}
{mode === 'edit' && (
<div className="space-y-1">
<Typography as="label" variant="label">상태</Typography>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
<SelectTrigger id="form-member-status" className="w-full">
<SelectValue>
{(value) => USER_STATUS_LABEL[Number(value) as Member['status']] ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.values(UserStatus).map((s) => (
<SelectItem key={s} value={String(s)}>
{USER_STATUS_LABEL[s]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
)}
</div>
{/* 이메일 */}
<div className="space-y-1">
<Typography as="label" variant="label">이메일</Typography>
<Input
id="form-member-email"
type="email"
{...register('email')}
className={inputClass}
placeholder="staff@company.co.kr"
/>
{errors.email && <p className="text-[10px] text-rose-500">{errors.email.message}</p>}
</div>
{/* 연락처 */}
<div className="space-y-1">
<Typography as="label" variant="label">연락처</Typography>
<Input
id="form-member-phone"
type="text"
{...register('contactNumber')}
className={inputClass}
placeholder="010-XXXX-XXXX"
/>
</div>
{/* 버튼 */}
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
{mode === 'edit' && member && (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
onDelete(member.user_id, member.name || member.id);
onClose();
}}
>
<Trash2 />
삭제
</Button>
)}
<div className="flex items-center gap-2 flex-1 justify-end">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
취소
</Button>
<Button type="submit" size="sm" disabled={isSubmitting}>
{mode === 'create' ? '계정 생성' : '변경사항 저장'}
</Button>
</div>
</div>
</form>
</Sheet>
);
}

View File

@ -0,0 +1,103 @@
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination';
import { USER_ROLE_LABEL } from '@/lib/enumLabels';
import { UserStatus, USER_STATUS_LABEL, type Member } from '../types';
type MemberTableProps = {
data: Member[];
onRowClick: (member: Member) => void;
page: number;
totalPages: number;
totalCount: number;
pageSize: number;
onPageChange: (page: number) => void;
};
const statusBadgeClass = (status: number) =>
status === UserStatus.ACTIVE
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/20 dark:text-emerald-400 border border-emerald-200'
: 'bg-zinc-100 text-zinc-500 border border-zinc-300';
const fmtDate = (v?: string | null) => (v ? new Date(v).toLocaleDateString('sv-SE') : '-');
export function MemberTable({
data,
onRowClick,
page,
totalPages,
totalCount,
pageSize,
onPageChange,
}: MemberTableProps) {
return (
<DataTable
data={data}
rowKey={(m) => m.user_id}
onRowClick={onRowClick}
empty="등록된 회사 계정이 없습니다."
footer={
<TablePagination
page={page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={onPageChange}
label="회사 계정"
unit="명"
/>
}
columns={[
{
header: '로그인 ID',
align: 'left',
headClassName: 'w-1/5',
cell: (m) => <span className="font-mono font-bold text-sm text-foreground">{m.id}</span>,
},
{
header: '이름',
align: 'left',
cell: (m) => <span className="font-semibold text-foreground">{m.name || '-'}</span>,
},
{
header: '이메일 / 연락처',
align: 'left',
mobileBlock: true,
cell: (m) => (
<div className="space-y-0.5 font-mono text-xs">
<div className="text-foreground">{m.email || '-'}</div>
<div className="text-[10px] text-muted-foreground">{m.contact_number || '-'}</div>
</div>
),
},
{
header: '권한',
align: 'center',
cell: (m) => (
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-bold rounded-full">
{USER_ROLE_LABEL[m.role]}
</Badge>
),
},
{
header: '상태',
align: 'center',
cell: (m) => (
<Badge
variant="outline"
className={`px-2 py-0.5 text-[10px] font-bold rounded-full ${statusBadgeClass(m.status)}`}
>
{USER_STATUS_LABEL[m.status]}
</Badge>
),
},
{
header: '최근 접속',
align: 'center',
cellClassName: 'font-mono text-[10px] text-muted-foreground',
cell: (m) => fmtDate(m.last_accessed_at),
},
]}
/>
);
}

View File

@ -0,0 +1,65 @@
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ErrorInfo } from '@/api/generated/model/errorInfo';
import { toMessage, resultMessage } from '@/lib/apiError';
import {
listCompanyUsers,
createCompanyUser,
updateCompanyUser,
deleteCompanyUser,
type ListCompanyUsersParams,
} from '../api';
import type { Member, ReqCreateCompanyUser, ReqUpdateCompanyUser } from '../types';
const LIST_KEY = '/v1/company/user/list';
// HTTP 에러(ApiError, 예: 403)와 응답봉투(result.success=false)를 같은 한글 에러로 일원화.
async function call<T extends { result?: ErrorInfo }>(p: Promise<T>): Promise<T> {
let res: T;
try {
res = await p;
} catch (err) {
throw new Error(toMessage(err));
}
const msg = resultMessage(res.result);
if (msg) throw new Error(msg);
return res;
}
export function useMembers(params: ListCompanyUsersParams) {
const queryClient = useQueryClient();
const membersQuery = useQuery({
queryKey: [LIST_KEY, params],
queryFn: ({ signal }) => listCompanyUsers(params, signal),
placeholderData: keepPreviousData,
});
// /v1/company/user/list 로 시작하는 모든 페이지 쿼리를 prefix 매칭으로 재조회.
const refresh = () => queryClient.invalidateQueries({ queryKey: [LIST_KEY] });
const createMember = async (data: ReqCreateCompanyUser) => {
await call(createCompanyUser(data));
await refresh();
};
const updateMember = async (userId: string, data: ReqUpdateCompanyUser) => {
await call(updateCompanyUser(userId, data));
await refresh();
};
const deleteMember = async (userId: string) => {
await call(deleteCompanyUser(userId));
await refresh();
};
const members: Member[] = membersQuery.data?.users ?? [];
const total = membersQuery.data?.total ?? 0;
return {
members,
total,
createMember,
updateMember,
deleteMember,
refresh,
isLoading: membersQuery.isLoading,
};
}

View File

@ -0,0 +1,44 @@
import { UserRole } from '@/api/generated/model';
// 회사 유저(계정) 상태 코드 — 백엔드 UserStatus 미러.
// (orval 재생성 전까지 로컬 정의. 재생성 후 @/api/generated/model 의 UserStatus 로 교체 가능)
export const UserStatus = { ACTIVE: 1, INACTIVE: 2 } as const;
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
export const USER_STATUS_LABEL: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: '활성',
[UserStatus.INACTIVE]: '비활성',
};
// 백엔드 CompanyUserData 와 1:1.
export interface CompanyUserData {
user_id: string;
company_id: string;
id: string; // 로그인 ID
name?: string | null;
email?: string | null;
contact_number?: string | null;
status: UserStatus;
role: UserRole;
last_accessed_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type Member = CompanyUserData;
export interface ReqCreateCompanyUser {
id: string;
password: string;
name?: string;
email?: string;
contact_number?: string;
}
export interface ReqUpdateCompanyUser {
name?: string | null;
email?: string | null;
contact_number?: string | null;
status?: UserStatus | null;
password?: string | null;
}

View File

@ -0,0 +1,46 @@
import { ApiError } from '@/api/mutator/custom-fetch';
import type { ErrorInfo } from '@/api/generated/model/errorInfo';
// 백엔드 식별자(ErrorType.name = 응답봉투 result.desc / HTTPException detail) → 사용자용 한글.
// 서버는 안정적인 코드명만 주고, 표시 문구는 여기서만 정한다(서버·프론트 메시지 단일화).
const MESSAGES: Record<string, string> = {
// HTTP 예외(라우터 단에서 raise)
HTTP_FORBIDDEN: '최고관리자 권한이 필요합니다.',
HTTP_INVALID_CLIENT_ACCESS: '인증에 실패했습니다. 다시 로그인해 주세요.',
HTTP_ACCESS_TOKEN_EXPIRED: '세션이 만료되었습니다. 다시 로그인해 주세요.',
HTTP_REFRESH_TOKEN_EXPIRED: '세션이 만료되었습니다. 다시 로그인해 주세요.',
HTTP_INVALID_TOKEN_ACCESS: '인증에 실패했습니다. 다시 로그인해 주세요.',
// 응답 봉투(result.success=false)
ACCOUNT_INVALID_INFO: '아이디 또는 비밀번호가 올바르지 않습니다.',
ACCOUNT_ALREADY_EXIST: '이미 사용 중인 로그인 ID 입니다.',
ACCOUNT_BLOCKED_USER: '비활성화된 계정입니다.',
ACCOUNT_NOT_FOUND: '대상 계정을 찾을 수 없습니다.',
ACCOUNT_FORBIDDEN: '해당 계정은 수정·삭제할 수 없습니다(최고관리자 대상).',
};
const STATUS_FALLBACK: Record<number, string> = {
401: '인증에 실패했습니다. 다시 로그인해 주세요.',
403: MESSAGES.HTTP_FORBIDDEN,
404: '대상을 찾을 수 없습니다.',
500: '서버 오류가 발생했습니다.',
};
const DEFAULT = '요청 처리 중 오류가 발생했습니다.';
const byKey = (key?: string | null): string | null => (key ? MESSAGES[key] ?? null : null);
// HTTP 에러(ApiError)/일반 에러 → 사용자용 한글. catch 블록에서 사용.
export function toMessage(err: unknown, fallback: string = DEFAULT): string {
if (err instanceof ApiError) {
const detail = (err.data as { detail?: string } | null)?.detail;
return byKey(detail) ?? STATUS_FALLBACK[err.status] ?? fallback;
}
if (err instanceof Error) return err.message;
return fallback;
}
// 응답 봉투(result.success=false) → 한글. 정상이면 null.
export function resultMessage(result?: ErrorInfo, fallback = '요청에 실패했습니다.'): string | null {
if (!result || result.success !== false) return null;
return byKey(result.desc) ?? result.desc ?? fallback;
}

View File

@ -0,0 +1,96 @@
import { Plus } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Button } from '@/components/ui/button';
import { useServerList } from '@/lib/useServerList';
import { useMembers } from '@/features/members/hooks/useMembers';
import { MemberTable } from '@/features/members/components/MemberTable';
import { MemberFormSheet } from '@/features/members/components/MemberFormSheet';
import type { Member } from '@/features/members/types';
import type { ListCompanyUsersParams } from '@/features/members/api';
export default function MembersPage() {
const list = useServerList({ pageSize: 10 });
const params: ListCompanyUsersParams = {
search: list.debouncedSearch || undefined,
page: list.page,
size: list.pageSize,
};
const { members, total, createMember, updateMember, deleteMember } = useMembers(params);
const totalPages = list.totalPages(total);
const overlay = useOverlayRouter(['new', 'detail']);
const editId = overlay.get('detail');
const editing = editId ? members.find((m) => m.user_id === editId) ?? null : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (member: Member) => overlay.open('detail', member.user_id);
const handleDelete = async (userId: string, label: string) => {
if (
await confirm({
title: '계정 삭제',
description: `[${label}] 계정을 삭제하시겠습니까?`,
confirmText: '삭제',
destructive: true,
})
) {
try {
await deleteMember(userId);
showToast('계정이 삭제되었습니다.', 'info');
} catch (err) {
showToast(err instanceof Error ? err.message : '계정 삭제 실패', 'error');
}
}
};
return (
<PageContainer>
<PageToolbar
actions={
<Button onClick={openCreate}>
<Plus />
신규 계정 생성
</Button>
}
>
<SearchInput
id="member-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="로그인 ID, 이름 또는 이메일로 검색..."
/>
</PageToolbar>
<MemberTable
data={members}
onRowClick={openEdit}
page={list.page}
totalPages={totalPages}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
/>
{isFormOpen && (
<MemberFormSheet
key={`${formMode}-${editing?.user_id ?? 'new'}`}
open
mode={formMode}
member={editing}
onCreate={createMember}
onUpdate={updateMember}
onDelete={handleDelete}
onClose={overlay.close}
/>
)}
</PageContainer>
);
}

View File

@ -1,8 +1,9 @@
import {create} from 'zustand';
export type UserRole = '관리자' | '일반';
export type UserRole = '최고관리자' | '일반';
export interface AuthUser {
userId: string;
company: string;
name: string;
loginId: string;