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 = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ sx={{ mt: 2, width: "180px" }}
+ >
+ Save changes
+
+
+
+
+
+
+
+
+
+
+
+ {/* @ts-ignore */}
+ }
+ sx={{ width: "180px" }}
+ >
+ Change avatar
+
+
+
+
+
+ );
+
+ 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
});
}