♻️ New UI Login & Register
This commit is contained in:
parent
bb65b11566
commit
518b2f2503
1
.idea/.name
Normal file
1
.idea/.name
Normal file
@ -0,0 +1 @@
|
|||||||
|
Identity
|
@ -1,5 +0,0 @@
|
|||||||
/dist
|
|
||||||
/node_modules
|
|
||||||
/package-lock.json
|
|
||||||
|
|
||||||
*.lock
|
|
18
pkg/view/.eslintrc.cjs
Normal file
18
pkg/view/.eslintrc.cjs
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
@ -1,28 +1,30 @@
|
|||||||
## Usage
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
```bash
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
$ npm install # or pnpm install or yarn install
|
|
||||||
|
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)
|
- 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`
|
||||||
## Available Scripts
|
- 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
|
||||||
|
|
||||||
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)
|
|
||||||
|
BIN
pkg/view/bun.lockb
Executable file
BIN
pkg/view/bun.lockb
Executable file
Binary file not shown.
@ -8,6 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,27 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "@hydrogen/identity-web",
|
"name": "identity-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
"@emotion/react": "^11.11.3",
|
||||||
"@solidjs/router": "^0.10.10",
|
"@emotion/styled": "^11.11.0",
|
||||||
"solid-js": "^1.8.7",
|
"@mui/icons-material": "^5.15.10",
|
||||||
"universal-cookie": "^7.0.2"
|
"@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": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^10.4.17",
|
"@types/node": "^20.11.20",
|
||||||
"daisyui": "^4.6.0",
|
"@types/react": "^18.2.56",
|
||||||
"postcss": "^8.4.33",
|
"@types/react-dom": "^18.2.19",
|
||||||
"solid-devtools": "^0.29.3",
|
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"@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",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.8",
|
"unocss": "^0.58.5",
|
||||||
"vite-plugin-solid": "^2.8.0"
|
"vite": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
0
pkg/view/public/favicon.svg
Normal file → Executable file
0
pkg/view/public/favicon.svg
Normal file → Executable file
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 120,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
@ -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+ */
|
|
||||||
}
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
14
pkg/view/src/components/AppLoader.tsx
Normal file
14
pkg/view/src/components/AppLoader.tsx
Normal 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;
|
||||||
|
}
|
136
pkg/view/src/components/AppShell.tsx
Normal file
136
pkg/view/src/components/AppShell.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
151
pkg/view/src/components/NavigationDrawer.tsx
Normal file
151
pkg/view/src/components/NavigationDrawer.tsx
Normal 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
1
pkg/view/src/consts.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const SITE_NAME = "Goatpass";
|
23
pkg/view/src/error.tsx
Normal file
23
pkg/view/src/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,8 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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
60
pkg/view/src/main.tsx
Normal 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);
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
327
pkg/view/src/pages/auth/sign-in.tsx
Normal file
327
pkg/view/src/pages/auth/sign-in.tsx
Normal 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>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
200
pkg/view/src/pages/auth/sign-up.tsx
Normal file
200
pkg/view/src/pages/auth/sign-up.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
22
pkg/view/src/pages/landing.tsx
Normal file
22
pkg/view/src/pages/landing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,7 +1,6 @@
|
|||||||
import Cookie from "universal-cookie";
|
import Cookie from "universal-cookie";
|
||||||
import { createContext, useContext } from "solid-js";
|
|
||||||
import { createStore } from "solid-js/store";
|
|
||||||
import { request } from "../scripts/request.ts";
|
import { request } from "../scripts/request.ts";
|
||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
export interface Userinfo {
|
export interface Userinfo {
|
||||||
isLoggedIn: boolean,
|
isLoggedIn: boolean,
|
||||||
@ -10,8 +9,6 @@ export interface Userinfo {
|
|||||||
meta: any
|
meta: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserinfoContext = createContext<Userinfo>();
|
|
||||||
|
|
||||||
const defaultUserinfo: Userinfo = {
|
const defaultUserinfo: Userinfo = {
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
displayName: "Citizen",
|
displayName: "Citizen",
|
||||||
@ -19,17 +16,20 @@ const defaultUserinfo: Userinfo = {
|
|||||||
meta: null
|
meta: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
|
const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo });
|
||||||
|
|
||||||
export function getAtk(): string {
|
export function UserinfoProvider(props: any) {
|
||||||
|
const [userinfo, setUserinfo] = useState<Userinfo>(structuredClone(defaultUserinfo));
|
||||||
|
|
||||||
|
function getAtk(): string {
|
||||||
return new Cookie().get("identity_auth_key");
|
return new Cookie().get("identity_auth_key");
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkLoggedIn(): boolean {
|
function checkLoggedIn(): boolean {
|
||||||
return new Cookie().get("identity_auth_key");
|
return new Cookie().get("identity_auth_key");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readProfiles() {
|
async function readProfiles() {
|
||||||
if (!checkLoggedIn()) return;
|
if (!checkLoggedIn()) return;
|
||||||
|
|
||||||
const res = await request("/api/users/me", {
|
const res = await request("/api/users/me", {
|
||||||
@ -49,9 +49,9 @@ export async function readProfiles() {
|
|||||||
profiles: null,
|
profiles: null,
|
||||||
meta: data
|
meta: data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearUserinfo() {
|
function clearUserinfo() {
|
||||||
const cookies = document.cookie.split(";");
|
const cookies = document.cookie.split(";");
|
||||||
for (let i = 0; i < cookies.length; i++) {
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
const cookie = cookies[i];
|
const cookie = cookies[i];
|
||||||
@ -61,11 +61,10 @@ export function clearUserinfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUserinfo(defaultUserinfo);
|
setUserinfo(defaultUserinfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserinfoProvider(props: any) {
|
|
||||||
return (
|
return (
|
||||||
<UserinfoContext.Provider value={userinfo}>
|
<UserinfoContext.Provider value={{ userinfo, readProfiles, checkLoggedIn, getAtk, clearUserinfo }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</UserinfoContext.Provider>
|
</UserinfoContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -1,19 +1,18 @@
|
|||||||
import { createContext, useContext } from "solid-js";
|
import { createContext, useContext, useState } from "react";
|
||||||
import { createStore } from "solid-js/store";
|
|
||||||
import { request } from "../scripts/request.ts";
|
import { request } from "../scripts/request.ts";
|
||||||
|
|
||||||
const WellKnownContext = createContext<any>();
|
const WellKnownContext = createContext<any>(null);
|
||||||
|
|
||||||
const [wellKnown, setWellKnown] = createStore<any>(null);
|
|
||||||
|
|
||||||
export async function readWellKnown() {
|
|
||||||
const res = await request("/.well-known")
|
|
||||||
setWellKnown(await res.json())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WellKnownProvider(props: any) {
|
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 (
|
return (
|
||||||
<WellKnownContext.Provider value={wellKnown}>
|
<WellKnownContext.Provider value={{ wellKnown, readWellKnown }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</WellKnownContext.Provider>
|
</WellKnownContext.Provider>
|
||||||
);
|
);
|
||||||
|
20
pkg/view/src/theme.ts
Normal file
20
pkg/view/src/theme.ts
Normal 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" },
|
||||||
|
},
|
||||||
|
});
|
@ -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")]
|
|
||||||
};
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
@ -12,14 +12,18 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "solid-js",
|
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
@ -4,7 +4,8 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
5
pkg/view/uno.config.ts
Normal file
5
pkg/view/uno.config.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig, presetUno } from "unocss";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [presetUno({ preflight: false })]
|
||||||
|
});
|
@ -1,13 +1,20 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from 'vite'
|
||||||
import solid from "vite-plugin-solid";
|
import path from "path";
|
||||||
import devtools from "solid-devtools/vite";
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
import unocss from "unocss/vite"
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [devtools({ autoname: true }), solid()],
|
plugins: [react(), unocss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8444",
|
"/.well-known": "http://localhost:8444",
|
||||||
"/.well-known": "http://localhost:8444"
|
"/api": "http://localhost:8444"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
1689
pkg/view/yarn.lock
1689
pkg/view/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user