diff options
| -rw-r--r-- | app/api/auth/first-auth/route.ts | 46 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 54 | ||||
| -rw-r--r-- | i18n/locales/en/login.json | 7 | ||||
| -rw-r--r-- | i18n/locales/ko/login.json | 7 | ||||
| -rw-r--r-- | lib/admin-users/service.ts | 20 | ||||
| -rw-r--r-- | lib/admin-users/table/add-ausers-dialog.tsx | 50 | ||||
| -rw-r--r-- | lib/admin-users/table/ausers-table.tsx | 5 | ||||
| -rw-r--r-- | lib/admin-users/table/update-auser-sheet.tsx | 56 | ||||
| -rw-r--r-- | lib/admin-users/validations.ts | 33 | ||||
| -rw-r--r-- | lib/users/auth/verifyCredentails.ts | 1 | ||||
| -rw-r--r-- | lib/users/session/helper.ts | 4 | ||||
| -rw-r--r-- | lib/vendor-users/service.ts | 3 |
12 files changed, 238 insertions, 48 deletions
diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts index ff92e71c..e8d86a02 100644 --- a/app/api/auth/first-auth/route.ts +++ b/app/api/auth/first-auth/route.ts @@ -18,6 +18,7 @@ interface FirstAuthResponse { userId?: number email?: string error?: string + errorCode?: string } export async function POST(request: NextRequest): Promise<NextResponse<FirstAuthResponse>> { @@ -63,19 +64,52 @@ export async function POST(request: NextRequest): Promise<NextResponse<FirstAuth const authResult = await authHelpers.performFirstAuth(username, password, provider) if (!authResult.success) { - // 인증 실패 응답 + // 인증 실패 응답 - 세분화된 에러 코드 처리 let errorMessage = '인증에 실패했습니다.' + let errorCode = 'AUTHENTICATION_FAILED' - if (provider === 'sgips') { - errorMessage = 'S-Gips 계정 정보가 올바르지 않습니다.' - } else { - errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.' + // authResult.error에서 세분화된 에러 타입 확인 + if (authResult.error) { + switch (authResult.error) { + case 'INVALID_CREDENTIALS': + errorCode = 'INVALID_CREDENTIALS' + errorMessage = provider === 'sgips' + ? 'S-GIPS 계정 정보를 확인해주세요.' + : '이메일 또는 비밀번호를 확인해주세요.' + break + case 'ACCOUNT_LOCKED': + errorCode = 'ACCOUNT_LOCKED' + errorMessage = '계정이 일시적으로 잠겼습니다. 잠시 후 다시 시도해주세요.' + break + case 'ACCOUNT_DEACTIVATED': + errorCode = 'ACCOUNT_DEACTIVATED' + errorMessage = '비활성화된 계정입니다. 관리자에게 문의해주세요.' + break + case 'RATE_LIMITED': + errorCode = 'RATE_LIMITED' + errorMessage = '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.' + break + case 'VENDOR_NOT_FOUND': + errorCode = 'VENDOR_NOT_FOUND' + errorMessage = '등록되지 않은 벤더입니다. 담당자에게 문의해주세요.' + break + case 'SYSTEM_ERROR': + errorCode = 'SYSTEM_ERROR' + errorMessage = '일시적인 시스템 오류입니다. 계속 문제가 발생하면 담당자에게 문의해주세요.' + break + default: + errorCode = 'AUTHENTICATION_FAILED' + errorMessage = provider === 'sgips' + ? 'S-Gips 인증에 실패했습니다.' + : '인증에 실패했습니다.' + } } return NextResponse.json( { success: false, - error: authResult.error || errorMessage + error: errorMessage, + errorCode: errorCode }, { status: 401 } ) diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index f8ba21d9..7453edb6 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -9,7 +9,7 @@ import { useToast } from "@/hooks/use-toast"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" import { useTranslation } from '@/i18n/client' import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; -import { signIn, getSession } from 'next-auth/react'; +import { signIn } from 'next-auth/react'; import { buttonVariants } from "@/components/ui/button" import Link from "next/link" import Image from 'next/image'; @@ -23,10 +23,7 @@ import { requestPasswordResetAction } from "@/lib/users/auth/partners-auth"; type LoginMethod = 'username' | 'sgips'; -export function LoginForm({ - className, - ...props -}: React.ComponentProps<"div">) { +export function LoginForm() { const params = useParams() || {}; const pathname = usePathname() || ''; const router = useRouter(); @@ -75,6 +72,32 @@ export function LoginForm({ const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); + // 세분화된 에러 메시지 처리 함수 + const getErrorMessage = (error: { errorCode?: string; message?: string }, provider: 'email' | 'sgips') => { + const errorCode = error.errorCode; + + if (!errorCode) { + return error.message || t('authenticationFailed'); + } + + switch (errorCode) { + case 'INVALID_CREDENTIALS': + return provider === 'sgips' ? t('sgipsInvalidCredentials') : t('invalidCredentials'); + case 'ACCOUNT_LOCKED': + return t('accountLocked'); + case 'ACCOUNT_DEACTIVATED': + return t('accountDeactivated'); + case 'RATE_LIMITED': + return t('rateLimited'); + case 'VENDOR_NOT_FOUND': + return t('vendorNotFound'); + case 'SYSTEM_ERROR': + return t('systemError'); + default: + return error.message || t('authenticationFailed'); + } + }; + const goToVendorRegistration = () => { router.push(`/${lng}/partners/repository`); }; @@ -120,7 +143,10 @@ export function LoginForm({ const result = await response.json(); if (!response.ok) { - throw new Error(result.error || t('authenticationFailed')); + // 세분화된 에러 메시지 처리 + const error = new Error(result.error || t('authenticationFailed')) as Error & { errorCode?: string }; + error.errorCode = result.errorCode; + throw error; } return result; @@ -216,7 +242,7 @@ export function LoginForm({ const callbackUrl = new URL(callbackUrlParam); const relativeUrl = callbackUrl.pathname + callbackUrl.search; router.push(relativeUrl); - } catch (e) { + } catch { router.push(callbackUrlParam); } } else { @@ -297,13 +323,10 @@ export function LoginForm({ description: t('sendingCodeToPhone'), }); } - } catch (error: any) { + } catch (error: unknown) { console.error('Username login error:', error); - let errorMessage = t('invalidCredentials'); - if (error.message) { - errorMessage = error.message; - } + const errorMessage = getErrorMessage(error as { errorCode?: string; message?: string }, 'email'); toast({ title: t('errorTitle'), @@ -356,13 +379,10 @@ export function LoginForm({ description: t('sendingCodeToSgipsPhone'), }); } - } catch (error: any) { + } catch (error: unknown) { console.error('S-Gips login error:', error); - let errorMessage = t('sgipsLoginFailed'); - if (error.message) { - errorMessage = error.message; - } + const errorMessage = getErrorMessage(error as { errorCode?: string; message?: string }, 'sgips'); toast({ title: t('errorTitle'), diff --git a/i18n/locales/en/login.json b/i18n/locales/en/login.json index 674279f2..a05913b1 100644 --- a/i18n/locales/en/login.json +++ b/i18n/locales/en/login.json @@ -96,6 +96,13 @@ "mfaAuthError": "An error occurred during MFA authentication.", "resetPasswordTitle": "Set New Password", "resetPasswordDescription": "Please set a strong password for your account security.", + "invalidCredentials": "Please check your email or password.", + "sgipsInvalidCredentials": "Please check your S-GIPS account information.", + "accountLocked": "Account is temporarily locked. Please try again later.", + "accountDeactivated": "Account has been deactivated. Please contact administrator.", + "rateLimited": "Too many login attempts. Please try again later.", + "systemError": "Temporary system error. Please contact support if the problem persists.", + "vendorNotFound": "Vendor not found. Please contact administrator.", "newPassword": "New Password", "newPasswordPlaceholder": "Enter your new password", "confirmPassword": "Confirm Password", diff --git a/i18n/locales/ko/login.json b/i18n/locales/ko/login.json index 9dec5c56..f2247c0e 100644 --- a/i18n/locales/ko/login.json +++ b/i18n/locales/ko/login.json @@ -96,6 +96,13 @@ "mfaAuthError": "MFA 인증 중 오류가 발생했습니다.", "resetPasswordTitle": "새 비밀번호 설정", "resetPasswordDescription": "계정 보안을 위해 강력한 비밀번호를 설정해주세요.", + "invalidCredentials": "이메일 또는 비밀번호를 확인해주세요.", + "sgipsInvalidCredentials": "S-GIPS 계정 정보를 확인해주세요.", + "accountLocked": "계정이 일시적으로 잠겼습니다. 잠시 후 다시 시도해주세요.", + "accountDeactivated": "비활성화된 계정입니다. 관리자에게 문의해주세요.", + "rateLimited": "로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.", + "systemError": "일시적인 시스템 오류입니다. 계속 문제가 발생하면 담당자에게 문의해주세요.", + "vendorNotFound": "등록되지 않은 벤더입니다. 담당자에게 문의해주세요.", "newPassword": "새 비밀번호", "newPasswordPlaceholder": "새 비밀번호를 입력하세요", "confirmPassword": "비밀번호 확인", diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts index 44111bef..b67aef20 100644 --- a/lib/admin-users/service.ts +++ b/lib/admin-users/service.ts @@ -5,8 +5,7 @@ import db from "@/db/db"; import logger from '@/lib/logger'; import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블 -import { type Company } from "@/db/schema/companies"; // User 테이블 -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { asc, desc, ilike, and, or, eq } from "drizzle-orm"; import { headers } from 'next/headers'; // 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정 @@ -99,7 +98,7 @@ export async function getUsers(input: GetUsersSchema) { const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; - } catch (err) { + } catch { return { data: [], pageCount: 0 }; } }, @@ -185,7 +184,7 @@ export async function findUserById(id: number) { // } // } -export async function createAdminUser(input: CreateUserSchema & { language?: string }) { +export async function createAdminUser(input: CreateUserSchema & { language?: string; phone?: string }) { unstable_noStore(); // Next.js 캐싱 방지 try { @@ -252,6 +251,7 @@ export async function createAdminUser(input: CreateUserSchema & { language?: str const [newUser] = await insertUser(tx, { name: input.name, email: input.email, + phone: input.phone, // 전화번호 필드 추가 domain: input.domain, companyId: input.companyId ?? null, // 기타 필요한 필드 추가 @@ -300,7 +300,7 @@ export async function getUserCountGroupByCompany() { return obj; }); return result; - } catch (err) { + } catch { return {}; } }, @@ -335,8 +335,7 @@ export async function getUserCountGroupByRole() { // 여기서 result를 반환해 줘야 함! return result; - } catch (err) { - console.error("getUserCountGroupByRole error:", err); + } catch { return {}; } }, @@ -349,7 +348,7 @@ export async function getUserCountGroupByRole() { /** * 단건 업데이트 */ -export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string }) { +export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string; phone?: string }) { unstable_noStore(); try { @@ -363,6 +362,7 @@ export async function modifiUser(input: UpdateUserSchema & { id: number } & { la name: input.name, companyId: input.companyId, email: input.email, + phone: input.phone, // 전화번호 필드 추가 }); // 2) roles가 함께 왔다면, 기존 roles 삭제 → 새 roles 삽입 @@ -507,7 +507,7 @@ export async function removeUsers(input: { ids: number[] }) { export async function getAllCompanies(): Promise<Vendor[]> { try { return await findAllCompanies(); // Company[] - } catch (err) { + } catch { throw new Error("Failed to get companies"); } } @@ -515,7 +515,7 @@ export async function getAllCompanies(): Promise<Vendor[]> { export async function getAllRoles(): Promise<Role[]> { try { return await findAllRoles(); - } catch (err) { + } catch { throw new Error("Failed to get roles"); } } diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx index 64941965..3b29adcf 100644 --- a/lib/admin-users/table/add-ausers-dialog.tsx +++ b/lib/admin-users/table/add-ausers-dialog.tsx @@ -47,12 +47,31 @@ import { Check, ChevronsUpDown, Loader } from "lucide-react" import { cn } from "@/lib/utils" import { toast } from "sonner" import { Vendor } from "@/db/schema/vendors" +import { FormDescription } from "@/components/ui/form" const languageOptions = [ { value: "ko", label: "한국어" }, { value: "en", label: "English" }, ] +// Phone validation helper +const validatePhoneNumber = (phone: string): boolean => { + if (!phone) return true; // Optional field + // Basic international phone number validation + const cleanPhone = phone.replace(/[\s\-\(\)]/g, ''); + return /^\+\d{3,20}$/.test(cleanPhone); +}; + +// Get phone placeholder +const getPhonePlaceholder = (): string => { + return "+82-10-1234-5678"; +}; + +// Get phone description +const getPhoneDescription = (): string => { + return "국제 전화번호를 입력하세요. (예: +82-10-1234-5678)"; +}; + export function AddUserDialog() { const [open, setOpen] = React.useState(false) @@ -74,11 +93,12 @@ export function AddUserDialog() { }, []) // react-hook-form 세팅 - const form = useForm<CreateUserSchema & { language?: string }>({ + const form = useForm<CreateUserSchema & { language?: string; phone?: string }>({ resolver: zodResolver(createUserSchema), defaultValues: { name: "", email: "", + phone: "", // Add phone field companyId: null, language:'en', // roles는 array<string>, 여기서는 단일 선택 시 [role]로 담음 @@ -89,9 +109,11 @@ export function AddUserDialog() { }) - async function onSubmit(data: CreateUserSchema & { language?: string }) { + async function onSubmit(data: CreateUserSchema & { language?: string; phone?: string }) { data.domain = "partners" + // Phone validation is now handled by zod schema + // 만약 단일 Select로 role을 정했다면, data.roles = ["manager"] 이런 식 startAddTransition(async ()=> { const result = await createAdminUser(data) @@ -171,6 +193,30 @@ export function AddUserDialog() { )} /> + {/* 전화번호 - 새로 추가 */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>Phone Number</FormLabel> + <FormControl> + <Input + placeholder={getPhonePlaceholder()} + {...field} + className={cn( + field.value && !validatePhoneNumber(field.value) && "border-red-500" + )} + /> + </FormControl> + <FormDescription className="text-xs text-muted-foreground"> + {getPhoneDescription()} + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + {/* 회사 선택 (companyId) */} <FormField control={form.control} diff --git a/lib/admin-users/table/ausers-table.tsx b/lib/admin-users/table/ausers-table.tsx index 1e254b5c..98319826 100644 --- a/lib/admin-users/table/ausers-table.tsx +++ b/lib/admin-users/table/ausers-table.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { userRoles , type UserView} from "@/db/schema/users" +import { type UserView} from "@/db/schema/users" import type { DataTableAdvancedFilterField, DataTableFilterField, @@ -12,11 +12,8 @@ import { toSentenceCase } from "@/lib/utils" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" import type { - getUserCountGroupByCompany, - getUserCountGroupByRole, getUsers, getAllCompanies, getAllRoles } from "@/lib//admin-users/service" diff --git a/lib/admin-users/table/update-auser-sheet.tsx b/lib/admin-users/table/update-auser-sheet.tsx index ddf1f932..fbd3a42f 100644 --- a/lib/admin-users/table/update-auser-sheet.tsx +++ b/lib/admin-users/table/update-auser-sheet.tsx @@ -23,6 +23,7 @@ import { FormItem, FormLabel, FormMessage, + FormDescription, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { @@ -31,12 +32,12 @@ import { SelectContent, SelectItem, SelectValue, - SelectGroup, } from "@/components/ui/select" // import your MultiSelect or other role selection import { MultiSelect } from "@/components/ui/multi-select" +import { cn } from "@/lib/utils" -import { userRoles, type UserView } from "@/db/schema/users" +import { type UserView } from "@/db/schema/users" import { updateUserSchema, type UpdateUserSchema } from "@/lib/admin-users/validations" import { modifiUser } from "@/lib/admin-users/service" @@ -50,16 +51,35 @@ const languageOptions = [ { value: "en", label: "English" }, ] +// Phone validation helper +const validatePhoneNumber = (phone: string): boolean => { + if (!phone) return true; // Optional field + // Basic international phone number validation + const cleanPhone = phone.replace(/[\s\-\(\)]/g, ''); + return /^\+\d{3,20}$/.test(cleanPhone); +}; + +// Get phone placeholder +const getPhonePlaceholder = (): string => { + return "+82-10-1234-5678"; +}; + +// Get phone description +const getPhoneDescription = (): string => { + return "국제 전화번호를 입력하세요. (예: +82-10-1234-5678)"; +}; + export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) { const [isUpdatePending, startUpdateTransition] = React.useTransition() // 1) RHF 설정 - const form = useForm<UpdateUserSchema & { language?: string }>({ + const form = useForm<UpdateUserSchema & { language?: string; phone?: string }>({ resolver: zodResolver(updateUserSchema), defaultValues: { name: user?.user_name ?? "", email: user?.user_email ?? "", + phone: user?.user_phone ?? "", // Add phone field companyId: user?.company_id ?? null, roles: user?.roles ?? [], language:'en', @@ -72,15 +92,19 @@ export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) { form.reset({ name: user.user_name, email: user.user_email, + phone: user.user_phone || "", // Add phone field companyId: user.company_id, roles: user.roles, + language: 'en', // You might want to get this from user object }) } }, [user, form]) // 3) onSubmit - async function onSubmit(input: UpdateUserSchema & { language?: string }) { + async function onSubmit(input: UpdateUserSchema & { language?: string; phone?: string }) { + // Phone validation is now handled by zod schema + startUpdateTransition(async () => { if (!user) return @@ -147,6 +171,30 @@ export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) { )} /> + {/* 전화번호 - 새로 추가 */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>Phone Number</FormLabel> + <FormControl> + <Input + placeholder={getPhonePlaceholder()} + {...field} + className={cn( + field.value && !validatePhoneNumber(field.value) && "border-red-500" + )} + /> + </FormControl> + <FormDescription className="text-xs text-muted-foreground"> + {getPhoneDescription()} + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + {/* roles */} <FormField control={form.control} diff --git a/lib/admin-users/validations.ts b/lib/admin-users/validations.ts index 3c2fdb9c..86ff8d20 100644 --- a/lib/admin-users/validations.ts +++ b/lib/admin-users/validations.ts @@ -9,6 +9,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" import { checkEmailExists } from "./service"; +import { fallbackModeToStaticPathsResult } from "next/dist/lib/fallback"; @@ -65,7 +66,21 @@ export const createUserSchema = z.object({ domain: z.enum(users.domain.enumValues), // "evcp" | "partners" companyId: z.number().nullable().optional(), // number | null | undefined roles:z.array(z.string()).min(1, "At least one role must be selected"), - language: z.enum(["ko", "en"]).optional(), + language: z.enum(["ko", "en"]).optional(), + phone: z + .string() + .refine( + (phone) => { + if (!phone) return false; // Optional field + // Remove spaces, hyphens, and parentheses for validation + const cleanPhone = phone.replace(/[\s\-\(\)]/g, ''); + // Basic international phone number validation + return /^\+\d{3,20}$/.test(cleanPhone); + }, + { + message: "올바른 국제 전화번호 형식이 아닙니다. +로 시작하는 3-20자리 번호를 입력해주세요. (예: +82-10-1234-5678)" + } + ), // 전화번호 필드 추가 }); @@ -75,7 +90,21 @@ export const updateUserSchema = z.object({ domain: z.enum(users.domain.enumValues).optional(), companyId: z.number().nullable().optional(), roles: z.array(z.string()).optional(), - language: z.enum(["ko", "en"]).optional(), + language: z.enum(["ko", "en"]).optional(), + phone: z + .string() + .refine( + (phone) => { + if (!phone) return false; // Optional field + // Remove spaces, hyphens, and parentheses for validation + const cleanPhone = phone.replace(/[\s\-\(\)]/g, ''); + // Basic international phone number validation + return /^\+\d{3,20}$/.test(cleanPhone); + }, + { + message: "올바른 국제 전화번호 형식이 아닙니다. +로 시작하는 3-20자리 번호를 입력해주세요. (예: +82-10-1234-5678)" + } + ), // 전화번호 필드 추가 }); export type GetUsersSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index 5cb9c24f..8cb3c434 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -315,6 +315,7 @@ export async function verifyExternalCredentials( // 타이밍 공격 방지를 위해 가짜 해시 연산 await bcrypt.compare(password, '$2a$12$fake.hash.to.prevent.timing.attacks'); await logLoginAttempt(username, null, false, 'INVALID_CREDENTIALS'); + // 보안상 계정 존재 여부와 비밀번호 오류를 구분하지 않습니다 return { success: false, error: 'INVALID_CREDENTIALS' }; } diff --git a/lib/users/session/helper.ts b/lib/users/session/helper.ts index 439ab32d..f99ca80a 100644 --- a/lib/users/session/helper.ts +++ b/lib/users/session/helper.ts @@ -17,7 +17,7 @@ export const authHelpers = { } if (!authResult.success || !authResult.user) { - return { success: false, error: 'Invalid credentials' } + return { success: false, error: authResult.error || 'INVALID_CREDENTIALS' } } // DB에 임시 인증 세션 생성 @@ -45,7 +45,7 @@ export const authHelpers = { } } catch (error) { console.error('First auth error:', error) - return { success: false, error: 'Authentication failed' } + return { success: false, error: 'SYSTEM_ERROR' } } }, diff --git a/lib/vendor-users/service.ts b/lib/vendor-users/service.ts index 428e8b73..c6d07d3f 100644 --- a/lib/vendor-users/service.ts +++ b/lib/vendor-users/service.ts @@ -242,7 +242,7 @@ export async function getUserCountGroupByRoleAndVendor() { /** * 단건 업데이트 */ -export async function modifiVendorUser(input: UpdateVendorUserSchema & { id: number } & { language?: string }) { +export async function modifiVendorUser(input: UpdateVendorUserSchema & { id: number } & { language?: string; phone?: string }) { unstable_noStore(); try { @@ -271,6 +271,7 @@ export async function modifiVendorUser(input: UpdateVendorUserSchema & { id: num const [res] = await updateUser(tx, input.id, { name: input.name, email: input.email, + phone: input.phone, // 전화번호 필드 추가 }); // 2) roles 업데이트 (같은 회사 내에서만) |
