From e4ace4324a4a7503a59e1e9bdec5c607004daf38 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 28 Feb 2024 23:30:29 +0800 Subject: [PATCH] :recycle: Brand new user center --- pkg/models/accounts.go | 1 + pkg/models/profiles.go | 1 - pkg/server/accounts_api.go | 12 +- pkg/server/notifications_api.go | 14 +- pkg/views/package.json | 6 +- pkg/views/src/components/AppShell.tsx | 2 +- pkg/views/src/main.tsx | 41 ++- pkg/views/src/pages/users/dashboard.tsx | 35 +++ pkg/views/src/pages/users/layout.tsx | 65 +++++ pkg/views/src/pages/users/notifications.tsx | 87 +++++++ pkg/views/src/pages/users/personalize.tsx | 250 ++++++++++++++++++ pkg/views/src/pages/users/security.tsx | 267 ++++++++++++++++++++ pkg/views/src/stores/userinfo.tsx | 9 +- 13 files changed, 759 insertions(+), 31 deletions(-) create mode 100644 pkg/views/src/pages/users/dashboard.tsx create mode 100644 pkg/views/src/pages/users/layout.tsx create mode 100644 pkg/views/src/pages/users/notifications.tsx create mode 100644 pkg/views/src/pages/users/personalize.tsx create mode 100644 pkg/views/src/pages/users/security.tsx diff --git a/pkg/models/accounts.go b/pkg/models/accounts.go index 7464bd3..56f635f 100644 --- a/pkg/models/accounts.go +++ b/pkg/models/accounts.go @@ -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"` diff --git a/pkg/models/profiles.go b/pkg/models/profiles.go index d2da447..8ff124f 100644 --- a/pkg/models/profiles.go +++ b/pkg/models/profiles.go @@ -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"` diff --git a/pkg/server/accounts_api.go b/pkg/server/accounts_api.go index 7c199e1..30947ca 100644 --- a/pkg/server/accounts_api.go +++ b/pkg/server/accounts_api.go @@ -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 diff --git a/pkg/server/notifications_api.go b/pkg/server/notifications_api.go index d5bdb69..c8f9cdf 100644 --- a/pkg/server/notifications_api.go +++ b/pkg/server/notifications_api.go @@ -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"). diff --git a/pkg/views/package.json b/pkg/views/package.json index 2c57de9..61e5ddc 100644 --- a/pkg/views/package.json +++ b/pkg/views/package.json @@ -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" diff --git a/pkg/views/src/components/AppShell.tsx b/pkg/views/src/components/AppShell.tsx index e232f91..1de91bc 100644 --- a/pkg/views/src/components/AppShell.tsx +++ b/pkg/views/src/components/AppShell.tsx @@ -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}`} > diff --git a/pkg/views/src/main.tsx b/pkg/views/src/main.tsx index 9f4f21d..942fdfb 100644 --- a/pkg/views/src/main.tsx +++ b/pkg/views/src/main.tsx @@ -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: , errorElement: , children: [ - { path: "/", element: } + { path: "/", element: }, + { + path: "/users", + element: , + children: [ + { path: "/users", element: }, + { path: "/users/notifications", element: }, + { path: "/users/personalize", element: }, + { path: "/users/security", element: } + ] + } ] }, { path: "/auth/sign-up", element: , errorElement: }, @@ -45,16 +62,18 @@ const router = createBrowserRouter([ const element = ( - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/pkg/views/src/pages/users/dashboard.tsx b/pkg/views/src/pages/users/dashboard.tsx new file mode 100644 index 0000000..f0e1dc6 --- /dev/null +++ b/pkg/views/src/pages/users/dashboard.tsx @@ -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 ( + + + Welcome, {userinfo?.displayName} + What can I help you today? + + + { + !userinfo?.profiles?.confirmed_at && + + Your account haven't confirmed yet. Go to your linked email + inbox and check out our registration confirm email. + + } + + + Frequently Asked Questions + + + + 没有人有问题。没有人敢有问题。鲁迅曾经说过: + 解决不了问题,就解决提问题的人。 —— 鲁迅 + 所以,我们的客诉率是 0% 哦~ + + + + + ); +} \ No newline at end of file diff --git a/pkg/views/src/pages/users/layout.tsx b/pkg/views/src/pages/users/layout.tsx new file mode 100644 index 0000000..df6147c --- /dev/null +++ b/pkg/views/src/pages/users/layout.tsx @@ -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: , label: "Dashboard" }, + { icon: , label: "Notifications" }, + { icon: , label: "Personalize" }, + { icon: , 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 ( + + + 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) => ( + + ))} + + + + + + + + ); +} \ No newline at end of file diff --git a/pkg/views/src/pages/users/notifications.tsx b/pkg/views/src/pages/users/notifications.tsx new file mode 100644 index 0000000..16fafe0 --- /dev/null +++ b/pkg/views/src/pages/users/notifications.tsx @@ -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); + + const [notifications, setNotifications] = useState([]); + + 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 ( + + + + + + + {error} + + + + You are done! There's no unread notifications for you. + + + + + {notifications.map((item, idx) => ( + + markNotifications(item)} + > + + + }> + + + + ))} + + + + ); +} \ No newline at end of file diff --git a/pkg/views/src/pages/users/personalize.tsx b/pkg/views/src/pages/users/personalize.tsx new file mode 100644 index 0000000..a024259 --- /dev/null +++ b/pkg/views/src/pages/users/personalize.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + + async function submit(evt: FormEvent) { + 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) { + 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 = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* @ts-ignore */} + + + + + ); + + return ( + + + Personalize + + Customize your appearance and name card across all Goatworks information. + + + + + {error} + + + + + + + + + + + Information + + The information for public. Let us and others better to know who you are. + + + + { + userinfo?.data != null ? basisForm : + + + + } + + + + + + setDone(false)} + message="Your profile has been updated. Some settings maybe need sometime to apply across site." + /> + + ); +} \ No newline at end of file diff --git a/pkg/views/src/pages/users/security.tsx b/pkg/views/src/pages/users/security.tsx new file mode 100644 index 0000000..166edc0 --- /dev/null +++ b/pkg/views/src/pages/users/security.tsx @@ -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) => [ + } + 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([]); + const [challengeCount, setChallengeCount] = useState(0); + const [sessions, setSessions] = useState([]); + const [sessionCount, setSessionCount] = useState(0); + const [events, setEvents] = useState([]); + 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(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 ( + + + Security + + Overview and control all security details in your account. + + + + + {error} + + + + + + + + + + + + + setDataPane(val)}> + + + + + + + + + setPagination({ ...pagination, challenges: val })} + checkboxSelection + /> + + + setPagination({ ...pagination, sessions: val })} + checkboxSelection + /> + + + setPagination({ ...pagination, events: val })} + checkboxSelection + /> + + + + + + + + + ); +} \ No newline at end of file diff --git a/pkg/views/src/stores/userinfo.tsx b/pkg/views/src/stores/userinfo.tsx index 35d8f2c..e3400f7 100644 --- a/pkg/views/src/stores/userinfo.tsx +++ b/pkg/views/src/stores/userinfo.tsx @@ -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({ userinfo: defaultUserinfo }); @@ -46,8 +44,7 @@ export function UserinfoProvider(props: any) { setUserinfo({ isLoggedIn: true, displayName: data["nick"], - profiles: null, - meta: data + data: data }); }