diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
| commit | 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch) | |
| tree | 8a5587f10ca55b162d7e3254cb088b323a34c41b /lib/users | |
initial commit
Diffstat (limited to 'lib/users')
| -rw-r--r-- | lib/users/repository.ts | 128 | ||||
| -rw-r--r-- | lib/users/send-otp.ts | 71 | ||||
| -rw-r--r-- | lib/users/service.ts | 413 | ||||
| -rw-r--r-- | lib/users/table/assign-roles-dialog.tsx | 194 | ||||
| -rw-r--r-- | lib/users/table/users-table-columns.tsx | 154 | ||||
| -rw-r--r-- | lib/users/table/users-table-toolbar-actions.tsx | 61 | ||||
| -rw-r--r-- | lib/users/table/users-table.tsx | 150 | ||||
| -rw-r--r-- | lib/users/verifyOtp.ts | 28 | ||||
| -rw-r--r-- | lib/users/verifyToken.ts | 38 |
9 files changed, 1237 insertions, 0 deletions
diff --git a/lib/users/repository.ts b/lib/users/repository.ts new file mode 100644 index 00000000..78d1668b --- /dev/null +++ b/lib/users/repository.ts @@ -0,0 +1,128 @@ +// lib/users/repository.ts +import db from '@/db/db'; +import { users, otps, type User, Role, roles, userRoles } from '@/db/schema/users'; +import { Otp } from '@/types/user'; +import { eq,and ,asc} from 'drizzle-orm'; + +// 모든 사용자 조회 +export const getAllUsers = async (): Promise<User[]> => { + const usersRes = await db.select().from(users).execute(); + return usersRes +}; + +export async function getRoleAssignedUsers(roleId: number) { + const rows = await db + .select() + .from(userRoles) + .where(eq(userRoles.roleId, roleId)) + return rows.map((r) => r.userId) // [1, 2, 5, ...] +} + +// ID로 사용자 조회 +export const getUserById = async (id: number): Promise<User | null> => { + const usersRes = await db.select().from(users).where(eq(users.id, id)).execute(); + if (usersRes.length === 0) return null; + + const user = usersRes[0]; + return user +}; + +// Email로 사용자 조회 +export const getUserByEmail = async (email: string): Promise<User | null> => { + const usersRes = await db.select().from(users).where(eq(users.email, email)).execute(); + if (usersRes.length === 0) return null; + + const user = usersRes[0]; + return user +}; + + +// 새 사용자 생성 +export const createUser = async (name: string, email: string): Promise<User> => { + const usersRes = await db.insert(users).values({ name, email }).returning(); + const user = usersRes[0]; + return user +}; + +// 사용자 업데이트 +export const updateUser = async (id: number, data: Partial<User>): Promise<User | null> => { + const usersRes = await db.update(users).set(data).where(eq(users.id, id)).returning(); + if (usersRes.length === 0) return null; + const user = usersRes[0]; + return user +}; + +// 사용자 삭제 +export const deleteUser = async (id: number): Promise<boolean> => { + const result = await db.delete(users).where(eq(users.id, id)).execute(); + return (result.rowCount ?? 0) > 0; // null일 경우 0으로 처리 +}; + + +// 새 otp 생성 +export const createOtp = async ( email: string, code:string, createdAt:Date, otpToken:string, otpExpires:Date ): Promise<Otp> => { + const otp = await db.insert(otps).values({ email, code, createdAt, otpToken,otpExpires }).returning(); + return otp[0] +}; + + +export const findOtpByEmail = async (email: string): Promise<Otp | null> => { + const [otpRecord] = await db + .select() + .from(otps) + .where(eq(otps.email, email)) + + return otpRecord ?? null +} + +export const updateOtp = async ( + email: string, + code: string, + createdAt: Date, + otpToken: string, + otpExpires: Date +): Promise<Otp> => { + const rows = await db + .update(otps) + .set({ + code, + createdAt, + otpToken, + otpExpires, + }) + .where(eq(otps.email, email)) + .returning(); + + return rows[0]; +}; + +// Email 및 토큰으로 opt 조회 +export const getOtpByEmailAndToken = async (email: string, token:string): Promise<Otp | null> => { + const opts = await db.select().from(otps).where(eq(otps.email, email)).execute(); + if (opts.length === 0) return null; + + const otp = opts[0]; + return otp +}; + + +export const getOtpByEmailAndCode = async ( + email: string, + code: string +): Promise<Otp | null> => { + + console.log(email, code, "db") + + const [otp] = await db + .select() + .from(otps) + .where( + and(eq(otps.email, email), eq(otps.code, code)) + ); + + return otp ?? null; +}; + +export async function findAllRoles(): Promise<Role[]> { + return db.select().from(roles).where(eq(roles.domain ,'evcp')).orderBy(asc(roles.name)); +}
\ No newline at end of file diff --git a/lib/users/send-otp.ts b/lib/users/send-otp.ts new file mode 100644 index 00000000..c8cfb83d --- /dev/null +++ b/lib/users/send-otp.ts @@ -0,0 +1,71 @@ +"use server"; + +import { headers } from 'next/headers'; +import { sendEmail } from '@/lib/mail/sendEmail'; +import jwt from 'jsonwebtoken'; +import { findUserByEmail, addNewOtp } from '@/lib/users/service'; + + +export async function sendOtpAction(email: string, lng: string) { + // Next.js의 headers() API로 헤더 정보를 얻을 수 있습니다. + const headersList = await headers(); + + // 호스트 정보 (request.nextUrl.host 대체) + const host = headersList.get('host') || 'localhost:3000'; + + // 사용자 조회 + const user = await findUserByEmail(email); + + if (!user) { + // 서버 액션에서 에러 던지면, 클라이언트 컴포넌트에서 try-catch로 잡을 수 있습니다. + throw new Error('User does not exist'); + } + + // OTP 및 만료 시간 생성 + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료 + const token = jwt.sign( + { + email, + otp, + exp: Math.floor(expires.getTime() / 1000), + }, + process.env.JWT_SECRET! + ); + + // DB에 OTP 추가 + await addNewOtp(email, otp, new Date(), token, expires); + + // 이메일에서 사용할 URL 구성 + const verificationUrl = `http://${host}/ko/login?token=${token}`; + + // IP 정보로부터 지역 조회 (ip-api 사용) + const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || ''; + let location = ''; + try { + const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`); + const data = await response.json(); + location = data.city && data.country ? `${data.city}, ${data.country}` : ''; + } catch (error) { + // 위치 조회 실패 시 무시 + } + + // OTP 이메일 발송 + await sendEmail({ + to: email, + subject: `${otp} - SHI eVCP Sign-in Verification`, + template: 'otp', + context: { + name: user.name, + otp, + verificationUrl, + location, + language: lng, + }, + }); + + // 클라이언트로 반환할 수 있는 값 + return { + success: true, + }; +}
\ No newline at end of file diff --git a/lib/users/service.ts b/lib/users/service.ts new file mode 100644 index 00000000..ae97beed --- /dev/null +++ b/lib/users/service.ts @@ -0,0 +1,413 @@ +// lib/users/service.ts +"use server"; + +import { Otp } from '@/types/user'; +import { getAllUsers, createUser, getUserById, updateUser, deleteUser, getUserByEmail, createOtp,getOtpByEmailAndToken, updateOtp, findOtpByEmail ,getOtpByEmailAndCode, findAllRoles, getRoleAssignedUsers} from './repository'; +import logger from '@/lib/logger'; +import { Role, userRoles, users, userView, type User } from '@/db/schema/users'; +import { saveDocument } from '../storage'; +import { GetUsersSchema } from '../admin-users/validations'; +import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; +import { filterColumns } from '../filter-columns'; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; +import db from "@/db/db"; +import { getErrorMessage } from "@/lib/handle-error"; + +interface AssignUsersArgs { + roleId: number + userIds: number[] +} + + +export const fetchAllUsers = async (): Promise<User[]> => { + try { + logger.info('Fetching all users'); + const users = await getAllUsers(); + logger.debug({ count: users.length }, 'Fetched users successfully'); + return users; + } catch (error) { + logger.error({ error }, 'Error fetching all users'); + throw new Error('Failed to fetch users'); + } +}; + + +export const fetchRoleAssignedUserID = async (roleId: number) => { + try { + logger.info('Fetching all users'); + const users = await getRoleAssignedUsers(roleId); + logger.debug({ count: users.length }, 'Fetched users successfully'); + return users; + } catch (error) { + logger.error({ error }, 'Error fetching all users'); + throw new Error('Failed to fetch users'); + } +}; + + +export const addNewUser = async (name: string, email: string): Promise<User> => { + try { + logger.info({ name, email }, 'Creating a new user'); + const user = await createUser(name, email); + logger.debug({ user }, 'User created successfully'); + return user; + } catch (error) { + logger.error({ error }, 'Error creating a new user'); + throw new Error('Failed to create user'); + } +}; + +export const findUserById = async (id: number): Promise<User | null> => { + try { + logger.info({ id }, 'Fetching user by ID'); + const user = await getUserById(id); + if (!user) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +export const findUserByEmail = async (email: string): Promise<User | null> => { + try { + logger.info({ email }, 'Fetching user by Email'); + const user = await getUserByEmail(email); + if (!user) { + logger.warn({ email }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +export const modifyUser = async (id: number, data: Partial<User>): Promise<User | null> => { + try { + logger.info({ id, data }, 'Updating user'); + const user = await updateUser(id, data); + if (!user) { + logger.warn({ id }, 'User not found for update'); + } else { + logger.debug({ user }, 'User updated successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error updating user'); + throw new Error('Failed to update user'); + } +}; + +export const removeUser = async (id: number): Promise<boolean> => { + try { + logger.info({ id }, 'Deleting user'); + const success = await deleteUser(id); + if (success) { + logger.debug({ id }, 'User deleted successfully'); + } else { + logger.warn({ id }, 'User not found for deletion'); + } + return success; + } catch (error) { + logger.error({ error }, 'Error deleting user'); + throw new Error('Failed to delete user'); + } +}; + +export const addNewOtp = async ( + email: string, + code: string, + createdAt: Date, + otpToken: string, + otpExpires: Date +): Promise<Otp> => { + try { + logger.info({ email }, 'Creating or updating an OTP record'); + + // 1) 먼저 email로 Otp가 있는지 조회 + const existingOtp = await findOtpByEmail(email); + + // 2) 이미 있으면 update + if (existingOtp) { + const otp = await updateOtp(email, code, createdAt, otpToken, otpExpires); + logger.debug({ otp }, 'OTP updated successfully'); + return otp; + } + // 3) 없으면 새로 생성 + else { + const otp = await createOtp(email, code, createdAt, otpToken, otpExpires); + logger.debug({ otp }, 'OTP created successfully'); + return otp; + } + } catch (error) { + logger.error({ error }, 'Error creating or updating OTP'); + throw new Error('Failed to create or update OTP'); + } +}; + +export const findOtpByEmailandToken = async (email: string, otpToken: string): Promise<Otp | null> => { + try { + logger.info({ email }, 'Fetching otp by Email'); + const otp = await getOtpByEmailAndToken(email, otpToken); + if (!otp) { + logger.warn({ email }, 'Otp not found'); + } else { + logger.debug({ otp }, 'Otp fetched successfully'); + } + return otp; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + + +export async function findEmailandOtp(email: string, code: string) { + try { + // 1) otp 조회 + const otpRecord: Otp | null = await getOtpByEmailAndCode(email, code) + if (!otpRecord) { + return null + } + + // 2) 사용자 정보 추가로 조회 + const userRecord: User | null = await getUserByEmail(email) + if (!userRecord) { + return null + } + + // 3) 필요한 형태로 "통합된 객체"를 반환 + return { + otpExpires: otpRecord.otpExpires, + email: userRecord.email, + name: userRecord.name, // DB 에서 가져온 실제 이름 + id: userRecord.id, // user id + imageUrl:userRecord.imageUrl, + companyId:userRecord.companyId, + domain:userRecord.domain + // 기타 필요한 필드... + } + + } catch (error) { + // 에러 처리 + throw new Error('Failed to fetch user & otp') + } +} + +export async function updateUserProfileImage(formData: FormData) { + // 1) FormData에서 데이터 꺼내기 + const file = formData.get("file") as File | null + const userId = Number(formData.get("userId")) + const name = formData.get("name") as string + const email = formData.get("email") as string + + // 2) 기본적인 유효성 검증 + if (!file) { + throw new Error("No file found in the FormData.") + } + if (!userId) { + throw new Error("userId is required.") + } + + try { + // 3) 파일 저장 (해시 생성) + const directory = './public/profiles' + const { hashedFileName } = await saveDocument(file, directory) + + // 4) DB 업데이트 + const imageUrl = hashedFileName + const data = { name, email, imageUrl } + const user = await updateUser(userId, data) + if (!user) { + // updateUser가 null을 반환하면, DB 업데이트 실패 혹은 해당 유저가 없음 + throw new Error(`User with id=${userId} not found or update failed.`) + } + + // 5) 성공 시 성공 정보 반환 + return { success: true, user } + } catch (err: any) { + // DB 업데이트 중 발생하는 에러나 saveDocument 내부 에러 등을 처리 + console.error("[updateUserProfileImage] Error:", err) + throw new Error(err.message ?? "Failed to update user profile.") + } +} + +export async function getUsersEVCP(input: GetUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain"); + if (!hasDomainFilter) { + domainWhere = eq(userView.user_domain, "evcp"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + +export async function getAllRoles(): Promise<Role[]> { + try { + return await findAllRoles(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} + + +export async function getUsersAll(input: GetUsersSchema, domain: string) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) domainWhere - 무조건 들어가야 하는 domain 조건 + const domainWhere = eq(userView.user_domain, domain); + + // (4) 최종 where + // domainWhere과 advancedWhere, globalWhere를 모두 and로 묶는다. + // (globalWhere가 존재하지 않을 수 있으니, and() 호출 시 undefined를 자동 무시할 수도 있음) + const finalWhere = and(domainWhere, advancedWhere, globalWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + // (6) 캐시 종속성 배열에 domain도 추가 + [JSON.stringify(input), domain], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + + +export async function assignUsersToRole(roleId: number, userIds: number[]) { + unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) + try{ + await db.transaction(async (tx) => { + // 1) 기존 userRoles 레코드 삭제 + await tx.delete(userRoles).where(eq(userRoles.roleId, roleId)) + + // 2) 새로 넣기 + if (userIds.length > 0) { + await tx.insert(userRoles).values( + userIds.map((uid) => ({ userId: uid, roleId })) + ) + } + }) + revalidateTag("users"); + revalidateTag("roles"); + + return { data: null, error: null }; + } catch (err){ + return { data: null, error: getErrorMessage(err) }; + + } + +} diff --git a/lib/users/table/assign-roles-dialog.tsx b/lib/users/table/assign-roles-dialog.tsx new file mode 100644 index 00000000..003f6500 --- /dev/null +++ b/lib/users/table/assign-roles-dialog.tsx @@ -0,0 +1,194 @@ +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Check, ChevronsUpDown, Loader, UserRoundPlus } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { Textarea } from "@/components/ui/textarea" +import { Company } from "@/db/schema/companies" +import { getAllCompanies } from "@/lib/admin-users/service" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { assignRolesToUsers, getAllRoleView } from "@/lib/roles/services" +import { RoleView } from "@/db/schema/users" +import { type UserView } from "@/db/schema/users" +import { type Row } from "@tanstack/react-table" +import { createRoleAssignmentSchema, CreateRoleAssignmentSchema, createRoleSchema, CreateRoleSchema } from "@/lib/roles/validations" +import { MultiSelect } from "@/components/ui/multi-select" + +interface AssignRoleDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + users: Row<UserView>["original"][] + +} + + +export function AssignRoleDialog({ users }: AssignRoleDialogProps) { + const [open, setOpen] = React.useState(false) + const [isAddPending, startAddTransition] = React.useTransition() + const [roles, setRoles] = React.useState<RoleView[]>([]) // 회사 목록 + const [loading, setLoading] = React.useState(false) + + const partnersRoles = roles.filter(v => v.domain === "partners") + const evcpRoles = roles.filter(v => v.domain === "evcp") + + + React.useEffect(() => { + getAllRoleView("evcp").then((res) => { + setRoles(res) + }) + }, []) + + + const form = useForm<CreateRoleAssignmentSchema>({ + resolver: zodResolver(createRoleAssignmentSchema), + defaultValues: { + evcpRoles: [], + }, + }) + + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + const evcpUsers = users.filter(v => v.user_domain === "evcp"); + + + async function onSubmit(data: CreateRoleAssignmentSchema) { + console.log(data.evcpRoles.map((v)=>Number(v))) + startAddTransition(async () => { + + + // if(partnerUsers.length>0){ + // const result = await assignRolesToUsers( partnerUsers.map(v=>v.user_id) ,data.partnersRoles) + + // if (result.error) { + // toast.error(`에러: ${result.error}`) + // return + // } + // } + + if (evcpUsers.length > 0) { + const result = await assignRolesToUsers( data.evcpRoles.map((v)=>Number(v)), evcpUsers.map(v => v.user_id)) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + } + + form.reset() + setOpen(false) + toast.success("Role assgined") + }) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <UserRoundPlus className="mr-2 size-4" aria-hidden="true" /> + Assign Role ({users.length}) + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Assign Roles to {evcpUsers.length} Users</DialogTitle> + <DialogDescription> + Role을 Multi-select 하시기 바랍니다. + </DialogDescription> + </DialogHeader> + + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* evcp 롤 선택 */} + {evcpUsers.length > 0 && + <FormField + control={form.control} + name="evcpRoles" + render={({ field }) => ( + <FormItem> + <FormLabel>eVCP Role</FormLabel> + <FormControl> + <MultiSelect + options={evcpRoles.map((role) => ({ value: String(role.id), label: role.name }))} + onValueChange={(values) => { + field.onChange(values); + }} + + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + } + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isAddPending} + > + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || isAddPending} + > + {isAddPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Assgin + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/users/table/users-table-columns.tsx b/lib/users/table/users-table-columns.tsx new file mode 100644 index 00000000..c0eb9520 --- /dev/null +++ b/lib/users/table/users-table-columns.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { userRoles, type UserView } from "@/db/schema/users" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { UserWithCompanyAndRoles } from "@/types/user" +import { getErrorMessage } from "@/lib/handle-error" + +import { modifiUser } from "@/lib/admin-users/service" +import { toast } from "sonner" + +import { euserColumnsConfig } from "@/config/euserColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<UserView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + + + const groupMap: Record<string, ColumnDef<UserView>[]> = {} + + euserColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<UserView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "created_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "roles") { + const roleValues = row.original.roles; + return ( + <div className="flex flex-wrap gap-1"> + {roleValues.map((v) => ( + v === null?"": + <Badge key={v} variant="outline"> + {v} + </Badge> + ))} + </div> + ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<UserView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + ] +}
\ No newline at end of file diff --git a/lib/users/table/users-table-toolbar-actions.tsx b/lib/users/table/users-table-toolbar-actions.tsx new file mode 100644 index 00000000..106953a6 --- /dev/null +++ b/lib/users/table/users-table-toolbar-actions.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + + +import { UserView } from "@/db/schema/users" +import { DeleteUsersDialog } from "@/lib/admin-users/table/delete-ausers-dialog" +import { AssignRoleDialog } from "./assign-roles-dialog" + +interface UsersTableToolbarActionsProps { + table: Table<UserView> +} + +export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <AssignRoleDialog + users={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + /> + ) : null} + + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "roles", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/users/table/users-table.tsx b/lib/users/table/users-table.tsx new file mode 100644 index 00000000..53cb961e --- /dev/null +++ b/lib/users/table/users-table.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { userRoles , type UserView} from "@/db/schema/users" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +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 { + getAllRoles, getUsersEVCP +} from "@/lib//users/service" +import { getColumns } from "./users-table-columns" +import { UsersTableToolbarActions } from "./users-table-toolbar-actions" + + + +interface UsersTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getUsersEVCP>>, + Record<number, number>, + Awaited<ReturnType<typeof getAllRoles>> + ] + > +} +type RoleCounts = Record<string, number> + +export function UserTable({ promises }: UsersTableProps) { + + const [{ data, pageCount }, roleCountsRaw, roles] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<UserView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const roleCounts = roleCountsRaw as RoleCounts + + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<UserView>[] = [ + { + id: "user_email", + label: "Email", + placeholder: "Filter email...", + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<UserView>[] = [ + { + id: "user_name", + label: "User Name", + type: "text", + }, + { + id: "user_email", + label: "Email", + type: "text", + }, + + { + id: "roles", + label: "Roles", + type: "multi-select", + options: roles.map((role) => { + return { + label: toSentenceCase(role.name), + value: role.id, + count: roleCounts[role.id], // 이 값이 undefined인지 확인 + }; + }), + }, + { + id: "created_at", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "created_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.user_id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <UsersTableToolbarActions table={table}/> + </DataTableAdvancedToolbar> + + </DataTable> + + + </> + ) +} diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts new file mode 100644 index 00000000..5de76f90 --- /dev/null +++ b/lib/users/verifyOtp.ts @@ -0,0 +1,28 @@ +// lib/users/verifyOtp.ts +import { findEmailandOtp } from '@/lib/users/service' + +// "email과 code가 맞으면 유저 정보, 아니면 null" 형태로 작성 +export async function verifyOtp(email: string, code: string) { + // DB에서 email과 code가 맞는지, 만료 안됐는지 검증 + const otpRecord = await findEmailandOtp(email, code) + if (!otpRecord) { + return null + } + + // 만료 체크 + if (otpRecord.otpExpires && otpRecord.otpExpires < new Date()) { + return null + } + + // 여기서 otpRecord에 유저 정보가 있다고 가정 + // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등 + // 실제 DB 설계에 맞춰 필드명을 조정하세요. + return { + email: otpRecord.email, + name: otpRecord.name, + id: otpRecord.id, + imageUrl: otpRecord.imageUrl, + companyId: otpRecord.companyId, + domain: otpRecord.domain, + } +}
\ No newline at end of file diff --git a/lib/users/verifyToken.ts b/lib/users/verifyToken.ts new file mode 100644 index 00000000..745a1052 --- /dev/null +++ b/lib/users/verifyToken.ts @@ -0,0 +1,38 @@ +"use server"; + +import jwt from 'jsonwebtoken'; +import { findOtpByEmailandToken } from '@/lib/users/service'; + +export async function verifyTokenAction(token: string) { + if (!token) { + // 토큰이 없으면 바로 false 반환 + return { valid: false }; + } + + try { + // 토큰 검증 + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { email: string; otp: string }; + const { email } = decoded; + + // DB에서 OTP 정보 조회 + const otp = await findOtpByEmailandToken(email, token); + if (!otp) { + // 해당하는 OTP/토큰이 없으면 invalid + return { valid: false }; + } + + // 토큰 동일성 및 만료 확인 + if (otp.otpToken !== token || (otp.otpExpires && otp.otpExpires < new Date())) { + return { valid: false }; + } + + // 여기까지 통과하면 valid + return { + valid: true, + email, + }; + } catch (error) { + // JWT 검증 실패 + return { valid: false }; + } +}
\ No newline at end of file |
