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}> - - - - Logo - - - - {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}> + + + + Logo + + + + {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 }}> + setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> {item.icon} {open ? : } - + - - - + ); } else { return ( - - + + {item.icon} - - + + ); } }); } -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 = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* @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/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