diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 4b9f862..f040de9 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -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): diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py index 58a1cbf..25ba03e 100644 --- a/negodata/backend/crud/user_crud.py +++ b/negodata/backend/crud/user_crud.py @@ -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 diff --git a/negodata/backend/router/v1/auth/account.py b/negodata/backend/router/v1/auth/account.py index f2a7d3c..8eb38ca 100644 --- a/negodata/backend/router/v1/auth/account.py +++ b/negodata/backend/router/v1/auth/account.py @@ -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)) diff --git a/negodata/backend/router/v1/auth/protocol.py b/negodata/backend/router/v1/auth/protocol.py index f07c0d0..1134e1e 100644 --- a/negodata/backend/router/v1/auth/protocol.py +++ b/negodata/backend/router/v1/auth/protocol.py @@ -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): diff --git a/negodata/backend/router/v1/company/protocol.py b/negodata/backend/router/v1/company/protocol.py new file mode 100644 index 0000000..91bd450 --- /dev/null +++ b/negodata/backend/router/v1/company/protocol.py @@ -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 diff --git a/negodata/backend/router/v1/company/user.py b/negodata/backend/router/v1/company/user.py new file mode 100644 index 0000000..76df668 --- /dev/null +++ b/negodata/backend/router/v1/company/user.py @@ -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))) diff --git a/negodata/backend/router/v1/validator/dependencies.py b/negodata/backend/router/v1/validator/dependencies.py index 72b70cf..0bb784f 100644 --- a/negodata/backend/router/v1/validator/dependencies.py +++ b/negodata/backend/router/v1/validator/dependencies.py @@ -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....) 형태로 반환한다. diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index d3c39af..4eb0a1c 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_service.py @@ -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차 수행됨. diff --git a/negodata/backend/services/company_user_service.py b/negodata/backend/services/company_user_service.py new file mode 100644 index 0000000..a45c98f --- /dev/null +++ b/negodata/backend/services/company_user_service.py @@ -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 diff --git a/negodata/front/src/api/generated/auth/auth.ts b/negodata/front/src/api/generated/auth/auth.ts index 1ce886e..ade4767 100644 --- a/negodata/front/src/api/generated/auth/auth.ts +++ b/negodata/front/src/api/generated/auth/auth.ts @@ -25,9 +25,8 @@ import type { import type { HTTPValidationError, - ReqCreateAccount, ReqLogin, - ResCreateAccount, + ReqUpdateMe, ResLogin, ResMe, ResRefreshToken @@ -106,71 +105,6 @@ export const useLogin = ,signal?: AbortSignal -) => { - - - return customFetch( - {url: `/v1/auth/create`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreateAccount, signal - }, - options); - } - - - -export const getCreateAccountMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter} -): UseMutationOptions>, 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>, {data: ReqCreateAccount}> = (props) => { - const {data} = props ?? {}; - - return createAccount(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateAccountMutationResult = NonNullable>> - export type CreateAccountMutationBody = ReqCreateAccount - export type CreateAccountMutationError = void | HTTPValidationError - - /** - * @summary 계정 생성 - */ -export const useCreateAccount = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: ReqCreateAccount}, - TContext - > => { - - const mutationOptions = getCreateAccountMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 */ @@ -326,3 +260,68 @@ export function useMe>, TError = void>( +/** + * 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가). + * @summary 내 정보 수정 + */ +export const updateMe = ( + reqUpdateMe: ReqUpdateMe, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/auth/me`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateMe + }, + options); + } + + + +export const getUpdateMeMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ReqUpdateMe}> = (props) => { + const {data} = props ?? {}; + + return updateMe(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateMeMutationResult = NonNullable>> + export type UpdateMeMutationBody = ReqUpdateMe + export type UpdateMeMutationError = void | HTTPValidationError + + /** + * @summary 내 정보 수정 + */ +export const useUpdateMe = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqUpdateMe}, + TContext + > => { + + const mutationOptions = getUpdateMeMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/company-user/company-user.ts b/negodata/front/src/api/generated/company-user/company-user.ts new file mode 100644 index 0000000..c6af7f6 --- /dev/null +++ b/negodata/front/src/api/generated/company-user/company-user.ts @@ -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 unknown> = Parameters[1]; + + + +/** + * @summary 회사 유저 목록(최고관리자) + */ +export const listUsers = ( + params?: ListUsersParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {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 = >, TError = void | HTTPValidationError>(params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listUsers(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListUsersQueryResult = NonNullable>> +export type ListUsersQueryError = void | HTTPValidationError + + +export function useListUsers>, TError = void | HTTPValidationError>( + params: undefined | ListUsersParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 유저 목록(최고관리자) + */ + +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListUsersQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 유저 생성(일반 권한 고정) + */ +export const createUser = ( + reqCreateCompanyUser: ReqCreateCompanyUser, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/create`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqCreateCompanyUser, signal + }, + options); + } + + + +export const getCreateUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ReqCreateCompanyUser}> = (props) => { + const {data} = props ?? {}; + + return createUser(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type CreateUserMutationResult = NonNullable>> + export type CreateUserMutationBody = ReqCreateCompanyUser + export type CreateUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 생성(일반 권한 고정) + */ +export const useCreateUser = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqCreateCompanyUser}, + TContext + > => { + + const mutationOptions = getCreateUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 회사 유저 조회 + */ +export const getUser = ( + userId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/${userId}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetUserQueryKey = (userId?: string,) => { + return [ + `/v1/company/user/${userId}` + ] as const; + } + + +export const getGetUserQueryOptions = >, TError = void | HTTPValidationError>(userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetUserQueryKey(userId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getUser(userId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(userId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetUserQueryResult = NonNullable>> +export type GetUserQueryError = void | HTTPValidationError + + +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 유저 조회 + */ + +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetUserQueryOptions(userId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 유저 수정 + */ +export const updateUser = ( + userId: string, + reqUpdateCompanyUser: ReqUpdateCompanyUser, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/user/update/${userId}`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateCompanyUser + }, + options); + } + + + +export const getUpdateUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {userId: string;data: ReqUpdateCompanyUser}> = (props) => { + const {userId,data} = props ?? {}; + + return updateUser(userId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateUserMutationResult = NonNullable>> + export type UpdateUserMutationBody = ReqUpdateCompanyUser + export type UpdateUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 수정 + */ +export const useUpdateUser = (options?: { mutation?:UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: string;data: ReqUpdateCompanyUser}, + TContext + > => { + + const mutationOptions = getUpdateUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 회사 유저 삭제 + */ +export const deleteUser = ( + userId: string, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/user/delete/${userId}`, method: 'DELETE' + }, + options); + } + + + +export const getDeleteUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {userId: string}> = (props) => { + const {userId} = props ?? {}; + + return deleteUser(userId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteUserMutationResult = NonNullable>> + + export type DeleteUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 삭제 + */ +export const useDeleteUser = (options?: { mutation?:UseMutationOptions>, TError,{userId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: string}, + TContext + > => { + + const mutationOptions = getDeleteUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/companyUserData.ts b/negodata/front/src/api/generated/model/companyUserData.ts new file mode 100644 index 0000000..2e5e0cd --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserData.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts b/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts new file mode 100644 index 0000000..b0b22b7 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts b/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts new file mode 100644 index 0000000..f404d0c --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserDataEmail.ts b/negodata/front/src/api/generated/model/companyUserDataEmail.ts new file mode 100644 index 0000000..4f7ae11 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataEmail.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts b/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts new file mode 100644 index 0000000..5934ea6 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCreateAccountMsg.ts b/negodata/front/src/api/generated/model/companyUserDataName.ts similarity index 71% rename from negodata/front/src/api/generated/model/resCreateAccountMsg.ts rename to negodata/front/src/api/generated/model/companyUserDataName.ts index 4f87fb8..09040fc 100644 --- a/negodata/front/src/api/generated/model/resCreateAccountMsg.ts +++ b/negodata/front/src/api/generated/model/companyUserDataName.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResCreateAccountMsg = string | null; +export type CompanyUserDataName = string | null; diff --git a/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts b/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts new file mode 100644 index 0000000..cfe5ed5 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqCreateAccount.ts b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts similarity index 74% rename from negodata/front/src/api/generated/model/reqCreateAccount.ts rename to negodata/front/src/api/generated/model/reqCreateCompanyUser.ts index 9fd90fe..795c416 100644 --- a/negodata/front/src/api/generated/model/reqCreateAccount.ts +++ b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts new file mode 100644 index 0000000..04d58ee --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts new file mode 100644 index 0000000..9c9d955 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts new file mode 100644 index 0000000..f1f6ea8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts new file mode 100644 index 0000000..6c35074 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts new file mode 100644 index 0000000..b64bc68 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts new file mode 100644 index 0000000..de4ead2 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCompanyUser.ts b/negodata/front/src/api/generated/model/resCompanyUser.ts new file mode 100644 index 0000000..cf32213 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUser.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/resCompanyUserList.ts b/negodata/front/src/api/generated/model/resCompanyUserList.ts new file mode 100644 index 0000000..da6eecc --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserList.ts @@ -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[]; +} diff --git a/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts b/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts new file mode 100644 index 0000000..54d0fae --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCompanyUserMsg.ts b/negodata/front/src/api/generated/model/resCompanyUserMsg.ts new file mode 100644 index 0000000..031ecba --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserMsg.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCompanyUserUser.ts b/negodata/front/src/api/generated/model/resCompanyUserUser.ts new file mode 100644 index 0000000..f1fdf01 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserUser.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCreateAccount.ts b/negodata/front/src/api/generated/model/resDeleteCompanyUser.ts similarity index 56% rename from negodata/front/src/api/generated/model/resCreateAccount.ts rename to negodata/front/src/api/generated/model/resDeleteCompanyUser.ts index 527ebe5..7a03135 100644 --- a/negodata/front/src/api/generated/model/resCreateAccount.ts +++ b/negodata/front/src/api/generated/model/resDeleteCompanyUser.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts b/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts new file mode 100644 index 0000000..ad9f70c --- /dev/null +++ b/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/userRole.ts b/negodata/front/src/api/generated/model/userRole.ts index 14c2610..860ab73 100644 --- a/negodata/front/src/api/generated/model/userRole.ts +++ b/negodata/front/src/api/generated/model/userRole.ts @@ -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; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 821e90f..0809f23 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -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, + }, ], }, { diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 444902f..b47f3c0 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -9,6 +9,7 @@ const PAGE_TO_PATH: Record = { PARTNERS: '/partners', QUOTATION: '/quotation', CARDS: '/cards', + MEMBERS: '/members', }; export default function AuthenticatedLayout() { diff --git a/negodata/front/src/features/auth/components/ProfileSheet.tsx b/negodata/front/src/features/auth/components/ProfileSheet.tsx new file mode 100644 index 0000000..71f62cf --- /dev/null +++ b/negodata/front/src/features/auth/components/ProfileSheet.tsx @@ -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; + +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({ + 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 ( + +
+ {/* 읽기 전용: 회사 / 로그인ID / 권한 */} +
+
+ 회사 + {user?.company} +
+
+ 로그인 ID + {user?.loginId} +
+
+ 권한등급 + {user?.role} +
+
+ + {/* 이름 */} +
+ 이름 + +
+ + {/* 이메일 */} +
+ 이메일 + + {errors.email &&

{errors.email.message}

} +
+ + {/* 연락처 */} +
+ 연락처 + +
+ + {/* 비밀번호 변경(옵션) */} +
+ 비밀번호 변경 (변경 시에만 입력) + + {errors.password &&

{errors.password.message}

} +
+ + {/* 비밀번호 확인 */} +
+ 비밀번호 확인 + + {errors.passwordConfirm &&

{errors.passwordConfirm.message}

} +
+ +
+ + +
+
+
+ ); +} diff --git a/negodata/front/src/features/auth/service.ts b/negodata/front/src/features/auth/service.ts index 4245c5c..529c074 100644 --- a/negodata/front/src/features/auth/service.ts +++ b/negodata/front/src/features/auth/service.ts @@ -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(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 { 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 { + let me: ResMe; + try { + me = await customFetch({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 { localStorage.removeItem(ACCESS_KEY); diff --git a/negodata/front/src/features/members/api.ts b/negodata/front/src/features/members/api.ts new file mode 100644 index 0000000..85aa5cb --- /dev/null +++ b/negodata/front/src/features/members/api.ts @@ -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({ + url: '/v1/company/user/list', + method: 'GET', + params: params as Record, + signal, + }); + +export const createCompanyUser = (data: ReqCreateCompanyUser) => + customFetch({ url: '/v1/company/user/create', method: 'POST', data }); + +export const updateCompanyUser = (userId: string, data: ReqUpdateCompanyUser) => + customFetch({ url: `/v1/company/user/update/${userId}`, method: 'PATCH', data }); + +export const deleteCompanyUser = (userId: string) => + customFetch({ url: `/v1/company/user/delete/${userId}`, method: 'DELETE' }); diff --git a/negodata/front/src/features/members/components/MemberFormSheet.tsx b/negodata/front/src/features/members/components/MemberFormSheet.tsx new file mode 100644 index 0000000..f94182d --- /dev/null +++ b/negodata/front/src/features/members/components/MemberFormSheet.tsx @@ -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; + +type MemberFormSheetProps = { + open: boolean; + mode: 'create' | 'edit'; + member: Member | null; + onCreate: (data: ReqCreateCompanyUser) => Promise; + onUpdate: (userId: string, data: ReqUpdateCompanyUser) => Promise; + 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({ + 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 ( + +
+ {/* 로그인 ID */} +
+ 로그인 ID + {mode === 'create' ? ( + + ) : ( + + )} + {errors.id &&

{errors.id.message}

} +
+ + {/* 비밀번호 */} +
+ + {mode === 'create' ? '비밀번호' : '비밀번호 초기화 (변경 시에만 입력)'} + + + {errors.password &&

{errors.password.message}

} +
+ + {/* 비밀번호 확인 */} +
+ 비밀번호 확인 + + {errors.passwordConfirm &&

{errors.passwordConfirm.message}

} +
+ +
+ {/* 이름 */} +
+ 이름 + +
+ {/* 상태 (edit 전용) */} + {mode === 'edit' && ( +
+ 상태 + ( + + )} + /> +
+ )} +
+ + {/* 이메일 */} +
+ 이메일 + + {errors.email &&

{errors.email.message}

} +
+ + {/* 연락처 */} +
+ 연락처 + +
+ + {/* 버튼 */} +
+ {mode === 'edit' && member && ( + + )} +
+ + +
+
+
+
+ ); +} diff --git a/negodata/front/src/features/members/components/MemberTable.tsx b/negodata/front/src/features/members/components/MemberTable.tsx new file mode 100644 index 0000000..0d9241b --- /dev/null +++ b/negodata/front/src/features/members/components/MemberTable.tsx @@ -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 ( + m.user_id} + onRowClick={onRowClick} + empty="등록된 회사 계정이 없습니다." + footer={ + + } + columns={[ + { + header: '로그인 ID', + align: 'left', + headClassName: 'w-1/5', + cell: (m) => {m.id}, + }, + { + header: '이름', + align: 'left', + cell: (m) => {m.name || '-'}, + }, + { + header: '이메일 / 연락처', + align: 'left', + mobileBlock: true, + cell: (m) => ( +
+
{m.email || '-'}
+
{m.contact_number || '-'}
+
+ ), + }, + { + header: '권한', + align: 'center', + cell: (m) => ( + + {USER_ROLE_LABEL[m.role]} + + ), + }, + { + header: '상태', + align: 'center', + cell: (m) => ( + + {USER_STATUS_LABEL[m.status]} + + ), + }, + { + header: '최근 접속', + align: 'center', + cellClassName: 'font-mono text-[10px] text-muted-foreground', + cell: (m) => fmtDate(m.last_accessed_at), + }, + ]} + /> + ); +} diff --git a/negodata/front/src/features/members/hooks/useMembers.ts b/negodata/front/src/features/members/hooks/useMembers.ts new file mode 100644 index 0000000..0ae444b --- /dev/null +++ b/negodata/front/src/features/members/hooks/useMembers.ts @@ -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(p: Promise): Promise { + 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, + }; +} diff --git a/negodata/front/src/features/members/types.ts b/negodata/front/src/features/members/types.ts new file mode 100644 index 0000000..e668efe --- /dev/null +++ b/negodata/front/src/features/members/types.ts @@ -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.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; +} diff --git a/negodata/front/src/lib/apiError.ts b/negodata/front/src/lib/apiError.ts new file mode 100644 index 0000000..4e820b7 --- /dev/null +++ b/negodata/front/src/lib/apiError.ts @@ -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 = { + // 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 = { + 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; +} diff --git a/negodata/front/src/pages/members.tsx b/negodata/front/src/pages/members.tsx new file mode 100644 index 0000000..bac9412 --- /dev/null +++ b/negodata/front/src/pages/members.tsx @@ -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 ( + + + + 신규 계정 생성 + + } + > + list.setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + placeholder="로그인 ID, 이름 또는 이메일로 검색..." + /> + + + + + {isFormOpen && ( + + )} + + ); +} diff --git a/negodata/front/src/stores/auth.ts b/negodata/front/src/stores/auth.ts index 8bdbe6d..167c5c6 100644 --- a/negodata/front/src/stores/auth.ts +++ b/negodata/front/src/stores/auth.ts @@ -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;