diff options
Diffstat (limited to 'lib/login-session')
| -rw-r--r-- | lib/login-session/service.ts | 118 | ||||
| -rw-r--r-- | lib/login-session/table/login-sessions-table-columns.tsx | 243 | ||||
| -rw-r--r-- | lib/login-session/table/login-sessions-table-toolbar-actions.tsx | 78 | ||||
| -rw-r--r-- | lib/login-session/table/login-sessions-table.tsx | 137 | ||||
| -rw-r--r-- | lib/login-session/validation.ts | 45 |
5 files changed, 621 insertions, 0 deletions
diff --git a/lib/login-session/service.ts b/lib/login-session/service.ts new file mode 100644 index 00000000..4fa35376 --- /dev/null +++ b/lib/login-session/service.ts @@ -0,0 +1,118 @@ +import db from "@/db/db" +import { loginSessions, users } from "@/db/schema" +import { and, or, ilike, eq, desc, asc, count, sql } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns"; +import type { GetLoginSessionsSchema, ExtendedLoginSession } from "./validation" + +export async function getLoginSessions(input: GetLoginSessionsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: loginSessions, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(users.email, s), + ilike(users.name, s), + ilike(loginSessions.authMethod, s), + ilike(loginSessions.ipAddress, s) + ); + } + + // 조건 결합 + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 정렬 처리 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + // 사용자 관련 필드 정렬 + if (item.id === 'userEmail') { + return item.desc ? desc(users.email) : asc(users.email); + } else if (item.id === 'userName') { + return item.desc ? desc(users.name) : asc(users.name); + } else { + // 세션 필드 정렬 + return item.desc + ? desc(loginSessions[item.id as keyof typeof loginSessions.$inferSelect]) + : asc(loginSessions[item.id as keyof typeof loginSessions.$inferSelect]); + } + }) + : [desc(loginSessions.loginAt)]; + + // 데이터 조회 + const data = await db + .select({ + id: loginSessions.id, + userId: loginSessions.userId, + loginAt: loginSessions.loginAt, + logoutAt: loginSessions.logoutAt, + lastActivityAt: loginSessions.lastActivityAt, + ipAddress: loginSessions.ipAddress, + userAgent: loginSessions.userAgent, + authMethod: loginSessions.authMethod, + isActive: loginSessions.isActive, + sessionExpiredAt: loginSessions.sessionExpiredAt, + createdAt: loginSessions.createdAt, + userEmail: users.email, + userName: users.name, + // 세션 지속 시간 계산 (분 단위) + sessionDuration: sql<number>` + CASE + WHEN ${loginSessions.logoutAt} IS NOT NULL THEN + EXTRACT(EPOCH FROM (${loginSessions.logoutAt} - ${loginSessions.loginAt})) / 60 + WHEN ${loginSessions.isActive} = true THEN + EXTRACT(EPOCH FROM (${loginSessions.lastActivityAt} - ${loginSessions.loginAt})) / 60 + ELSE NULL + END + `, + // 현재 활성 여부 + isCurrentlyActive: sql<boolean>` + CASE + WHEN ${loginSessions.isActive} = true + AND (${loginSessions.sessionExpiredAt} IS NULL + OR ${loginSessions.sessionExpiredAt} > NOW()) + THEN true + ELSE false + END + ` + }) + .from(loginSessions) + .innerJoin(users, eq(loginSessions.userId, users.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(loginSessions) + .innerJoin(users, eq(loginSessions.userId, users.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + const pageCount = Math.ceil(total / input.perPage); + + return { data: data as ExtendedLoginSession[], pageCount }; + } catch (err) { + console.error("Failed to fetch login sessions:", err); + return { data: [], pageCount: 0 }; + } +}
\ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table-columns.tsx b/lib/login-session/table/login-sessions-table-columns.tsx new file mode 100644 index 00000000..e3d8bc2f --- /dev/null +++ b/lib/login-session/table/login-sessions-table-columns.tsx @@ -0,0 +1,243 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { formatDate} from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ExtendedLoginSession } from "../validation" +import { Eye, Shield, LogOut, Ellipsis } from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ExtendedLoginSession> | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedLoginSession>[] { + return [ + { + 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" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + id: "사용자 정보", + header: "사용자 정보", + columns: [ + { + accessorKey: "userEmail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="이메일" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-medium">{row.getValue("userEmail")}</span> + <span className="text-xs text-muted-foreground"> + {row.original.userName} + </span> + </div> + ), + }, + ], + }, + { + id: "세션 정보", + header: "세션 정보", + columns: [ + { + accessorKey: "loginAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="로그인 시간" /> + ), + cell: ({ row }) => { + const date = row.getValue("loginAt") as Date + return ( + <Tooltip> + <TooltipTrigger> + <div className="text-sm"> + {formatDate(date, "KR")} + </div> + </TooltipTrigger> + <TooltipContent> + {formatDate(date)} + </TooltipContent> + </Tooltip> + ) + }, + }, + { + accessorKey: "logoutAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="로그아웃 시간" /> + ), + cell: ({ row }) => { + const date = row.getValue("logoutAt") as Date | null + if (!date) { + return <span className="text-muted-foreground">-</span> + } + return ( + <Tooltip> + <TooltipTrigger> + <div className="text-sm"> + {formatDate(date, "KR")} + </div> + </TooltipTrigger> + <TooltipContent> + {formatDate(date)} + </TooltipContent> + </Tooltip> + ) + }, + }, + { + accessorKey: "sessionDuration", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="세션 지속시간" /> + ), + cell: ({ row }) => { + const duration = row.getValue("sessionDuration") as number | null + if (!duration) { + return <span className="text-muted-foreground">-</span> + } + + const hours = Math.floor(duration / 60) + const minutes = Math.floor(duration % 60) + + if (hours > 0) { + return `${hours}시간 ${minutes}분` + } + return `${minutes}분` + }, + }, + ], + }, + { + id: "인증 및 보안", + header: "인증 및 보안", + columns: [ + { + accessorKey: "authMethod", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인증 방식" /> + ), + cell: ({ row }) => { + const authMethod = row.getValue("authMethod") as string + const variants = { + otp: "default", + email: "secondary", + sgips: "outline", + saml: "destructive", + } as const + + return ( + <Badge variant={variants[authMethod as keyof typeof variants] || "default"}> + {authMethod.toUpperCase()} + </Badge> + ) + }, + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="IP 주소" /> + ), + cell: ({ row }) => ( + <code className="text-xs bg-muted px-2 py-1 rounded"> + {row.getValue("ipAddress")} + </code> + ), + }, + { + accessorKey: "isCurrentlyActive", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const isActive = row.getValue("isCurrentlyActive") as boolean + return ( + <Badge variant={isActive ? "default" : "secondary"}> + {isActive ? "활성" : "비활성"} + </Badge> + ) + }, + }, + ], + }, + { + id: "actions", + cell: function Cell({ row }) { + const session = row.original + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "view", row })} + > + <Eye className="mr-2 size-4" aria-hidden="true" /> + 상세 보기 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "viewSecurity", row })} + > + <Shield className="mr-2 size-4" aria-hidden="true" /> + 보안 정보 + </DropdownMenuItem> + {session.isCurrentlyActive && ( + <DropdownMenuItem + onSelect={() => setRowAction({ type: "forceLogout", row })} + className="text-red-600" + > + <LogOut className="mr-2 size-4" aria-hidden="true" /> + 강제 로그아웃 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableSorting: false, + enableHiding: false, + }, + ] +}
\ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table-toolbar-actions.tsx b/lib/login-session/table/login-sessions-table-toolbar-actions.tsx new file mode 100644 index 00000000..36665bc0 --- /dev/null +++ b/lib/login-session/table/login-sessions-table-toolbar-actions.tsx @@ -0,0 +1,78 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import { Download, RotateCcw, Shield } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { ExtendedLoginSession } from "../validation" +import { exportTableToExcel } from "@/lib/export_all" + +interface LoginSessionsTableToolbarActionsProps { + table: Table<ExtendedLoginSession> +} + +export function LoginSessionsTableToolbarActions({ + table, +}: LoginSessionsTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "login-sessions", + excludeColumns: ["select", "actions"], + }) + } + > + <Download className="mr-2 size-4" aria-hidden="true" /> + Export + </Button> + </TooltipTrigger> + <TooltipContent> + <p>로그인 세션 데이터를 엑셀로 내보내기</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => window.location.reload()} + > + <RotateCcw className="mr-2 size-4" aria-hidden="true" /> + 새로고침 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>데이터 새로고침</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + // 보안 리포트 생성 기능 + console.log("Generate security report") + }} + > + <Shield className="mr-2 size-4" aria-hidden="true" /> + 보안 리포트 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>보안 분석 리포트 생성</p> + </TooltipContent> + </Tooltip> + </div> + ) +}
\ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table.tsx b/lib/login-session/table/login-sessions-table.tsx new file mode 100644 index 00000000..43568f41 --- /dev/null +++ b/lib/login-session/table/login-sessions-table.tsx @@ -0,0 +1,137 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +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 { useFeatureFlags } from "@/components/data-table/feature-flags-provider" + +import { getLoginSessions } from "../service" +import { LoginSessionsTableToolbarActions } from "./login-sessions-table-toolbar-actions" +import { getColumns } from "./login-sessions-table-columns" +import { ExtendedLoginSession } from "../validation" + +interface LoginSessionsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getLoginSessions>>, + ] + > +} + +export function LoginSessionsTable({ promises }: LoginSessionsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ExtendedLoginSession> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 기본 필터 필드 + const filterFields: DataTableFilterField<ExtendedLoginSession>[] = [ + { + id: "authMethod", + label: "인증 방식", + options: [ + { label: "OTP", value: "otp" }, + { label: "Email", value: "email" }, + { label: "SGIPS", value: "sgips" }, + { label: "SAML", value: "saml" }, + ], + }, + { + id: "isActive", + label: "세션 상태", + options: [ + { label: "활성", value: "true" }, + { label: "비활성", value: "false" }, + ], + }, + ] + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField<ExtendedLoginSession>[] = [ + { + id: "userEmail", + label: "사용자 이메일", + type: "text", + }, + { + id: "userName", + label: "사용자 이름", + type: "text", + }, + { + id: "authMethod", + label: "인증 방식", + type: "multi-select", + options: [ + { label: "OTP", value: "otp" }, + { label: "Email", value: "email" }, + { label: "SGIPS", value: "sgips" }, + { label: "SAML", value: "saml" }, + ], + }, + { + id: "ipAddress", + label: "IP 주소", + type: "text", + }, + { + id: "isActive", + label: "활성 상태", + type: "boolean", + }, + { + id: "loginAt", + label: "로그인 시간", + type: "date", + }, + { + id: "logoutAt", + label: "로그아웃 시간", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "loginAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <LoginSessionsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/login-session/validation.ts b/lib/login-session/validation.ts new file mode 100644 index 00000000..9c84fb4c --- /dev/null +++ b/lib/login-session/validation.ts @@ -0,0 +1,45 @@ +// app/admin/sessions/login-history/validation.ts +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { loginSessions, users } from "@/db/schema" + + // 조인된 데이터 타입 정의 + export type ExtendedLoginSession = typeof loginSessions.$inferSelect & { + userEmail: string; + userName: string; + sessionDuration?: number; // 계산된 필드 + isCurrentlyActive: boolean; // 계산된 필드 + }; + + // 검색 파라미터 캐시 정의 + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<ExtendedLoginSession>().withDefault([ + { id: "loginAt", desc: true }, + ]), + + // 기본 필터 + userEmail: parseAsString.withDefault(""), + authMethod: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + }); + + // 타입 내보내기 + export type GetLoginSessionsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
\ No newline at end of file |
