♻️ 按照 Material Design + Reactjs 重构 #1

Merged
LittleSheep merged 3 commits from refactor/new-ui into master 2024-02-29 14:24:21 +00:00
43 changed files with 1247 additions and 366 deletions

View File

@ -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"`

View File

@ -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"`

View File

@ -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

View File

@ -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").

View File

@ -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.

View File

@ -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>
</>
);
}

View File

@ -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

Binary file not shown.

View File

@ -1,4 +1,4 @@
package view package views
import "embed" import "embed"

View File

@ -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"

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View 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)} />
</>
);
}

View File

@ -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
View 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);

View 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>
</>
);
}

View 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>
)
}

View File

@ -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>&nbsp; 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>&nbsp;
</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> </>
); );
} }

View File

@ -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> </>
); );
} }

View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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
}); });
} }