♻️ New UI Login & Register

This commit is contained in:
LittleSheep 2024-02-25 23:12:42 +08:00
parent bb65b11566
commit 518b2f2503
62 changed files with 1114 additions and 3406 deletions

1
.idea/.name Normal file
View File

@ -0,0 +1 @@
Identity

View File

@ -1,5 +0,0 @@
/dist
/node_modules
/package-lock.json
*.lock

18
pkg/view/.eslintrc.cjs Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@ -1,28 +1,30 @@
## Usage
# React + TypeScript + Vite
```bash
$ npm install # or pnpm install or yarn install
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

BIN
pkg/view/bun.lockb Executable file

Binary file not shown.

View File

@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,27 +1,42 @@
{
"name": "@hydrogen/identity-web",
"name": "identity-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@solidjs/router": "^0.10.10",
"solid-js": "^1.8.7",
"universal-cookie": "^7.0.2"
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@unocss/reset": "^0.58.5",
"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",
"sort-by": "^1.2.0",
"universal-cookie": "^7.1.0",
"use-debounce": "^10.0.0"
},
"devDependencies": {
"autoprefixer": "^10.4.17",
"daisyui": "^4.6.0",
"postcss": "^8.4.33",
"solid-devtools": "^0.29.3",
"tailwindcss": "^3.4.1",
"@types/node": "^20.11.20",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-solid": "^2.8.0"
"unocss": "^0.58.5",
"vite": "^5.1.4"
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

0
pkg/view/public/favicon.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false
}

View File

@ -1,184 +0,0 @@
:root {
--bs-body-font-family: "IBM Plex Sans", "Noto Serif SC", sans-serif !important;
}
html,
body {
font-family: var(--bs-body-font-family);
}
/* ibm-plex-sans-100 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 100;
src: url('./ibm-plex-sans-v19-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-100italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 100;
src: url('./ibm-plex-sans-v19-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 200;
src: url('./ibm-plex-sans-v19-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 200;
src: url('./ibm-plex-sans-v19-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 300;
src: url('./ibm-plex-sans-v19-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 300;
src: url('./ibm-plex-sans-v19-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
src: url('./ibm-plex-sans-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 400;
src: url('./ibm-plex-sans-v19-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
src: url('./ibm-plex-sans-v19-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 500;
src: url('./ibm-plex-sans-v19-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
src: url('./ibm-plex-sans-v19-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 600;
src: url('./ibm-plex-sans-v19-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
src: url('./ibm-plex-sans-v19-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-sans-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 700;
src: url('./ibm-plex-sans-v19-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-200 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 200;
src: url("./noto-serif-sc-v22-chinese-simplified-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-300 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 300;
src: url("./noto-serif-sc-v22-chinese-simplified-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-regular - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 400;
src: url("./noto-serif-sc-v22-chinese-simplified-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-500 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 500;
src: url("./noto-serif-sc-v22-chinese-simplified-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-600 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 600;
src: url("./noto-serif-sc-v22-chinese-simplified-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-700 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 700;
src: url("./noto-serif-sc-v22-chinese-simplified-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-900 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 900;
src: url("./noto-serif-sc-v22-chinese-simplified-900.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

@ -0,0 +1,14 @@
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;
}

View File

@ -0,0 +1,136 @@
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 (
<Slide appear={false} direction="down" in={!trigger}>
{children}
</Slide>
);
}
interface AppBarProps extends MuiAppBarProps {
open?: boolean;
}
const ShellAppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== "open",
})<AppBarProps>(({ 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 (
<>
<HideOnScroll window={() => documentWindow}>
<ShellAppBar open={open} 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={{ width: 64, mr: 1, display: !isMobile && open ? "none" : "block" }}
>
<MenuIcon />
</IconButton>
</Toolbar>
</ShellAppBar>
</HideOnScroll>
<Box sx={{ display: "flex" }}>
<AppMain open={open}>
<AppNavigationHeader />
{children}
</AppMain>
<NavigationDrawer open={open} onClose={() => setOpen(false)} />
</Box>
</>
);
}

View File

@ -0,0 +1,151 @@
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 { theme } from "@/theme";
import { Fragment, ReactNode, useState } from "react";
import HomeIcon from "@mui/icons-material/Home";
import ArticleIcon from "@mui/icons-material/Article";
import FeedIcon from "@mui/icons-material/RssFeed";
import InfoIcon from "@mui/icons-material/Info";
import GavelIcon from "@mui/icons-material/Gavel";
import PolicyIcon from "@mui/icons-material/Policy";
import SupervisedUserCircleIcon from "@mui/icons-material/SupervisedUserCircle";
import ExpandLess from "@mui/icons-material/ExpandLess";
import ExpandMore from "@mui/icons-material/ExpandMore";
export interface NavigationItem {
icon?: ReactNode;
title?: string;
link?: string;
divider?: boolean;
children?: NavigationItem[];
}
export const DRAWER_WIDTH = 320;
export const NAVIGATION_ITEMS: NavigationItem[] = [
{ icon: <HomeIcon />, title: "首页", link: "/" },
{ icon: <ArticleIcon />, title: "博客", link: "/posts" },
{
icon: <InfoIcon />, title: "信息中心", children: [
{ icon: <GavelIcon />, title: "用户协议", link: "/i/user-agreement" },
{ icon: <PolicyIcon />, title: "隐私协议", link: "/i/privacy-policy" },
{ icon: <SupervisedUserCircleIcon />, title: "社区准则", link: "/i/community-guidelines" }
]
},
{ divider: true },
{ icon: <FeedIcon />, title: "订阅源", link: "/feed" }
];
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}>
<ListItemButton onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2 }}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.title} />
{open ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
<AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} />
</List>
</Collapse>
</Fragment>
);
} else {
return (
<a key={idx} href={item.link ?? "/"}>
<ListItemButton sx={{ pl: 2 + (depth ?? 0) * 2 }}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.title} />
</ListItemButton>
</a>
);
}
});
}
export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onClose: () => void }) {
return (
<>
<AppNavigationHeader>
{showClose && (
<IconButton onClick={onClose}>
{theme.direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />}
</IconButton>
)}
</AppNavigationHeader>
<Divider />
<List>
<AppNavigationSection items={NAVIGATION_ITEMS} />
</List>
</>
);
}
export const isMobileQuery = theme.breakpoints.down("md");
export default function NavigationDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const isMobile = useMediaQuery(isMobileQuery);
return isMobile ? (
<>
<Box sx={{ flexShrink: 0, width: DRAWER_WIDTH }} />
<Drawer
keepMounted
anchor="right"
variant="temporary"
open={open}
onClose={onClose}
sx={{
"& .MuiDrawer-paper": {
boxSizing: "border-box",
width: DRAWER_WIDTH
}
}}
>
<AppNavigation onClose={onClose} />
</Drawer>
</>
) : (
<Drawer
variant="persistent"
anchor="right"
open={open}
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH
}
}}
>
<AppNavigation showClose onClose={onClose} />
</Drawer>
);
}

1
pkg/view/src/consts.tsx Normal file
View File

@ -0,0 +1 @@
export const SITE_NAME = "Goatpass";

23
pkg/view/src/error.tsx Normal file
View File

@ -0,0 +1,23 @@
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>
);
}

View File

@ -1,8 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
}

View File

@ -1,70 +0,0 @@
import "solid-devtools";
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import "./assets/fonts/fonts.css";
import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import "@fortawesome/fontawesome-free/css/all.min.css";
import RootLayout from "./layouts/RootLayout.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
const root = document.getElementById("root");
const router = (basename?: string) => (
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout} base={basename}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} />
<Route path="/security" component={lazy(() => import("./pages/security.tsx"))} />
<Route path="/personalise" component={lazy(() => import("./pages/personalise.tsx"))} />
<Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} />
<Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} />
<Route path="/auth/o/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} />
<Route path="/auth/o/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
<Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} />
</Router>
</UserinfoProvider>
</WellKnownProvider>
);
declare const __GARFISH_EXPORTS__: {
provider: Object;
registerProvider?: (provider: any) => void;
};
declare global {
interface Window {
__GARFISH__: boolean;
__LAUNCHPAD_TARGET__?: string;
}
}
export const provider = () => ({
render: ({ dom, basename }: { dom: any, basename: string }) => {
render(
() => router(basename),
dom.querySelector("#root")
);
},
destroy: () => {
}
});
if (!window.__GARFISH__) {
console.log("Running directly!")
render(router, root!);
} else if (typeof __GARFISH_EXPORTS__ !== "undefined") {
console.log("Running in launchpad container!")
console.log("Launchpad target:", window.__LAUNCHPAD_TARGET__)
if (__GARFISH_EXPORTS__.registerProvider) {
__GARFISH_EXPORTS__.registerProvider(provider);
} else {
__GARFISH_EXPORTS__.provider = provider;
}
}

View File

@ -1,61 +0,0 @@
import Navigator from "./shared/Navigator.tsx";
import { readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createEffect, createMemo, createSignal, Show } from "solid-js";
import { readWellKnown } from "../stores/wellKnown.tsx";
import { BeforeLeaveEventArgs, useLocation, useNavigate, useSearchParams } from "@solidjs/router";
export default function RootLayout(props: any) {
const [ready, setReady] = createSignal(false);
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
const navigate = useNavigate();
const userinfo = useUserinfo();
const [searchParams] = useSearchParams();
const location = useLocation();
createEffect(() => {
if (ready()) {
keepGate(location.pathname + location.search, searchParams["embedded"] != null);
}
}, [ready, userinfo, location]);
function keepGate(path: string, embedded: boolean, e?: BeforeLeaveEventArgs) {
const pathname = path.split("?")[0];
const whitelist = ["/auth/login", "/auth/register", "/users/me/confirm"];
if (!userinfo?.isLoggedIn && !whitelist.includes(pathname)) {
if (!e?.defaultPrevented) e?.preventDefault();
if (embedded) {
navigate(`/auth/login?redirect_uri=${encodeURIComponent(path)}&embedded=yes`);
} else {
navigate(`/auth/login?redirect_uri=${encodeURIComponent(path)}`);
}
}
}
const mainContentStyles = createMemo(() => {
if (searchParams["embedded"]) {
return "h-screen";
} else {
return "h-[calc(100vh-64px)] mt-[64px]";
}
});
return (
<Show when={ready()} fallback={
<div class="h-screen w-screen flex justify-center items-center">
<div>
<span class="loading loading-lg loading-infinity"></span>
</div>
</div>
}>
<Show when={!searchParams["embedded"]}>
<Navigator />
</Show>
<main class={`${mainContentStyles()} px-5`}>{props.children}</main>
</Show>
);
}

View File

@ -1,75 +0,0 @@
import { For, Match, Show, Switch } from "solid-js";
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
interface MenuItem {
label: string;
href?: string;
children?: MenuItem[];
}
export default function Navigator() {
const nav: MenuItem[] = [
{
label: "You", children: [
{ label: "Dashboard", href: "/" },
{ label: "Security", href: "/security" },
{ label: "Personalise", href: "/personalise" }
]
}
];
const userinfo = useUserinfo();
const navigate = useNavigate();
function logout() {
clearUserinfo();
navigate("/auth/login");
}
return (
<div class="navbar bg-base-100 shadow-md px-5 z-10 fixed top-0">
<div class="navbar-start">
<a class="btn btn-ghost text-xl p-2 w-[48px] h-[48px] max-lg:ml-2.5" href="/">
<img width="40" height="40" src="/favicon.svg" alt="Logo" />
</a>
</div>
<div class="navbar-center flex">
<ul class="menu menu-horizontal px-1">
<For each={nav}>
{(item) => (
<li>
<Show when={item.children} fallback={<a href={item.href}>{item.label}</a>}>
<details>
<summary>
<a href={item.href}>{item.label}</a>
</summary>
<ul class="p-2">
<For each={item.children}>
{(item) =>
<li>
<a href={item.href}>{item.label}</a>
</li>
}
</For>
</ul>
</details>
</Show>
</li>
)}
</For>
</ul>
</div>
<div class="navbar-end pe-5">
<Switch>
<Match when={userinfo?.isLoggedIn}>
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>Logout</button>
</Match>
<Match when={!userinfo?.isLoggedIn}>
<a href="/auth/login" class="btn btn-sm btn-primary">Login</a>
</Match>
</Switch>
</div>
</div>
);
}

60
pkg/view/src/main.tsx Normal file
View File

@ -0,0 +1,60 @@
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 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: <AppShell><Outlet /></AppShell>,
errorElement: <ErrorBoundary />,
children: [
{ path: "/", element: <LandingPage /> }
]
},
{ path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> },
{ path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> }
]);
const element = (
<React.StrictMode>
<ThemeProvider theme={theme}>
<WellKnownProvider>
<UserinfoProvider>
<AppLoader>
<CssBaseline />
<RouterProvider router={router} />
</AppLoader>
</UserinfoProvider>
</WellKnownProvider>
</ThemeProvider>
</React.StrictMode>
);
ReactDOM.createRoot(document.getElementById("root")!).render(element);

View File

@ -1,30 +0,0 @@
import { useSearchParams } from "@solidjs/router";
export default function DefaultCallbackPage() {
const [searchParams] = useSearchParams();
return (
<div class="h-full flex justify-center items-center mx-5">
<div class="card w-screen max-w-[480px] shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
{/* Just Kidding */}
<h1 class="text-xl font-bold">Default Callback</h1>
<p>
If you see this page, it means some genius developer forgot to set the redirect address, so you visited
this default callback address.
General Douglas MacArthur, a five-star general in the United States, commented on this: "If I let my
soldiers use default callbacks, they'd rather die."
The large documentary film "Callback Legend" is currently in theaters.
</p>
</div>
<div class="text-center">
<p>Authorization Code</p>
<code>{searchParams["code"]}</code>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,141 +0,0 @@
import { createSignal, Show } from "solid-js";
import { useLocation, useSearchParams } from "@solidjs/router";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function OauthConnectPage() {
const [title, setTitle] = createSignal("Connect Third-party");
const [subtitle, setSubtitle] = createSignal("Via your Goatpass account");
const [error, setError] = createSignal<string | null>(null);
const [status, setStatus] = createSignal("Handshaking...");
const [loading, setLoading] = createSignal(true);
const [client, setClient] = createSignal<any>(null);
const [searchParams] = useSearchParams();
const userinfo = useUserinfo();
const location = useLocation();
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"]) {
setStatus("Redirecting...");
redirect(data["session"]);
} else {
setTitle(`Connect ${data["client"].name}`);
setSubtitle(`Via ${userinfo?.displayName}`);
setClient(data["client"]);
setLoading(false);
}
}
}
function decline() {
if (window.history.length > 0) {
window.history.back();
} else {
window.close();
}
}
async function approve() {
setLoading(true);
setStatus("Approving...");
const res = await request("/api/auth/o/connect?" + new URLSearchParams({
client_id: searchParams["client_id"] as string,
redirect_uri: encodeURIComponent(searchParams["redirect_uri"] as string),
response_type: "code",
scope: searchParams["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();
setStatus("Redirecting...");
setTimeout(() => redirect(data["session"]), 1850);
}
}
function redirect(session: any) {
const url = `${searchParams["redirect_uri"]}?code=${session["grant_token"]}&state=${searchParams["state"]}`;
window.open(url, "_self");
}
preConnect();
return (
<div class="h-full flex justify-center items-center mx-5">
<div class="card w-screen max-w-[480px] shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
<Show when={loading()}>
<div class="py-16 text-center">
<div class="text-center">
<div>
<span class="loading loading-lg loading-bars"></span>
</div>
<span>{status()}</span>
</div>
</div>
</Show>
<Show when={!loading()}>
<div class="mb-3">
<h2 class="font-bold">About who you connecting to</h2>
<p>{client().description}</p>
</div>
<div class="mb-3">
<h2 class="font-bold">Make sure you trust them</h2>
<p>You may share your personal information after connect them. Learn about their privacy policy and user
agreement to keep your personal information in safe.</p>
</div>
<div class="mb-5">
<h2 class="font-bold">After approve this request</h2>
<p>
You will be redirect to{" "}
<span class="link link-primary cursor-not-allowed">{searchParams["redirect_uri"]}</span>
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2">
<button class="btn btn-accent" onClick={() => decline()}>Decline</button>
<button class="btn btn-primary" onClick={() => approve()}>Approve</button>
</div>
</Show>
</div>
</div>
</div>
);
}

View File

@ -1,241 +0,0 @@
import { readProfiles } from "../../stores/userinfo.tsx";
import { useNavigate, useSearchParams } from "@solidjs/router";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { request } from "../../scripts/request.ts";
export default function LoginPage() {
const [title, setTitle] = createSignal("Sign in");
const [subtitle, setSubtitle] = createSignal("Via your Goatpass account");
const [error, setError] = createSignal<null | string>(null);
const [loading, setLoading] = createSignal(false);
const [factor, setFactor] = createSignal<number>();
const [factors, setFactors] = createSignal<any[]>([]);
const [challenge, setChallenge] = createSignal<any>();
const [stage, setStage] = createSignal("starting");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const handlers: { [id: string]: any } = {
"starting": async (evt: SubmitEvent) => {
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();
setTitle(`Welcome, ${data["display_name"]}`);
setSubtitle("Before continue, we need verify that's you");
setFactors(data["factors"]);
setChallenge(data["challenge"]);
setError(null);
setStage("choosing");
}
setLoading(false);
},
"choosing": async (evt: SubmitEvent) => {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.factor) return;
setLoading(true);
const res = await request(`/api/auth/factors/${data.factor}`, {
method: "POST"
});
if (res.status !== 200 && res.status !== 204) {
setError(await res.text());
} else {
setTitle(`Enter the code`);
setSubtitle(res.status === 204 ? "Enter your credentials" : "Code has been sent to your inbox");
setError(null);
setFactor(parseInt(data.factor as string));
setStage("verifying");
}
setLoading(false);
},
"verifying": 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();
navigate(searchParams["redirect_uri"] ? decodeURIComponent(searchParams["redirect_uri"]) : "/");
} else {
setError(null);
setStage("choosing");
setTitle("Continue verifying");
setSubtitle("You passed one check, but that's not enough.");
setChallenge(data["challenge"]);
}
}
setLoading(false);
}
};
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);
}
}
function getFactorAvailable(factor: any) {
const blacklist: number[] = challenge()?.blacklist_factors ?? [];
return blacklist.includes(factor.id);
}
function getFactorName(factor: any) {
switch (factor.type) {
case 0:
return "Password Verification";
case 1:
return "Email Verification Code";
default:
return "Unknown";
}
}
return (
<div class="h-full flex justify-center items-center mx-5">
<div>
<div class="card w-screen max-w-[480px] shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
<form id="form" onSubmit={(e) => handlers[stage()](e)}>
<Switch>
<Match when={stage() === "starting"}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Account ID</span>
</div>
<input name="id" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Your username, email or phone number.</span>
</div>
</label>
</Match>
<Match when={stage() === "choosing"}>
<div class="join join-vertical w-full">
<For each={factors()}>
{item =>
<input class="join-item btn" type="radio" name="factor"
value={item.id}
disabled={getFactorAvailable(item)}
aria-label={getFactorName(item)}
/>
}
</For>
</div>
<p class="text-center text-sm mt-2">Choose a way to verify that's you</p>
</Match>
<Match when={stage() === "verifying"}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Credentials</span>
</div>
<input name="credentials" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Password or one time password.</span>
</div>
</label>
</Match>
</Switch>
<button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</form>
</div>
<div id="tips" class="flex flex-col gap-4">
<Show when={challenge()}>
<div class="px-7 py-5 text-center bg-base-200">
<progress
class="progress w-2/3"
value={challenge().progress / challenge().requirements * 100}
max="100"
/>
<div class="mt-3 flex justify-center gap-2">
<span>Risk <b>{challenge().risk_level}</b></span>
<span>Progress <b>{challenge().progress}/{challenge().requirements}</b></span>
</div>
</div>
</Show>
<Show when={searchParams["redirect_uri"]}>
<div role="alert" class="px-7 py-5 text-center bg-base-200">
<span>You need to login before access that.</span>
</div>
</Show>
</div>
</div>
<div class="text-sm text-center mt-3">
<a target="_blank" href="/auth/register?closable=yes" class="link">Haven't an account? Click here to create
one!</a>
</div>
</div>
</div>
);
}

View File

@ -1,154 +0,0 @@
import { createSignal, Show } from "solid-js";
import { useWellKnown } from "../../stores/wellKnown.tsx";
import { useNavigate, useSearchParams } from "@solidjs/router";
import { request } from "../../scripts/request.ts";
export default function RegisterPage() {
const [title, setTitle] = createSignal("Create an account");
const [subtitle, setSubtitle] = createSignal("The first step to join our community.");
const [error, setError] = createSignal<null | string>(null);
const [loading, setLoading] = createSignal(false);
const [done, setDone] = createSignal(false);
const [searchParams] = useSearchParams()
const metadata = useWellKnown();
const navigate = useNavigate();
async function submit(evt: SubmitEvent) {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
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);
setTitle("Congratulations!");
setSubtitle("Your account has been created and activation email has sent to your inbox!");
setDone(true);
}
setLoading(false);
}
function callback() {
if(searchParams["closable"]) {
window.close()
} else {
navigate("/auth/login")
}
}
return (
<div class="h-full flex justify-center items-center mx-5">
<div>
<div class="card w-screen max-w-[480px] shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
<Show when={!done()}>
<form id="form" onSubmit={submit}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
<span class="label-text-alt font-bold">Cannot be modify</span>
</div>
<input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Lowercase alphabet and numbers only, maximum 16 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nickname</span>
</div>
<input name="nick" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Maximum length is 24 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Email Address</span>
</div>
<input name="email" type="email" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Do not accept address with plus sign</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Password</span>
</div>
<input name="password" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Must be secure</span>
</div>
</label>
<Show when={!metadata?.open_registration}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Magic Token</span>
</div>
<input name="magic_token" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
This server enabled invitation only, so you need a magic token.
</span>
</div>
</label>
</Show>
<button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</form>
</Show>
<Show when={done()}>
<div class="py-12 text-center">
<h2 class="text-lg font-bold">What's next?</h2>
<span>
<a onClick={() => callback()} class="link">Go login</a>{" "}
then you can take part in the entire smartsheep community.
</span>
</div>
</Show>
</div>
</div>
<div class="text-sm text-center mt-3">
<a onClick={() => callback()} class="link">Already had an account? Login now!</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,327 @@
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 default function SignInPage() {
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 ? "Signing Now..." : "Sign In"}
</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 ? "Signing Now..." : "Sign In"}
</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 ? "Signing Now..." : "Sign In"}
</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 (
<Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Box style={{ width: "100vw", maxWidth: "450px" }}>
{error && <Alert severity="error" 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>
<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>&nbsp;
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>
</Box>
</Box>
);
}

View File

@ -0,0 +1,200 @@
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 default function SignUpPage() {
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 (
<Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Box style={{ width: "100vw", maxWidth: "450px" }}>
{error && <Alert severity="error" 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>
</Box>
</Box>
);
}

View File

@ -1,114 +0,0 @@
import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, For, Show } from "solid-js";
import { request } from "../scripts/request.ts";
export default function DashboardPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<string | null>(null);
function getGreeting() {
const currentHour = new Date().getHours();
if (currentHour >= 0 && currentHour < 12) {
return "Good morning! Wishing you a day filled with joy and success. ☀️";
} else if (currentHour >= 12 && currentHour < 18) {
return "Afternoon! Hope you have a productive and joyful afternoon! ☀️";
} else {
return "Good evening! Wishing you a relaxing and pleasant evening. 🌙";
}
}
async function readNotification(item: any) {
const res = await request(`/api/notifications/${item.id}/read`, {
method: "PUT",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readProfiles();
setError(null);
}
}
return (
<div class="max-w-[720px] mx-auto pt-12">
<div id="greeting" class="px-5">
<h1 class="text-2xl font-bold">{userinfo?.displayName}</h1>
<p>{getGreeting()}</p>
</div>
<div id="alerts">
<Show when={!userinfo?.meta?.confirmed_at}>
<div role="alert" class="alert alert-warning mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span>Your account isn't confirmed yet. Please check your inbox and confirm your account.</span> <br />
<span>Otherwise your account will be deactivate after 48 hours.</span>
</div>
</div>
</Show>
<Show when={error()}>
<div role="alert" class="alert alert-error mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="card shadow-xl mt-5">
<div class="card-body">
<h2 class="card-title">Notifications</h2>
<div class="bg-base-200 mt-3 mx-[-32px]">
<Show when={userinfo?.meta?.notifications?.length <= 0}>
<table class="table">
<tbody>
<tr>
<td class="px-[32px]">You're done! There are no notifications unread for you.</td>
</tr>
</tbody>
</table>
</Show>
<Show when={userinfo?.meta?.notifications?.length > 0}>
<table class="table">
<tbody>
<For each={userinfo?.meta?.notifications}>
{item =>
<tr>
<td class="px-[32px]">
<h2 class="font-bold">{item.subject}</h2>
<p>{item.content}</p>
<div class="flex gap-2">
<For each={item.links}>
{item => <a class="link" href={item.url}>{item.label}</a>}
</For>
</div>
<div class="flex gap-2">
<Show when={item.is_important}>
<span class="font-bold">Important</span>
</Show>
<a class="link" onClick={() => readNotification(item)}>Mark as read</a>
</div>
</td>
</tr>
}
</For>
</tbody>
</table>
</Show>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { Button, Container, Grid, Typography } from "@mui/material";
import { Link as RouterLink } from "react-router-dom";
export default function LandingPage() {
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>
);
}

View File

@ -1,164 +0,0 @@
import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, Show } from "solid-js";
import { request } from "../scripts/request.ts";
export default function PersonalPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<null | string>(null);
const [success, setSuccess] = createSignal<null | string>(null);
const [loading, setLoading] = createSignal(false);
async function updateBasis(evt: SubmitEvent) {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
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) {
setSuccess(null);
setError(await res.text());
} else {
await readProfiles();
setSuccess("Your basic information has been update.");
setError(null);
}
setLoading(false);
}
async function updateAvatar(evt: SubmitEvent) {
evt.preventDefault();
setLoading(true);
const data = new FormData(evt.target as HTMLFormElement);
const res = await request("/api/avatar", {
method: "PUT",
headers: { "Authorization": `Bearer ${getAtk()}` },
body: data
});
if (res.status !== 200) {
setSuccess(null);
setError(await res.text());
} else {
await readProfiles();
setSuccess("Your avatar has been update.");
setError(null);
}
setLoading(false);
}
return (
<div class="max-w-[720px] mx-auto pt-12">
<div class="px-5">
<h1 class="text-2xl font-bold">Personalize</h1>
<p>Customize your account and let us provide a better service to you.</p>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error mt-3">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
<Show when={success()}>
<div role="alert" class="alert alert-success mt-3">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{success()}</span>
</div>
</Show>
</div>
<div class="card shadow-xl mt-5">
<div class="card-body border-b border-base-200">
<form class="grid grid-cols-1 gap-2" onSubmit={updateBasis}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
</div>
<input value={userinfo?.meta?.name} name="name" type="text" placeholder="Type here"
class="input input-bordered w-full" disabled />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nickname</span>
</div>
<input value={userinfo?.meta?.nick} name="nick" type="text" placeholder="Type here"
class="input input-bordered w-full" />
</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-4">
<label class="form-control w-full">
<div class="label">
<span class="label-text">First Name</span>
</div>
<input value={userinfo?.meta?.profile?.first_name} name="first_name" type="text"
placeholder="Type here" class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Middle Name</span>
</div>
<input value={userinfo?.meta?.profile?.middle_name} name="middle_name" type="text"
placeholder="Type here" class="input input-bordered w-full" />
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Last Name</span>
</div>
<input value={userinfo?.meta?.profile?.last_name} name="last_name" type="text"
placeholder="Type here" class="input input-bordered w-full" />
</label>
</div>
<div>
<button type="submit" class="btn btn-primary mt-5" disabled={loading()}>
<Show when={loading()} fallback={"Save changes"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</div>
</form>
</div>
<div class="card-body">
<form onSubmit={updateAvatar}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Pick an avatar</span>
</div>
<input type="file" name="avatar" accept="image/*" class="file-input file-input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Will took some time to apply to entire site</span>
</div>
</label>
<button type="submit" class="btn btn-primary mt-5" disabled={loading()}>
<Show when={loading()} fallback={"Save changes"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -1,235 +0,0 @@
import { getAtk } from "../stores/userinfo.tsx";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { request } from "../scripts/request.ts";
export default function DashboardPage() {
const [challenges, setChallenges] = createSignal<any[]>([]);
const [challengeCount, setChallengeCount] = createSignal(0);
const [sessions, setSessions] = createSignal<any[]>([]);
const [sessionCount, setSessionCount] = createSignal(0);
const [events, setEvents] = createSignal<any[]>([]);
const [eventCount, setEventCount] = createSignal(0);
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [contentTab, setContentTab] = createSignal(0);
async function readChallenges() {
const res = await request("/api/users/me/challenges?take=10", {
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setChallenges(data["data"]);
setChallengeCount(data["count"]);
}
}
async function readSessions() {
const res = await request("/api/users/me/sessions?take=10", {
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setSessions(data["data"]);
setSessionCount(data["count"]);
}
}
async function readEvents() {
const res = await request("/api/users/me/events?take=10", {
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setEvents(data["data"]);
setEventCount(data["count"]);
}
}
async function killSession(item: any) {
setSubmitting(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);
}
setSubmitting(false);
}
readChallenges();
readSessions();
readEvents();
return (
<div class="max-w-[720px] mx-auto pt-12">
<div id="greeting" class="px-5">
<h1 class="text-2xl font-bold">Security</h1>
<p>Here is your account status of security.</p>
</div>
<div id="alerts">
<Show when={error()}>
<div role="alert" class="alert alert-error mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div id="overview" class="mt-5">
<div class="stats shadow w-full">
<div class="stat">
<div class="stat-figure text-secondary">
<i class="fa-solid fa-door-open inline-block text-[28px] w-8 h-8 stroke-current"></i>
</div>
<div class="stat-title">Challenges</div>
<div class="stat-value">{challengeCount()}</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<i class="fa-solid fa-key inline-block text-[28px] w-8 h-8 stroke-current"></i>
</div>
<div class="stat-title">Sessions</div>
<div class="stat-value">{sessionCount()}</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<i class="fa-solid fa-person-walking inline-block text-[28px] w-8 h-8 stroke-current"></i>
</div>
<div class="stat-title">Events</div>
<div class="stat-value">{eventCount()}</div>
</div>
</div>
</div>
<div id="switch-area" class="mt-5">
<div role="tablist" class="tabs tabs-boxed">
<input type="radio" name="content-switch" role="tab" class="tab" aria-label="Challenges"
checked={contentTab() === 0} onChange={() => setContentTab(0)} />
<input type="radio" name="content-switch" role="tab" class="tab" aria-label="Sessions"
checked={contentTab() === 1} onChange={() => setContentTab(1)} />
<input type="radio" name="content-switch" role="tab" class="tab" aria-label="Events"
checked={contentTab() === 2} onChange={() => setContentTab(2)} />
</div>
</div>
<div id="data-area" class="mt-5 shadow">
<Switch>
<Match when={contentTab() === 0}>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>State</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<For each={challenges()}>
{item => <tr>
<th>{item.id}</th>
<td>{item.state}</td>
<td>{item.ip_address}</td>
<td>
<span class="tooltip" data-tip={item.user_agent}>
{item.user_agent.substring(0, 10) + "..."}
</span>
</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
</tr>}
</For>
</tbody>
</table>
</div>
</Match>
<Match when={contentTab() === 1}>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>Third Client</th>
<th>Audiences</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={sessions()}>
{item => <tr>
<th>{item.id}</th>
<td>{item.client_id ? "Linked" : "Non-linked"}</td>
<td>{item.audiences?.join(", ")}</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
<td class="py-0">
<button disabled={submitting()} onClick={() => killSession(item)}>
<i class="fa-solid fa-right-from-bracket"></i>
</button>
</td>
</tr>}
</For>
</tbody>
</table>
</div>
</Match>
<Match when={contentTab() === 2}>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>Type</th>
<th>Target</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<For each={events()}>
{item => <tr>
<th>{item.id}</th>
<td>{item.type}</td>
<td>{item.target}</td>
<td>{item.ip_address}</td>
<td>
<span class="tooltip" data-tip={item.user_agent}>
{item.user_agent.substring(0, 10) + "..."}
</span>
</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
</tr>}
</For>
</tbody>
</table>
</div>
</Match>
</Switch>
</div>
</div>
);
}

View File

@ -1,71 +0,0 @@
import { createSignal, Show } from "solid-js";
import { useNavigate, useSearchParams } from "@solidjs/router";
import { readProfiles } from "../../stores/userinfo.tsx";
import { request } from "../../scripts/request.ts";
export default function ConfirmRegistrationPage() {
const [error, setError] = createSignal<string | null>(null);
const [status, setStatus] = createSignal("Confirming your account...");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
async function doConfirm() {
if (!searchParams["tk"]) {
setError("Bad Request: Code was not exists");
}
const res = await request("/api/users/me/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: searchParams["tk"]
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
setStatus("Confirmed. Redirecting to dashboard...");
await readProfiles();
navigate("/");
}
}
doConfirm();
return (
<div class="h-full flex justify-center items-center mx-5">
<div class="card w-screen max-w-[480px] shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">Confirm your account</h1>
<p>Hold on, we are working on it. Almost finished.</p>
</div>
<div class="pt-16 text-center">
<div class="text-center">
<div>
<span class="loading loading-lg loading-bars"></span>
</div>
<span>{status()}</span>
</div>
</div>
<Show when={error()} fallback={<div class="mt-16"></div>}>
<div id="alerts" class="mt-16">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,6 @@
import Cookie from "universal-cookie";
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { request } from "../scripts/request.ts";
import { createContext, useContext, useState } from "react";
export interface Userinfo {
isLoggedIn: boolean,
@ -10,8 +9,6 @@ export interface Userinfo {
meta: any
}
const UserinfoContext = createContext<Userinfo>();
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
@ -19,53 +16,55 @@ const defaultUserinfo: Userinfo = {
meta: null
};
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
export function getAtk(): string {
return new Cookie().get("identity_auth_key");
}
function checkLoggedIn(): boolean {
return new Cookie().get("identity_auth_key");
}
export async function readProfiles() {
if (!checkLoggedIn()) return;
const res = await request("/api/users/me", {
credentials: "include"
});
if (res.status !== 200) {
clearUserinfo();
window.location.reload();
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["nick"],
profiles: null,
meta: data
});
}
export 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);
}
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()) return;
const res = await request("/api/users/me", {
credentials: "include"
});
if (res.status !== 200) {
clearUserinfo();
window.location.reload();
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["nick"],
profiles: null,
meta: 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}>
<UserinfoContext.Provider value={{ userinfo, readProfiles, checkLoggedIn, getAtk, clearUserinfo }}>
{props.children}
</UserinfoContext.Provider>
);

View File

@ -1,19 +1,18 @@
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { createContext, useContext, useState } from "react";
import { request } from "../scripts/request.ts";
const WellKnownContext = createContext<any>();
const [wellKnown, setWellKnown] = createStore<any>(null);
export async function readWellKnown() {
const res = await request("/.well-known")
setWellKnown(await res.json())
}
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}>
<WellKnownContext.Provider value={{ wellKnown, readWellKnown }}>
{props.children}
</WellKnownContext.Provider>
);

20
pkg/view/src/theme.ts Normal file
View File

@ -0,0 +1,20 @@
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" },
},
});

View File

@ -1,44 +0,0 @@
/** @type {import("tailwindcss").Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}"
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
},
{
dark: {
...require("daisyui/src/theming/themes")["dark"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
}
]
},
plugins: [require("daisyui")]
};

View File

@ -2,8 +2,8 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
@ -12,14 +12,18 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -4,7 +4,8 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

5
pkg/view/uno.config.ts Normal file
View File

@ -0,0 +1,5 @@
import { defineConfig, presetUno } from "unocss";
export default defineConfig({
presets: [presetUno({ preflight: false })]
});

View File

@ -1,13 +1,20 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
import { defineConfig } from 'vite'
import path from "path";
import react from '@vitejs/plugin-react-swc'
import unocss from "unocss/vite"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [devtools({ autoname: true }), solid()],
plugins: [react(), unocss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://localhost:8444",
"/.well-known": "http://localhost:8444"
"/.well-known": "http://localhost:8444",
"/api": "http://localhost:8444"
}
}
});
})

File diff suppressed because it is too large Load Diff