diff --git a/src/main/front/package-lock.json b/src/main/front/package-lock.json index 9f70161..a573458 100644 --- a/src/main/front/package-lock.json +++ b/src/main/front/package-lock.json @@ -13,6 +13,7 @@ "@mui/icons-material": "^6.1.2", "@mui/material": "^6.1.2", "@react-oauth/google": "^0.12.1", + "date-fns": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-infinite-scroller": "^1.2.6", @@ -2271,6 +2272,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", diff --git a/src/main/front/package.json b/src/main/front/package.json index dbe4a31..2af35e2 100644 --- a/src/main/front/package.json +++ b/src/main/front/package.json @@ -15,6 +15,7 @@ "@mui/icons-material": "^6.1.2", "@mui/material": "^6.1.2", "@react-oauth/google": "^0.12.1", + "date-fns": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-infinite-scroller": "^1.2.6", diff --git a/src/main/front/src/admin/Files/NoExtFile.jsx b/src/main/front/src/admin/Files/NoExtFile.jsx new file mode 100644 index 0000000..e69de29 diff --git a/src/main/front/src/admin/Files/index.jsx b/src/main/front/src/admin/Files/index.jsx new file mode 100644 index 0000000..9542382 --- /dev/null +++ b/src/main/front/src/admin/Files/index.jsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import PropTypes from "prop-types"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; +import Box from "@mui/material/Box"; + +function CustomTabPanel(props) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +CustomTabPanel.propTypes = { + children: PropTypes.node, + index: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, +}; + +function a11yProps(index) { + return { + id: `simple-tab-${index}`, + "aria-controls": `simple-tabpanel-${index}`, + }; +} + +export default function AdminFilesComp() { + const [value, setValue] = React.useState(1); + + const handleChange = (event, newValue) => { + setValue(newValue); + }; + + return ( + + + + + + + + + + + + Item One + + + Item Two + + + Item Three + + + ); +} diff --git a/src/main/front/src/components/MainDrawer.jsx b/src/main/front/src/components/MainDrawer.jsx index 0359ff8..e5f44b8 100644 --- a/src/main/front/src/components/MainDrawer.jsx +++ b/src/main/front/src/components/MainDrawer.jsx @@ -17,6 +17,7 @@ import { Badge, BottomNavigation, BottomNavigationAction, + Divider, Paper, Tooltip, } from "@mui/material"; @@ -131,7 +132,7 @@ export default function MainDrawer() { null}> */} - + {MENUS.map((menu, index) => ( ))} + , }, + ...ADMINMENU.map((menu) => ({ + path: `/admin/${menu.id}`, + element: , + })), ]); const theme = createTheme({ diff --git a/src/main/front/src/pages/admin/AdminFeed.jsx b/src/main/front/src/pages/admin/AdminFeed.jsx new file mode 100644 index 0000000..9b76cdb --- /dev/null +++ b/src/main/front/src/pages/admin/AdminFeed.jsx @@ -0,0 +1,43 @@ +import InfiniteScroll from "react-infinite-scroller"; +import AdminPage from "./AdminPage"; +import FeedCard from "../../components/FeedCard"; +import useLoadData from "../../hooks/useLoadData"; +import { Box, Paper } from "@mui/material"; + +function AdminFeed() { + const [allFeeds, hasMore, loadData] = useLoadData(); + + return ( + + ( + + ))} + > + {allFeeds.map((item) => ( + // + + + + + Hello! + + ))} + + + ); +} + +export default AdminFeed; diff --git a/src/main/front/src/pages/admin/AdminFiles.jsx b/src/main/front/src/pages/admin/AdminFiles.jsx new file mode 100644 index 0000000..8c10974 --- /dev/null +++ b/src/main/front/src/pages/admin/AdminFiles.jsx @@ -0,0 +1,16 @@ +import InfiniteScroll from "react-infinite-scroller"; +import AdminPage from "./AdminPage"; +import FeedCard from "../../components/FeedCard"; +import useLoadData from "../../hooks/useLoadData"; +import { Box, Paper } from "@mui/material"; +import AdminFilesComp from "../../admin/Files"; + +function AdminFiles() { + return ( + + + + ); +} + +export default AdminFiles; diff --git a/src/main/front/src/pages/admin/AdminPage.jsx b/src/main/front/src/pages/admin/AdminPage.jsx new file mode 100644 index 0000000..755a089 --- /dev/null +++ b/src/main/front/src/pages/admin/AdminPage.jsx @@ -0,0 +1,205 @@ +import * as React from "react"; +import PropTypes from "prop-types"; +import AppBar from "@mui/material/AppBar"; +import Box from "@mui/material/Box"; +import CssBaseline from "@mui/material/CssBaseline"; +import Divider from "@mui/material/Divider"; +import Drawer from "@mui/material/Drawer"; +import IconButton from "@mui/material/IconButton"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import MenuIcon from "@mui/icons-material/Menu"; +import Toolbar from "@mui/material/Toolbar"; +import Typography from "@mui/material/Typography"; +import ExitToAppIcon from "@mui/icons-material/ExitToApp"; + +import { ADMINMENU } from "."; +import { Link, useLocation } from "react-router-dom"; +import { Button, Tooltip } from "@mui/material"; + +const drawerWidth = 220; + +function AdminPage(props) { + const { window } = props; + const [mobileOpen, setMobileOpen] = React.useState(false); + const [isClosing, setIsClosing] = React.useState(false); + + const location = useLocation(); + const currentMenuIndex = ADMINMENU.findIndex( + (menu) => menu.id === location.pathname.split("/").reverse()[0] + ); + console.log(location.pathname, currentMenuIndex); + + const handleDrawerClose = () => { + setIsClosing(true); + setMobileOpen(false); + }; + + const handleDrawerTransitionEnd = () => { + setIsClosing(false); + }; + + const handleDrawerToggle = () => { + if (!isClosing) { + setMobileOpen(!mobileOpen); + } + }; + + const drawer = ( +
+ + + + {ADMINMENU.map((item, index) => ( + + theme.palette.primary.main, + fontWeight: "bold", + }, + ]} + > + theme.palette.primary.main, + }, + ]} + > + {item.icon} + + + + + ))} + +
+ ); + + // Remove this const when copying and pasting into your project. + const container = + window !== undefined ? () => window().document.body : undefined; + + return ( + + + + + + + + + {currentMenuIndex >= 0 + ? ADMINMENU[currentMenuIndex].title + : props.title} + + + + + + + + + + {/* The implementation can be swapped with js to avoid SEO duplication of links. */} + + {drawer} + + + {drawer} + + + + + {props.children} + + + ); +} + +AdminPage.propTypes = { + /** + * Injected by the documentation to work in an iframe. + * Remove this when copying and pasting into your project. + */ + window: PropTypes.func, +}; + +export default AdminPage; diff --git a/src/main/front/src/pages/admin/UsersTable.jsx b/src/main/front/src/pages/admin/UsersTable.jsx new file mode 100644 index 0000000..d271c34 --- /dev/null +++ b/src/main/front/src/pages/admin/UsersTable.jsx @@ -0,0 +1,306 @@ +import Paper from "@mui/material/Paper"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; +import TableRow from "@mui/material/TableRow"; +import { useEffect, useMemo, useState } from "react"; +import { useFetchBe } from "../../tools/api"; +import { visuallyHidden } from "@mui/utils"; + +import { formatDistanceToNow, parseISO } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Box, + Checkbox, + TableSortLabel, + Tooltip, + Typography, +} from "@mui/material"; +import AdminPage from "./AdminPage"; + +const columns = [ + { + id: "name", + label: "Name", + minWidth: 170, + format: (value, row) => ( +
+ {value} + + {row.id} + +
+ ), + }, + { + id: "createdAt", + label: "Created At", + minWidth: 170, + format: (value) => ( + + + {formatDistanceToNow(parseISO(value), { + addSuffix: true, + locale: ko, + })} + + + ), + }, + { + id: "lastLoginTime", + label: "Last Login", + minWidth: 170, + format: (value) => ( + + + {formatDistanceToNow(parseISO(value), { + addSuffix: true, + locale: ko, + })} + + + ), + }, + { + id: "lastReadTime", + label: "Last Read", + minWidth: 170, + format: (value) => ( + + + {value && + formatDistanceToNow(parseISO(value), { + addSuffix: true, + locale: ko, + })} + + + ), + }, +]; + +function descendingComparator(a, b, orderBy) { + if (!a[orderBy]) return 1; + if (!b[orderBy]) return -1; + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +function getComparator(order, orderBy) { + return order === "desc" + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +function EnhancedTableHead(props) { + const { + onSelectAllClick, + order, + orderBy, + numSelected, + rowCount, + onRequestSort, + } = props; + const createSortHandler = (property) => (event) => { + onRequestSort(event, property); + }; + + return ( + + + {/* + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + */} + {columns.map((headCell) => ( + + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === "desc" ? "sorted descending" : "sorted ascending"} + + ) : null} + + + ))} + + + ); +} + +export default function UsersTable() { + const [allData, setAllData] = useState([]); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const [order, setOrder] = useState("desc"); + const [orderBy, setOrderBy] = useState("lastReadTime"); + const [selected, setSelected] = useState([]); + const [dense, setDense] = useState(false); + + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelected = allData.map((n) => n.id); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleClick = (event, id) => { + const selectedIndex = selected.indexOf(id); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + setSelected(newSelected); + }; + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const handleChangeDense = (event) => { + setDense(event.target.checked); + }; + + // Avoid a layout jump when reaching the last page with empty allData. + const emptyRows = + page > 0 ? Math.max(0, (1 + page) * rowsPerPage - allData.length) : 0; + + const visibleRows = useMemo( + () => + [...allData] + .sort(getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage), + [order, orderBy, page, rowsPerPage, allData] + ); + + const fetchBe = useFetchBe(); + + useEffect(() => { + // Get Admin Info + fetchBe("/admin/users").then((doc) => setAllData(doc)); + }, []); + + return ( + + + + + {/* + + {columns.map((column) => ( + + {column.label} + + ))} + + */} + + + {visibleRows + // .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row) => { + return ( + + {columns.map((column) => { + // console.log(row, column.id); + return ( + + {column.format + ? column.format(row[column.id], row) + : row[column.id]} + + ); + })} + + ); + })} + {emptyRows > 0 && ( + + + + )} + +
+
+ +
+
+ ); +} diff --git a/src/main/front/src/pages/admin/index.jsx b/src/main/front/src/pages/admin/index.jsx new file mode 100644 index 0000000..0099a57 --- /dev/null +++ b/src/main/front/src/pages/admin/index.jsx @@ -0,0 +1,28 @@ +import PeopleIcon from "@mui/icons-material/People"; +import FeedIcon from "@mui/icons-material/Feed"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; + +import UsersTable from "./UsersTable"; +import AdminFeed from "./AdminFeed"; +import AdminFiles from "./AdminFiles"; + +export const ADMINMENU = [ + { + title: "이용자 관리", + icon: , + id: "users", + comp: UsersTable, + }, + { + title: "게시글 관리", + icon: , + id: "posts", + comp: AdminFeed, + }, + { + title: "파일 관리", + icon: , + id: "files", + comp: AdminFiles, + }, +]; diff --git a/src/main/java/com/thc/realspr/controller/TbadminController.java b/src/main/java/com/thc/realspr/controller/TbadminController.java new file mode 100644 index 0000000..ac9b7ba --- /dev/null +++ b/src/main/java/com/thc/realspr/controller/TbadminController.java @@ -0,0 +1,31 @@ +package com.thc.realspr.controller; + +import com.thc.realspr.dto.TbadminDto; +import com.thc.realspr.service.TbadminService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RequestMapping("/api/admin") +@RestController +public class TbadminController { + private final TbadminService tbadminService; + + public TbadminController(TbadminService tbadminService) { + this.tbadminService = tbadminService; + } + + @GetMapping("/users") + public List adminGetUser(@RequestParam Map param, HttpServletRequest request) { + String reqUserId = request.getAttribute("reqUserId").toString(); + return tbadminService.adminGetUser(reqUserId, param); + } + + @GetMapping("/firebasefilelist") + public List adminGetFirebaseStorageList(@RequestParam Map param, HttpServletRequest request) { + String reqUserId = request.getAttribute("reqUserId").toString(); + return tbadminService.adminGetFirebaseStorageList(reqUserId); + } +} diff --git a/src/main/java/com/thc/realspr/domain/TbUserPerm.java b/src/main/java/com/thc/realspr/domain/TbUserPerm.java new file mode 100644 index 0000000..cf6fee2 --- /dev/null +++ b/src/main/java/com/thc/realspr/domain/TbUserPerm.java @@ -0,0 +1,41 @@ +package com.thc.realspr.domain; + +import com.thc.realspr.id.UserPermId; +import com.thc.realspr.id.UserSubjectId; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@Entity +@EntityListeners(AuditingEntityListener.class) // For Audit +public class TbUserPerm { + @EmbeddedId UserPermId id; + @Setter @Column(nullable = false) private String deleted; // 삭제여부 + + @CreatedDate + private LocalDateTime createdDate; + @LastModifiedDate + private LocalDateTime modifiedDate; + + protected TbUserPerm() { + } + + private TbUserPerm(String userId, String permission) { + this.id = new UserPermId(userId, permission); + } + + public static TbUserPerm of(String userId, String permission) { + return new TbUserPerm(userId, permission); + } + + @PrePersist + public void onPrePersist() { + this.deleted = "N"; + } +} diff --git a/src/main/java/com/thc/realspr/dto/TbadminDto.java b/src/main/java/com/thc/realspr/dto/TbadminDto.java new file mode 100644 index 0000000..e8415a2 --- /dev/null +++ b/src/main/java/com/thc/realspr/dto/TbadminDto.java @@ -0,0 +1,25 @@ +package com.thc.realspr.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +public class TbadminDto { + @Schema + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + public static class UserDetail { + private String id; + private String name; + private LocalDateTime createdAt; + private LocalDateTime lastLoginTime; + private LocalDateTime lastReadTime; + + } +} diff --git a/src/main/java/com/thc/realspr/id/UserPermId.java b/src/main/java/com/thc/realspr/id/UserPermId.java new file mode 100644 index 0000000..f042cfa --- /dev/null +++ b/src/main/java/com/thc/realspr/id/UserPermId.java @@ -0,0 +1,17 @@ +package com.thc.realspr.id; + +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class UserPermId implements Serializable { + private String userId; + private String permission; +} diff --git a/src/main/java/com/thc/realspr/mapper/TbadminMapper.java b/src/main/java/com/thc/realspr/mapper/TbadminMapper.java new file mode 100644 index 0000000..7f63ff7 --- /dev/null +++ b/src/main/java/com/thc/realspr/mapper/TbadminMapper.java @@ -0,0 +1,9 @@ +package com.thc.realspr.mapper; + +import com.thc.realspr.dto.TbadminDto; + +import java.util.List; + +public interface TbadminMapper { + List allUsers(); +} diff --git a/src/main/java/com/thc/realspr/repository/TbUserPermRepository.java b/src/main/java/com/thc/realspr/repository/TbUserPermRepository.java new file mode 100644 index 0000000..4f6490a --- /dev/null +++ b/src/main/java/com/thc/realspr/repository/TbUserPermRepository.java @@ -0,0 +1,16 @@ +package com.thc.realspr.repository; + +import com.thc.realspr.domain.TbUserLike; +import com.thc.realspr.domain.TbUserPerm; +import com.thc.realspr.id.UserPermId; +import com.thc.realspr.id.UserSubjectId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TbUserPermRepository extends JpaRepository { + Optional findById(UserPermId userPermId); +} diff --git a/src/main/java/com/thc/realspr/service/FirebaseService.java b/src/main/java/com/thc/realspr/service/FirebaseService.java index 0d4049c..c613cc8 100644 --- a/src/main/java/com/thc/realspr/service/FirebaseService.java +++ b/src/main/java/com/thc/realspr/service/FirebaseService.java @@ -1,5 +1,6 @@ package com.thc.realspr.service; +import com.google.cloud.storage.Bucket; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -8,6 +9,8 @@ import com.google.cloud.storage.Storage; import com.google.firebase.cloud.StorageClient; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -33,4 +36,23 @@ public CompletableFuture getSignedUrlAsync(String path) { String url = getSignedUrl(path); // Assume this is the existing method to get signed URL return CompletableFuture.completedFuture(url); } + + public List listAllFiles(String folderName) { + Storage storage = StorageClient.getInstance().bucket().getStorage(); + Bucket bucket = StorageClient.getInstance().bucket(); + + List fileList = new ArrayList<>(); +// for (Blob blob : bucket.list().iterateAll()) { + for (Blob blob : bucket.list(Storage.BlobListOption.prefix(folderName + "/")).iterateAll()) { + fileList.add(blob.getName()); + } + + return fileList; + } + + @Async + public CompletableFuture> listAllFilesAsync(String folderName) { + List files = listAllFiles(folderName); + return CompletableFuture.completedFuture(files); + } } diff --git a/src/main/java/com/thc/realspr/service/TbadminService.java b/src/main/java/com/thc/realspr/service/TbadminService.java new file mode 100644 index 0000000..cc6e8ad --- /dev/null +++ b/src/main/java/com/thc/realspr/service/TbadminService.java @@ -0,0 +1,14 @@ +package com.thc.realspr.service; + +import com.thc.realspr.dto.TbadminDto; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +public interface TbadminService { + public List adminGetUser(String userId, Map param); + + public List adminGetFirebaseStorageList(String userId); +} diff --git a/src/main/java/com/thc/realspr/service/impl/TbadminServiceImpl.java b/src/main/java/com/thc/realspr/service/impl/TbadminServiceImpl.java new file mode 100644 index 0000000..460c03c --- /dev/null +++ b/src/main/java/com/thc/realspr/service/impl/TbadminServiceImpl.java @@ -0,0 +1,41 @@ +package com.thc.realspr.service.impl; + +import com.thc.realspr.dto.TbadminDto; +import com.thc.realspr.exception.NoAuthorizationException; +import com.thc.realspr.id.UserPermId; +import com.thc.realspr.mapper.TbadminMapper; +import com.thc.realspr.repository.TbUserPermRepository; +import com.thc.realspr.service.FirebaseService; +import com.thc.realspr.service.TbadminService; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +public class TbadminServiceImpl implements TbadminService { + private final TbadminMapper tbadminMapper; + private final TbUserPermRepository tbUserPermRepository; + private final FirebaseService firebaseService; + + public TbadminServiceImpl(TbadminMapper tbadminMapper, TbUserPermRepository tbUserPermRepository, FirebaseService firebaseService) { + this.tbadminMapper = tbadminMapper; + this.tbUserPermRepository = tbUserPermRepository; + this.firebaseService = firebaseService; + } + + public List adminGetUser(String userId, Map param) { + + if (tbUserPermRepository.findById(new UserPermId(userId, "adminGetUser")).isEmpty()) + throw new NoAuthorizationException("No Admin Permission"); + return tbadminMapper.allUsers(); + } + + public List adminGetFirebaseStorageList(String userId) { + + if (tbUserPermRepository.findById(new UserPermId(userId, "adminFirebaseFiles")).isEmpty()) + throw new NoAuthorizationException("No Admin Permission"); + return firebaseService.listAllFiles("KaFile"); + } + +} diff --git a/src/main/resources/mapper/TbadminMapper.xml b/src/main/resources/mapper/TbadminMapper.xml new file mode 100644 index 0000000..dcdabcd --- /dev/null +++ b/src/main/resources/mapper/TbadminMapper.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file