summaryrefslogtreecommitdiff
path: root/lib/users
diff options
context:
space:
mode:
Diffstat (limited to 'lib/users')
-rw-r--r--lib/users/repository.ts128
-rw-r--r--lib/users/send-otp.ts71
-rw-r--r--lib/users/service.ts413
-rw-r--r--lib/users/table/assign-roles-dialog.tsx194
-rw-r--r--lib/users/table/users-table-columns.tsx154
-rw-r--r--lib/users/table/users-table-toolbar-actions.tsx61
-rw-r--r--lib/users/table/users-table.tsx150
-rw-r--r--lib/users/verifyOtp.ts28
-rw-r--r--lib/users/verifyToken.ts38
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