🔀 Merge pull request '♻️ 按照 Material Design + Reactjs 重构' (#1) from refactor/new-ui into master
Reviewed-on: https://code.smartsheep.studio/Hydrogen/Identity/pulls/1
This commit is contained in:
		| @@ -12,6 +12,7 @@ type Account struct { | |||||||
|  |  | ||||||
| 	Name              string                   `json:"name" gorm:"uniqueIndex"` | 	Name              string                   `json:"name" gorm:"uniqueIndex"` | ||||||
| 	Nick              string                   `json:"nick"` | 	Nick              string                   `json:"nick"` | ||||||
|  | 	Description       string                   `json:"description"` | ||||||
| 	Avatar            string                   `json:"avatar"` | 	Avatar            string                   `json:"avatar"` | ||||||
| 	Profile           AccountProfile           `json:"profile"` | 	Profile           AccountProfile           `json:"profile"` | ||||||
| 	Sessions          []AuthSession            `json:"sessions"` | 	Sessions          []AuthSession            `json:"sessions"` | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ type AccountProfile struct { | |||||||
| 	BaseModel | 	BaseModel | ||||||
|  |  | ||||||
| 	FirstName  string     `json:"first_name"` | 	FirstName  string     `json:"first_name"` | ||||||
| 	MiddleName string     `json:"middle_name"` |  | ||||||
| 	LastName   string     `json:"last_name"` | 	LastName   string     `json:"last_name"` | ||||||
| 	Experience uint64     `json:"experience"` | 	Experience uint64     `json:"experience"` | ||||||
| 	Birthday   *time.Time `json:"birthday"` | 	Birthday   *time.Time `json:"birthday"` | ||||||
|   | |||||||
| @@ -76,11 +76,11 @@ func editUserinfo(c *fiber.Ctx) error { | |||||||
| 	user := c.Locals("principal").(models.Account) | 	user := c.Locals("principal").(models.Account) | ||||||
|  |  | ||||||
| 	var data struct { | 	var data struct { | ||||||
| 		Nick       string    `json:"nick" validate:"required,min=4,max=24"` | 		Nick        string    `json:"nick" validate:"required,min=4,max=24"` | ||||||
| 		FirstName  string    `json:"first_name"` | 		Description string    `json:"description"` | ||||||
| 		MiddleName string    `json:"middle_name"` | 		FirstName   string    `json:"first_name"` | ||||||
| 		LastName   string    `json:"last_name"` | 		LastName    string    `json:"last_name"` | ||||||
| 		Birthday   time.Time `json:"birthday"` | 		Birthday    time.Time `json:"birthday"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := BindAndValidate(c, &data); err != nil { | 	if err := BindAndValidate(c, &data); err != nil { | ||||||
| @@ -96,8 +96,8 @@ func editUserinfo(c *fiber.Ctx) error { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	account.Nick = data.Nick | 	account.Nick = data.Nick | ||||||
|  | 	account.Description = data.Description | ||||||
| 	account.Profile.FirstName = data.FirstName | 	account.Profile.FirstName = data.FirstName | ||||||
| 	account.Profile.MiddleName = data.MiddleName |  | ||||||
| 	account.Profile.LastName = data.LastName | 	account.Profile.LastName = data.LastName | ||||||
| 	account.Profile.Birthday = &data.Birthday | 	account.Profile.Birthday = &data.Birthday | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14,17 +14,21 @@ func getNotifications(c *fiber.Ctx) error { | |||||||
| 	take := c.QueryInt("take", 0) | 	take := c.QueryInt("take", 0) | ||||||
| 	offset := c.QueryInt("offset", 0) | 	offset := c.QueryInt("offset", 0) | ||||||
|  |  | ||||||
|  | 	only_unread := c.QueryBool("only_unread", true) | ||||||
|  |  | ||||||
|  | 	tx := database.C.Where(&models.Notification{RecipientID: user.ID}).Model(&models.Notification{}) | ||||||
|  | 	if only_unread { | ||||||
|  | 		tx = tx.Where("read_at IS NULL") | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	var count int64 | 	var count int64 | ||||||
| 	var notifications []models.Notification | 	var notifications []models.Notification | ||||||
| 	if err := database.C. | 	if err := tx. | ||||||
| 		Where(&models.Notification{RecipientID: user.ID}). |  | ||||||
| 		Model(&models.Notification{}). |  | ||||||
| 		Count(&count).Error; err != nil { | 		Count(&count).Error; err != nil { | ||||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := database.C. | 	if err := tx. | ||||||
| 		Where(&models.Notification{RecipientID: user.ID}). |  | ||||||
| 		Limit(take). | 		Limit(take). | ||||||
| 		Offset(offset). | 		Offset(offset). | ||||||
| 		Order("read_at desc"). | 		Order("read_at desc"). | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| package server | package server | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"code.smartsheep.studio/hydrogen/identity/pkg/view" | 	"code.smartsheep.studio/hydrogen/identity/pkg/views" | ||||||
| 	"github.com/gofiber/fiber/v2" | 	"github.com/gofiber/fiber/v2" | ||||||
| 	"github.com/gofiber/fiber/v2/middleware/cache" | 	"github.com/gofiber/fiber/v2/middleware/cache" | ||||||
| 	"github.com/gofiber/fiber/v2/middleware/cors" | 	"github.com/gofiber/fiber/v2/middleware/cors" | ||||||
| @@ -92,7 +92,7 @@ func NewServer() { | |||||||
| 		Expiration:   24 * time.Hour, | 		Expiration:   24 * time.Hour, | ||||||
| 		CacheControl: true, | 		CacheControl: true, | ||||||
| 	}), filesystem.New(filesystem.Config{ | 	}), filesystem.New(filesystem.Config{ | ||||||
| 		Root:         http.FS(view.FS), | 		Root:         http.FS(views.FS), | ||||||
| 		PathPrefix:   "dist", | 		PathPrefix:   "dist", | ||||||
| 		Index:        "index.html", | 		Index:        "index.html", | ||||||
| 		NotFoundFile: "dist/index.html", | 		NotFoundFile: "dist/index.html", | ||||||
|   | |||||||
										
											Binary file not shown.
										
									
								
							| @@ -1,136 +0,0 @@ | |||||||
| import { |  | ||||||
|   Slide, |  | ||||||
|   Toolbar, |  | ||||||
|   Typography, |  | ||||||
|   AppBar as MuiAppBar, |  | ||||||
|   AppBarProps as MuiAppBarProps, |  | ||||||
|   useScrollTrigger, |  | ||||||
|   IconButton, |  | ||||||
|   styled, |  | ||||||
|   Box, |  | ||||||
|   useMediaQuery, |  | ||||||
| } from "@mui/material"; |  | ||||||
| import { ReactElement, ReactNode, useEffect, useState } from "react"; |  | ||||||
| import { SITE_NAME } from "@/consts"; |  | ||||||
| import { Link } from "react-router-dom"; |  | ||||||
| import NavigationDrawer, { DRAWER_WIDTH, AppNavigationHeader, isMobileQuery } from "@/components/NavigationDrawer"; |  | ||||||
| import MenuIcon from "@mui/icons-material/Menu"; |  | ||||||
|  |  | ||||||
| function HideOnScroll(props: { window?: () => Window; children: ReactElement }) { |  | ||||||
|   const { children, window } = props; |  | ||||||
|   const trigger = useScrollTrigger({ |  | ||||||
|     target: window ? window() : undefined, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <Slide appear={false} direction="down" in={!trigger}> |  | ||||||
|       {children} |  | ||||||
|     </Slide> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| interface AppBarProps extends MuiAppBarProps { |  | ||||||
|   open?: boolean; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const ShellAppBar = styled(MuiAppBar, { |  | ||||||
|   shouldForwardProp: (prop) => prop !== "open", |  | ||||||
| })<AppBarProps>(({ theme, open }) => { |  | ||||||
|   const isMobile = useMediaQuery(isMobileQuery); |  | ||||||
|  |  | ||||||
|   return { |  | ||||||
|     transition: theme.transitions.create(["margin", "width"], { |  | ||||||
|       easing: theme.transitions.easing.sharp, |  | ||||||
|       duration: theme.transitions.duration.leavingScreen, |  | ||||||
|     }), |  | ||||||
|     ...(!isMobile && |  | ||||||
|       open && { |  | ||||||
|         width: `calc(100% - ${DRAWER_WIDTH}px)`, |  | ||||||
|         transition: theme.transitions.create(["margin", "width"], { |  | ||||||
|           easing: theme.transitions.easing.easeOut, |  | ||||||
|           duration: theme.transitions.duration.enteringScreen, |  | ||||||
|         }), |  | ||||||
|         marginRight: DRAWER_WIDTH, |  | ||||||
|       }), |  | ||||||
|   }; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const AppMain = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ |  | ||||||
|   open?: boolean; |  | ||||||
| }>(({ theme, open }) => { |  | ||||||
|   const isMobile = useMediaQuery(isMobileQuery); |  | ||||||
|  |  | ||||||
|   return { |  | ||||||
|     flexGrow: 1, |  | ||||||
|     transition: theme.transitions.create("margin", { |  | ||||||
|       easing: theme.transitions.easing.sharp, |  | ||||||
|       duration: theme.transitions.duration.leavingScreen, |  | ||||||
|     }), |  | ||||||
|     marginRight: -DRAWER_WIDTH, |  | ||||||
|     ...(!isMobile && |  | ||||||
|       open && { |  | ||||||
|         transition: theme.transitions.create("margin", { |  | ||||||
|           easing: theme.transitions.easing.easeOut, |  | ||||||
|           duration: theme.transitions.duration.enteringScreen, |  | ||||||
|         }), |  | ||||||
|         marginRight: 0, |  | ||||||
|       }), |  | ||||||
|     position: "relative", |  | ||||||
|   }; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export default function AppShell({ children }: { children: ReactNode }) { |  | ||||||
|   let documentWindow: Window; |  | ||||||
|  |  | ||||||
|   const isMobile = useMediaQuery(isMobileQuery); |  | ||||||
|   const [open, setOpen] = useState(false); |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     documentWindow = window; |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <> |  | ||||||
|       <HideOnScroll window={() => documentWindow}> |  | ||||||
|         <ShellAppBar open={open} position="fixed"> |  | ||||||
|           <Toolbar sx={{ height: 64 }}> |  | ||||||
|             <IconButton |  | ||||||
|               size="large" |  | ||||||
|               edge="start" |  | ||||||
|               color="inherit" |  | ||||||
|               aria-label="menu" |  | ||||||
|               sx={{ ml: isMobile ? 0.5 : 0, mr: 2 }} |  | ||||||
|             > |  | ||||||
|               <img src="/favicon.svg" alt="Logo" width={32} height={32} /> |  | ||||||
|             </IconButton> |  | ||||||
|  |  | ||||||
|             <Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}> |  | ||||||
|               <Link to="/">{SITE_NAME}</Link> |  | ||||||
|             </Typography> |  | ||||||
|  |  | ||||||
|             <IconButton |  | ||||||
|               size="large" |  | ||||||
|               edge="start" |  | ||||||
|               color="inherit" |  | ||||||
|               aria-label="menu" |  | ||||||
|               onClick={() => setOpen(true)} |  | ||||||
|               sx={{ width: 64, mr: 1, display: !isMobile && open ? "none" : "block" }} |  | ||||||
|             > |  | ||||||
|               <MenuIcon /> |  | ||||||
|             </IconButton> |  | ||||||
|           </Toolbar> |  | ||||||
|         </ShellAppBar> |  | ||||||
|       </HideOnScroll> |  | ||||||
|  |  | ||||||
|       <Box sx={{ display: "flex" }}> |  | ||||||
|         <AppMain open={open}> |  | ||||||
|           <AppNavigationHeader /> |  | ||||||
|  |  | ||||||
|           {children} |  | ||||||
|         </AppMain> |  | ||||||
|  |  | ||||||
|         <NavigationDrawer open={open} onClose={() => setOpen(false)} /> |  | ||||||
|       </Box> |  | ||||||
|     </> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| @@ -1,61 +0,0 @@ | |||||||
| import React from "react"; |  | ||||||
| import ReactDOM from "react-dom/client"; |  | ||||||
| import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; |  | ||||||
| import { CssBaseline, ThemeProvider } from "@mui/material"; |  | ||||||
| import { theme } from "@/theme.ts"; |  | ||||||
|  |  | ||||||
| import "virtual:uno.css"; |  | ||||||
|  |  | ||||||
| import "./index.css"; |  | ||||||
| import "@unocss/reset/tailwind.css"; |  | ||||||
| import "@fontsource/roboto/latin.css"; |  | ||||||
|  |  | ||||||
| import AppShell from "@/components/AppShell.tsx"; |  | ||||||
| import LandingPage from "@/pages/landing.tsx"; |  | ||||||
| import SignUpPage from "@/pages/auth/sign-up.tsx"; |  | ||||||
| import SignInPage from "@/pages/auth/sign-in.tsx"; |  | ||||||
| import ErrorBoundary from "@/error.tsx"; |  | ||||||
| import AppLoader from "@/components/AppLoader.tsx"; |  | ||||||
| import { UserinfoProvider } from "@/stores/userinfo.tsx"; |  | ||||||
| import { WellKnownProvider } from "@/stores/wellKnown.tsx"; |  | ||||||
|  |  | ||||||
| declare const __GARFISH_EXPORTS__: { |  | ||||||
|   provider: Object; |  | ||||||
|   registerProvider?: (provider: any) => void; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| declare global { |  | ||||||
|   interface Window { |  | ||||||
|     __LAUNCHPAD_TARGET__?: string; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const router = createBrowserRouter([ |  | ||||||
|   { |  | ||||||
|     path: "/", |  | ||||||
|     element: <AppShell><Outlet /></AppShell>, |  | ||||||
|     errorElement: <ErrorBoundary />, |  | ||||||
|     children: [ |  | ||||||
|       { path: "/", element: <LandingPage /> } |  | ||||||
|     ] |  | ||||||
|   }, |  | ||||||
|   { path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> }, |  | ||||||
|   { path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> } |  | ||||||
| ]); |  | ||||||
|  |  | ||||||
| const element = ( |  | ||||||
|   <React.StrictMode> |  | ||||||
|     <ThemeProvider theme={theme}> |  | ||||||
|       <WellKnownProvider> |  | ||||||
|         <UserinfoProvider> |  | ||||||
|           <AppLoader> |  | ||||||
|             <CssBaseline /> |  | ||||||
|             <RouterProvider router={router} /> |  | ||||||
|           </AppLoader> |  | ||||||
|         </UserinfoProvider> |  | ||||||
|       </WellKnownProvider> |  | ||||||
|     </ThemeProvider> |  | ||||||
|   </React.StrictMode> |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| ReactDOM.createRoot(document.getElementById("root")!).render(element); |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								pkg/views/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								pkg/views/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| package view | package views | ||||||
| 
 | 
 | ||||||
| import "embed" | import "embed" | ||||||
| 
 | 
 | ||||||
| @@ -14,14 +14,18 @@ | |||||||
|     "@emotion/styled": "^11.11.0", |     "@emotion/styled": "^11.11.0", | ||||||
|     "@fontsource/roboto": "^5.0.8", |     "@fontsource/roboto": "^5.0.8", | ||||||
|     "@mui/icons-material": "^5.15.10", |     "@mui/icons-material": "^5.15.10", | ||||||
|  |     "@mui/lab": "^5.0.0-alpha.166", | ||||||
|     "@mui/material": "^5.15.10", |     "@mui/material": "^5.15.10", | ||||||
|  |     "@mui/x-data-grid": "^6.19.5", | ||||||
|  |     "@mui/x-date-pickers": "^6.19.5", | ||||||
|     "@unocss/reset": "^0.58.5", |     "@unocss/reset": "^0.58.5", | ||||||
|  |     "dayjs": "^1.11.10", | ||||||
|     "localforage": "^1.10.0", |     "localforage": "^1.10.0", | ||||||
|     "match-sorter": "^6.3.4", |     "match-sorter": "^6.3.4", | ||||||
|     "react": "^18.2.0", |     "react": "^18.2.0", | ||||||
|     "react-dom": "^18.2.0", |     "react-dom": "^18.2.0", | ||||||
|     "react-router-dom": "^6.22.1", |     "react-router-dom": "^6.22.1", | ||||||
|     "react-swipeable-views": "^0.14.0", |     "react-transition-group": "^4.4.5", | ||||||
|     "sort-by": "^1.2.0", |     "sort-by": "^1.2.0", | ||||||
|     "universal-cookie": "^7.1.0", |     "universal-cookie": "^7.1.0", | ||||||
|     "use-debounce": "^10.0.0" |     "use-debounce": "^10.0.0" | ||||||
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										95
									
								
								pkg/views/src/components/AppShell.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								pkg/views/src/components/AppShell.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | |||||||
|  | import { | ||||||
|  |   AppBar, | ||||||
|  |   Avatar, | ||||||
|  |   Box, | ||||||
|  |   IconButton, | ||||||
|  |   Slide, | ||||||
|  |   Toolbar, | ||||||
|  |   Typography, | ||||||
|  |   useMediaQuery, | ||||||
|  |   useScrollTrigger | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; | ||||||
|  | import { SITE_NAME } from "@/consts"; | ||||||
|  | import { Link } from "react-router-dom"; | ||||||
|  | import NavigationMenu, { AppNavigationHeader, isMobileQuery } from "@/components/NavigationMenu.tsx"; | ||||||
|  | import AccountCircleIcon from "@mui/icons-material/AccountCircleOutlined"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  |  | ||||||
|  | function HideOnScroll(props: { window?: () => Window; children: ReactElement }) { | ||||||
|  |   const { children, window } = props; | ||||||
|  |   const trigger = useScrollTrigger({ | ||||||
|  |     target: window ? window() : undefined | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Slide appear={false} direction="down" in={!trigger}> | ||||||
|  |       {children} | ||||||
|  |     </Slide> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default function AppShell({ children }: { children: ReactNode }) { | ||||||
|  |   let documentWindow: Window; | ||||||
|  |  | ||||||
|  |   const { userinfo } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const isMobile = useMediaQuery(isMobileQuery); | ||||||
|  |   const [open, setOpen] = useState(false); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     documentWindow = window; | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const container = useRef<HTMLDivElement>(null); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <HideOnScroll window={() => documentWindow}> | ||||||
|  |         <AppBar position="fixed"> | ||||||
|  |           <Toolbar sx={{ height: 64 }}> | ||||||
|  |             <IconButton | ||||||
|  |               size="large" | ||||||
|  |               edge="start" | ||||||
|  |               color="inherit" | ||||||
|  |               aria-label="menu" | ||||||
|  |               sx={{ ml: isMobile ? 0.5 : 0, mr: 2 }} | ||||||
|  |             > | ||||||
|  |               <img src="/favicon.svg" alt="Logo" width={32} height={32} /> | ||||||
|  |             </IconButton> | ||||||
|  |  | ||||||
|  |             <Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}> | ||||||
|  |               <Link to="/">{SITE_NAME}</Link> | ||||||
|  |             </Typography> | ||||||
|  |  | ||||||
|  |             <IconButton | ||||||
|  |               size="large" | ||||||
|  |               edge="start" | ||||||
|  |               color="inherit" | ||||||
|  |               aria-label="menu" | ||||||
|  |               onClick={() => setOpen(true)} | ||||||
|  |               sx={{ mr: 1 }} | ||||||
|  |             > | ||||||
|  |               <Avatar | ||||||
|  |                 sx={{ width: 32, height: 32, bgcolor: "transparent" }} | ||||||
|  |                 ref={container} | ||||||
|  |                 alt={userinfo?.displayName} | ||||||
|  |                 src={`/api/avatar/${userinfo?.data?.avatar}`} | ||||||
|  |               > | ||||||
|  |                 <AccountCircleIcon /> | ||||||
|  |               </Avatar> | ||||||
|  |             </IconButton> | ||||||
|  |           </Toolbar> | ||||||
|  |         </AppBar> | ||||||
|  |       </HideOnScroll> | ||||||
|  |  | ||||||
|  |       <Box component="main"> | ||||||
|  |         <AppNavigationHeader /> | ||||||
|  |  | ||||||
|  |         {children} | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       <NavigationMenu anchorEl={container.current} open={open} onClose={() => setOpen(false)} /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -1,27 +1,15 @@ | |||||||
| import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; | import { Collapse, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled } from "@mui/material"; | ||||||
| import ChevronRightIcon from "@mui/icons-material/ChevronRight"; |  | ||||||
| import { |  | ||||||
|   Box, |  | ||||||
|   Collapse, |  | ||||||
|   Divider, |  | ||||||
|   Drawer, |  | ||||||
|   IconButton, |  | ||||||
|   List, |  | ||||||
|   ListItemButton, |  | ||||||
|   ListItemIcon, |  | ||||||
|   ListItemText, |  | ||||||
|   styled, |  | ||||||
|   useMediaQuery |  | ||||||
| } from "@mui/material"; |  | ||||||
| import { theme } from "@/theme"; | import { theme } from "@/theme"; | ||||||
| import { Fragment, ReactNode, useState } from "react"; | import { Fragment, ReactNode, useState } from "react"; | ||||||
| import HowToRegIcon from "@mui/icons-material/HowToReg"; | import HowToRegIcon from "@mui/icons-material/HowToReg"; | ||||||
| import LoginIcon from "@mui/icons-material/Login"; | import LoginIcon from "@mui/icons-material/Login"; | ||||||
| import FaceIcon from "@mui/icons-material/Face"; | import FaceIcon from "@mui/icons-material/Face"; | ||||||
| import LogoutIcon from "@mui/icons-material/Logout"; | import LogoutIcon from "@mui/icons-material/ExitToApp"; | ||||||
| import ExpandLess from "@mui/icons-material/ExpandLess"; | import ExpandLess from "@mui/icons-material/ExpandLess"; | ||||||
| import ExpandMore from "@mui/icons-material/ExpandMore"; | import ExpandMore from "@mui/icons-material/ExpandMore"; | ||||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  | import { PopoverProps } from "@mui/material/Popover"; | ||||||
|  | import { Link } from "react-router-dom"; | ||||||
| 
 | 
 | ||||||
| export interface NavigationItem { | export interface NavigationItem { | ||||||
|   icon?: ReactNode; |   icon?: ReactNode; | ||||||
| @@ -51,32 +39,30 @@ export function AppNavigationSection({ items, depth }: { items: NavigationItem[] | |||||||
|     } else if (item.children) { |     } else if (item.children) { | ||||||
|       return ( |       return ( | ||||||
|         <Fragment key={idx}> |         <Fragment key={idx}> | ||||||
|           <ListItemButton onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2 }}> |           <MenuItem onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> | ||||||
|             <ListItemIcon>{item.icon}</ListItemIcon> |             <ListItemIcon>{item.icon}</ListItemIcon> | ||||||
|             <ListItemText primary={item.title} /> |             <ListItemText primary={item.title} /> | ||||||
|             {open ? <ExpandLess /> : <ExpandMore />} |             {open ? <ExpandLess /> : <ExpandMore />} | ||||||
|           </ListItemButton> |           </MenuItem> | ||||||
|           <Collapse in={open} timeout="auto" unmountOnExit> |           <Collapse in={open} timeout="auto" unmountOnExit> | ||||||
|             <List component="div" disablePadding> |             <AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} /> | ||||||
|               <AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} /> |  | ||||||
|             </List> |  | ||||||
|           </Collapse> |           </Collapse> | ||||||
|         </Fragment> |         </Fragment> | ||||||
|       ); |       ); | ||||||
|     } else { |     } else { | ||||||
|       return ( |       return ( | ||||||
|         <a key={idx} href={item.link ?? "/"}> |         <Link key={idx} to={item.link ?? "/"}> | ||||||
|           <ListItemButton sx={{ pl: 2 + (depth ?? 0) * 2 }}> |           <MenuItem sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> | ||||||
|             <ListItemIcon>{item.icon}</ListItemIcon> |             <ListItemIcon>{item.icon}</ListItemIcon> | ||||||
|             <ListItemText primary={item.title} /> |             <ListItemText primary={item.title} /> | ||||||
|           </ListItemButton> |           </MenuItem> | ||||||
|         </a> |         </Link> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onClose: () => void }) { | export function AppNavigation() { | ||||||
|   const { checkLoggedIn } = useUserinfo(); |   const { checkLoggedIn } = useUserinfo(); | ||||||
| 
 | 
 | ||||||
|   const nav: NavigationItem[] = [ |   const nav: NavigationItem[] = [ | ||||||
| @@ -94,61 +80,19 @@ export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onC | |||||||
|     ) |     ) | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   return ( |   return <AppNavigationSection items={nav} />; | ||||||
|     <> |  | ||||||
|       <AppNavigationHeader> |  | ||||||
|         {showClose && ( |  | ||||||
|           <IconButton onClick={onClose}> |  | ||||||
|             {theme.direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />} |  | ||||||
|           </IconButton> |  | ||||||
|         )} |  | ||||||
|       </AppNavigationHeader> |  | ||||||
|       <Divider /> |  | ||||||
|       <List> |  | ||||||
|         <AppNavigationSection items={nav} /> |  | ||||||
|       </List> |  | ||||||
|     </> |  | ||||||
|   ); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const isMobileQuery = theme.breakpoints.down("md"); | export const isMobileQuery = theme.breakpoints.down("md"); | ||||||
| 
 | 
 | ||||||
| export default function NavigationDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { | export default function NavigationMenu({ anchorEl, open, onClose }: { | ||||||
|   const isMobile = useMediaQuery(isMobileQuery); |   anchorEl: PopoverProps["anchorEl"]; | ||||||
| 
 |   open: boolean; | ||||||
|   return isMobile ? ( |   onClose: () => void | ||||||
|     <> | }) { | ||||||
|       <Box sx={{ flexShrink: 0, width: DRAWER_WIDTH }} /> |   return ( | ||||||
|       <Drawer |     <Menu anchorEl={anchorEl} open={open} onClose={onClose}> | ||||||
|         keepMounted |       <AppNavigation /> | ||||||
|         anchor="right" |     </Menu> | ||||||
|         variant="temporary" |  | ||||||
|         open={open} |  | ||||||
|         onClose={onClose} |  | ||||||
|         sx={{ |  | ||||||
|           "& .MuiDrawer-paper": { |  | ||||||
|             boxSizing: "border-box", |  | ||||||
|             width: DRAWER_WIDTH |  | ||||||
|           } |  | ||||||
|         }} |  | ||||||
|       > |  | ||||||
|         <AppNavigation onClose={onClose} /> |  | ||||||
|       </Drawer> |  | ||||||
|     </> |  | ||||||
|   ) : ( |  | ||||||
|     <Drawer |  | ||||||
|       variant="persistent" |  | ||||||
|       anchor="right" |  | ||||||
|       open={open} |  | ||||||
|       sx={{ |  | ||||||
|         width: DRAWER_WIDTH, |  | ||||||
|         flexShrink: 0, |  | ||||||
|         "& .MuiDrawer-paper": { |  | ||||||
|           width: DRAWER_WIDTH |  | ||||||
|         } |  | ||||||
|       }} |  | ||||||
|     > |  | ||||||
|       <AppNavigation showClose onClose={onClose} /> |  | ||||||
|     </Drawer> |  | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
							
								
								
									
										97
									
								
								pkg/views/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								pkg/views/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | import React from "react"; | ||||||
|  | import ReactDOM from "react-dom/client"; | ||||||
|  | import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; | ||||||
|  | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||||
|  | import { LocalizationProvider } from "@mui/x-date-pickers"; | ||||||
|  | import { CssBaseline, ThemeProvider } from "@mui/material"; | ||||||
|  | import { theme } from "@/theme.ts"; | ||||||
|  |  | ||||||
|  | import "virtual:uno.css"; | ||||||
|  |  | ||||||
|  | import "./index.css"; | ||||||
|  | import "@unocss/reset/tailwind.css"; | ||||||
|  | import "@fontsource/roboto/latin.css"; | ||||||
|  |  | ||||||
|  | import AppShell from "@/components/AppShell.tsx"; | ||||||
|  | import LandingPage from "@/pages/landing.tsx"; | ||||||
|  | import SignUpPage from "@/pages/auth/sign-up.tsx"; | ||||||
|  | import SignInPage from "@/pages/auth/sign-in.tsx"; | ||||||
|  | import OauthConnectPage from "@/pages/auth/connect.tsx"; | ||||||
|  | import DashboardPage from "@/pages/users/dashboard.tsx"; | ||||||
|  | import ErrorBoundary from "@/error.tsx"; | ||||||
|  | import AppLoader from "@/components/AppLoader.tsx"; | ||||||
|  | import UserLayout from "@/pages/users/layout.tsx"; | ||||||
|  | import NotificationsPage from "@/pages/users/notifications.tsx"; | ||||||
|  | import PersonalizePage from "@/pages/users/personalize.tsx"; | ||||||
|  | import SecurityPage from "@/pages/users/security.tsx"; | ||||||
|  | import { UserinfoProvider } from "@/stores/userinfo.tsx"; | ||||||
|  | import { WellKnownProvider } from "@/stores/wellKnown.tsx"; | ||||||
|  | import AuthLayout from "@/pages/auth/layout.tsx"; | ||||||
|  | import AuthGuard from "@/pages/guard.tsx"; | ||||||
|  |  | ||||||
|  | declare const __GARFISH_EXPORTS__: { | ||||||
|  |   provider: Object; | ||||||
|  |   registerProvider?: (provider: any) => void; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | declare global { | ||||||
|  |   interface Window { | ||||||
|  |     __LAUNCHPAD_TARGET__?: string; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const router = createBrowserRouter([ | ||||||
|  |   { | ||||||
|  |     path: "/", | ||||||
|  |     element: <AppShell><Outlet /></AppShell>, | ||||||
|  |     errorElement: <ErrorBoundary />, | ||||||
|  |     children: [ | ||||||
|  |       { path: "/", element: <LandingPage /> }, | ||||||
|  |       { | ||||||
|  |         path: "/", | ||||||
|  |         element: <AuthGuard />, | ||||||
|  |         children: [ | ||||||
|  |           { | ||||||
|  |             path: "/users", | ||||||
|  |             element: <UserLayout />, | ||||||
|  |             children: [ | ||||||
|  |               { path: "/users", element: <DashboardPage /> }, | ||||||
|  |               { path: "/users/notifications", element: <NotificationsPage /> }, | ||||||
|  |               { path: "/users/personalize", element: <PersonalizePage /> }, | ||||||
|  |               { path: "/users/security", element: <SecurityPage /> } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: "/auth", | ||||||
|  |     element: <AuthLayout />, | ||||||
|  |     errorElement: <ErrorBoundary />, | ||||||
|  |     children: [ | ||||||
|  |       { path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> }, | ||||||
|  |       { path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> }, | ||||||
|  |       { path: "/auth/o/connect", element: <OauthConnectPage />, errorElement: <ErrorBoundary /> } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const element = ( | ||||||
|  |   <React.StrictMode> | ||||||
|  |     <LocalizationProvider dateAdapter={AdapterDayjs}> | ||||||
|  |       <ThemeProvider theme={theme}> | ||||||
|  |         <WellKnownProvider> | ||||||
|  |           <UserinfoProvider> | ||||||
|  |             <AppLoader> | ||||||
|  |               <CssBaseline /> | ||||||
|  |               <RouterProvider router={router} /> | ||||||
|  |             </AppLoader> | ||||||
|  |           </UserinfoProvider> | ||||||
|  |         </WellKnownProvider> | ||||||
|  |       </ThemeProvider> | ||||||
|  |     </LocalizationProvider> | ||||||
|  |   </React.StrictMode> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | ReactDOM.createRoot(document.getElementById("root")!).render(element); | ||||||
							
								
								
									
										182
									
								
								pkg/views/src/pages/auth/connect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								pkg/views/src/pages/auth/connect.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | |||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { | ||||||
|  |   Alert, | ||||||
|  |   Avatar, | ||||||
|  |   Box, | ||||||
|  |   Button, | ||||||
|  |   Card, | ||||||
|  |   CardContent, | ||||||
|  |   Collapse, | ||||||
|  |   Grid, | ||||||
|  |   LinearProgress, | ||||||
|  |   Typography | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { request } from "@/scripts/request.ts"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  | import { useSearchParams } from "react-router-dom"; | ||||||
|  | import OutletIcon from "@mui/icons-material/Outlet"; | ||||||
|  | import WhatshotIcon from "@mui/icons-material/Whatshot"; | ||||||
|  |  | ||||||
|  | export default function OauthConnectPage() { | ||||||
|  |   const { getAtk } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const [panel, setPanel] = useState(0); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|  |   const [client, setClient] = useState<any>(null); | ||||||
|  |  | ||||||
|  |   const [searchParams] = useSearchParams(); | ||||||
|  |  | ||||||
|  |   async function preconnect() { | ||||||
|  |     const res = await request(`/api/auth/o/connect${location.search}`, { | ||||||
|  |       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       const data = await res.json(); | ||||||
|  |  | ||||||
|  |       if (data["session"]) { | ||||||
|  |         setPanel(1); | ||||||
|  |         redirect(data["session"]); | ||||||
|  |       } else { | ||||||
|  |         setClient(data["client"]); | ||||||
|  |         setLoading(false); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     preconnect().then(() => console.log("Fetched metadata")); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   function decline() { | ||||||
|  |     if (window.history.length > 0) { | ||||||
|  |       window.history.back(); | ||||||
|  |     } else { | ||||||
|  |       window.close(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function approve() { | ||||||
|  |     setLoading(true); | ||||||
|  |  | ||||||
|  |     const res = await request("/api/auth/o/connect?" + new URLSearchParams({ | ||||||
|  |       client_id: searchParams.get("client_id") as string, | ||||||
|  |       redirect_uri: encodeURIComponent(searchParams.get("redirect_uri") as string), | ||||||
|  |       response_type: "code", | ||||||
|  |       scope: searchParams.get("scope") as string | ||||||
|  |     }), { | ||||||
|  |       method: "POST", | ||||||
|  |       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |       setLoading(false); | ||||||
|  |     } else { | ||||||
|  |       const data = await res.json(); | ||||||
|  |       setPanel(1); | ||||||
|  |       setTimeout(() => redirect(data["session"]), 1850); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function redirect(session: any) { | ||||||
|  |     const url = `${searchParams.get("redirect_uri")}?code=${session["grant_token"]}&state=${searchParams.get("state")}`; | ||||||
|  |     window.open(url, "_self"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const elements = [ | ||||||
|  |     ( | ||||||
|  |       <> | ||||||
|  |         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||||
|  |           <OutletIcon /> | ||||||
|  |         </Avatar> | ||||||
|  |         <Typography component="h1" variant="h5"> | ||||||
|  |           Sign in to {client?.name} | ||||||
|  |         </Typography> | ||||||
|  |         <Box sx={{ mt: 3, width: "100%" }}> | ||||||
|  |           <Grid container spacing={2}> | ||||||
|  |             <Grid item xs={12}> | ||||||
|  |               <Typography fontWeight="bold">About this app</Typography> | ||||||
|  |               <Typography variant="body2">{client?.description}</Typography> | ||||||
|  |             </Grid> | ||||||
|  |             <Grid item xs={12}> | ||||||
|  |               <Typography fontWeight="bold">Make you trust this app</Typography> | ||||||
|  |               <Typography variant="body2"> | ||||||
|  |                 After you click Approve button, you will share your basic personal information to this application | ||||||
|  |                 developer. Some of them will leak your data. Think twice. | ||||||
|  |               </Typography> | ||||||
|  |             </Grid> | ||||||
|  |             <Grid item xs={12} md={6}> | ||||||
|  |               <Button | ||||||
|  |                 fullWidth | ||||||
|  |                 color="info" | ||||||
|  |                 variant="outlined" | ||||||
|  |                 disabled={loading} | ||||||
|  |                 sx={{ mt: 3 }} | ||||||
|  |                 onClick={() => decline()} | ||||||
|  |               > | ||||||
|  |                 Decline | ||||||
|  |               </Button> | ||||||
|  |             </Grid> | ||||||
|  |             <Grid item xs={12} md={6}> | ||||||
|  |               <Button | ||||||
|  |                 fullWidth | ||||||
|  |                 variant="outlined" | ||||||
|  |                 disabled={loading} | ||||||
|  |                 sx={{ mt: 3 }} | ||||||
|  |                 onClick={() => approve()} | ||||||
|  |               > | ||||||
|  |                 Approve | ||||||
|  |               </Button> | ||||||
|  |             </Grid> | ||||||
|  |           </Grid> | ||||||
|  |         </Box> | ||||||
|  |       </> | ||||||
|  |     ), | ||||||
|  |     ( | ||||||
|  |       <> | ||||||
|  |         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||||
|  |           <WhatshotIcon /> | ||||||
|  |         </Avatar> | ||||||
|  |         <Typography component="h1" variant="h5"> | ||||||
|  |           Authorized | ||||||
|  |         </Typography> | ||||||
|  |         <Box sx={{ mt: 3, width: "100%", textAlign: "center" }}> | ||||||
|  |           <Grid container spacing={2}> | ||||||
|  |             <Grid item xs={12} sx={{ my: 8 }}> | ||||||
|  |               <Typography variant="h6">Now Redirecting...</Typography> | ||||||
|  |               <Typography>Hold on a second, we are going to redirect you to the target.</Typography> | ||||||
|  |             </Grid> | ||||||
|  |           </Grid> | ||||||
|  |         </Box> | ||||||
|  |       </> | ||||||
|  |     ) | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||||
|  |  | ||||||
|  |       <Card variant="outlined"> | ||||||
|  |         <Collapse in={loading}> | ||||||
|  |           <LinearProgress /> | ||||||
|  |         </Collapse> | ||||||
|  |  | ||||||
|  |         <CardContent | ||||||
|  |           style={{ padding: "40px 48px 36px" }} | ||||||
|  |           sx={{ | ||||||
|  |             display: "flex", | ||||||
|  |             flexDirection: "column", | ||||||
|  |             alignItems: "center" | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {elements[panel]} | ||||||
|  |         </CardContent> | ||||||
|  |       </Card> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								pkg/views/src/pages/auth/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								pkg/views/src/pages/auth/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { Box } from "@mui/material"; | ||||||
|  | import { Outlet } from "react-router-dom"; | ||||||
|  |  | ||||||
|  | export default function AuthLayout() { | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> | ||||||
|  |       <Box style={{ width: "100vw", maxWidth: "450px" }}> | ||||||
|  |         <Outlet /> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ) | ||||||
|  | } | ||||||
| @@ -277,51 +277,55 @@ export default function SignInPage() { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> |     <> | ||||||
|       <Box style={{ width: "100vw", maxWidth: "450px" }}> |       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||||
|         {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} |  | ||||||
| 
 | 
 | ||||||
|         <Card variant="outlined"> |       <Collapse in={searchParams.has("redirect_uri")}> | ||||||
|           <Collapse in={loading}> |         <Alert severity="info" sx={{ mb: 2 }}> | ||||||
|             <LinearProgress /> |           You need sign in before take an action. After that, we will take you back to your work. | ||||||
|           </Collapse> |         </Alert> | ||||||
|  |       </Collapse> | ||||||
| 
 | 
 | ||||||
|           <CardContent |       <Card variant="outlined"> | ||||||
|             style={{ padding: "40px 48px 36px" }} |         <Collapse in={loading}> | ||||||
|             sx={{ |           <LinearProgress /> | ||||||
|               display: "flex", |         </Collapse> | ||||||
|               flexDirection: "column", |  | ||||||
|               alignItems: "center" |  | ||||||
|             }} |  | ||||||
|           > |  | ||||||
|             {elements[panel]} |  | ||||||
|           </CardContent> |  | ||||||
| 
 | 
 | ||||||
|           <Collapse in={challenge != null} unmountOnExit> |         <CardContent | ||||||
|             <Box> |           style={{ padding: "40px 48px 36px" }} | ||||||
|               <Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}> |           sx={{ | ||||||
|                 <Typography sx={{ mb: 2 }}> |             display: "flex", | ||||||
|                   Risk <b className="font-mono">{challenge?.risk_level}</b>  |             flexDirection: "column", | ||||||
|                   Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b> |             alignItems: "center" | ||||||
|                 </Typography> |           }} | ||||||
|                 <LinearProgress |         > | ||||||
|                   variant="determinate" |           {elements[panel]} | ||||||
|                   value={challenge?.progress / challenge?.requirements * 100} |         </CardContent> | ||||||
|                   sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }} |  | ||||||
|                 /> |  | ||||||
|               </Paper> |  | ||||||
|             </Box> |  | ||||||
|           </Collapse> |  | ||||||
|         </Card> |  | ||||||
| 
 | 
 | ||||||
|         <Grid container justifyContent="center" sx={{ mt: 2 }}> |         <Collapse in={challenge != null} unmountOnExit> | ||||||
|           <Grid item> |           <Box> | ||||||
|             <Link component={RouterLink} to="/auth/sign-up" variant="body2"> |             <Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}> | ||||||
|               Haven't an account? Sign up! |               <Typography sx={{ mb: 2 }}> | ||||||
|             </Link> |                 Risk <b className="font-mono">{challenge?.risk_level}</b>  | ||||||
|           </Grid> |                 Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b> | ||||||
|  |               </Typography> | ||||||
|  |               <LinearProgress | ||||||
|  |                 variant="determinate" | ||||||
|  |                 value={challenge?.progress / challenge?.requirements * 100} | ||||||
|  |                 sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }} | ||||||
|  |               /> | ||||||
|  |             </Paper> | ||||||
|  |           </Box> | ||||||
|  |         </Collapse> | ||||||
|  |       </Card> | ||||||
|  | 
 | ||||||
|  |       <Grid container justifyContent="center" sx={{ mt: 2 }}> | ||||||
|  |         <Grid item> | ||||||
|  |           <Link component={RouterLink} to="/auth/sign-up" variant="body2"> | ||||||
|  |             Haven't an account? Sign up! | ||||||
|  |           </Link> | ||||||
|         </Grid> |         </Grid> | ||||||
|       </Box> |       </Grid> | ||||||
|     </Box> |     </> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| @@ -166,35 +166,33 @@ export default function SignUpPage() { | |||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> |     <> | ||||||
|       <Box style={{ width: "100vw", maxWidth: "450px" }}> |       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||||
|         {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} |  | ||||||
| 
 | 
 | ||||||
|         <Card variant="outlined"> |       <Card variant="outlined"> | ||||||
|           <Collapse in={loading}> |         <Collapse in={loading}> | ||||||
|             <LinearProgress /> |           <LinearProgress /> | ||||||
|           </Collapse> |         </Collapse> | ||||||
| 
 | 
 | ||||||
|           <CardContent |         <CardContent | ||||||
|             style={{ padding: "40px 48px 36px" }} |           style={{ padding: "40px 48px 36px" }} | ||||||
|             sx={{ |           sx={{ | ||||||
|               display: "flex", |             display: "flex", | ||||||
|               flexDirection: "column", |             flexDirection: "column", | ||||||
|               alignItems: "center" |             alignItems: "center" | ||||||
|             }} |           }} | ||||||
|           > |         > | ||||||
|             {!done ? elements[0] : elements[1]} |           {!done ? elements[0] : elements[1]} | ||||||
|           </CardContent> |         </CardContent> | ||||||
|         </Card> |       </Card> | ||||||
| 
 | 
 | ||||||
|         <Grid container justifyContent="center" sx={{ mt: 2 }}> |       <Grid container justifyContent="center" sx={{ mt: 2 }}> | ||||||
|           <Grid item> |         <Grid item> | ||||||
|             <Link component={RouterLink} to="/auth/sign-in" variant="body2"> |           <Link component={RouterLink} to="/auth/sign-in" variant="body2"> | ||||||
|               Already have an account? Sign in! |             Already have an account? Sign in! | ||||||
|             </Link> |           </Link> | ||||||
|           </Grid> |  | ||||||
|         </Grid> |         </Grid> | ||||||
|       </Box> |       </Grid> | ||||||
|     </Box> |     </> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
							
								
								
									
										29
									
								
								pkg/views/src/pages/guard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								pkg/views/src/pages/guard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | import { useEffect } from "react"; | ||||||
|  | import { Box, CircularProgress } from "@mui/material"; | ||||||
|  | import { Outlet, useLocation, useNavigate } from "react-router-dom"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  |  | ||||||
|  | export default function AuthGuard() { | ||||||
|  |   const { userinfo } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const location = useLocation(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     console.log(userinfo) | ||||||
|  |     if (userinfo?.isReady) { | ||||||
|  |       if (!userinfo?.isLoggedIn) { | ||||||
|  |         const callback = location.pathname + location.search; | ||||||
|  |         navigate({ pathname: "/auth/sign-in", search: `redirect_uri=${callback}` }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [userinfo]); | ||||||
|  |  | ||||||
|  |   return !userinfo?.isReady ? ( | ||||||
|  |     <Box sx={{ pt: 32, display: "flex", justifyContent: "center", alignItems: "center" }}> | ||||||
|  |       <Box> | ||||||
|  |         <CircularProgress /> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ) : <Outlet />; | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								pkg/views/src/pages/users/dashboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								pkg/views/src/pages/users/dashboard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | import { Alert, Box, Card, CardContent, Container, Typography } from "@mui/material"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  |  | ||||||
|  | export default function DashboardPage() { | ||||||
|  |   const { userinfo } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||||
|  |       <Box sx={{ px: 3 }}> | ||||||
|  |         <Typography variant="h5">Welcome, {userinfo?.displayName}</Typography> | ||||||
|  |         <Typography variant="body2">What can I help you today?</Typography> | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       { | ||||||
|  |         !userinfo?.profiles?.confirmed_at && | ||||||
|  |         <Alert severity="warning" sx={{ mt: 3, mx: 1 }}> | ||||||
|  |           Your account haven't confirmed yet. Go to your linked email | ||||||
|  |           inbox and check out our registration confirm email. | ||||||
|  |         </Alert> | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       <Box sx={{ px: 1, mt: 3 }}> | ||||||
|  |         <Typography variant="h6" sx={{ px: 2 }}>Frequently Asked Questions</Typography> | ||||||
|  |  | ||||||
|  |         <Card variant="outlined" sx={{ mt: 1 }}> | ||||||
|  |           <CardContent style={{ padding: "40px" }}> | ||||||
|  |             <Typography>没有人有问题。没有人敢有问题。鲁迅曾经说过:</Typography> | ||||||
|  |             <Typography sx={{ pl: 4 }} fontWeight="bold">解决不了问题,就解决提问题的人。 —— 鲁迅</Typography> | ||||||
|  |             <Typography>所以,我们的客诉率是 0% 哦~</Typography> | ||||||
|  |           </CardContent> | ||||||
|  |         </Card> | ||||||
|  |       </Box> | ||||||
|  |     </Container> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										65
									
								
								pkg/views/src/pages/users/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								pkg/views/src/pages/users/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | |||||||
|  | import { Outlet, useLocation, useNavigate } from "react-router-dom"; | ||||||
|  | import { Box, Tab, Tabs, useMediaQuery } from "@mui/material"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { theme } from "@/theme.ts"; | ||||||
|  | import DashboardIcon from "@mui/icons-material/Dashboard"; | ||||||
|  | import InboxIcon from "@mui/icons-material/Inbox"; | ||||||
|  | import DrawIcon from "@mui/icons-material/Draw"; | ||||||
|  | import SecurityIcon from "@mui/icons-material/Security"; | ||||||
|  |  | ||||||
|  | export default function UserLayout() { | ||||||
|  |   const [focus, setFocus] = useState(0); | ||||||
|  |  | ||||||
|  |   const isMobile = useMediaQuery(theme.breakpoints.down("md")); | ||||||
|  |  | ||||||
|  |   const locations = ["/users", "/users/notifications", "/users/personalize", "/users/security"]; | ||||||
|  |   const tabs = [ | ||||||
|  |     { icon: <DashboardIcon />, label: "Dashboard" }, | ||||||
|  |     { icon: <InboxIcon />, label: "Notifications" }, | ||||||
|  |     { icon: <DrawIcon />, label: "Personalize" }, | ||||||
|  |     { icon: <SecurityIcon />, label: "Security" } | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const location = useLocation(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const idx = locations.indexOf(location.pathname); | ||||||
|  |     setFocus(idx); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   function swap(idx: number) { | ||||||
|  |     navigate(locations[idx]); | ||||||
|  |     setFocus(idx); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", height: "calc(100vh - 64px)" }}> | ||||||
|  |       <Box sx={{ width: isMobile ? "100%" : 280 }}> | ||||||
|  |         <Tabs | ||||||
|  |           orientation={isMobile ? "horizontal" : "vertical"} | ||||||
|  |           variant="scrollable" | ||||||
|  |           value={focus} | ||||||
|  |           onChange={(_, val) => swap(val)} | ||||||
|  |           sx={{ | ||||||
|  |             borderRight: isMobile ? 0 : 1, | ||||||
|  |             borderBottom: isMobile ? 1 : 0, | ||||||
|  |             borderColor: "divider", | ||||||
|  |             height: isMobile ? "fit-content" : "100%", | ||||||
|  |             py: isMobile ? 0 : 1, | ||||||
|  |             px: isMobile ? 1 : 0 | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {tabs.map((tab, idx) => ( | ||||||
|  |             <Tab key={idx} icon={tab.icon} iconPosition={isMobile ? "top" : "start"} label={tab.label} | ||||||
|  |                  sx={{ px: 5, justifyContent: isMobile ? "center" : "left" }} /> | ||||||
|  |           ))} | ||||||
|  |         </Tabs> | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       <Box sx={{ flexGrow: 1 }}> | ||||||
|  |         <Outlet /> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										87
									
								
								pkg/views/src/pages/users/notifications.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								pkg/views/src/pages/users/notifications.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | import { Alert, Box, Collapse, IconButton, LinearProgress, List, ListItem, ListItemText } from "@mui/material"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  | import { request } from "@/scripts/request.ts"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { TransitionGroup } from "react-transition-group"; | ||||||
|  | import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead"; | ||||||
|  |  | ||||||
|  | export default function NotificationsPage() { | ||||||
|  |   const { userinfo, readProfiles, getAtk } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const [loading, setLoading] = useState(true); | ||||||
|  |   const [error, setError] = useState<null | string>(null); | ||||||
|  |  | ||||||
|  |   const [notifications, setNotifications] = useState<any[]>([]); | ||||||
|  |  | ||||||
|  |   async function readNotifications() { | ||||||
|  |     const res = await request(`/api/notifications?take=100`, { | ||||||
|  |       headers: { Authorization: `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       const data = await res.json(); | ||||||
|  |       setNotifications(data["data"]); | ||||||
|  |       setError(null); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function markNotifications(item: any) { | ||||||
|  |     setLoading(true); | ||||||
|  |     const res = await request(`/api/notifications/${item.id}/read`, { | ||||||
|  |       method: "PUT", | ||||||
|  |       headers: { Authorization: `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       readNotifications().then(() => readProfiles()); | ||||||
|  |       setError(null); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     readNotifications().then(() => setLoading(false)); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box> | ||||||
|  |       <Collapse in={loading}> | ||||||
|  |         <LinearProgress color="info" /> | ||||||
|  |       </Collapse> | ||||||
|  |  | ||||||
|  |       <Collapse in={error != null}> | ||||||
|  |         <Alert severity="error" variant="filled" square>{error}</Alert> | ||||||
|  |       </Collapse> | ||||||
|  |  | ||||||
|  |       <Collapse in={userinfo?.data?.notifications?.length <= 0}> | ||||||
|  |         <Alert severity="success" variant="filled" square>You are done! There's no unread notifications for you.</Alert> | ||||||
|  |       </Collapse> | ||||||
|  |  | ||||||
|  |       <List sx={{ width: "100%", bgcolor: "background.paper" }}> | ||||||
|  |         <TransitionGroup> | ||||||
|  |           {notifications.map((item, idx) => ( | ||||||
|  |             <Collapse key={idx} sx={{ px: 5 }}> | ||||||
|  |               <ListItem alignItems="flex-start" secondaryAction={ | ||||||
|  |                 <IconButton | ||||||
|  |                   edge="end" | ||||||
|  |                   aria-label="delete" | ||||||
|  |                   title="Delete" | ||||||
|  |                   onClick={() => markNotifications(item)} | ||||||
|  |                 > | ||||||
|  |                   <MarkEmailReadIcon /> | ||||||
|  |                 </IconButton> | ||||||
|  |               }> | ||||||
|  |                 <ListItemText | ||||||
|  |                   primary={item.subject} | ||||||
|  |                   secondary={item.content} | ||||||
|  |                 /> | ||||||
|  |               </ListItem> | ||||||
|  |             </Collapse> | ||||||
|  |           ))} | ||||||
|  |         </TransitionGroup> | ||||||
|  |       </List> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										250
									
								
								pkg/views/src/pages/users/personalize.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								pkg/views/src/pages/users/personalize.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,250 @@ | |||||||
|  | import { | ||||||
|  |   Alert, | ||||||
|  |   Avatar, | ||||||
|  |   Box, | ||||||
|  |   Button, | ||||||
|  |   Card, | ||||||
|  |   CardContent, | ||||||
|  |   CircularProgress, | ||||||
|  |   Collapse, | ||||||
|  |   Container, | ||||||
|  |   Divider, | ||||||
|  |   Grid, | ||||||
|  |   LinearProgress, | ||||||
|  |   Snackbar, | ||||||
|  |   styled, | ||||||
|  |   TextField, | ||||||
|  |   Typography | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  | import { ChangeEvent, FormEvent, useState } from "react"; | ||||||
|  | import { DatePicker } from "@mui/x-date-pickers"; | ||||||
|  | import { request } from "@/scripts/request.ts"; | ||||||
|  | import SaveIcon from "@mui/icons-material/Save"; | ||||||
|  | import PublishIcon from "@mui/icons-material/Publish"; | ||||||
|  | import NoAccountsIcon from "@mui/icons-material/NoAccounts"; | ||||||
|  | import dayjs from "dayjs"; | ||||||
|  |  | ||||||
|  | const VisuallyHiddenInput = styled("input")({ | ||||||
|  |   clip: "rect(0 0 0 0)", | ||||||
|  |   clipPath: "inset(50%)", | ||||||
|  |   height: 1, | ||||||
|  |   overflow: "hidden", | ||||||
|  |   position: "absolute", | ||||||
|  |   bottom: 0, | ||||||
|  |   left: 0, | ||||||
|  |   whiteSpace: "nowrap", | ||||||
|  |   width: 1 | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default function PersonalizePage() { | ||||||
|  |   const { userinfo, readProfiles, getAtk } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const [done, setDone] = useState(false); | ||||||
|  |   const [error, setError] = useState<any>(null); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|  |   async function submit(evt: FormEvent<HTMLFormElement>) { | ||||||
|  |     evt.preventDefault(); | ||||||
|  |  | ||||||
|  |     const data: any = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||||
|  |     if (data.birthday) data.birthday = new Date(data.birthday); | ||||||
|  |  | ||||||
|  |     setLoading(true); | ||||||
|  |     const res = await request("/api/users/me", { | ||||||
|  |       method: "PUT", | ||||||
|  |       headers: { "Content-Type": "application/json", "Authorization": `Bearer ${getAtk()}` }, | ||||||
|  |       body: JSON.stringify(data) | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       await readProfiles(); | ||||||
|  |       setDone(true); | ||||||
|  |       setError(null); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function changeAvatar(evt: ChangeEvent<HTMLInputElement>) { | ||||||
|  |     if (!evt.target.files) return; | ||||||
|  |  | ||||||
|  |     const file = evt.target.files[0]; | ||||||
|  |     const payload = new FormData(); | ||||||
|  |     payload.set("avatar", file); | ||||||
|  |  | ||||||
|  |     setLoading(true); | ||||||
|  |     const res = await request("/api/avatar", { | ||||||
|  |       method: "PUT", | ||||||
|  |       headers: { "Authorization": `Bearer ${getAtk()}` }, | ||||||
|  |       body: payload | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       await readProfiles(); | ||||||
|  |       setDone(true); | ||||||
|  |       setError(null); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function getBirthday() { | ||||||
|  |     return userinfo?.data?.profile?.birthday ? dayjs(userinfo?.data?.profile?.birthday) : undefined; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const basisForm = ( | ||||||
|  |     <Box component="form" onSubmit={submit} sx={{ mt: 3 }}> | ||||||
|  |       <Grid container spacing={2}> | ||||||
|  |         <Grid item xs={6}> | ||||||
|  |           <TextField | ||||||
|  |             name="name" | ||||||
|  |             required | ||||||
|  |             disabled | ||||||
|  |             fullWidth | ||||||
|  |             label="Username" | ||||||
|  |             autoComplete="username" | ||||||
|  |             defaultValue={userinfo?.data?.name} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |           /> | ||||||
|  |         </Grid> | ||||||
|  |         <Grid item xs={6}> | ||||||
|  |           <TextField | ||||||
|  |             name="nick" | ||||||
|  |             required | ||||||
|  |             fullWidth | ||||||
|  |             label="Nickname" | ||||||
|  |             autoComplete="nickname" | ||||||
|  |             defaultValue={userinfo?.data?.nick} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |           /> | ||||||
|  |         </Grid> | ||||||
|  |         <Grid item xs={12}> | ||||||
|  |           <TextField | ||||||
|  |             name="description" | ||||||
|  |             multiline | ||||||
|  |             fullWidth | ||||||
|  |             label="Description" | ||||||
|  |             autoComplete="bio" | ||||||
|  |             defaultValue={userinfo?.data?.description} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |           /> | ||||||
|  |         </Grid> | ||||||
|  |         <Grid item xs={6} md={4}> | ||||||
|  |           <TextField | ||||||
|  |             name="first_name" | ||||||
|  |             fullWidth | ||||||
|  |             label="First Name" | ||||||
|  |             autoComplete="given_name" | ||||||
|  |             defaultValue={userinfo?.data?.profile?.first_name} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |           /> | ||||||
|  |         </Grid> | ||||||
|  |         <Grid item xs={6} md={4}> | ||||||
|  |           <TextField | ||||||
|  |             name="last_name" | ||||||
|  |             fullWidth | ||||||
|  |             label="Last Name" | ||||||
|  |             autoComplete="famliy_name" | ||||||
|  |             defaultValue={userinfo?.data?.profile?.last_name} | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |           /> | ||||||
|  |         </Grid> | ||||||
|  |         <Grid item xs={12} md={4}> | ||||||
|  |           <DatePicker | ||||||
|  |             name="birthday" | ||||||
|  |             label="Birthday" | ||||||
|  |             defaultValue={getBirthday()} | ||||||
|  |             sx={{ width: "100%" }} | ||||||
|  |           /> | ||||||
|  |         </Grid> | ||||||
|  |       </Grid> | ||||||
|  |  | ||||||
|  |       <Button | ||||||
|  |         type="submit" | ||||||
|  |         variant="contained" | ||||||
|  |         disabled={loading} | ||||||
|  |         startIcon={<SaveIcon />} | ||||||
|  |         sx={{ mt: 2, width: "180px" }} | ||||||
|  |       > | ||||||
|  |         Save changes | ||||||
|  |       </Button> | ||||||
|  |  | ||||||
|  |       <Divider sx={{ my: 2, mx: -3 }} /> | ||||||
|  |  | ||||||
|  |       <Box sx={{ mt: 2.5, display: "flex", gap: 1, alignItems: "center" }}> | ||||||
|  |         <Box> | ||||||
|  |           <Avatar | ||||||
|  |             sx={{ width: 32, height: 32 }} | ||||||
|  |             alt={userinfo?.displayName} | ||||||
|  |             src={`/api/avatar/${userinfo?.data?.avatar}`} | ||||||
|  |           > | ||||||
|  |             <NoAccountsIcon /> | ||||||
|  |           </Avatar> | ||||||
|  |         </Box> | ||||||
|  |         <Box> | ||||||
|  |           {/* @ts-ignore */} | ||||||
|  |           <Button | ||||||
|  |             type="button" | ||||||
|  |             color="info" | ||||||
|  |             component="label" | ||||||
|  |             tabIndex={-1} | ||||||
|  |             disabled={loading} | ||||||
|  |             startIcon={<PublishIcon />} | ||||||
|  |             sx={{ width: "180px" }} | ||||||
|  |           > | ||||||
|  |             Change avatar | ||||||
|  |             <VisuallyHiddenInput type="file" accept="image/*" onChange={changeAvatar} /> | ||||||
|  |           </Button> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||||
|  |       <Box sx={{ px: 3 }}> | ||||||
|  |         <Typography variant="h5">Personalize</Typography> | ||||||
|  |         <Typography variant="body2"> | ||||||
|  |           Customize your appearance and name card across all Goatworks information. | ||||||
|  |         </Typography> | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       <Collapse in={error}> | ||||||
|  |         <Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert> | ||||||
|  |       </Collapse> | ||||||
|  |  | ||||||
|  |       <Box sx={{ mt: 2 }}> | ||||||
|  |         <Card variant="outlined"> | ||||||
|  |           <Collapse in={loading}> | ||||||
|  |             <LinearProgress /> | ||||||
|  |           </Collapse> | ||||||
|  |  | ||||||
|  |           <CardContent style={{ padding: "20px 24px" }}> | ||||||
|  |             <Box sx={{ px: 1, my: 1 }}> | ||||||
|  |               <Typography variant="h6">Information</Typography> | ||||||
|  |               <Typography variant="subtitle2"> | ||||||
|  |                 The information for public. Let us and others better to know who you are. | ||||||
|  |               </Typography> | ||||||
|  |             </Box> | ||||||
|  |  | ||||||
|  |             { | ||||||
|  |               userinfo?.data != null ? basisForm : | ||||||
|  |                 <Box sx={{ pt: 1, px: 1 }}> | ||||||
|  |                   <CircularProgress /> | ||||||
|  |                 </Box> | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |           </CardContent> | ||||||
|  |         </Card> | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       <Snackbar | ||||||
|  |         open={done} | ||||||
|  |         autoHideDuration={1000 * 10} | ||||||
|  |         onClose={() => setDone(false)} | ||||||
|  |         message="Your profile has been updated. Some settings maybe need sometime to apply across site." | ||||||
|  |       /> | ||||||
|  |     </Container> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										267
									
								
								pkg/views/src/pages/users/security.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								pkg/views/src/pages/users/security.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,267 @@ | |||||||
|  | import { | ||||||
|  |   Alert, | ||||||
|  |   Box, | ||||||
|  |   Card, | ||||||
|  |   CardContent, | ||||||
|  |   Collapse, | ||||||
|  |   Container, | ||||||
|  |   Grid, | ||||||
|  |   LinearProgress, | ||||||
|  |   Tab, | ||||||
|  |   Tabs, | ||||||
|  |   Typography | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||||
|  | import { TabContext, TabPanel } from "@mui/lab"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { DataGrid, GridActionsCellItem, GridColDef, GridRowParams, GridValueGetterParams } from "@mui/x-data-grid"; | ||||||
|  | import { request } from "@/scripts/request.ts"; | ||||||
|  | import ExitToAppIcon from "@mui/icons-material/ExitToApp"; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export default function SecurityPage() { | ||||||
|  |   const dataDefinitions: { [id: string]: GridColDef[] } = { | ||||||
|  |     challenges: [ | ||||||
|  |       { field: "id", headerName: "ID", width: 64 }, | ||||||
|  |       { field: "ip_address", headerName: "IP Address", minWidth: 128 }, | ||||||
|  |       { field: "user_agent", headerName: "User Agent", minWidth: 320 }, | ||||||
|  |       { | ||||||
|  |         field: "created_at", | ||||||
|  |         headerName: "Issued At", | ||||||
|  |         minWidth: 160, | ||||||
|  |         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     sessions: [ | ||||||
|  |       { field: "id", headerName: "ID", width: 64 }, | ||||||
|  |       { | ||||||
|  |         field: "audiences", | ||||||
|  |         headerName: "Audiences", | ||||||
|  |         minWidth: 128, | ||||||
|  |         valueGetter: (params: GridValueGetterParams) => params.row.audiences.join(", ") | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "claims", | ||||||
|  |         headerName: "Claims", | ||||||
|  |         minWidth: 224, | ||||||
|  |         valueGetter: (params: GridValueGetterParams) => params.row.claims.join(", ") | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "created_at", | ||||||
|  |         headerName: "Issued At", | ||||||
|  |         minWidth: 160, | ||||||
|  |         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "actions", | ||||||
|  |         type: "actions", | ||||||
|  |         getActions: (params: GridRowParams) => [ | ||||||
|  |           <GridActionsCellItem | ||||||
|  |             icon={<ExitToAppIcon />} | ||||||
|  |             onClick={() => killSession(params.row)} | ||||||
|  |             disabled={loading} | ||||||
|  |             label="Sign Out" | ||||||
|  |           /> | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     events: [ | ||||||
|  |       { field: "id", headerName: "ID", width: 64 }, | ||||||
|  |       { field: "type", headerName: "Type", minWidth: 128 }, | ||||||
|  |       { field: "target", headerName: "Affected Object", minWidth: 128 }, | ||||||
|  |       { field: "ip_address", headerName: "IP Address", minWidth: 128 }, | ||||||
|  |       { field: "user_agent", headerName: "User Agent", minWidth: 128 }, | ||||||
|  |       { | ||||||
|  |         field: "created_at", | ||||||
|  |         headerName: "Performed At", | ||||||
|  |         minWidth: 160, | ||||||
|  |         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const { getAtk } = useUserinfo(); | ||||||
|  |  | ||||||
|  |   const [challenges, setChallenges] = useState<any[]>([]); | ||||||
|  |   const [challengeCount, setChallengeCount] = useState(0); | ||||||
|  |   const [sessions, setSessions] = useState<any[]>([]); | ||||||
|  |   const [sessionCount, setSessionCount] = useState(0); | ||||||
|  |   const [events, setEvents] = useState<any[]>([]); | ||||||
|  |   const [eventCount, setEventCount] = useState(0); | ||||||
|  |  | ||||||
|  |   const [pagination, setPagination] = useState({ | ||||||
|  |     challenges: { page: 0, pageSize: 5 }, | ||||||
|  |     sessions: { page: 0, pageSize: 5 }, | ||||||
|  |     events: { page: 0, pageSize: 5 } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [reverting] = useState({ | ||||||
|  |     challenges: true, | ||||||
|  |     sessions: true, | ||||||
|  |     events: true | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const [dataPane, setDataPane] = useState("challenges"); | ||||||
|  |  | ||||||
|  |   async function readChallenges() { | ||||||
|  |     reverting.challenges = true; | ||||||
|  |     const res = await request("/api/users/me/challenges?" + new URLSearchParams({ | ||||||
|  |       take: pagination.challenges.pageSize.toString(), | ||||||
|  |       offset: (pagination.challenges.page * pagination.challenges.pageSize).toString() | ||||||
|  |     }), { | ||||||
|  |       headers: { Authorization: `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       const data = await res.json(); | ||||||
|  |       setChallenges(data["data"]); | ||||||
|  |       setChallengeCount(data["count"]); | ||||||
|  |     } | ||||||
|  |     reverting.challenges = false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function readSessions() { | ||||||
|  |     reverting.sessions = true; | ||||||
|  |     const res = await request("/api/users/me/sessions?" + new URLSearchParams({ | ||||||
|  |       take: pagination.sessions.pageSize.toString(), | ||||||
|  |       offset: (pagination.sessions.page * pagination.sessions.pageSize).toString() | ||||||
|  |     }), { | ||||||
|  |       headers: { Authorization: `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       const data = await res.json(); | ||||||
|  |       setSessions(data["data"]); | ||||||
|  |       setSessionCount(data["count"]); | ||||||
|  |     } | ||||||
|  |     reverting.sessions = false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function readEvents() { | ||||||
|  |     reverting.events = true; | ||||||
|  |     const res = await request("/api/users/me/events?" + new URLSearchParams({ | ||||||
|  |       take: pagination.events.pageSize.toString(), | ||||||
|  |       offset: (pagination.events.page * pagination.events.pageSize).toString() | ||||||
|  |     }), { | ||||||
|  |       headers: { Authorization: `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       const data = await res.json(); | ||||||
|  |       setEvents(data["data"]); | ||||||
|  |       setEventCount(data["count"]); | ||||||
|  |     } | ||||||
|  |     reverting.events = false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function killSession(item: any) { | ||||||
|  |     setLoading(true); | ||||||
|  |     const res = await request(`/api/users/me/sessions/${item.id}`, { | ||||||
|  |       method: "DELETE", | ||||||
|  |       headers: { Authorization: `Bearer ${getAtk()}` } | ||||||
|  |     }); | ||||||
|  |     if (res.status !== 200) { | ||||||
|  |       setError(await res.text()); | ||||||
|  |     } else { | ||||||
|  |       await readSessions(); | ||||||
|  |       setError(null); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     readChallenges().then(() => console.log("Refreshed challenges list.")); | ||||||
|  |   }, [pagination.challenges]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     readSessions().then(() => console.log("Refreshed sessions list.")); | ||||||
|  |   }, [pagination.sessions]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     readEvents().then(() => console.log("Refreshed events list.")); | ||||||
|  |   }, [pagination.events]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||||
|  |       <Box sx={{ px: 3 }}> | ||||||
|  |         <Typography variant="h5">Security</Typography> | ||||||
|  |         <Typography variant="body2"> | ||||||
|  |           Overview and control all security details in your account. | ||||||
|  |         </Typography> | ||||||
|  |       </Box> | ||||||
|  |  | ||||||
|  |       <Collapse in={error != null}> | ||||||
|  |         <Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert> | ||||||
|  |       </Collapse> | ||||||
|  |  | ||||||
|  |       <Grid container> | ||||||
|  |         <Grid item xs={12}> | ||||||
|  |           <Box sx={{ mt: 2 }}> | ||||||
|  |             <Card variant="outlined"> | ||||||
|  |               <Collapse in={loading}> | ||||||
|  |                 <LinearProgress /> | ||||||
|  |               </Collapse> | ||||||
|  |  | ||||||
|  |               <TabContext value={dataPane}> | ||||||
|  |                 <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | ||||||
|  |                   <Tabs centered value={dataPane} onChange={(_, val) => setDataPane(val)}> | ||||||
|  |                     <Tab label="Challenges" value="challenges" /> | ||||||
|  |                     <Tab label="Sessions" value="sessions" /> | ||||||
|  |                     <Tab label="Events" value="events" /> | ||||||
|  |                   </Tabs> | ||||||
|  |                 </Box> | ||||||
|  |  | ||||||
|  |                 <CardContent style={{ padding: "20px 24px" }}> | ||||||
|  |                   <TabPanel value={"challenges"}> | ||||||
|  |                     <DataGrid | ||||||
|  |                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||||
|  |                       paginationMode="server" | ||||||
|  |                       loading={reverting.challenges} | ||||||
|  |                       rows={challenges} | ||||||
|  |                       rowCount={challengeCount} | ||||||
|  |                       columns={dataDefinitions.challenges} | ||||||
|  |                       paginationModel={pagination.challenges} | ||||||
|  |                       onPaginationModelChange={(val) => setPagination({ ...pagination, challenges: val })} | ||||||
|  |                       checkboxSelection | ||||||
|  |                     /> | ||||||
|  |                   </TabPanel> | ||||||
|  |                   <TabPanel value={"sessions"}> | ||||||
|  |                     <DataGrid | ||||||
|  |                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||||
|  |                       paginationMode="server" | ||||||
|  |                       loading={reverting.sessions} | ||||||
|  |                       rows={sessions} | ||||||
|  |                       rowCount={sessionCount} | ||||||
|  |                       columns={dataDefinitions.sessions} | ||||||
|  |                       paginationModel={pagination.sessions} | ||||||
|  |                       onPaginationModelChange={(val) => setPagination({ ...pagination, sessions: val })} | ||||||
|  |                       checkboxSelection | ||||||
|  |                     /> | ||||||
|  |                   </TabPanel> | ||||||
|  |                   <TabPanel value={"events"}> | ||||||
|  |                     <DataGrid | ||||||
|  |                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||||
|  |                       paginationMode="server" | ||||||
|  |                       loading={reverting.events} | ||||||
|  |                       rows={events} | ||||||
|  |                       rowCount={eventCount} | ||||||
|  |                       columns={dataDefinitions.events} | ||||||
|  |                       paginationModel={pagination.events} | ||||||
|  |                       onPaginationModelChange={(val) => setPagination({ ...pagination, events: val })} | ||||||
|  |                       checkboxSelection | ||||||
|  |                     /> | ||||||
|  |                   </TabPanel> | ||||||
|  |                 </CardContent> | ||||||
|  |               </TabContext> | ||||||
|  |             </Card> | ||||||
|  |           </Box> | ||||||
|  |         </Grid> | ||||||
|  |       </Grid> | ||||||
|  |     </Container> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -3,17 +3,17 @@ import { request } from "../scripts/request.ts"; | |||||||
| import { createContext, useContext, useState } from "react"; | import { createContext, useContext, useState } from "react"; | ||||||
| 
 | 
 | ||||||
| export interface Userinfo { | export interface Userinfo { | ||||||
|  |   isReady: boolean, | ||||||
|   isLoggedIn: boolean, |   isLoggedIn: boolean, | ||||||
|   displayName: string, |   displayName: string, | ||||||
|   profiles: any, |   data: any, | ||||||
|   meta: any |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const defaultUserinfo: Userinfo = { | const defaultUserinfo: Userinfo = { | ||||||
|  |   isReady: false, | ||||||
|   isLoggedIn: false, |   isLoggedIn: false, | ||||||
|   displayName: "Citizen", |   displayName: "Citizen", | ||||||
|   profiles: null, |   data: null | ||||||
|   meta: null |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo }); | const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo }); | ||||||
| @@ -30,10 +30,15 @@ export function UserinfoProvider(props: any) { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async function readProfiles() { |   async function readProfiles() { | ||||||
|     if (!checkLoggedIn()) return; |     if (!checkLoggedIn()) { | ||||||
|  |       setUserinfo((data) => { | ||||||
|  |         data.isReady = true; | ||||||
|  |         return data; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     const res = await request("/api/users/me", { |     const res = await request("/api/users/me", { | ||||||
|       credentials: "include" |       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     if (res.status !== 200) { |     if (res.status !== 200) { | ||||||
| @@ -44,10 +49,10 @@ export function UserinfoProvider(props: any) { | |||||||
|     const data = await res.json(); |     const data = await res.json(); | ||||||
| 
 | 
 | ||||||
|     setUserinfo({ |     setUserinfo({ | ||||||
|  |       isReady: true, | ||||||
|       isLoggedIn: true, |       isLoggedIn: true, | ||||||
|       displayName: data["nick"], |       displayName: data["nick"], | ||||||
|       profiles: null, |       data: data | ||||||
|       meta: data |  | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
		Reference in New Issue
	
	Block a user