[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()
|
||||
|
||||
# http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_INVALID_CLIENT_REQUEST = 419
|
||||
HTTP_TO_MANY_REQUEST = 429
|
||||
HTTP_INVALID_CLIENT_ACCESS = 433
|
||||
@ -46,6 +47,8 @@ class ErrorType(Enum):
|
||||
ACCOUNT_INVALID_INFO = 1200
|
||||
ACCOUNT_ALREADY_EXIST = auto()
|
||||
ACCOUNT_BLOCKED_USER = auto()
|
||||
ACCOUNT_NOT_FOUND = auto()
|
||||
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
|
||||
|
||||
# 상품 관련 에러
|
||||
ITEM_NOT_FOUND = 1300
|
||||
@ -71,8 +74,13 @@ class ErrorType(Enum):
|
||||
IMAGE_TOO_LARGE = auto()
|
||||
IMAGE_UPLOAD_FAILED = auto()
|
||||
|
||||
# 초청 메일 발송 관련 에러
|
||||
EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요)
|
||||
EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공)
|
||||
|
||||
|
||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
|
||||
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
||||
EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name)
|
||||
EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name)
|
||||
@ -105,10 +113,12 @@ class UserStatus(CodeEnum):
|
||||
|
||||
|
||||
class UserRole(CodeEnum):
|
||||
"""users.role 코드값."""
|
||||
"""users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) —
|
||||
의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다.
|
||||
1=일반, 2=최고관리자(직원 계정 생성·관리)."""
|
||||
|
||||
USER = 1
|
||||
MANAGER = 2
|
||||
OWNER = 2 # 최고관리자: 자기 회사 유저(직원 계정)를 생성·관리
|
||||
|
||||
|
||||
class CompanyStatus(CodeEnum):
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
@ -35,6 +35,18 @@ class IUserCRUD(ABC):
|
||||
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class UserCRUD(IUserCRUD):
|
||||
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
|
||||
@ -90,3 +102,57 @@ class UserCRUD(IUserCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def list_by_company(
|
||||
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
|
||||
if search:
|
||||
conditions.append(
|
||||
or_(
|
||||
users.id.ilike(f"%{search}%"),
|
||||
users.name.ilike(f"%{search}%"),
|
||||
users.email.ilike(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
where = and_(*conditions)
|
||||
|
||||
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
|
||||
if cnt_err != ErrorType.SUCCESS:
|
||||
return cnt_err, [], 0
|
||||
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||
|
||||
list_err, rows = await DB_SESSION_MNG.execute(
|
||||
cdb,
|
||||
select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit),
|
||||
)
|
||||
if list_err != ErrorType.SUCCESS:
|
||||
return list_err, [], 0
|
||||
return ErrorType.SUCCESS, list(rows), total
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, [], 0
|
||||
|
||||
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
|
||||
try:
|
||||
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
if len(row_list) != 1:
|
||||
return ErrorType.DB_INVALID_KEY, None
|
||||
return ErrorType.SUCCESS, row_list[0]
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
|
||||
try:
|
||||
if not data:
|
||||
return ErrorType.SUCCESS
|
||||
query = update(users).where(users.user_id == user_id).values(**data)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
|
||||
from services.auth_service import AuthService
|
||||
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
|
||||
from .protocol import Req_Login, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@ -17,13 +17,6 @@ async def login(request: Request, req: Req_Login, service: AuthService = Depends
|
||||
return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host))
|
||||
|
||||
|
||||
@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.")
|
||||
async def create_account(req: Req_CreateAccount, service: AuthService = Depends()):
|
||||
return RemoveNoneResponse(
|
||||
await service.create_account(req.id, req.password, req.company_id, req.name, req.email, req.contact_number, req.role)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/refresh_token",
|
||||
dependencies=[Depends(IsValidRefreshToken)],
|
||||
@ -43,3 +36,13 @@ async def refresh_token(service: AuthService = Depends(), credentials: HTTPAutho
|
||||
)
|
||||
async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_me(user_info))
|
||||
|
||||
|
||||
@router.patch(
|
||||
path="/me",
|
||||
response_model=Res_Me,
|
||||
summary="내 정보 수정",
|
||||
description="본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).",
|
||||
)
|
||||
async def update_me(req: Req_UpdateMe, service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.update_me(user_info, req))
|
||||
|
||||
@ -22,18 +22,12 @@ class Res_Login(Res_WebPacketProtocol):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class Req_CreateAccount(AuthProtocol):
|
||||
id: str = ""
|
||||
password: str = ""
|
||||
company_id: str = ""
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
contact_number: str = ""
|
||||
role: int = UserRole.USER.value
|
||||
|
||||
|
||||
class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
user_id: str = ""
|
||||
class Req_UpdateMe(AuthProtocol):
|
||||
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
contact_number: Optional[str] = None
|
||||
password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지
|
||||
|
||||
|
||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
|
||||
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 (
|
||||
EXCEPTION_ACCESS_TOKEN_EXPIRED,
|
||||
EXCEPTION_FORBIDDEN,
|
||||
EXCEPTION_INVALID_CLIENT_ACCESS,
|
||||
EXCEPTION_REFRESH_TOKEN_EXPIRED,
|
||||
UserRole,
|
||||
)
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
@ -98,6 +100,13 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
|
||||
return DecodeRefreshToken(credentials.credentials)
|
||||
|
||||
|
||||
# 최고관리자 전용 엔드포인트 게이트. 액세스 토큰 검증 + role==OWNER 가 아니면 403.
|
||||
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
|
||||
if user_info.role != UserRole.OWNER.value:
|
||||
raise EXCEPTION_FORBIDDEN
|
||||
return user_info
|
||||
|
||||
|
||||
# ---- ResponseNone 처리 -----------------------------------------------------
|
||||
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
|
||||
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.
|
||||
|
||||
@ -4,11 +4,11 @@ from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import users
|
||||
from common.enums import DBWRType, ErrorType, UserStatus, UserRole
|
||||
from common.enums import DBWRType, ErrorType, UserStatus
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.user_crud import IUserCRUD, UserCRUD
|
||||
from router.v1.auth.protocol import CompanyData, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
|
||||
from router.v1.auth.protocol import CompanyData, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
|
||||
from router.v1.validator.dependencies import (
|
||||
CreateAccessToken,
|
||||
CreateRefreshToken,
|
||||
@ -38,6 +38,7 @@ class AuthService:
|
||||
user_id=str(user.user_id),
|
||||
id=user.id,
|
||||
company_id=str(user.company_id),
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
|
||||
@ -82,50 +83,6 @@ class AuthService:
|
||||
|
||||
return res
|
||||
|
||||
async def create_account(
|
||||
self, login_id: str, password: str, company_id: str, name: str, email: str, contact_number: str, role: int
|
||||
) -> Res_CreateAccount:
|
||||
LOG.i(f"CREATE : id={login_id}, company_id={company_id}")
|
||||
res = Res_CreateAccount()
|
||||
|
||||
# 1) 중복 ID 확인 (Read DB)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda(
|
||||
users.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.is_user(s, login_id),
|
||||
)
|
||||
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
|
||||
return res
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장)
|
||||
user = users(
|
||||
company_id=uuid.UUID(company_id),
|
||||
id=login_id,
|
||||
password=await GetHashedPW(password),
|
||||
name=name or None,
|
||||
email=email or None,
|
||||
contact_number=contact_number or None,
|
||||
role=role or UserRole.USER.value,
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[users.DBType()],
|
||||
[lambda s: self.user_crud.add_user(s, user)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑.
|
||||
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
|
||||
else:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.user_id = str(user.user_id)
|
||||
return res
|
||||
|
||||
async def get_me(self, user_info: UserInfo) -> Res_Me:
|
||||
res = Res_Me()
|
||||
|
||||
@ -159,6 +116,32 @@ class AuthService:
|
||||
res.company = company
|
||||
return res
|
||||
|
||||
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:
|
||||
res = Res_Me()
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
|
||||
# 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함.
|
||||
if data.get("password"):
|
||||
data["password"] = await GetHashedPW(data["password"])
|
||||
else:
|
||||
data.pop("password", None)
|
||||
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
|
||||
for k in ("name", "email", "contact_number"):
|
||||
if k in data and data[k] == "":
|
||||
data[k] = None
|
||||
|
||||
if data:
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[users.DBType()],
|
||||
[lambda s: self.user_crud.update_user(s, uuid.UUID(user_info.user_id), data)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 갱신 후 최신 정보로 응답(프론트가 스토어 갱신에 사용).
|
||||
return await self.get_me(user_info)
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
|
||||
res = Res_RefreshToken()
|
||||
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.
|
||||
|
||||
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 {
|
||||
HTTPValidationError,
|
||||
ReqCreateAccount,
|
||||
ReqLogin,
|
||||
ResCreateAccount,
|
||||
ReqUpdateMe,
|
||||
ResLogin,
|
||||
ResMe,
|
||||
ResRefreshToken
|
||||
@ -106,71 +105,6 @@ export const useLogin = <TError = void | HTTPValidationError,
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* 새 계정을 생성한다.
|
||||
* @summary 계정 생성
|
||||
*/
|
||||
export const createAccount = (
|
||||
reqCreateAccount: ReqCreateAccount,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResCreateAccount>(
|
||||
{url: `/v1/auth/create`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqCreateAccount, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getCreateAccountMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext> => {
|
||||
|
||||
const mutationKey = ['createAccount'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createAccount>>, {data: ReqCreateAccount}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return createAccount(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type CreateAccountMutationResult = NonNullable<Awaited<ReturnType<typeof createAccount>>>
|
||||
export type CreateAccountMutationBody = ReqCreateAccount
|
||||
export type CreateAccountMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 계정 생성
|
||||
*/
|
||||
export const useCreateAccount = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createAccount>>,
|
||||
TError,
|
||||
{data: ReqCreateAccount},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getCreateAccountMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* refresh 토큰으로 access 토큰을 재발급한다.
|
||||
* @summary 액세스 토큰 갱신
|
||||
*/
|
||||
@ -326,3 +260,68 @@ export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>(
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).
|
||||
* @summary 내 정보 수정
|
||||
*/
|
||||
export const updateMe = (
|
||||
reqUpdateMe: ReqUpdateMe,
|
||||
options?: SecondParameter<typeof customFetch>,) => {
|
||||
|
||||
|
||||
return customFetch<ResMe>(
|
||||
{url: `/v1/auth/me`, method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqUpdateMe
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getUpdateMeMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext> => {
|
||||
|
||||
const mutationKey = ['updateMe'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMe>>, {data: ReqUpdateMe}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return updateMe(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UpdateMeMutationResult = NonNullable<Awaited<ReturnType<typeof updateMe>>>
|
||||
export type UpdateMeMutationBody = ReqUpdateMe
|
||||
export type UpdateMeMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 내 정보 수정
|
||||
*/
|
||||
export const useUpdateMe = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMe>>,
|
||||
TError,
|
||||
{data: ReqUpdateMe},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getUpdateMeMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
export interface ReqCreateAccount {
|
||||
export interface ReqCreateCompanyUser {
|
||||
id?: string;
|
||||
password?: string;
|
||||
company_id?: string;
|
||||
name?: string;
|
||||
email?: 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
|
||||
*/
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResCreateAccountMsg } from './resCreateAccountMsg';
|
||||
import type { ResDeleteCompanyUserMsg } from './resDeleteCompanyUserMsg';
|
||||
|
||||
export interface ResCreateAccount {
|
||||
export interface ResDeleteCompanyUser {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResCreateAccountMsg;
|
||||
user_id?: string;
|
||||
msg?: ResDeleteCompanyUserMsg;
|
||||
}
|
||||
@ -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];
|
||||
|
||||
@ -14,5 +16,5 @@ export type UserRole = typeof UserRole[keyof typeof UserRole];
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const UserRole = {
|
||||
USER: 1,
|
||||
MANAGER: 2,
|
||||
OWNER: 2,
|
||||
} as const;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {createBrowserRouter, redirect} from 'react-router';
|
||||
import {initAuth} from '../features/auth/service';
|
||||
import {isLoggedIn} from '../stores/auth';
|
||||
import {isLoggedIn, hasRole} from '../stores/auth';
|
||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||
import LoginPage from '../pages/login';
|
||||
import ForbiddenPage from '../pages/forbidden';
|
||||
@ -9,6 +9,7 @@ import ProductsPage from '../pages/products';
|
||||
import PartnersPage from '../pages/partners';
|
||||
import QuotationPage from '../pages/quotation';
|
||||
import CardsPage from '../pages/cards';
|
||||
import MembersPage from '../pages/members';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
|
||||
@ -56,6 +57,12 @@ export const router = createBrowserRouter([
|
||||
{path: 'partners', Component: PartnersPage},
|
||||
{path: 'quotation', Component: QuotationPage},
|
||||
{path: 'cards', Component: CardsPage},
|
||||
{
|
||||
// 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다.
|
||||
path: 'members',
|
||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||
Component: MembersPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -9,6 +9,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
PARTNERS: '/partners',
|
||||
QUOTATION: '/quotation',
|
||||
CARDS: '/cards',
|
||||
MEMBERS: '/members',
|
||||
};
|
||||
|
||||
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 {
|
||||
login as loginRequest,
|
||||
refreshToken as refreshRequest,
|
||||
@ -9,6 +9,7 @@ import type {ErrorInfo} from '../../api/generated/model/errorInfo';
|
||||
import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
|
||||
import {UserRole as UserRoleCode} from '../../api/generated/model';
|
||||
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
|
||||
import {toMessage, resultMessage} from '../../lib/apiError';
|
||||
|
||||
const ACCESS_KEY = 'negodata.accessToken';
|
||||
const REFRESH_KEY = 'negodata.refreshToken';
|
||||
@ -23,6 +24,7 @@ function ensureOk<T extends {result?: ErrorInfo}>(res: T): T {
|
||||
|
||||
function toAuthUser(me: ResMe): AuthUser {
|
||||
return {
|
||||
userId: me.user_id ?? '',
|
||||
company: me.company?.name ?? '',
|
||||
name: me.name ?? me.id ?? '',
|
||||
loginId: me.id ?? '',
|
||||
@ -75,6 +77,28 @@ export async function login(loginId: string, password: string): Promise<ResMe> {
|
||||
return me;
|
||||
}
|
||||
|
||||
export interface UpdateMePayload {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
contact_number?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
// 본인 정보 수정(PATCH /v1/auth/me). 성공 시 최신 정보로 스토어 갱신.
|
||||
// HTTP 에러(ApiError)·응답봉투 모두 toMessage/resultMessage 로 한글 통일.
|
||||
export async function updateMe(payload: UpdateMePayload): Promise<ResMe> {
|
||||
let me: ResMe;
|
||||
try {
|
||||
me = await customFetch<ResMe>({url: '/v1/auth/me', method: 'PATCH', data: payload});
|
||||
} catch (err) {
|
||||
throw new Error(toMessage(err));
|
||||
}
|
||||
const msg = resultMessage(me.result);
|
||||
if (msg) throw new Error(msg);
|
||||
useAuthStore.getState().setUser(toAuthUser(me));
|
||||
return me;
|
||||
}
|
||||
|
||||
// 백엔드에 logout 엔드포인트가 없으므로 클라이언트 상태만 비운다.
|
||||
export async function logout(): Promise<void> {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
|
||||
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';
|
||||
|
||||
export type UserRole = '관리자' | '일반';
|
||||
export type UserRole = '최고관리자' | '일반';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
company: string;
|
||||
name: string;
|
||||
loginId: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user