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

View File

@ -1,7 +1,7 @@
from abc import ABC, abstractmethod 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 sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG 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]: async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass 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): class UserCRUD(IUserCRUD):
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: 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: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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 common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from services.auth_service import AuthService 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() 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)) 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( @router.post(
path="/refresh_token", path="/refresh_token",
dependencies=[Depends(IsValidRefreshToken)], 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)): async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_me(user_info)) 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" token_type: str = "bearer"
class Req_CreateAccount(AuthProtocol): class Req_UpdateMe(AuthProtocol):
id: str = "" # 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
password: str = "" name: Optional[str] = None
company_id: str = "" email: Optional[str] = None
name: str = "" contact_number: Optional[str] = None
email: str = "" password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지
contact_number: str = ""
role: int = UserRole.USER.value
class Res_CreateAccount(Res_WebPacketProtocol):
user_id: str = ""
class Res_RefreshToken(Res_WebPacketProtocol): 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 ( from common.enums import (
EXCEPTION_ACCESS_TOKEN_EXPIRED, EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS, EXCEPTION_INVALID_CLIENT_ACCESS,
EXCEPTION_REFRESH_TOKEN_EXPIRED, EXCEPTION_REFRESH_TOKEN_EXPIRED,
UserRole,
) )
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
@ -98,6 +100,13 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
return DecodeRefreshToken(credentials.credentials) 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 처리 ----------------------------------------------------- # ---- ResponseNone 처리 -----------------------------------------------------
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다. # 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다. # 모든 라우터는 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.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users 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.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD 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 ( from router.v1.validator.dependencies import (
CreateAccessToken, CreateAccessToken,
CreateRefreshToken, CreateRefreshToken,
@ -38,6 +38,7 @@ class AuthService:
user_id=str(user.user_id), user_id=str(user.user_id),
id=user.id, id=user.id,
company_id=str(user.company_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: async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
@ -82,50 +83,6 @@ class AuthService:
return res 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: async def get_me(self, user_info: UserInfo) -> Res_Me:
res = Res_Me() res = Res_Me()
@ -159,6 +116,32 @@ class AuthService:
res.company = company res.company = company
return res 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: async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
res = Res_RefreshToken() res = Res_RefreshToken()
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨. # 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 { import type {
HTTPValidationError, HTTPValidationError,
ReqCreateAccount,
ReqLogin, ReqLogin,
ResCreateAccount, ReqUpdateMe,
ResLogin, ResLogin,
ResMe, ResMe,
ResRefreshToken ResRefreshToken
@ -106,71 +105,6 @@ export const useLogin = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); 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 토큰을 재발급한다. * refresh 토큰으로 access 토큰을 재발급한다.
* @summary 액세스 토큰 갱신 * @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 * 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 * OpenAPI spec version: 0.1.0
*/ */
export interface ReqCreateAccount { export interface ReqCreateCompanyUser {
id?: string; id?: string;
password?: string; password?: string;
company_id?: string;
name?: string; name?: string;
email?: string; email?: string;
contact_number?: 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 * OpenAPI spec version: 0.1.0
*/ */
import type { ErrorInfo } from './errorInfo'; import type { ErrorInfo } from './errorInfo';
import type { ResCreateAccountMsg } from './resCreateAccountMsg'; import type { ResDeleteCompanyUserMsg } from './resDeleteCompanyUserMsg';
export interface ResCreateAccount { export interface ResDeleteCompanyUser {
result?: ErrorInfo; result?: ErrorInfo;
msg?: ResCreateAccountMsg; msg?: ResDeleteCompanyUserMsg;
user_id?: string;
} }

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]; 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 // eslint-disable-next-line @typescript-eslint/no-redeclare
export const UserRole = { export const UserRole = {
USER: 1, USER: 1,
MANAGER: 2, OWNER: 2,
} as const; } as const;

View File

@ -1,6 +1,6 @@
import {createBrowserRouter, redirect} from 'react-router'; import {createBrowserRouter, redirect} from 'react-router';
import {initAuth} from '../features/auth/service'; import {initAuth} from '../features/auth/service';
import {isLoggedIn} from '../stores/auth'; import {isLoggedIn, hasRole} from '../stores/auth';
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
import LoginPage from '../pages/login'; import LoginPage from '../pages/login';
import ForbiddenPage from '../pages/forbidden'; import ForbiddenPage from '../pages/forbidden';
@ -9,6 +9,7 @@ import ProductsPage from '../pages/products';
import PartnersPage from '../pages/partners'; import PartnersPage from '../pages/partners';
import QuotationPage from '../pages/quotation'; import QuotationPage from '../pages/quotation';
import CardsPage from '../pages/cards'; import CardsPage from '../pages/cards';
import MembersPage from '../pages/members';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과 // dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
@ -56,6 +57,12 @@ export const router = createBrowserRouter([
{path: 'partners', Component: PartnersPage}, {path: 'partners', Component: PartnersPage},
{path: 'quotation', Component: QuotationPage}, {path: 'quotation', Component: QuotationPage},
{path: 'cards', Component: CardsPage}, {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', PARTNERS: '/partners',
QUOTATION: '/quotation', QUOTATION: '/quotation',
CARDS: '/cards', CARDS: '/cards',
MEMBERS: '/members',
}; };
export default function AuthenticatedLayout() { 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 { import {
login as loginRequest, login as loginRequest,
refreshToken as refreshRequest, 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 {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
import {UserRole as UserRoleCode} from '../../api/generated/model'; import {UserRole as UserRoleCode} from '../../api/generated/model';
import {USER_ROLE_LABEL} from '../../lib/enumLabels'; import {USER_ROLE_LABEL} from '../../lib/enumLabels';
import {toMessage, resultMessage} from '../../lib/apiError';
const ACCESS_KEY = 'negodata.accessToken'; const ACCESS_KEY = 'negodata.accessToken';
const REFRESH_KEY = 'negodata.refreshToken'; const REFRESH_KEY = 'negodata.refreshToken';
@ -23,6 +24,7 @@ function ensureOk<T extends {result?: ErrorInfo}>(res: T): T {
function toAuthUser(me: ResMe): AuthUser { function toAuthUser(me: ResMe): AuthUser {
return { return {
userId: me.user_id ?? '',
company: me.company?.name ?? '', company: me.company?.name ?? '',
name: me.name ?? me.id ?? '', name: me.name ?? me.id ?? '',
loginId: me.id ?? '', loginId: me.id ?? '',
@ -75,6 +77,28 @@ export async function login(loginId: string, password: string): Promise<ResMe> {
return me; 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 엔드포인트가 없으므로 클라이언트 상태만 비운다. // 백엔드에 logout 엔드포인트가 없으므로 클라이언트 상태만 비운다.
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
localStorage.removeItem(ACCESS_KEY); 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'; import {create} from 'zustand';
export type UserRole = '관리자' | '일반'; export type UserRole = '최고관리자' | '일반';
export interface AuthUser { export interface AuthUser {
userId: string;
company: string; company: string;
name: string; name: string;
loginId: string; loginId: string;