[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:
parent
7da1925365
commit
a40257ddc1
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
58
negodata/backend/router/v1/company/protocol.py
Normal file
58
negodata/backend/router/v1/company/protocol.py
Normal 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
|
||||||
51
negodata/backend/router/v1/company/user.py
Normal file
51
negodata/backend/router/v1/company/user.py
Normal 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)))
|
||||||
@ -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....) 형태로 반환한다.
|
||||||
|
|||||||
@ -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차 수행됨.
|
||||||
|
|||||||
158
negodata/backend/services/company_user_service.py
Normal file
158
negodata/backend/services/company_user_service.py
Normal 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
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
417
negodata/front/src/api/generated/company-user/company-user.ts
Normal file
417
negodata/front/src/api/generated/company-user/company-user.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
28
negodata/front/src/api/generated/model/companyUserData.ts
Normal file
28
negodata/front/src/api/generated/model/companyUserData.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
15
negodata/front/src/api/generated/model/resCompanyUser.ts
Normal file
15
negodata/front/src/api/generated/model/resCompanyUser.ts
Normal 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;
|
||||||
|
}
|
||||||
18
negodata/front/src/api/generated/model/resCompanyUserList.ts
Normal file
18
negodata/front/src/api/generated/model/resCompanyUserList.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
143
negodata/front/src/features/auth/components/ProfileSheet.tsx
Normal file
143
negodata/front/src/features/auth/components/ProfileSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
51
negodata/front/src/features/members/api.ts
Normal file
51
negodata/front/src/features/members/api.ts
Normal 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' });
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
negodata/front/src/features/members/components/MemberTable.tsx
Normal file
103
negodata/front/src/features/members/components/MemberTable.tsx
Normal 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),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
negodata/front/src/features/members/hooks/useMembers.ts
Normal file
65
negodata/front/src/features/members/hooks/useMembers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
44
negodata/front/src/features/members/types.ts
Normal file
44
negodata/front/src/features/members/types.ts
Normal 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;
|
||||||
|
}
|
||||||
46
negodata/front/src/lib/apiError.ts
Normal file
46
negodata/front/src/lib/apiError.ts
Normal 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;
|
||||||
|
}
|
||||||
96
negodata/front/src/pages/members.tsx
Normal file
96
negodata/front/src/pages/members.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user