🎉 Initial New Design Project
This commit is contained in:
15
pkg/views/src/assets/utils.css
Normal file
15
pkg/views/src/assets/utils.css
Normal file
@@ -0,0 +1,15 @@
|
||||
html,
|
||||
body,
|
||||
#app,
|
||||
.v-application {
|
||||
overflow: auto !important;
|
||||
font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
@@ -1,14 +0,0 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { useWellKnown } from "@/stores/wellKnown.tsx";
|
||||
import { useUserinfo } from "@/stores/userinfo.tsx";
|
||||
|
||||
export default function AppLoader({ children }: { children: ReactNode }) {
|
||||
const { readWellKnown } = useWellKnown();
|
||||
const { readProfiles } = useUserinfo();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([readWellKnown(), readProfiles()]);
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
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 (
|
||||
<Slide appear={false} direction="down" in={!trigger}>
|
||||
{children}
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HideOnScroll window={() => documentWindow}>
|
||||
<AppBar position="fixed">
|
||||
<Toolbar sx={{ height: 64 }}>
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{ ml: isMobile ? 0.5 : 0, mr: 2 }}
|
||||
>
|
||||
<img src="/favicon.svg" alt="Logo" width={32} height={32} />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}>
|
||||
<Link to="/">{SITE_NAME}</Link>
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<Avatar
|
||||
sx={{ width: 32, height: 32, bgcolor: "transparent" }}
|
||||
ref={container}
|
||||
alt={userinfo?.displayName}
|
||||
src={`/api/avatar/${userinfo?.data?.avatar}`}
|
||||
>
|
||||
<AccountCircleIcon />
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</HideOnScroll>
|
||||
|
||||
<Box component="main">
|
||||
<AppNavigationHeader />
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
<NavigationMenu anchorEl={container.current} open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,98 +0,0 @@
|
||||
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/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;
|
||||
title?: string;
|
||||
link?: string;
|
||||
divider?: boolean;
|
||||
children?: NavigationItem[];
|
||||
}
|
||||
|
||||
export const DRAWER_WIDTH = 320;
|
||||
|
||||
export const AppNavigationHeader = styled("div")(({ theme }) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(0, 1),
|
||||
justifyContent: "flex-start",
|
||||
height: 64,
|
||||
...theme.mixins.toolbar
|
||||
}));
|
||||
|
||||
export function AppNavigationSection({ items, depth }: { items: NavigationItem[], depth?: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return items.map((item, idx) => {
|
||||
if (item.divider) {
|
||||
return <Divider key={idx} sx={{ my: 1 }} />;
|
||||
} else if (item.children) {
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<MenuItem onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.title} />
|
||||
{open ? <ExpandLess /> : <ExpandMore />}
|
||||
</MenuItem>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} />
|
||||
</Collapse>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Link key={idx} to={item.link ?? "/"}>
|
||||
<MenuItem sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.title} />
|
||||
</MenuItem>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function AppNavigation() {
|
||||
const { checkLoggedIn } = useUserinfo();
|
||||
|
||||
const nav: NavigationItem[] = [
|
||||
...(
|
||||
checkLoggedIn() ?
|
||||
[
|
||||
{ icon: <FaceIcon />, title: "Account", link: "/users" },
|
||||
{ divider: true },
|
||||
{ icon: <LogoutIcon />, title: "Sign out", link: "/auth/sign-out" }
|
||||
] :
|
||||
[
|
||||
{ icon: <HowToRegIcon />, title: "Sign up", link: "/auth/sign-up" },
|
||||
{ icon: <LoginIcon />, title: "Sign in", link: "/auth/sign-in" }
|
||||
]
|
||||
)
|
||||
];
|
||||
|
||||
return <AppNavigationSection items={nav} />;
|
||||
}
|
||||
|
||||
export const isMobileQuery = theme.breakpoints.down("md");
|
||||
|
||||
export default function NavigationMenu({ anchorEl, open, onClose }: {
|
||||
anchorEl: PopoverProps["anchorEl"];
|
||||
open: boolean;
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
|
||||
<AppNavigation />
|
||||
</Menu>
|
||||
);
|
||||
}
|
@@ -1 +0,0 @@
|
||||
export const SITE_NAME = "Goatpass";
|
@@ -1,23 +0,0 @@
|
||||
import { Link as RouterLink, useRouteError } from "react-router-dom";
|
||||
import { Box, Container, Link, Typography } from "@mui/material";
|
||||
|
||||
export default function ErrorBoundary() {
|
||||
const error = useRouteError() as any;
|
||||
|
||||
return (
|
||||
<Container sx={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center"
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="h1">{error.status}</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>{error?.message ?? "Something went wrong"}</Typography>
|
||||
|
||||
<Link component={RouterLink} to="/">Back to homepage</Link>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
5
pkg/views/src/index.vue
Normal file
5
pkg/views/src/index.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<router-view />
|
||||
</v-app>
|
||||
</template>
|
60
pkg/views/src/layouts/master.vue
Normal file
60
pkg/views/src/layouts/master.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<v-app-bar height="64" color="primary" scroll-behavior="elevate" flat>
|
||||
<div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center">
|
||||
<router-link :to="{ name: 'dashboard' }">
|
||||
<h2 class="ml-2 text-lg font-500">Solarpass</h2>
|
||||
</router-link>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn flat exact v-bind="props" icon>
|
||||
<v-avatar color="transparent" icon="mdi-account-circle" :src="id.userinfo.data?.avatar" />
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-item title="Sign in" prepend-icon="mdi-login-variant" />
|
||||
<v-list-item title="Create account" prepend-icon="mdi-account-plus" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const username = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return "@" + id.userinfo.data?.name
|
||||
} else {
|
||||
return "@vistor"
|
||||
}
|
||||
})
|
||||
const nickname = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return id.userinfo.data?.nick
|
||||
} else {
|
||||
return "Anonymous"
|
||||
}
|
||||
})
|
||||
|
||||
id.readProfiles()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-fab {
|
||||
position: fixed !important;
|
||||
bottom: 16px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
54
pkg/views/src/main.ts
Normal file
54
pkg/views/src/main.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import "virtual:uno.css"
|
||||
|
||||
import "./assets/utils.css"
|
||||
|
||||
import { createApp } from "vue"
|
||||
import { createPinia } from "pinia"
|
||||
|
||||
import "vuetify/styles"
|
||||
import { createVuetify } from "vuetify"
|
||||
import { md3 } from "vuetify/blueprints"
|
||||
import * as components from "vuetify/components"
|
||||
import * as labsComponents from "vuetify/labs/components"
|
||||
import * as directives from "vuetify/directives"
|
||||
|
||||
import "@mdi/font/css/materialdesignicons.min.css"
|
||||
import "@fontsource/roboto/latin.css"
|
||||
import "@unocss/reset/tailwind.css"
|
||||
|
||||
import index from "./index.vue"
|
||||
import router from "./router"
|
||||
|
||||
const app = createApp(index)
|
||||
|
||||
app.use(
|
||||
createVuetify({
|
||||
directives,
|
||||
components: {
|
||||
...components,
|
||||
...labsComponents,
|
||||
},
|
||||
blueprint: md3,
|
||||
theme: {
|
||||
defaultTheme: "original",
|
||||
themes: {
|
||||
original: {
|
||||
colors: {
|
||||
primary: "#4a5099",
|
||||
secondary: "#2196f3",
|
||||
accent: "#009688",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
info: "#03a9f4",
|
||||
success: "#4caf50",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount("#app")
|
@@ -1,90 +0,0 @@
|
||||
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 ErrorBoundary from "@/error.tsx";
|
||||
import AppLoader from "@/components/AppLoader.tsx";
|
||||
import UserLayout from "@/pages/users/layout.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: <AppShell><Outlet /></AppShell>,
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{ path: "/", lazy: () => import("@/pages/landing.tsx") },
|
||||
{
|
||||
path: "/",
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
path: "/users",
|
||||
element: <UserLayout />,
|
||||
children: [
|
||||
{ path: "/users", lazy: () => import("@/pages/users/dashboard.tsx") },
|
||||
{ path: "/users/notifications", lazy: () => import("@/pages/users/notifications.tsx") },
|
||||
{ path: "/users/personalize", lazy: () => import("@/pages/users/personalize.tsx") },
|
||||
{ path: "/users/security", lazy: () => import("@/pages/users/security.tsx") }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/auth",
|
||||
element: <AuthLayout />,
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{ path: "/auth/sign-up", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/sign-up.tsx") },
|
||||
{ path: "/auth/sign-in", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/sign-in.tsx") },
|
||||
{ path: "/auth/sign-out", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/sign-out.tsx") },
|
||||
{ path: "/auth/o/connect", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/connect.tsx") }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
const element = (
|
||||
<React.StrictMode>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<WellKnownProvider>
|
||||
<UserinfoProvider>
|
||||
<AppLoader>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</AppLoader>
|
||||
</UserinfoProvider>
|
||||
</WellKnownProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(element);
|
@@ -1,182 +0,0 @@
|
||||
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 function Component() {
|
||||
const { getAtk } = useUserinfo();
|
||||
|
||||
const [panel, setPanel] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [client, setClient] = useState<any>(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 = [
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<OutletIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign in to {client?.name}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 3, width: "100%" }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography fontWeight="bold">About this app</Typography>
|
||||
<Typography variant="body2">{client?.description}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography fontWeight="bold">Make you trust this app</Typography>
|
||||
<Typography variant="body2">
|
||||
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.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
color="info"
|
||||
variant="outlined"
|
||||
disabled={loading}
|
||||
sx={{ mt: 3 }}
|
||||
onClick={() => decline()}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={loading}
|
||||
sx={{ mt: 3 }}
|
||||
onClick={() => approve()}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<WhatshotIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Authorized
|
||||
</Typography>
|
||||
<Box sx={{ mt: 3, width: "100%", textAlign: "center" }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sx={{ my: 8 }}>
|
||||
<Typography variant="h6">Now Redirecting...</Typography>
|
||||
<Typography>Hold on a second, we are going to redirect you to the target.</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Card variant="outlined">
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress />
|
||||
</Collapse>
|
||||
|
||||
<CardContent
|
||||
style={{ padding: "40px 48px 36px" }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{elements[panel]}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,12 +0,0 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Box style={{ width: "100vw", maxWidth: "450px" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
@@ -1,331 +0,0 @@
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Collapse,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Link,
|
||||
Paper,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { request } from "@/scripts/request.ts";
|
||||
import { useUserinfo } from "@/stores/userinfo.tsx";
|
||||
import LoginIcon from "@mui/icons-material/Login";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
import KeyIcon from "@mui/icons-material/Key";
|
||||
import PasswordIcon from "@mui/icons-material/Password";
|
||||
import EmailIcon from "@mui/icons-material/Email";
|
||||
|
||||
export function Component() {
|
||||
const [panel, setPanel] = useState(0);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [factor, setFactor] = useState<number>();
|
||||
const [factorType, setFactorType] = useState<any>();
|
||||
|
||||
const [factors, setFactors] = useState<any>(null);
|
||||
const [challenge, setChallenge] = useState<any>(null);
|
||||
|
||||
const { readProfiles } = useUserinfo();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handlers: any[] = [
|
||||
async (evt: FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
|
||||
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
||||
if (!data.id) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await request("/api/auth", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setFactors(data["factors"]);
|
||||
setChallenge(data["challenge"]);
|
||||
setPanel(1);
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
async (evt: FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
|
||||
if (!factor) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await request(`/api/auth/factors/${factor}`, {
|
||||
method: "POST"
|
||||
});
|
||||
if (res.status !== 200 && res.status !== 204) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const item = factors.find((item: any) => item.id === factor).type;
|
||||
setError(null);
|
||||
setPanel(2);
|
||||
setFactorType(factorTypes[item]);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
async (evt: SubmitEvent) => {
|
||||
evt.preventDefault();
|
||||
|
||||
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
||||
if (!data.credentials) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await request(`/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challenge_id: challenge?.id,
|
||||
factor_id: factor,
|
||||
secret: data.credentials
|
||||
})
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (data["is_finished"]) {
|
||||
await grantToken(data["session"]["grant_token"]);
|
||||
await readProfiles();
|
||||
callback();
|
||||
} else {
|
||||
setError(null);
|
||||
setPanel(1);
|
||||
setFactor(undefined);
|
||||
setFactorType(undefined);
|
||||
setChallenge(data["challenge"]);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
];
|
||||
|
||||
function callback() {
|
||||
if (searchParams.has("closable")) {
|
||||
window.close();
|
||||
} else if (searchParams.has("redirect_uri")) {
|
||||
window.open(searchParams.get("redirect_uri") ?? "/", "_self");
|
||||
} else {
|
||||
navigate("/users");
|
||||
}
|
||||
}
|
||||
|
||||
function getFactorAvailable(factor: any) {
|
||||
const blacklist: number[] = challenge?.blacklist_factors ?? [];
|
||||
return blacklist.includes(factor.id);
|
||||
}
|
||||
|
||||
const factorTypes = [
|
||||
{ icon: <PasswordIcon />, label: "Password Verification", autoComplete: "password" },
|
||||
{ icon: <EmailIcon />, label: "Email One Time Password", autoComplete: "one-time-code" }
|
||||
];
|
||||
|
||||
const elements = [
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<LoginIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Welcome back
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
autoComplete="username"
|
||||
name="id"
|
||||
required
|
||||
fullWidth
|
||||
label="Account ID"
|
||||
helperText={"Use your username, email or phone number."}
|
||||
autoFocus
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
{loading ? "Processing..." : "Next"}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<SecurityIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Verify that's you
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
orientation="vertical"
|
||||
color="info"
|
||||
value={factor}
|
||||
sx={{ width: "100%" }}
|
||||
onChange={(_, val) => setFactor(val)}
|
||||
>
|
||||
{factors?.map((item: any, idx: number) => (
|
||||
<ToggleButton key={idx} value={item.id} disabled={getFactorAvailable(item)}>
|
||||
<Grid container>
|
||||
<Grid item xs={2}>
|
||||
{factorTypes[item.type]?.icon}
|
||||
</Grid>
|
||||
<Grid item xs="auto">
|
||||
{factorTypes[item.type]?.label}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
{loading ? "Processing..." : "Next"}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<KeyIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Enter the credentials
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
autoComplete={factorType?.autoComplete ?? "password"}
|
||||
name="credentials"
|
||||
type="password"
|
||||
required
|
||||
fullWidth
|
||||
label="Credentials"
|
||||
autoFocus
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
{loading ? "Processing..." : "Next"}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
];
|
||||
|
||||
async function grantToken(tk: string) {
|
||||
const res = await request("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code: tk,
|
||||
grant_type: "grant_token"
|
||||
})
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const err = await res.text();
|
||||
setError(err);
|
||||
throw new Error(err);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Collapse in={searchParams.has("redirect_uri")}>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
You need sign in before take an action. After that, we will take you back to your work.
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Card variant="outlined">
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress />
|
||||
</Collapse>
|
||||
|
||||
<CardContent
|
||||
style={{ padding: "40px 48px 36px" }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{elements[panel]}
|
||||
</CardContent>
|
||||
|
||||
<Collapse in={challenge != null} unmountOnExit>
|
||||
<Box>
|
||||
<Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}>
|
||||
<Typography sx={{ mb: 2 }}>
|
||||
Risk <b className="font-mono">{challenge?.risk_level}</b>
|
||||
Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b>
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={challenge?.progress / challenge?.requirements * 100}
|
||||
sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
<Grid container justifyContent="center" sx={{ mt: 2 }}>
|
||||
<Grid item>
|
||||
<Link component={RouterLink} to="/auth/sign-up" variant="body2">
|
||||
Haven't an account? Sign up!
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
import { Avatar, Button, Card, CardContent, Typography } from "@mui/material";
|
||||
import { useUserinfo } from "@/stores/userinfo.tsx";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function Component() {
|
||||
const { clearUserinfo } = useUserinfo();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function signout() {
|
||||
clearUserinfo();
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card variant="outlined">
|
||||
<CardContent
|
||||
style={{ padding: "40px 48px 36px" }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<LogoutIcon />
|
||||
</Avatar>
|
||||
|
||||
<Typography gutterBottom variant="h5" component="h1">Sign out</Typography>
|
||||
<Typography variant="body1">
|
||||
Sign out will clear your data on this device. Also will affected those use union identification services.
|
||||
You need sign in again get access them.
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ mt: 3 }}
|
||||
onClick={() => signout()}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,198 +0,0 @@
|
||||
import UserIcon from "@mui/icons-material/PersonAddAlt1";
|
||||
import HowToRegIcon from "@mui/icons-material/HowToReg";
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Link,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { request } from "@/scripts/request.ts";
|
||||
import { useWellKnown } from "@/stores/wellKnown.tsx";
|
||||
|
||||
export function Component() {
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { wellKnown } = useWellKnown();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function submit(evt: FormEvent<HTMLFormElement>) {
|
||||
evt.preventDefault();
|
||||
|
||||
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
||||
if (!data.human_verification) return;
|
||||
if (!data.name || !data.nick || !data.email || !data.password) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await request("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
setError(null);
|
||||
setDone(true);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function callback() {
|
||||
if (searchParams.has("closable")) {
|
||||
window.close();
|
||||
} else {
|
||||
navigate("/auth/sign-in");
|
||||
}
|
||||
}
|
||||
|
||||
const elements = [
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ mb: 1, bgcolor: "secondary.main" }}>
|
||||
<UserIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Create an account
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={submit} sx={{ mt: 3, width: "100%" }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
name="name"
|
||||
required
|
||||
fullWidth
|
||||
label="Username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
name="nick"
|
||||
required
|
||||
fullWidth
|
||||
label="Nickname"
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
required
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Password"
|
||||
name="password"
|
||||
required
|
||||
fullWidth
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Grid>
|
||||
{
|
||||
!wellKnown?.open_registration && <Grid item xs={12}>
|
||||
<TextField
|
||||
label="Magic Token"
|
||||
name="magic_token"
|
||||
required
|
||||
fullWidth
|
||||
type="password"
|
||||
autoComplete="magic-token"
|
||||
helperText={"This server uses invitations only."}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
name="human_verification"
|
||||
control={<Checkbox value="allowExtraEmails" color="primary" />}
|
||||
label={"I'm not a robot."}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
{loading ? "Signing Now..." : "Sign Up"}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<HowToRegIcon />
|
||||
</Avatar>
|
||||
|
||||
<Typography gutterBottom variant="h5" component="h1">Congratulations!</Typography>
|
||||
<Typography variant="body1">
|
||||
Your account has been created and activation email has sent to your inbox!
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ my: 2 }}>
|
||||
<Link onClick={() => callback()} className="cursor-pointer">Go login</Link>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
After you login, then you can take part in the entire smartsheep community.
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Card variant="outlined">
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress />
|
||||
</Collapse>
|
||||
|
||||
<CardContent
|
||||
style={{ padding: "40px 48px 36px" }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{!done ? elements[0] : elements[1]}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Grid container justifyContent="center" sx={{ mt: 2 }}>
|
||||
<Grid item>
|
||||
<Link component={RouterLink} to="/auth/sign-in" variant="body2">
|
||||
Already have an account? Sign in!
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,29 +0,0 @@
|
||||
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 ? (
|
||||
<Box sx={{ pt: 32, display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||
<Box>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Box>
|
||||
) : <Outlet />;
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
import { Button, Container, Grid, Typography } from "@mui/material";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<Container sx={{ height: "calc(100vh - 64px)", display: "flex", alignItems: "center", textAlign: "center" }}>
|
||||
<Grid padding={5} spacing={8} container>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h3">All Goatworks<sup>®</sup> Services</Typography>
|
||||
<Typography variant="h3">In a single account</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ mt: 8 }}>That's</Typography>
|
||||
<Typography variant="h1">Goatpass</Typography>
|
||||
<Button component={RouterLink} to="/auth/sign-up" variant="contained" sx={{ mt: 2 }}>Getting Start</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} sx={{ order: { xs: -100, md: 0 } }}>
|
||||
<img src="/favicon.svg" alt="Logo" width={256} height={256} className="block mx-auto" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
@@ -1,35 +0,0 @@
|
||||
import { Alert, Box, Card, CardContent, Container, Typography } from "@mui/material";
|
||||
import { useUserinfo } from "@/stores/userinfo.tsx";
|
||||
|
||||
export function Component() {
|
||||
const { userinfo } = useUserinfo();
|
||||
|
||||
return (
|
||||
<Container sx={{ pt: 5 }} maxWidth="md">
|
||||
<Box sx={{ px: 3 }}>
|
||||
<Typography variant="h5">Welcome, {userinfo?.displayName}</Typography>
|
||||
<Typography variant="body2">What can I help you today?</Typography>
|
||||
</Box>
|
||||
|
||||
{
|
||||
!userinfo?.data?.confirmed_at &&
|
||||
<Alert severity="warning" sx={{ mt: 3, mx: 1 }}>
|
||||
Your account haven't confirmed yet. Go to your linked email
|
||||
inbox and check out our registration confirm email.
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<Box sx={{ px: 1, mt: 3 }}>
|
||||
<Typography variant="h6" sx={{ px: 2 }}>Frequently Asked Questions</Typography>
|
||||
|
||||
<Card variant="outlined" sx={{ mt: 1 }}>
|
||||
<CardContent style={{ padding: "40px" }}>
|
||||
<Typography>没有人有问题。没有人敢有问题。鲁迅曾经说过:</Typography>
|
||||
<Typography sx={{ pl: 4 }} fontWeight="bold">解决不了问题,就解决提问题的人。 —— 鲁迅</Typography>
|
||||
<Typography>所以,我们的客诉率是 0% 哦~</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
@@ -1,65 +0,0 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Box, Tab, Tabs, useMediaQuery } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { theme } from "@/theme.ts";
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import InboxIcon from "@mui/icons-material/Inbox";
|
||||
import DrawIcon from "@mui/icons-material/Draw";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
|
||||
export default function UserLayout() {
|
||||
const [focus, setFocus] = useState(0);
|
||||
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const locations = ["/users", "/users/notifications", "/users/personalize", "/users/security"];
|
||||
const tabs = [
|
||||
{ icon: <DashboardIcon />, label: "Dashboard" },
|
||||
{ icon: <InboxIcon />, label: "Notifications" },
|
||||
{ icon: <DrawIcon />, label: "Personalize" },
|
||||
{ icon: <SecurityIcon />, label: "Security" }
|
||||
];
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const idx = locations.indexOf(location.pathname);
|
||||
setFocus(idx);
|
||||
}, []);
|
||||
|
||||
function swap(idx: number) {
|
||||
navigate(locations[idx]);
|
||||
setFocus(idx);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", height: "calc(100vh - 64px)" }}>
|
||||
<Box sx={{ width: isMobile ? "100%" : 280 }}>
|
||||
<Tabs
|
||||
orientation={isMobile ? "horizontal" : "vertical"}
|
||||
variant="scrollable"
|
||||
value={focus}
|
||||
onChange={(_, val) => swap(val)}
|
||||
sx={{
|
||||
borderRight: isMobile ? 0 : 1,
|
||||
borderBottom: isMobile ? 1 : 0,
|
||||
borderColor: "divider",
|
||||
height: isMobile ? "fit-content" : "100%",
|
||||
py: isMobile ? 0 : 1,
|
||||
px: isMobile ? 1 : 0
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, idx) => (
|
||||
<Tab key={idx} icon={tab.icon} iconPosition={isMobile ? "top" : "start"} label={tab.label}
|
||||
sx={{ px: 5, justifyContent: isMobile ? "center" : "left" }} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -1,87 +0,0 @@
|
||||
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 function Component() {
|
||||
const { userinfo, readProfiles, getAtk } = useUserinfo();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
|
||||
async function readNotifications() {
|
||||
const res = await request(`/api/notifications?take=100`, {
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setNotifications(data["data"]);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function markNotifications(item: any) {
|
||||
setLoading(true);
|
||||
const res = await request(`/api/notifications/${item.id}/read`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
readNotifications().then(() => readProfiles());
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
readNotifications().then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress color="info" />
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={error != null}>
|
||||
<Alert severity="error" variant="filled" square>{error}</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={userinfo?.data?.notifications?.length <= 0}>
|
||||
<Alert severity="success" variant="filled" square>You are done! There's no unread notifications for you.</Alert>
|
||||
</Collapse>
|
||||
|
||||
<List sx={{ width: "100%", bgcolor: "background.paper" }}>
|
||||
<TransitionGroup>
|
||||
{notifications.map((item, idx) => (
|
||||
<Collapse key={idx} sx={{ px: 5 }}>
|
||||
<ListItem alignItems="flex-start" secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
title="Delete"
|
||||
onClick={() => markNotifications(item)}
|
||||
>
|
||||
<MarkEmailReadIcon />
|
||||
</IconButton>
|
||||
}>
|
||||
<ListItemText
|
||||
primary={item.subject}
|
||||
secondary={item.content}
|
||||
/>
|
||||
</ListItem>
|
||||
</Collapse>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -1,250 +0,0 @@
|
||||
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 function Component() {
|
||||
const { userinfo, readProfiles, getAtk } = useUserinfo();
|
||||
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(evt: FormEvent<HTMLFormElement>) {
|
||||
evt.preventDefault();
|
||||
|
||||
const data: any = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
||||
if (data.birthday) data.birthday = new Date(data.birthday);
|
||||
|
||||
setLoading(true);
|
||||
const res = await request("/api/users/me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${getAtk()}` },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readProfiles();
|
||||
setDone(true);
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function changeAvatar(evt: ChangeEvent<HTMLInputElement>) {
|
||||
if (!evt.target.files) return;
|
||||
|
||||
const file = evt.target.files[0];
|
||||
const payload = new FormData();
|
||||
payload.set("avatar", file);
|
||||
|
||||
setLoading(true);
|
||||
const res = await request("/api/avatar", {
|
||||
method: "PUT",
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` },
|
||||
body: payload
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readProfiles();
|
||||
setDone(true);
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getBirthday() {
|
||||
return userinfo?.data?.profile?.birthday ? dayjs(userinfo?.data?.profile?.birthday) : undefined;
|
||||
}
|
||||
|
||||
const basisForm = (
|
||||
<Box component="form" onSubmit={submit} sx={{ mt: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
name="name"
|
||||
required
|
||||
disabled
|
||||
fullWidth
|
||||
label="Username"
|
||||
autoComplete="username"
|
||||
defaultValue={userinfo?.data?.name}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
name="nick"
|
||||
required
|
||||
fullWidth
|
||||
label="Nickname"
|
||||
autoComplete="nickname"
|
||||
defaultValue={userinfo?.data?.nick}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
name="description"
|
||||
multiline
|
||||
fullWidth
|
||||
label="Description"
|
||||
autoComplete="bio"
|
||||
defaultValue={userinfo?.data?.description}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={4}>
|
||||
<TextField
|
||||
name="first_name"
|
||||
fullWidth
|
||||
label="First Name"
|
||||
autoComplete="given_name"
|
||||
defaultValue={userinfo?.data?.profile?.first_name}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={4}>
|
||||
<TextField
|
||||
name="last_name"
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
autoComplete="famliy_name"
|
||||
defaultValue={userinfo?.data?.profile?.last_name}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<DatePicker
|
||||
name="birthday"
|
||||
label="Birthday"
|
||||
defaultValue={getBirthday()}
|
||||
sx={{ width: "100%" }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
startIcon={<SaveIcon />}
|
||||
sx={{ mt: 2, width: "180px" }}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2, mx: -3 }} />
|
||||
|
||||
<Box sx={{ mt: 2.5, display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Avatar
|
||||
sx={{ width: 32, height: 32 }}
|
||||
alt={userinfo?.displayName}
|
||||
src={`/api/avatar/${userinfo?.data?.avatar}`}
|
||||
>
|
||||
<NoAccountsIcon />
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box>
|
||||
{/* @ts-ignore */}
|
||||
<Button
|
||||
type="button"
|
||||
color="info"
|
||||
component="label"
|
||||
tabIndex={-1}
|
||||
disabled={loading}
|
||||
startIcon={<PublishIcon />}
|
||||
sx={{ width: "180px" }}
|
||||
>
|
||||
Change avatar
|
||||
<VisuallyHiddenInput type="file" accept="image/*" onChange={changeAvatar} />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container sx={{ pt: 5 }} maxWidth="md">
|
||||
<Box sx={{ px: 3 }}>
|
||||
<Typography variant="h5">Personalize</Typography>
|
||||
<Typography variant="body2">
|
||||
Customize your appearance and name card across all Goatworks information.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={error}>
|
||||
<Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Card variant="outlined">
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress />
|
||||
</Collapse>
|
||||
|
||||
<CardContent style={{ padding: "20px 24px" }}>
|
||||
<Box sx={{ px: 1, my: 1 }}>
|
||||
<Typography variant="h6">Information</Typography>
|
||||
<Typography variant="subtitle2">
|
||||
The information for public. Let us and others better to know who you are.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{
|
||||
userinfo?.data != null ? basisForm :
|
||||
<Box sx={{ pt: 1, px: 1 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={done}
|
||||
autoHideDuration={1000 * 10}
|
||||
onClose={() => setDone(false)}
|
||||
message="Your profile has been updated. Some settings maybe need sometime to apply across site."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
@@ -1,267 +0,0 @@
|
||||
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 function Component() {
|
||||
const dataDefinitions: { [id: string]: GridColDef[] } = {
|
||||
challenges: [
|
||||
{ field: "id", headerName: "ID", width: 64 },
|
||||
{ field: "ip_address", headerName: "IP Address", minWidth: 128 },
|
||||
{ field: "user_agent", headerName: "User Agent", minWidth: 320 },
|
||||
{
|
||||
field: "created_at",
|
||||
headerName: "Issued At",
|
||||
minWidth: 160,
|
||||
valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString()
|
||||
}
|
||||
],
|
||||
sessions: [
|
||||
{ field: "id", headerName: "ID", width: 64 },
|
||||
{
|
||||
field: "audiences",
|
||||
headerName: "Audiences",
|
||||
minWidth: 128,
|
||||
valueGetter: (params: GridValueGetterParams) => params.row.audiences.join(", ")
|
||||
},
|
||||
{
|
||||
field: "claims",
|
||||
headerName: "Claims",
|
||||
minWidth: 224,
|
||||
valueGetter: (params: GridValueGetterParams) => params.row.claims.join(", ")
|
||||
},
|
||||
{
|
||||
field: "created_at",
|
||||
headerName: "Issued At",
|
||||
minWidth: 160,
|
||||
valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString()
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
type: "actions",
|
||||
getActions: (params: GridRowParams) => [
|
||||
<GridActionsCellItem
|
||||
icon={<ExitToAppIcon />}
|
||||
onClick={() => killSession(params.row)}
|
||||
disabled={loading}
|
||||
label="Sign Out"
|
||||
/>
|
||||
]
|
||||
}
|
||||
],
|
||||
events: [
|
||||
{ field: "id", headerName: "ID", width: 64 },
|
||||
{ field: "type", headerName: "Type", minWidth: 128 },
|
||||
{ field: "target", headerName: "Affected Object", minWidth: 128 },
|
||||
{ field: "ip_address", headerName: "IP Address", minWidth: 128 },
|
||||
{ field: "user_agent", headerName: "User Agent", minWidth: 128 },
|
||||
{
|
||||
field: "created_at",
|
||||
headerName: "Performed At",
|
||||
minWidth: 160,
|
||||
valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const { getAtk } = useUserinfo();
|
||||
|
||||
const [challenges, setChallenges] = useState<any[]>([]);
|
||||
const [challengeCount, setChallengeCount] = useState(0);
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [sessionCount, setSessionCount] = useState(0);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventCount, setEventCount] = useState(0);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
challenges: { page: 0, pageSize: 5 },
|
||||
sessions: { page: 0, pageSize: 5 },
|
||||
events: { page: 0, pageSize: 5 }
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reverting] = useState({
|
||||
challenges: true,
|
||||
sessions: true,
|
||||
events: true
|
||||
});
|
||||
|
||||
const [dataPane, setDataPane] = useState("challenges");
|
||||
|
||||
async function readChallenges() {
|
||||
reverting.challenges = true;
|
||||
const res = await request("/api/users/me/challenges?" + new URLSearchParams({
|
||||
take: pagination.challenges.pageSize.toString(),
|
||||
offset: (pagination.challenges.page * pagination.challenges.pageSize).toString()
|
||||
}), {
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setChallenges(data["data"]);
|
||||
setChallengeCount(data["count"]);
|
||||
}
|
||||
reverting.challenges = false;
|
||||
}
|
||||
|
||||
async function readSessions() {
|
||||
reverting.sessions = true;
|
||||
const res = await request("/api/users/me/sessions?" + new URLSearchParams({
|
||||
take: pagination.sessions.pageSize.toString(),
|
||||
offset: (pagination.sessions.page * pagination.sessions.pageSize).toString()
|
||||
}), {
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setSessions(data["data"]);
|
||||
setSessionCount(data["count"]);
|
||||
}
|
||||
reverting.sessions = false;
|
||||
}
|
||||
|
||||
async function readEvents() {
|
||||
reverting.events = true;
|
||||
const res = await request("/api/users/me/events?" + new URLSearchParams({
|
||||
take: pagination.events.pageSize.toString(),
|
||||
offset: (pagination.events.page * pagination.events.pageSize).toString()
|
||||
}), {
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setEvents(data["data"]);
|
||||
setEventCount(data["count"]);
|
||||
}
|
||||
reverting.events = false;
|
||||
}
|
||||
|
||||
async function killSession(item: any) {
|
||||
setLoading(true);
|
||||
const res = await request(`/api/users/me/sessions/${item.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${getAtk()}` }
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
setError(await res.text());
|
||||
} else {
|
||||
await readSessions();
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
readChallenges().then(() => console.log("Refreshed challenges list."));
|
||||
}, [pagination.challenges]);
|
||||
|
||||
useEffect(() => {
|
||||
readSessions().then(() => console.log("Refreshed sessions list."));
|
||||
}, [pagination.sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
readEvents().then(() => console.log("Refreshed events list."));
|
||||
}, [pagination.events]);
|
||||
|
||||
return (
|
||||
<Container sx={{ pt: 5 }} maxWidth="md">
|
||||
<Box sx={{ px: 3 }}>
|
||||
<Typography variant="h5">Security</Typography>
|
||||
<Typography variant="body2">
|
||||
Overview and control all security details in your account.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={error != null}>
|
||||
<Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Card variant="outlined">
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress />
|
||||
</Collapse>
|
||||
|
||||
<TabContext value={dataPane}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tabs centered value={dataPane} onChange={(_, val) => setDataPane(val)}>
|
||||
<Tab label="Challenges" value="challenges" />
|
||||
<Tab label="Sessions" value="sessions" />
|
||||
<Tab label="Events" value="events" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<CardContent style={{ padding: "20px 24px" }}>
|
||||
<TabPanel value={"challenges"}>
|
||||
<DataGrid
|
||||
pageSizeOptions={[5, 10, 15, 20, 25]}
|
||||
paginationMode="server"
|
||||
loading={reverting.challenges}
|
||||
rows={challenges}
|
||||
rowCount={challengeCount}
|
||||
columns={dataDefinitions.challenges}
|
||||
paginationModel={pagination.challenges}
|
||||
onPaginationModelChange={(val) => setPagination({ ...pagination, challenges: val })}
|
||||
checkboxSelection
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={"sessions"}>
|
||||
<DataGrid
|
||||
pageSizeOptions={[5, 10, 15, 20, 25]}
|
||||
paginationMode="server"
|
||||
loading={reverting.sessions}
|
||||
rows={sessions}
|
||||
rowCount={sessionCount}
|
||||
columns={dataDefinitions.sessions}
|
||||
paginationModel={pagination.sessions}
|
||||
onPaginationModelChange={(val) => setPagination({ ...pagination, sessions: val })}
|
||||
checkboxSelection
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={"events"}>
|
||||
<DataGrid
|
||||
pageSizeOptions={[5, 10, 15, 20, 25]}
|
||||
paginationMode="server"
|
||||
loading={reverting.events}
|
||||
rows={events}
|
||||
rowCount={eventCount}
|
||||
columns={dataDefinitions.events}
|
||||
paginationModel={pagination.events}
|
||||
onPaginationModelChange={(val) => setPagination({ ...pagination, events: val })}
|
||||
checkboxSelection
|
||||
/>
|
||||
</TabPanel>
|
||||
</CardContent>
|
||||
</TabContext>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
15
pkg/views/src/router/index.ts
Normal file
15
pkg/views/src/router/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
import MasterLayout from "@/layouts/master.vue"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: MasterLayout,
|
||||
children: [{ path: "/", name: "dashboard", component: () => import("@/views/dashboard.vue") }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
@@ -1,4 +1,10 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__LAUNCHPAD_TARGET__?: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function request(input: string, init?: RequestInit) {
|
||||
const prefix = window.__LAUNCHPAD_TARGET__ ?? "";
|
||||
const prefix = window.__LAUNCHPAD_TARGET__ ?? ""
|
||||
return await fetch(prefix + input, init)
|
||||
}
|
||||
}
|
||||
|
56
pkg/views/src/stores/userinfo.ts
Normal file
56
pkg/views/src/stores/userinfo.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import Cookie from "universal-cookie"
|
||||
import { defineStore } from "pinia"
|
||||
import { ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
|
||||
export interface Userinfo {
|
||||
isReady: boolean
|
||||
isLoggedIn: boolean
|
||||
displayName: string
|
||||
data: any
|
||||
}
|
||||
|
||||
const defaultUserinfo: Userinfo = {
|
||||
isReady: false,
|
||||
isLoggedIn: false,
|
||||
displayName: "Citizen",
|
||||
data: null
|
||||
}
|
||||
|
||||
export function getAtk(): string {
|
||||
return new Cookie().get("identity_auth_key")
|
||||
}
|
||||
|
||||
export function checkLoggedIn(): boolean {
|
||||
return new Cookie().get("identity_auth_key")
|
||||
}
|
||||
|
||||
export const useUserinfo = defineStore("userinfo", () => {
|
||||
const userinfo = ref(defaultUserinfo)
|
||||
const isReady = ref(false)
|
||||
|
||||
async function readProfiles() {
|
||||
if (!checkLoggedIn()) {
|
||||
isReady.value = true;
|
||||
}
|
||||
|
||||
const res = await request("/api/users/me", {
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
userinfo.value = {
|
||||
isReady: true,
|
||||
isLoggedIn: true,
|
||||
displayName: data["nick"],
|
||||
data: data
|
||||
};
|
||||
}
|
||||
|
||||
return { userinfo, isReady, readProfiles }
|
||||
})
|
@@ -1,79 +0,0 @@
|
||||
import Cookie from "universal-cookie";
|
||||
import { request } from "../scripts/request.ts";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
export interface Userinfo {
|
||||
isReady: boolean,
|
||||
isLoggedIn: boolean,
|
||||
displayName: string,
|
||||
data: any,
|
||||
}
|
||||
|
||||
const defaultUserinfo: Userinfo = {
|
||||
isReady: false,
|
||||
isLoggedIn: false,
|
||||
displayName: "Citizen",
|
||||
data: null
|
||||
};
|
||||
|
||||
const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo });
|
||||
|
||||
export function UserinfoProvider(props: any) {
|
||||
const [userinfo, setUserinfo] = useState<Userinfo>(structuredClone(defaultUserinfo));
|
||||
|
||||
function getAtk(): string {
|
||||
return new Cookie().get("identity_auth_key");
|
||||
}
|
||||
|
||||
function checkLoggedIn(): boolean {
|
||||
return new Cookie().get("identity_auth_key");
|
||||
}
|
||||
|
||||
async function readProfiles() {
|
||||
if (!checkLoggedIn()) {
|
||||
setUserinfo((data) => {
|
||||
data.isReady = true;
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
const res = await request("/api/users/me", {
|
||||
headers: { "Authorization": `Bearer ${getAtk()}` }
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
setUserinfo({
|
||||
isReady: true,
|
||||
isLoggedIn: true,
|
||||
displayName: data["nick"],
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
function clearUserinfo() {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i];
|
||||
const eqPos = cookie.indexOf("=");
|
||||
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
|
||||
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
}
|
||||
|
||||
setUserinfo(defaultUserinfo);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserinfoContext.Provider value={{ userinfo, readProfiles, checkLoggedIn, getAtk, clearUserinfo }}>
|
||||
{props.children}
|
||||
</UserinfoContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserinfo() {
|
||||
return useContext(UserinfoContext);
|
||||
}
|
@@ -1,23 +0,0 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { request } from "../scripts/request.ts";
|
||||
|
||||
const WellKnownContext = createContext<any>(null);
|
||||
|
||||
export function WellKnownProvider(props: any) {
|
||||
const [wellKnown, setWellKnown] = useState<any>(null);
|
||||
|
||||
async function readWellKnown() {
|
||||
const res = await request("/.well-known");
|
||||
setWellKnown(await res.json());
|
||||
}
|
||||
|
||||
return (
|
||||
<WellKnownContext.Provider value={{ wellKnown, readWellKnown }}>
|
||||
{props.children}
|
||||
</WellKnownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWellKnown() {
|
||||
return useContext(WellKnownContext);
|
||||
}
|
@@ -1,20 +0,0 @@
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#49509e",
|
||||
},
|
||||
secondary: {
|
||||
main: "#d43630",
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
h1: { fontSize: "2.5rem" },
|
||||
h2: { fontSize: "2rem" },
|
||||
h3: { fontSize: "1.75rem" },
|
||||
h4: { fontSize: "1.5rem" },
|
||||
h5: { fontSize: "1.25rem" },
|
||||
h6: { fontSize: "1.15rem" },
|
||||
},
|
||||
});
|
3
pkg/views/src/views/dashboard.vue
Normal file
3
pkg/views/src/views/dashboard.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<v-container>Hello, world!</v-container>
|
||||
</template>
|
1
pkg/views/src/vite-env.d.ts
vendored
1
pkg/views/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
Reference in New Issue
Block a user