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/server/startup.go b/pkg/server/startup.go
index 6df4bbe..f465083 100644
--- a/pkg/server/startup.go
+++ b/pkg/server/startup.go
@@ -1,7 +1,7 @@
package server
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/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
@@ -92,7 +92,7 @@ func NewServer() {
Expiration: 24 * time.Hour,
CacheControl: true,
}), filesystem.New(filesystem.Config{
- Root: http.FS(view.FS),
+ Root: http.FS(views.FS),
PathPrefix: "dist",
Index: "index.html",
NotFoundFile: "dist/index.html",
diff --git a/pkg/view/bun.lockb b/pkg/view/bun.lockb
deleted file mode 100755
index 4f36ee3..0000000
Binary files a/pkg/view/bun.lockb and /dev/null differ
diff --git a/pkg/view/src/components/AppShell.tsx b/pkg/view/src/components/AppShell.tsx
deleted file mode 100644
index 1184bcc..0000000
--- a/pkg/view/src/components/AppShell.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-}
-
-interface AppBarProps extends MuiAppBarProps {
- open?: boolean;
-}
-
-const ShellAppBar = styled(MuiAppBar, {
- shouldForwardProp: (prop) => prop !== "open",
-})(({ 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 (
- <>
- documentWindow}>
-
-
-
-
-
-
-
- {SITE_NAME}
-
-
- setOpen(true)}
- sx={{ width: 64, mr: 1, display: !isMobile && open ? "none" : "block" }}
- >
-
-
-
-
-
-
-
-
-
-
- {children}
-
-
- setOpen(false)} />
-
- >
- );
-}
diff --git a/pkg/view/src/main.tsx b/pkg/view/src/main.tsx
deleted file mode 100644
index 9f4f21d..0000000
--- a/pkg/view/src/main.tsx
+++ /dev/null
@@ -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: ,
- errorElement: ,
- children: [
- { path: "/", element: }
- ]
- },
- { path: "/auth/sign-up", element: , errorElement: },
- { path: "/auth/sign-in", element: , errorElement: }
-]);
-
-const element = (
-
-
-
-
-
-
-
-
-
-
-
-
-);
-
-ReactDOM.createRoot(document.getElementById("root")!).render(element);
\ No newline at end of file
diff --git a/pkg/view/.eslintrc.cjs b/pkg/views/.eslintrc.cjs
similarity index 100%
rename from pkg/view/.eslintrc.cjs
rename to pkg/views/.eslintrc.cjs
diff --git a/pkg/view/.gitignore b/pkg/views/.gitignore
similarity index 100%
rename from pkg/view/.gitignore
rename to pkg/views/.gitignore
diff --git a/pkg/view/README.md b/pkg/views/README.md
similarity index 100%
rename from pkg/view/README.md
rename to pkg/views/README.md
diff --git a/pkg/views/bun.lockb b/pkg/views/bun.lockb
new file mode 100755
index 0000000..b20726d
Binary files /dev/null and b/pkg/views/bun.lockb differ
diff --git a/pkg/view/embed.go b/pkg/views/embed.go
similarity index 79%
rename from pkg/view/embed.go
rename to pkg/views/embed.go
index ec34587..bc04fa4 100644
--- a/pkg/view/embed.go
+++ b/pkg/views/embed.go
@@ -1,4 +1,4 @@
-package view
+package views
import "embed"
diff --git a/pkg/view/index.html b/pkg/views/index.html
similarity index 100%
rename from pkg/view/index.html
rename to pkg/views/index.html
diff --git a/pkg/view/package.json b/pkg/views/package.json
similarity index 87%
rename from pkg/view/package.json
rename to pkg/views/package.json
index 2c57de9..61e5ddc 100644
--- a/pkg/view/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/view/public/favicon.svg b/pkg/views/public/favicon.svg
similarity index 100%
rename from pkg/view/public/favicon.svg
rename to pkg/views/public/favicon.svg
diff --git a/pkg/view/src/components/AppLoader.tsx b/pkg/views/src/components/AppLoader.tsx
similarity index 100%
rename from pkg/view/src/components/AppLoader.tsx
rename to pkg/views/src/components/AppLoader.tsx
diff --git a/pkg/views/src/components/AppShell.tsx b/pkg/views/src/components/AppShell.tsx
new file mode 100644
index 0000000..1de91bc
--- /dev/null
+++ b/pkg/views/src/components/AppShell.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+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(null);
+
+ return (
+ <>
+ documentWindow}>
+
+
+
+
+
+
+
+ {SITE_NAME}
+
+
+ setOpen(true)}
+ sx={{ mr: 1 }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+ setOpen(false)} />
+ >
+ );
+}
diff --git a/pkg/view/src/components/NavigationDrawer.tsx b/pkg/views/src/components/NavigationMenu.tsx
similarity index 50%
rename from pkg/view/src/components/NavigationDrawer.tsx
rename to pkg/views/src/components/NavigationMenu.tsx
index 34a128f..b00a387 100644
--- a/pkg/view/src/components/NavigationDrawer.tsx
+++ b/pkg/views/src/components/NavigationMenu.tsx
@@ -1,27 +1,15 @@
-import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
-import ChevronRightIcon from "@mui/icons-material/ChevronRight";
-import {
- Box,
- Collapse,
- Divider,
- Drawer,
- IconButton,
- List,
- ListItemButton,
- ListItemIcon,
- ListItemText,
- styled,
- useMediaQuery
-} from "@mui/material";
+import { Collapse, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled } from "@mui/material";
import { theme } from "@/theme";
import { Fragment, ReactNode, useState } from "react";
import HowToRegIcon from "@mui/icons-material/HowToReg";
import LoginIcon from "@mui/icons-material/Login";
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 ExpandMore from "@mui/icons-material/ExpandMore";
import { useUserinfo } from "@/stores/userinfo.tsx";
+import { PopoverProps } from "@mui/material/Popover";
+import { Link } from "react-router-dom";
export interface NavigationItem {
icon?: ReactNode;
@@ -51,32 +39,30 @@ export function AppNavigationSection({ items, depth }: { items: NavigationItem[]
} else if (item.children) {
return (
- setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2 }}>
+
+
-
-
-
+
);
} else {
return (
-
-
+
+
-
+
+
);
}
});
}
-export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onClose: () => void }) {
+export function AppNavigation() {
const { checkLoggedIn } = useUserinfo();
const nav: NavigationItem[] = [
@@ -94,61 +80,19 @@ export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onC
)
];
- return (
- <>
-
- {showClose && (
-
- {theme.direction === "rtl" ? : }
-
- )}
-
-
-
-
-
- >
- );
+ return ;
}
export const isMobileQuery = theme.breakpoints.down("md");
-export default function NavigationDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
- const isMobile = useMediaQuery(isMobileQuery);
-
- return isMobile ? (
- <>
-
-
-
-
- >
- ) : (
-
-
-
+export default function NavigationMenu({ anchorEl, open, onClose }: {
+ anchorEl: PopoverProps["anchorEl"];
+ open: boolean;
+ onClose: () => void
+}) {
+ return (
+
);
}
diff --git a/pkg/view/src/consts.tsx b/pkg/views/src/consts.tsx
similarity index 100%
rename from pkg/view/src/consts.tsx
rename to pkg/views/src/consts.tsx
diff --git a/pkg/view/src/error.tsx b/pkg/views/src/error.tsx
similarity index 100%
rename from pkg/view/src/error.tsx
rename to pkg/views/src/error.tsx
diff --git a/pkg/view/src/index.css b/pkg/views/src/index.css
similarity index 100%
rename from pkg/view/src/index.css
rename to pkg/views/src/index.css
diff --git a/pkg/views/src/main.tsx b/pkg/views/src/main.tsx
new file mode 100644
index 0000000..e65d710
--- /dev/null
+++ b/pkg/views/src/main.tsx
@@ -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: ,
+ errorElement: ,
+ children: [
+ { path: "/", element: },
+ {
+ path: "/",
+ element: ,
+ children: [
+ {
+ path: "/users",
+ element: ,
+ children: [
+ { path: "/users", element: },
+ { path: "/users/notifications", element: },
+ { path: "/users/personalize", element: },
+ { path: "/users/security", element: }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ path: "/auth",
+ element: ,
+ errorElement: ,
+ children: [
+ { path: "/auth/sign-up", element: , errorElement: },
+ { path: "/auth/sign-in", element: , errorElement: },
+ { path: "/auth/o/connect", element: , errorElement: }
+ ]
+ }
+]);
+
+const element = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+ReactDOM.createRoot(document.getElementById("root")!).render(element);
\ No newline at end of file
diff --git a/pkg/views/src/pages/auth/connect.tsx b/pkg/views/src/pages/auth/connect.tsx
new file mode 100644
index 0000000..567a3ed
--- /dev/null
+++ b/pkg/views/src/pages/auth/connect.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+
+ const [client, setClient] = useState(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 = [
+ (
+ <>
+
+
+
+
+ Sign in to {client?.name}
+
+
+
+
+ About this app
+ {client?.description}
+
+
+ Make you trust this app
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ (
+ <>
+
+
+
+
+ Authorized
+
+
+
+
+ Now Redirecting...
+ Hold on a second, we are going to redirect you to the target.
+
+
+
+ >
+ )
+ ];
+
+ return (
+ <>
+ {error && {error}}
+
+
+
+
+
+
+
+ {elements[panel]}
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/pkg/views/src/pages/auth/layout.tsx b/pkg/views/src/pages/auth/layout.tsx
new file mode 100644
index 0000000..f9dec4c
--- /dev/null
+++ b/pkg/views/src/pages/auth/layout.tsx
@@ -0,0 +1,12 @@
+import { Box } from "@mui/material";
+import { Outlet } from "react-router-dom";
+
+export default function AuthLayout() {
+ return (
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/pkg/view/src/pages/auth/sign-in.tsx b/pkg/views/src/pages/auth/sign-in.tsx
similarity index 83%
rename from pkg/view/src/pages/auth/sign-in.tsx
rename to pkg/views/src/pages/auth/sign-in.tsx
index 2173d10..22a62a7 100644
--- a/pkg/view/src/pages/auth/sign-in.tsx
+++ b/pkg/views/src/pages/auth/sign-in.tsx
@@ -277,51 +277,55 @@ export default function SignInPage() {
}
return (
-
-
- {error && {error}}
+ <>
+ {error && {error}}
-
-
-
-
+
+
+ You need sign in before take an action. After that, we will take you back to your work.
+
+
-
- {elements[panel]}
-
+
+
+
+
-
-
-
-
- Risk {challenge?.risk_level}
- Progress {challenge?.progress}/{challenge?.requirements}
-
-
-
-
-
-
+
+ {elements[panel]}
+
-
-
-
- Haven't an account? Sign up!
-
-
+
+
+
+
+ Risk {challenge?.risk_level}
+ Progress {challenge?.progress}/{challenge?.requirements}
+
+
+
+
+
+
+
+
+
+
+ Haven't an account? Sign up!
+
-
-
+
+ >
);
}
\ No newline at end of file
diff --git a/pkg/view/src/pages/auth/sign-up.tsx b/pkg/views/src/pages/auth/sign-up.tsx
similarity index 83%
rename from pkg/view/src/pages/auth/sign-up.tsx
rename to pkg/views/src/pages/auth/sign-up.tsx
index ba3dc8b..0af8031 100644
--- a/pkg/view/src/pages/auth/sign-up.tsx
+++ b/pkg/views/src/pages/auth/sign-up.tsx
@@ -166,35 +166,33 @@ export default function SignUpPage() {
];
return (
-
-
- {error && {error}}
+ <>
+ {error && {error}}
-
-
-
-
+
+
+
+
-
- {!done ? elements[0] : elements[1]}
-
-
+
+ {!done ? elements[0] : elements[1]}
+
+
-
-
-
- Already have an account? Sign in!
-
-
+
+
+
+ Already have an account? Sign in!
+
-
-
+
+ >
);
}
\ No newline at end of file
diff --git a/pkg/views/src/pages/guard.tsx b/pkg/views/src/pages/guard.tsx
new file mode 100644
index 0000000..ce39a4c
--- /dev/null
+++ b/pkg/views/src/pages/guard.tsx
@@ -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 ? (
+
+
+
+
+
+ ) : ;
+}
\ No newline at end of file
diff --git a/pkg/view/src/pages/landing.tsx b/pkg/views/src/pages/landing.tsx
similarity index 100%
rename from pkg/view/src/pages/landing.tsx
rename to pkg/views/src/pages/landing.tsx
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/view/src/scripts/request.ts b/pkg/views/src/scripts/request.ts
similarity index 100%
rename from pkg/view/src/scripts/request.ts
rename to pkg/views/src/scripts/request.ts
diff --git a/pkg/view/src/stores/userinfo.tsx b/pkg/views/src/stores/userinfo.tsx
similarity index 85%
rename from pkg/view/src/stores/userinfo.tsx
rename to pkg/views/src/stores/userinfo.tsx
index 35d8f2c..4f70622 100644
--- a/pkg/view/src/stores/userinfo.tsx
+++ b/pkg/views/src/stores/userinfo.tsx
@@ -3,17 +3,17 @@ import { request } from "../scripts/request.ts";
import { createContext, useContext, useState } from "react";
export interface Userinfo {
+ isReady: boolean,
isLoggedIn: boolean,
displayName: string,
- profiles: any,
- meta: any
+ data: any,
}
const defaultUserinfo: Userinfo = {
+ isReady: false,
isLoggedIn: false,
displayName: "Citizen",
- profiles: null,
- meta: null
+ data: null
};
const UserinfoContext = createContext({ userinfo: defaultUserinfo });
@@ -30,10 +30,15 @@ export function UserinfoProvider(props: any) {
}
async function readProfiles() {
- if (!checkLoggedIn()) return;
+ if (!checkLoggedIn()) {
+ setUserinfo((data) => {
+ data.isReady = true;
+ return data;
+ });
+ }
const res = await request("/api/users/me", {
- credentials: "include"
+ headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
@@ -44,10 +49,10 @@ export function UserinfoProvider(props: any) {
const data = await res.json();
setUserinfo({
+ isReady: true,
isLoggedIn: true,
displayName: data["nick"],
- profiles: null,
- meta: data
+ data: data
});
}
diff --git a/pkg/view/src/stores/wellKnown.tsx b/pkg/views/src/stores/wellKnown.tsx
similarity index 100%
rename from pkg/view/src/stores/wellKnown.tsx
rename to pkg/views/src/stores/wellKnown.tsx
diff --git a/pkg/view/src/theme.ts b/pkg/views/src/theme.ts
similarity index 100%
rename from pkg/view/src/theme.ts
rename to pkg/views/src/theme.ts
diff --git a/pkg/view/src/vite-env.d.ts b/pkg/views/src/vite-env.d.ts
similarity index 100%
rename from pkg/view/src/vite-env.d.ts
rename to pkg/views/src/vite-env.d.ts
diff --git a/pkg/view/tsconfig.json b/pkg/views/tsconfig.json
similarity index 100%
rename from pkg/view/tsconfig.json
rename to pkg/views/tsconfig.json
diff --git a/pkg/view/tsconfig.node.json b/pkg/views/tsconfig.node.json
similarity index 100%
rename from pkg/view/tsconfig.node.json
rename to pkg/views/tsconfig.node.json
diff --git a/pkg/view/uno.config.ts b/pkg/views/uno.config.ts
similarity index 100%
rename from pkg/view/uno.config.ts
rename to pkg/views/uno.config.ts
diff --git a/pkg/view/vite.config.ts b/pkg/views/vite.config.ts
similarity index 100%
rename from pkg/view/vite.config.ts
rename to pkg/views/vite.config.ts