🔀 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:
commit
95328f42c2
@ -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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user