♻️ Brand new user center
This commit is contained in:
parent
14efa09486
commit
e4ace4324a
@ -12,6 +12,7 @@ type Account struct {
|
||||
|
||||
Name string `json:"name" gorm:"uniqueIndex"`
|
||||
Nick string `json:"nick"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
Profile AccountProfile `json:"profile"`
|
||||
Sessions []AuthSession `json:"sessions"`
|
||||
|
@ -6,7 +6,6 @@ type AccountProfile struct {
|
||||
BaseModel
|
||||
|
||||
FirstName string `json:"first_name"`
|
||||
MiddleName string `json:"middle_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Experience uint64 `json:"experience"`
|
||||
Birthday *time.Time `json:"birthday"`
|
||||
|
@ -76,11 +76,11 @@ func editUserinfo(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Nick string `json:"nick" validate:"required,min=4,max=24"`
|
||||
FirstName string `json:"first_name"`
|
||||
MiddleName string `json:"middle_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Birthday time.Time `json:"birthday"`
|
||||
Nick string `json:"nick" validate:"required,min=4,max=24"`
|
||||
Description string `json:"description"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Birthday time.Time `json:"birthday"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
@ -96,8 +96,8 @@ func editUserinfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
account.Nick = data.Nick
|
||||
account.Description = data.Description
|
||||
account.Profile.FirstName = data.FirstName
|
||||
account.Profile.MiddleName = data.MiddleName
|
||||
account.Profile.LastName = data.LastName
|
||||
account.Profile.Birthday = &data.Birthday
|
||||
|
||||
|
@ -14,17 +14,21 @@ func getNotifications(c *fiber.Ctx) error {
|
||||
take := c.QueryInt("take", 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 notifications []models.Notification
|
||||
if err := database.C.
|
||||
Where(&models.Notification{RecipientID: user.ID}).
|
||||
Model(&models.Notification{}).
|
||||
if err := tx.
|
||||
Count(&count).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if err := database.C.
|
||||
Where(&models.Notification{RecipientID: user.ID}).
|
||||
if err := tx.
|
||||
Limit(take).
|
||||
Offset(offset).
|
||||
Order("read_at desc").
|
||||
|
@ -14,14 +14,18 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^5.0.8",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
"@mui/lab": "^5.0.0-alpha.166",
|
||||
"@mui/material": "^5.15.10",
|
||||
"@mui/x-data-grid": "^6.19.5",
|
||||
"@mui/x-date-pickers": "^6.19.5",
|
||||
"@unocss/reset": "^0.58.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.1",
|
||||
"react-swipeable-views": "^0.14.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"sort-by": "^1.2.0",
|
||||
"universal-cookie": "^7.1.0",
|
||||
"use-debounce": "^10.0.0"
|
||||
|
@ -74,7 +74,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
sx={{ width: 32, height: 32, bgcolor: "transparent" }}
|
||||
ref={container}
|
||||
alt={userinfo?.displayName}
|
||||
src={userinfo?.profiles?.avatar}
|
||||
src={`/api/avatar/${userinfo?.data?.avatar}`}
|
||||
>
|
||||
<AccountCircleIcon />
|
||||
</Avatar>
|
||||
|
@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
@ -14,8 +16,13 @@ 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 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";
|
||||
|
||||
@ -36,7 +43,17 @@ const router = createBrowserRouter([
|
||||
element: <AppShell><Outlet /></AppShell>,
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{ path: "/", element: <LandingPage /> }
|
||||
{ path: "/", element: <LandingPage /> },
|
||||
{
|
||||
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/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> },
|
||||
@ -45,16 +62,18 @@ const router = createBrowserRouter([
|
||||
|
||||
const element = (
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<WellKnownProvider>
|
||||
<UserinfoProvider>
|
||||
<AppLoader>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</AppLoader>
|
||||
</UserinfoProvider>
|
||||
</WellKnownProvider>
|
||||
</ThemeProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<WellKnownProvider>
|
||||
<UserinfoProvider>
|
||||
<AppLoader>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</AppLoader>
|
||||
</UserinfoProvider>
|
||||
</WellKnownProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
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>
|
||||
);
|
||||
}
|
@ -5,15 +5,13 @@ import { createContext, useContext, useState } from "react";
|
||||
export interface Userinfo {
|
||||
isLoggedIn: boolean,
|
||||
displayName: string,
|
||||
profiles: any,
|
||||
meta: any
|
||||
data: any,
|
||||
}
|
||||
|
||||
const defaultUserinfo: Userinfo = {
|
||||
isLoggedIn: false,
|
||||
displayName: "Citizen",
|
||||
profiles: null,
|
||||
meta: null
|
||||
data: null,
|
||||
};
|
||||
|
||||
const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo });
|
||||
@ -46,8 +44,7 @@ export function UserinfoProvider(props: any) {
|
||||
setUserinfo({
|
||||
isLoggedIn: true,
|
||||
displayName: data["nick"],
|
||||
profiles: null,
|
||||
meta: data
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user