💄 全新设计重构 #2
| @@ -1,18 +1,15 @@ | ||||
| /* eslint-env node */ | ||||
| require("@rushstack/eslint-patch/modern-module-resolution") | ||||
|  | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   env: { browser: true, es2020: true }, | ||||
|   extends: [ | ||||
|     'eslint:recommended', | ||||
|     'plugin:@typescript-eslint/recommended', | ||||
|     'plugin:react-hooks/recommended', | ||||
|     "plugin:vue/vue3-essential", | ||||
|     "eslint:recommended", | ||||
|     "@vue/eslint-config-typescript", | ||||
|     "@vue/eslint-config-prettier/skip-formatting", | ||||
|   ], | ||||
|   ignorePatterns: ['dist', '.eslintrc.cjs'], | ||||
|   parser: '@typescript-eslint/parser', | ||||
|   plugins: ['react-refresh'], | ||||
|   rules: { | ||||
|     'react-refresh/only-export-components': [ | ||||
|       'warn', | ||||
|       { allowConstantExport: true }, | ||||
|     ], | ||||
|   parserOptions: { | ||||
|     ecmaVersion: "latest", | ||||
|   }, | ||||
| } | ||||
|   | ||||
							
								
								
									
										8
									
								
								pkg/views/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								pkg/views/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -8,17 +8,23 @@ pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| node_modules | ||||
| .DS_Store | ||||
| dist | ||||
| dist-ssr | ||||
| coverage | ||||
| *.local | ||||
|  | ||||
| /cypress/videos/ | ||||
| /cypress/screenshots/ | ||||
|  | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| .DS_Store | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
|  | ||||
| *.tsbuildinfo | ||||
|   | ||||
							
								
								
									
										8
									
								
								pkg/views/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								pkg/views/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/prettierrc", | ||||
|   "semi": false, | ||||
|   "tabWidth": 2, | ||||
|   "singleQuote": false, | ||||
|   "printWidth": 120, | ||||
|   "trailingComma": "all" | ||||
| } | ||||
							
								
								
									
										3
									
								
								pkg/views/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								pkg/views/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| { | ||||
|   "recommendations": ["Vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] | ||||
| } | ||||
| @@ -1,30 +1,39 @@ | ||||
| # React + TypeScript + Vite | ||||
| # views | ||||
|  | ||||
| This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. | ||||
| This template should help get you started developing with Vue 3 in Vite. | ||||
|  | ||||
| Currently, two official plugins are available: | ||||
| ## Recommended IDE Setup | ||||
|  | ||||
| - [@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 | ||||
| [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). | ||||
|  | ||||
| ## Expanding the ESLint configuration | ||||
| ## Type Support for `.vue` Imports in TS | ||||
|  | ||||
| If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: | ||||
| TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. | ||||
|  | ||||
| - Configure the top-level `parserOptions` property like this: | ||||
| ## Customize configuration | ||||
|  | ||||
| ```js | ||||
| export default { | ||||
|   // other rules... | ||||
|   parserOptions: { | ||||
|     ecmaVersion: 'latest', | ||||
|     sourceType: 'module', | ||||
|     project: ['./tsconfig.json', './tsconfig.node.json'], | ||||
|     tsconfigRootDir: __dirname, | ||||
|   }, | ||||
| } | ||||
| See [Vite Configuration Reference](https://vitejs.dev/config/). | ||||
|  | ||||
| ## Project Setup | ||||
|  | ||||
| ```sh | ||||
| npm install | ||||
| ``` | ||||
|  | ||||
| - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` | ||||
| - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` | ||||
| - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list | ||||
| ### Compile and Hot-Reload for Development | ||||
|  | ||||
| ```sh | ||||
| npm run dev | ||||
| ``` | ||||
|  | ||||
| ### Type-Check, Compile and Minify for Production | ||||
|  | ||||
| ```sh | ||||
| npm run build | ||||
| ``` | ||||
|  | ||||
| ### Lint with [ESLint](https://eslint.org/) | ||||
|  | ||||
| ```sh | ||||
| npm run lint | ||||
| ``` | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -2,12 +2,12 @@ | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> | ||||
|     <link rel="icon" href="/favicon.ico" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Goatpass</title> | ||||
|     <title>Solarpass</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="/src/main.tsx"></script> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/main.ts"></script> | ||||
|   </body> | ||||
| </html> | ||||
|   | ||||
| @@ -1,47 +1,43 @@ | ||||
| { | ||||
|   "name": "identity-web", | ||||
|   "private": true, | ||||
|   "name": "views", | ||||
|   "version": "0.0.0", | ||||
|   "private": true, | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "tsc && vite build", | ||||
|     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", | ||||
|     "preview": "vite preview" | ||||
|     "build": "run-p type-check \"build-only {@}\" --", | ||||
|     "preview": "vite preview", | ||||
|     "build-only": "vite build", | ||||
|     "type-check": "vue-tsc --build --force", | ||||
|     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", | ||||
|     "format": "prettier --write src/" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@emotion/react": "^11.11.3", | ||||
|     "@emotion/styled": "^11.11.0", | ||||
|     "@fontsource/roboto": "^5.0.8", | ||||
|     "@mui/icons-material": "^5.15.10", | ||||
|     "@mui/lab": "^5.0.0-alpha.166", | ||||
|     "@mui/material": "^5.15.10", | ||||
|     "@mui/x-data-grid": "^6.19.5", | ||||
|     "@mui/x-date-pickers": "^6.19.5", | ||||
|     "@fontsource/roboto": "^5.0.12", | ||||
|     "@mdi/font": "^7.4.47", | ||||
|     "@unocss/reset": "^0.58.5", | ||||
|     "dayjs": "^1.11.10", | ||||
|     "localforage": "^1.10.0", | ||||
|     "match-sorter": "^6.3.4", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-router-dom": "^6.22.1", | ||||
|     "react-transition-group": "^4.4.5", | ||||
|     "sort-by": "^1.2.0", | ||||
|     "pinia": "^2.1.7", | ||||
|     "universal-cookie": "^7.1.0", | ||||
|     "use-debounce": "^10.0.0" | ||||
|     "unocss": "^0.58.5", | ||||
|     "vue": "^3.4.21", | ||||
|     "vue-router": "^4.3.0", | ||||
|     "vuetify": "^3.5.8" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/node": "^20.11.20", | ||||
|     "@types/react": "^18.2.56", | ||||
|     "@types/react-dom": "^18.2.19", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.0.2", | ||||
|     "@typescript-eslint/parser": "^7.0.2", | ||||
|     "@vitejs/plugin-react-swc": "^3.5.0", | ||||
|     "eslint": "^8.56.0", | ||||
|     "eslint-plugin-react-hooks": "^4.6.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.5", | ||||
|     "typescript": "^5.2.2", | ||||
|     "unocss": "^0.58.5", | ||||
|     "vite": "^5.1.4" | ||||
|     "@rushstack/eslint-patch": "^1.3.3", | ||||
|     "@tsconfig/node20": "^20.1.2", | ||||
|     "@types/node": "^20.11.25", | ||||
|     "@vitejs/plugin-vue": "^5.0.4", | ||||
|     "@vitejs/plugin-vue-jsx": "^3.1.0", | ||||
|     "@vue/eslint-config-prettier": "^8.0.0", | ||||
|     "@vue/eslint-config-typescript": "^12.0.0", | ||||
|     "@vue/tsconfig": "^0.5.1", | ||||
|     "eslint": "^8.49.0", | ||||
|     "eslint-plugin-vue": "^9.17.0", | ||||
|     "npm-run-all2": "^6.1.2", | ||||
|     "prettier": "^3.0.3", | ||||
|     "typescript": "~5.4.0", | ||||
|     "vite": "^5.1.5", | ||||
|     "vue-tsc": "^2.0.6" | ||||
|   } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| Before Width: | Height: | Size: 12 KiB | 
							
								
								
									
										15
									
								
								pkg/views/src/assets/utils.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								pkg/views/src/assets/utils.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| html, | ||||
| body, | ||||
| #app, | ||||
| .v-application { | ||||
|   overflow: auto !important; | ||||
|   font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| .no-scrollbar { | ||||
|   scrollbar-width: none; | ||||
| } | ||||
|  | ||||
| .no-scrollbar::-webkit-scrollbar { | ||||
|   width: 0; | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| import { ReactNode, useEffect } from "react"; | ||||
| import { useWellKnown } from "@/stores/wellKnown.tsx"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| export default function AppLoader({ children }: { children: ReactNode }) { | ||||
|   const { readWellKnown } = useWellKnown(); | ||||
|   const { readProfiles } = useUserinfo(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     Promise.all([readWellKnown(), readProfiles()]); | ||||
|   }, []); | ||||
|  | ||||
|   return children; | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| import { | ||||
|   AppBar, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   IconButton, | ||||
|   Slide, | ||||
|   Toolbar, | ||||
|   Typography, | ||||
|   useMediaQuery, | ||||
|   useScrollTrigger | ||||
| } from "@mui/material"; | ||||
| import { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; | ||||
| import { SITE_NAME } from "@/consts"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import NavigationMenu, { AppNavigationHeader, isMobileQuery } from "@/components/NavigationMenu.tsx"; | ||||
| import AccountCircleIcon from "@mui/icons-material/AccountCircleOutlined"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| function HideOnScroll(props: { window?: () => Window; children: ReactElement }) { | ||||
|   const { children, window } = props; | ||||
|   const trigger = useScrollTrigger({ | ||||
|     target: window ? window() : undefined | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <Slide appear={false} direction="down" in={!trigger}> | ||||
|       {children} | ||||
|     </Slide> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default function AppShell({ children }: { children: ReactNode }) { | ||||
|   let documentWindow: Window; | ||||
|  | ||||
|   const { userinfo } = useUserinfo(); | ||||
|  | ||||
|   const isMobile = useMediaQuery(isMobileQuery); | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     documentWindow = window; | ||||
|   }, []); | ||||
|  | ||||
|   const container = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <HideOnScroll window={() => documentWindow}> | ||||
|         <AppBar position="fixed"> | ||||
|           <Toolbar sx={{ height: 64 }}> | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               edge="start" | ||||
|               color="inherit" | ||||
|               aria-label="menu" | ||||
|               sx={{ ml: isMobile ? 0.5 : 0, mr: 2 }} | ||||
|             > | ||||
|               <img src="/favicon.svg" alt="Logo" width={32} height={32} /> | ||||
|             </IconButton> | ||||
|  | ||||
|             <Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}> | ||||
|               <Link to="/">{SITE_NAME}</Link> | ||||
|             </Typography> | ||||
|  | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               edge="start" | ||||
|               color="inherit" | ||||
|               aria-label="menu" | ||||
|               onClick={() => setOpen(true)} | ||||
|               sx={{ mr: 1 }} | ||||
|             > | ||||
|               <Avatar | ||||
|                 sx={{ width: 32, height: 32, bgcolor: "transparent" }} | ||||
|                 ref={container} | ||||
|                 alt={userinfo?.displayName} | ||||
|                 src={`/api/avatar/${userinfo?.data?.avatar}`} | ||||
|               > | ||||
|                 <AccountCircleIcon /> | ||||
|               </Avatar> | ||||
|             </IconButton> | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|       </HideOnScroll> | ||||
|  | ||||
|       <Box component="main"> | ||||
|         <AppNavigationHeader /> | ||||
|  | ||||
|         {children} | ||||
|       </Box> | ||||
|  | ||||
|       <NavigationMenu anchorEl={container.current} open={open} onClose={() => setOpen(false)} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,98 +0,0 @@ | ||||
| import { Collapse, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled } from "@mui/material"; | ||||
| import { theme } from "@/theme"; | ||||
| import { Fragment, ReactNode, useState } from "react"; | ||||
| import HowToRegIcon from "@mui/icons-material/HowToReg"; | ||||
| import LoginIcon from "@mui/icons-material/Login"; | ||||
| import FaceIcon from "@mui/icons-material/Face"; | ||||
| import LogoutIcon from "@mui/icons-material/ExitToApp"; | ||||
| import ExpandLess from "@mui/icons-material/ExpandLess"; | ||||
| import ExpandMore from "@mui/icons-material/ExpandMore"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { PopoverProps } from "@mui/material/Popover"; | ||||
| import { Link } from "react-router-dom"; | ||||
|  | ||||
| export interface NavigationItem { | ||||
|   icon?: ReactNode; | ||||
|   title?: string; | ||||
|   link?: string; | ||||
|   divider?: boolean; | ||||
|   children?: NavigationItem[]; | ||||
| } | ||||
|  | ||||
| export const DRAWER_WIDTH = 320; | ||||
|  | ||||
| export const AppNavigationHeader = styled("div")(({ theme }) => ({ | ||||
|   display: "flex", | ||||
|   alignItems: "center", | ||||
|   padding: theme.spacing(0, 1), | ||||
|   justifyContent: "flex-start", | ||||
|   height: 64, | ||||
|   ...theme.mixins.toolbar | ||||
| })); | ||||
|  | ||||
| export function AppNavigationSection({ items, depth }: { items: NavigationItem[], depth?: number }) { | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   return items.map((item, idx) => { | ||||
|     if (item.divider) { | ||||
|       return <Divider key={idx} sx={{ my: 1 }} />; | ||||
|     } else if (item.children) { | ||||
|       return ( | ||||
|         <Fragment key={idx}> | ||||
|           <MenuItem onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> | ||||
|             <ListItemIcon>{item.icon}</ListItemIcon> | ||||
|             <ListItemText primary={item.title} /> | ||||
|             {open ? <ExpandLess /> : <ExpandMore />} | ||||
|           </MenuItem> | ||||
|           <Collapse in={open} timeout="auto" unmountOnExit> | ||||
|             <AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} /> | ||||
|           </Collapse> | ||||
|         </Fragment> | ||||
|       ); | ||||
|     } else { | ||||
|       return ( | ||||
|         <Link key={idx} to={item.link ?? "/"}> | ||||
|           <MenuItem sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> | ||||
|             <ListItemIcon>{item.icon}</ListItemIcon> | ||||
|             <ListItemText primary={item.title} /> | ||||
|           </MenuItem> | ||||
|         </Link> | ||||
|       ); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function AppNavigation() { | ||||
|   const { checkLoggedIn } = useUserinfo(); | ||||
|  | ||||
|   const nav: NavigationItem[] = [ | ||||
|     ...( | ||||
|       checkLoggedIn() ? | ||||
|         [ | ||||
|           { icon: <FaceIcon />, title: "Account", link: "/users" }, | ||||
|           { divider: true }, | ||||
|           { icon: <LogoutIcon />, title: "Sign out", link: "/auth/sign-out" } | ||||
|         ] : | ||||
|         [ | ||||
|           { icon: <HowToRegIcon />, title: "Sign up", link: "/auth/sign-up" }, | ||||
|           { icon: <LoginIcon />, title: "Sign in", link: "/auth/sign-in" } | ||||
|         ] | ||||
|     ) | ||||
|   ]; | ||||
|  | ||||
|   return <AppNavigationSection items={nav} />; | ||||
| } | ||||
|  | ||||
| export const isMobileQuery = theme.breakpoints.down("md"); | ||||
|  | ||||
| export default function NavigationMenu({ anchorEl, open, onClose }: { | ||||
|   anchorEl: PopoverProps["anchorEl"]; | ||||
|   open: boolean; | ||||
|   onClose: () => void | ||||
| }) { | ||||
|   return ( | ||||
|     <Menu anchorEl={anchorEl} open={open} onClose={onClose}> | ||||
|       <AppNavigation /> | ||||
|     </Menu> | ||||
|   ); | ||||
| } | ||||
| @@ -1 +0,0 @@ | ||||
| export const SITE_NAME = "Goatpass"; | ||||
| @@ -1,23 +0,0 @@ | ||||
| import { Link as RouterLink, useRouteError } from "react-router-dom"; | ||||
| import { Box, Container, Link, Typography } from "@mui/material"; | ||||
|  | ||||
| export default function ErrorBoundary() { | ||||
|   const error = useRouteError() as any; | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ | ||||
|       height: "100vh", | ||||
|       display: "flex", | ||||
|       justifyContent: "center", | ||||
|       alignItems: "center", | ||||
|       textAlign: "center" | ||||
|     }}> | ||||
|       <Box> | ||||
|         <Typography variant="h1">{error.status}</Typography> | ||||
|         <Typography variant="h6" sx={{ mb: 2 }}>{error?.message ?? "Something went wrong"}</Typography> | ||||
|  | ||||
|         <Link component={RouterLink} to="/">Back to homepage</Link> | ||||
|       </Box> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										5
									
								
								pkg/views/src/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								pkg/views/src/index.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| <template> | ||||
|   <v-app> | ||||
|     <router-view /> | ||||
|   </v-app> | ||||
| </template> | ||||
							
								
								
									
										60
									
								
								pkg/views/src/layouts/master.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								pkg/views/src/layouts/master.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| <template> | ||||
|   <v-app-bar height="64" color="primary" scroll-behavior="elevate" flat> | ||||
|     <div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center"> | ||||
|       <router-link :to="{ name: 'dashboard' }"> | ||||
|         <h2 class="ml-2 text-lg font-500">Solarpass</h2> | ||||
|       </router-link> | ||||
|  | ||||
|       <v-spacer /> | ||||
|  | ||||
|       <v-menu> | ||||
|         <template #activator="{ props }"> | ||||
|           <v-btn flat exact v-bind="props" icon> | ||||
|             <v-avatar color="transparent" icon="mdi-account-circle" :src="id.userinfo.data?.avatar" /> | ||||
|           </v-btn> | ||||
|         </template> | ||||
|  | ||||
|         <v-list density="compact"> | ||||
|           <v-list-item title="Sign in" prepend-icon="mdi-login-variant" /> | ||||
|           <v-list-item title="Create account" prepend-icon="mdi-account-plus" /> | ||||
|         </v-list> | ||||
|       </v-menu> | ||||
|     </div> | ||||
|   </v-app-bar> | ||||
|  | ||||
|   <v-main> | ||||
|     <router-view /> | ||||
|   </v-main> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { computed } from "vue" | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
|  | ||||
| const id = useUserinfo() | ||||
|  | ||||
| const username = computed(() => { | ||||
|   if (id.userinfo.isLoggedIn) { | ||||
|     return "@" + id.userinfo.data?.name | ||||
|   } else { | ||||
|     return "@vistor" | ||||
|   } | ||||
| }) | ||||
| const nickname = computed(() => { | ||||
|   if (id.userinfo.isLoggedIn) { | ||||
|     return id.userinfo.data?.nick | ||||
|   } else { | ||||
|     return "Anonymous" | ||||
|   } | ||||
| }) | ||||
|  | ||||
| id.readProfiles() | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .editor-fab { | ||||
|   position: fixed !important; | ||||
|   bottom: 16px; | ||||
|   right: 20px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										54
									
								
								pkg/views/src/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								pkg/views/src/main.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| import "virtual:uno.css" | ||||
|  | ||||
| import "./assets/utils.css" | ||||
|  | ||||
| import { createApp } from "vue" | ||||
| import { createPinia } from "pinia" | ||||
|  | ||||
| import "vuetify/styles" | ||||
| import { createVuetify } from "vuetify" | ||||
| import { md3 } from "vuetify/blueprints" | ||||
| import * as components from "vuetify/components" | ||||
| import * as labsComponents from "vuetify/labs/components" | ||||
| import * as directives from "vuetify/directives" | ||||
|  | ||||
| import "@mdi/font/css/materialdesignicons.min.css" | ||||
| import "@fontsource/roboto/latin.css" | ||||
| import "@unocss/reset/tailwind.css" | ||||
|  | ||||
| import index from "./index.vue" | ||||
| import router from "./router" | ||||
|  | ||||
| const app = createApp(index) | ||||
|  | ||||
| app.use( | ||||
|   createVuetify({ | ||||
|     directives, | ||||
|     components: { | ||||
|       ...components, | ||||
|       ...labsComponents, | ||||
|     }, | ||||
|     blueprint: md3, | ||||
|     theme: { | ||||
|       defaultTheme: "original", | ||||
|       themes: { | ||||
|         original: { | ||||
|           colors: { | ||||
|             primary: "#4a5099", | ||||
|             secondary: "#2196f3", | ||||
|             accent: "#009688", | ||||
|             error: "#f44336", | ||||
|             warning: "#ff9800", | ||||
|             info: "#03a9f4", | ||||
|             success: "#4caf50", | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }), | ||||
| ) | ||||
|  | ||||
| app.use(createPinia()) | ||||
| app.use(router) | ||||
|  | ||||
| app.mount("#app") | ||||
| @@ -1,90 +0,0 @@ | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { LocalizationProvider } from "@mui/x-date-pickers"; | ||||
| import { CssBaseline, ThemeProvider } from "@mui/material"; | ||||
| import { theme } from "@/theme.ts"; | ||||
|  | ||||
| import "virtual:uno.css"; | ||||
|  | ||||
| import "./index.css"; | ||||
| import "@unocss/reset/tailwind.css"; | ||||
| import "@fontsource/roboto/latin.css"; | ||||
|  | ||||
| import AppShell from "@/components/AppShell.tsx"; | ||||
| import ErrorBoundary from "@/error.tsx"; | ||||
| import AppLoader from "@/components/AppLoader.tsx"; | ||||
| import UserLayout from "@/pages/users/layout.tsx"; | ||||
| import { UserinfoProvider } from "@/stores/userinfo.tsx"; | ||||
| import { WellKnownProvider } from "@/stores/wellKnown.tsx"; | ||||
| import AuthLayout from "@/pages/auth/layout.tsx"; | ||||
| import AuthGuard from "@/pages/guard.tsx"; | ||||
|  | ||||
| declare const __GARFISH_EXPORTS__: { | ||||
|   provider: Object; | ||||
|   registerProvider?: (provider: any) => void; | ||||
| }; | ||||
|  | ||||
| declare global { | ||||
|   interface Window { | ||||
|     __LAUNCHPAD_TARGET__?: string; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const router = createBrowserRouter([ | ||||
|   { | ||||
|     path: "/", | ||||
|     element: <AppShell><Outlet /></AppShell>, | ||||
|     errorElement: <ErrorBoundary />, | ||||
|     children: [ | ||||
|       { path: "/", lazy: () => import("@/pages/landing.tsx") }, | ||||
|       { | ||||
|         path: "/", | ||||
|         element: <AuthGuard />, | ||||
|         children: [ | ||||
|           { | ||||
|             path: "/users", | ||||
|             element: <UserLayout />, | ||||
|             children: [ | ||||
|               { path: "/users", lazy: () => import("@/pages/users/dashboard.tsx") }, | ||||
|               { path: "/users/notifications", lazy: () => import("@/pages/users/notifications.tsx") }, | ||||
|               { path: "/users/personalize", lazy: () => import("@/pages/users/personalize.tsx") }, | ||||
|               { path: "/users/security", lazy: () => import("@/pages/users/security.tsx") } | ||||
|             ] | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     path: "/auth", | ||||
|     element: <AuthLayout />, | ||||
|     errorElement: <ErrorBoundary />, | ||||
|     children: [ | ||||
|       { path: "/auth/sign-up", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/sign-up.tsx") }, | ||||
|       { path: "/auth/sign-in", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/sign-in.tsx") }, | ||||
|       { path: "/auth/sign-out", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/sign-out.tsx") }, | ||||
|       { path: "/auth/o/connect", errorElement: <ErrorBoundary />, lazy: () => import("@/pages/auth/connect.tsx") } | ||||
|     ] | ||||
|   } | ||||
| ]); | ||||
|  | ||||
| const element = ( | ||||
|   <React.StrictMode> | ||||
|     <LocalizationProvider dateAdapter={AdapterDayjs}> | ||||
|       <ThemeProvider theme={theme}> | ||||
|         <WellKnownProvider> | ||||
|           <UserinfoProvider> | ||||
|             <AppLoader> | ||||
|               <CssBaseline /> | ||||
|               <RouterProvider router={router} /> | ||||
|             </AppLoader> | ||||
|           </UserinfoProvider> | ||||
|         </WellKnownProvider> | ||||
|       </ThemeProvider> | ||||
|     </LocalizationProvider> | ||||
|   </React.StrictMode> | ||||
| ); | ||||
|  | ||||
| ReactDOM.createRoot(document.getElementById("root")!).render(element); | ||||
| @@ -1,182 +0,0 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Collapse, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { useSearchParams } from "react-router-dom"; | ||||
| import OutletIcon from "@mui/icons-material/Outlet"; | ||||
| import WhatshotIcon from "@mui/icons-material/Whatshot"; | ||||
|  | ||||
| export function Component() { | ||||
|   const { getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [panel, setPanel] = useState(0); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   const [client, setClient] = useState<any>(null); | ||||
|  | ||||
|   const [searchParams] = useSearchParams(); | ||||
|  | ||||
|   async function preconnect() { | ||||
|     const res = await request(`/api/auth/o/connect${location.search}`, { | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|  | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|  | ||||
|       if (data["session"]) { | ||||
|         setPanel(1); | ||||
|         redirect(data["session"]); | ||||
|       } else { | ||||
|         setClient(data["client"]); | ||||
|         setLoading(false); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     preconnect().then(() => console.log("Fetched metadata")); | ||||
|   }, []); | ||||
|  | ||||
|   function decline() { | ||||
|     if (window.history.length > 0) { | ||||
|       window.history.back(); | ||||
|     } else { | ||||
|       window.close(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function approve() { | ||||
|     setLoading(true); | ||||
|  | ||||
|     const res = await request("/api/auth/o/connect?" + new URLSearchParams({ | ||||
|       client_id: searchParams.get("client_id") as string, | ||||
|       redirect_uri: encodeURIComponent(searchParams.get("redirect_uri") as string), | ||||
|       response_type: "code", | ||||
|       scope: searchParams.get("scope") as string | ||||
|     }), { | ||||
|       method: "POST", | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|  | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|       setLoading(false); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setPanel(1); | ||||
|       setTimeout(() => redirect(data["session"]), 1850); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function redirect(session: any) { | ||||
|     const url = `${searchParams.get("redirect_uri")}?code=${session["grant_token"]}&state=${searchParams.get("state")}`; | ||||
|     window.open(url, "_self"); | ||||
|   } | ||||
|  | ||||
|   const elements = [ | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <OutletIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Sign in to {client?.name} | ||||
|         </Typography> | ||||
|         <Box sx={{ mt: 3, width: "100%" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12}> | ||||
|               <Typography fontWeight="bold">About this app</Typography> | ||||
|               <Typography variant="body2">{client?.description}</Typography> | ||||
|             </Grid> | ||||
|             <Grid item xs={12}> | ||||
|               <Typography fontWeight="bold">Make you trust this app</Typography> | ||||
|               <Typography variant="body2"> | ||||
|                 After you click Approve button, you will share your basic personal information to this application | ||||
|                 developer. Some of them will leak your data. Think twice. | ||||
|               </Typography> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} md={6}> | ||||
|               <Button | ||||
|                 fullWidth | ||||
|                 color="info" | ||||
|                 variant="outlined" | ||||
|                 disabled={loading} | ||||
|                 sx={{ mt: 3 }} | ||||
|                 onClick={() => decline()} | ||||
|               > | ||||
|                 Decline | ||||
|               </Button> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} md={6}> | ||||
|               <Button | ||||
|                 fullWidth | ||||
|                 variant="outlined" | ||||
|                 disabled={loading} | ||||
|                 sx={{ mt: 3 }} | ||||
|                 onClick={() => approve()} | ||||
|               > | ||||
|                 Approve | ||||
|               </Button> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|         </Box> | ||||
|       </> | ||||
|     ), | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <WhatshotIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Authorized | ||||
|         </Typography> | ||||
|         <Box sx={{ mt: 3, width: "100%", textAlign: "center" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12} sx={{ my: 8 }}> | ||||
|               <Typography variant="h6">Now Redirecting...</Typography> | ||||
|               <Typography>Hold on a second, we are going to redirect you to the target.</Typography> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|         </Box> | ||||
|       </> | ||||
|     ) | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||
|  | ||||
|       <Card variant="outlined"> | ||||
|         <Collapse in={loading}> | ||||
|           <LinearProgress /> | ||||
|         </Collapse> | ||||
|  | ||||
|         <CardContent | ||||
|           style={{ padding: "40px 48px 36px" }} | ||||
|           sx={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center" | ||||
|           }} | ||||
|         > | ||||
|           {elements[panel]} | ||||
|         </CardContent> | ||||
|       </Card> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { Box } from "@mui/material"; | ||||
| import { Outlet } from "react-router-dom"; | ||||
|  | ||||
| export default function AuthLayout() { | ||||
|   return ( | ||||
|     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> | ||||
|       <Box style={{ width: "100vw", maxWidth: "450px" }}> | ||||
|         <Outlet /> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ) | ||||
| } | ||||
| @@ -1,331 +0,0 @@ | ||||
| import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom"; | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Collapse, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Link, | ||||
|   Paper, | ||||
|   TextField, | ||||
|   ToggleButton, | ||||
|   ToggleButtonGroup, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { FormEvent, useState } from "react"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import LoginIcon from "@mui/icons-material/Login"; | ||||
| import SecurityIcon from "@mui/icons-material/Security"; | ||||
| import KeyIcon from "@mui/icons-material/Key"; | ||||
| import PasswordIcon from "@mui/icons-material/Password"; | ||||
| import EmailIcon from "@mui/icons-material/Email"; | ||||
|  | ||||
| export function Component() { | ||||
|   const [panel, setPanel] = useState(0); | ||||
|  | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   const [factor, setFactor] = useState<number>(); | ||||
|   const [factorType, setFactorType] = useState<any>(); | ||||
|  | ||||
|   const [factors, setFactors] = useState<any>(null); | ||||
|   const [challenge, setChallenge] = useState<any>(null); | ||||
|  | ||||
|   const { readProfiles } = useUserinfo(); | ||||
|  | ||||
|   const [searchParams] = useSearchParams(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const handlers: any[] = [ | ||||
|     async (evt: FormEvent<HTMLFormElement>) => { | ||||
|       evt.preventDefault(); | ||||
|  | ||||
|       const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||
|       if (!data.id) return; | ||||
|  | ||||
|       setLoading(true); | ||||
|       const res = await request("/api/auth", { | ||||
|         method: "PUT", | ||||
|         headers: { "Content-Type": "application/json" }, | ||||
|         body: JSON.stringify(data) | ||||
|       }); | ||||
|       if (res.status !== 200) { | ||||
|         setError(await res.text()); | ||||
|       } else { | ||||
|         const data = await res.json(); | ||||
|         setFactors(data["factors"]); | ||||
|         setChallenge(data["challenge"]); | ||||
|         setPanel(1); | ||||
|         setError(null); | ||||
|       } | ||||
|       setLoading(false); | ||||
|     }, | ||||
|     async (evt: FormEvent<HTMLFormElement>) => { | ||||
|       evt.preventDefault(); | ||||
|  | ||||
|       if (!factor) return; | ||||
|  | ||||
|       setLoading(true); | ||||
|       const res = await request(`/api/auth/factors/${factor}`, { | ||||
|         method: "POST" | ||||
|       }); | ||||
|       if (res.status !== 200 && res.status !== 204) { | ||||
|         setError(await res.text()); | ||||
|       } else { | ||||
|         const item = factors.find((item: any) => item.id === factor).type; | ||||
|         setError(null); | ||||
|         setPanel(2); | ||||
|         setFactorType(factorTypes[item]); | ||||
|       } | ||||
|       setLoading(false); | ||||
|     }, | ||||
|     async (evt: SubmitEvent) => { | ||||
|       evt.preventDefault(); | ||||
|  | ||||
|       const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||
|       if (!data.credentials) return; | ||||
|  | ||||
|       setLoading(true); | ||||
|       const res = await request(`/api/auth`, { | ||||
|         method: "POST", | ||||
|         headers: { "Content-Type": "application/json" }, | ||||
|         body: JSON.stringify({ | ||||
|           challenge_id: challenge?.id, | ||||
|           factor_id: factor, | ||||
|           secret: data.credentials | ||||
|         }) | ||||
|       }); | ||||
|       if (res.status !== 200) { | ||||
|         setError(await res.text()); | ||||
|       } else { | ||||
|         const data = await res.json(); | ||||
|         if (data["is_finished"]) { | ||||
|           await grantToken(data["session"]["grant_token"]); | ||||
|           await readProfiles(); | ||||
|           callback(); | ||||
|         } else { | ||||
|           setError(null); | ||||
|           setPanel(1); | ||||
|           setFactor(undefined); | ||||
|           setFactorType(undefined); | ||||
|           setChallenge(data["challenge"]); | ||||
|         } | ||||
|       } | ||||
|       setLoading(false); | ||||
|     } | ||||
|   ]; | ||||
|  | ||||
|   function callback() { | ||||
|     if (searchParams.has("closable")) { | ||||
|       window.close(); | ||||
|     } else if (searchParams.has("redirect_uri")) { | ||||
|       window.open(searchParams.get("redirect_uri") ?? "/", "_self"); | ||||
|     } else { | ||||
|       navigate("/users"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function getFactorAvailable(factor: any) { | ||||
|     const blacklist: number[] = challenge?.blacklist_factors ?? []; | ||||
|     return blacklist.includes(factor.id); | ||||
|   } | ||||
|  | ||||
|   const factorTypes = [ | ||||
|     { icon: <PasswordIcon />, label: "Password Verification", autoComplete: "password" }, | ||||
|     { icon: <EmailIcon />, label: "Email One Time Password", autoComplete: "one-time-code" } | ||||
|   ]; | ||||
|  | ||||
|   const elements = [ | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <LoginIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Welcome back | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12}> | ||||
|               <TextField | ||||
|                 autoComplete="username" | ||||
|                 name="id" | ||||
|                 required | ||||
|                 fullWidth | ||||
|                 label="Account ID" | ||||
|                 helperText={"Use your username, email or phone number."} | ||||
|                 autoFocus | ||||
|               /> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|           <Button | ||||
|             type="submit" | ||||
|             fullWidth | ||||
|             variant="contained" | ||||
|             disabled={loading} | ||||
|             sx={{ mt: 3, mb: 2 }} | ||||
|           > | ||||
|             {loading ? "Processing..." : "Next"} | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </> | ||||
|     ), | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <SecurityIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Verify that's you | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12}> | ||||
|               <ToggleButtonGroup | ||||
|                 exclusive | ||||
|                 orientation="vertical" | ||||
|                 color="info" | ||||
|                 value={factor} | ||||
|                 sx={{ width: "100%" }} | ||||
|                 onChange={(_, val) => setFactor(val)} | ||||
|               > | ||||
|                 {factors?.map((item: any, idx: number) => ( | ||||
|                   <ToggleButton key={idx} value={item.id} disabled={getFactorAvailable(item)}> | ||||
|                     <Grid container> | ||||
|                       <Grid item xs={2}> | ||||
|                         {factorTypes[item.type]?.icon} | ||||
|                       </Grid> | ||||
|                       <Grid item xs="auto"> | ||||
|                         {factorTypes[item.type]?.label} | ||||
|                       </Grid> | ||||
|                     </Grid> | ||||
|                   </ToggleButton> | ||||
|                 ))} | ||||
|               </ToggleButtonGroup> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|           <Button | ||||
|             type="submit" | ||||
|             fullWidth | ||||
|             variant="contained" | ||||
|             disabled={loading} | ||||
|             sx={{ mt: 3, mb: 2 }} | ||||
|           > | ||||
|             {loading ? "Processing..." : "Next"} | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </> | ||||
|     ), | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <KeyIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Enter the credentials | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12}> | ||||
|               <TextField | ||||
|                 autoComplete={factorType?.autoComplete ?? "password"} | ||||
|                 name="credentials" | ||||
|                 type="password" | ||||
|                 required | ||||
|                 fullWidth | ||||
|                 label="Credentials" | ||||
|                 autoFocus | ||||
|               /> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|           <Button | ||||
|             type="submit" | ||||
|             fullWidth | ||||
|             variant="contained" | ||||
|             disabled={loading} | ||||
|             sx={{ mt: 3, mb: 2 }} | ||||
|           > | ||||
|             {loading ? "Processing..." : "Next"} | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </> | ||||
|     ) | ||||
|   ]; | ||||
|  | ||||
|   async function grantToken(tk: string) { | ||||
|     const res = await request("/api/auth/token", { | ||||
|       method: "POST", | ||||
|       headers: { "Content-Type": "application/json" }, | ||||
|       body: JSON.stringify({ | ||||
|         code: tk, | ||||
|         grant_type: "grant_token" | ||||
|       }) | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       const err = await res.text(); | ||||
|       setError(err); | ||||
|       throw new Error(err); | ||||
|     } else { | ||||
|       setError(null); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||
|  | ||||
|       <Collapse in={searchParams.has("redirect_uri")}> | ||||
|         <Alert severity="info" sx={{ mb: 2 }}> | ||||
|           You need sign in before take an action. After that, we will take you back to your work. | ||||
|         </Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Card variant="outlined"> | ||||
|         <Collapse in={loading}> | ||||
|           <LinearProgress /> | ||||
|         </Collapse> | ||||
|  | ||||
|         <CardContent | ||||
|           style={{ padding: "40px 48px 36px" }} | ||||
|           sx={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center" | ||||
|           }} | ||||
|         > | ||||
|           {elements[panel]} | ||||
|         </CardContent> | ||||
|  | ||||
|         <Collapse in={challenge != null} unmountOnExit> | ||||
|           <Box> | ||||
|             <Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}> | ||||
|               <Typography sx={{ mb: 2 }}> | ||||
|                 Risk <b className="font-mono">{challenge?.risk_level}</b>  | ||||
|                 Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b> | ||||
|               </Typography> | ||||
|               <LinearProgress | ||||
|                 variant="determinate" | ||||
|                 value={challenge?.progress / challenge?.requirements * 100} | ||||
|                 sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }} | ||||
|               /> | ||||
|             </Paper> | ||||
|           </Box> | ||||
|         </Collapse> | ||||
|       </Card> | ||||
|  | ||||
|       <Grid container justifyContent="center" sx={{ mt: 2 }}> | ||||
|         <Grid item> | ||||
|           <Link component={RouterLink} to="/auth/sign-up" variant="body2"> | ||||
|             Haven't an account? Sign up! | ||||
|           </Link> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,50 +0,0 @@ | ||||
| import { Avatar, Button, Card, CardContent, Typography } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import LogoutIcon from "@mui/icons-material/Logout"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
|  | ||||
| export function Component() { | ||||
|   const { clearUserinfo } = useUserinfo(); | ||||
|  | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   async function signout() { | ||||
|     clearUserinfo(); | ||||
|     navigate("/"); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Card variant="outlined"> | ||||
|         <CardContent | ||||
|           style={{ padding: "40px 48px 36px" }} | ||||
|           sx={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center" | ||||
|           }} | ||||
|         > | ||||
|           <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|             <LogoutIcon /> | ||||
|           </Avatar> | ||||
|  | ||||
|           <Typography gutterBottom variant="h5" component="h1">Sign out</Typography> | ||||
|           <Typography variant="body1"> | ||||
|             Sign out will clear your data on this device. Also will affected those use union identification services. | ||||
|             You need sign in again get access them. | ||||
|           </Typography> | ||||
|  | ||||
|           <Button | ||||
|             fullWidth | ||||
|             variant="contained" | ||||
|             color="secondary" | ||||
|             sx={{ mt: 3 }} | ||||
|             onClick={() => signout()} | ||||
|           > | ||||
|             Sign out | ||||
|           </Button> | ||||
|         </CardContent> | ||||
|       </Card> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,198 +0,0 @@ | ||||
| import UserIcon from "@mui/icons-material/PersonAddAlt1"; | ||||
| import HowToRegIcon from "@mui/icons-material/HowToReg"; | ||||
| import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom"; | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Checkbox, | ||||
|   Collapse, | ||||
|   FormControlLabel, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Link, | ||||
|   TextField, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { FormEvent, useState } from "react"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import { useWellKnown } from "@/stores/wellKnown.tsx"; | ||||
|  | ||||
| export function Component() { | ||||
|   const [done, setDone] = useState(false); | ||||
|  | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   const { wellKnown } = useWellKnown(); | ||||
|  | ||||
|   const [searchParams] = useSearchParams(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   async function submit(evt: FormEvent<HTMLFormElement>) { | ||||
|     evt.preventDefault(); | ||||
|  | ||||
|     const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||
|     if (!data.human_verification) return; | ||||
|     if (!data.name || !data.nick || !data.email || !data.password) return; | ||||
|  | ||||
|     setLoading(true); | ||||
|     const res = await request("/api/users", { | ||||
|       method: "POST", | ||||
|       headers: { "Content-Type": "application/json" }, | ||||
|       body: JSON.stringify(data) | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       setError(null); | ||||
|       setDone(true); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   function callback() { | ||||
|     if (searchParams.has("closable")) { | ||||
|       window.close(); | ||||
|     } else { | ||||
|       navigate("/auth/sign-in"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const elements = [ | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ mb: 1, bgcolor: "secondary.main" }}> | ||||
|           <UserIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Create an account | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={submit} sx={{ mt: 3, width: "100%" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12} sm={6}> | ||||
|               <TextField | ||||
|                 name="name" | ||||
|                 required | ||||
|                 fullWidth | ||||
|                 label="Username" | ||||
|                 autoComplete="username" | ||||
|               /> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} sm={6}> | ||||
|               <TextField | ||||
|                 name="nick" | ||||
|                 required | ||||
|                 fullWidth | ||||
|                 label="Nickname" | ||||
|                 autoComplete="nickname" | ||||
|               /> | ||||
|             </Grid> | ||||
|             <Grid item xs={12}> | ||||
|               <TextField | ||||
|                 autoComplete="email" | ||||
|                 name="email" | ||||
|                 required | ||||
|                 fullWidth | ||||
|                 label="Email Address" | ||||
|               /> | ||||
|             </Grid> | ||||
|             <Grid item xs={12}> | ||||
|               <TextField | ||||
|                 label="Password" | ||||
|                 name="password" | ||||
|                 required | ||||
|                 fullWidth | ||||
|                 type="password" | ||||
|                 autoComplete="new-password" | ||||
|               /> | ||||
|             </Grid> | ||||
|             { | ||||
|               !wellKnown?.open_registration && <Grid item xs={12}> | ||||
|                 <TextField | ||||
|                   label="Magic Token" | ||||
|                   name="magic_token" | ||||
|                   required | ||||
|                   fullWidth | ||||
|                   type="password" | ||||
|                   autoComplete="magic-token" | ||||
|                   helperText={"This server uses invitations only."} | ||||
|                 /> | ||||
|               </Grid> | ||||
|             } | ||||
|             <Grid item xs={12}> | ||||
|               <FormControlLabel | ||||
|                 name="human_verification" | ||||
|                 control={<Checkbox value="allowExtraEmails" color="primary" />} | ||||
|                 label={"I'm not a robot."} | ||||
|               /> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|           <Button | ||||
|             type="submit" | ||||
|             fullWidth | ||||
|             variant="contained" | ||||
|             disabled={loading} | ||||
|             sx={{ mt: 3, mb: 2 }} | ||||
|           > | ||||
|             {loading ? "Signing Now..." : "Sign Up"} | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </> | ||||
|     ), | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <HowToRegIcon /> | ||||
|         </Avatar> | ||||
|  | ||||
|         <Typography gutterBottom variant="h5" component="h1">Congratulations!</Typography> | ||||
|         <Typography variant="body1"> | ||||
|           Your account has been created and activation email has sent to your inbox! | ||||
|         </Typography> | ||||
|  | ||||
|         <Typography sx={{ my: 2 }}> | ||||
|           <Link onClick={() => callback()} className="cursor-pointer">Go login</Link> | ||||
|         </Typography> | ||||
|  | ||||
|         <Typography variant="body2"> | ||||
|           After you login, then you can take part in the entire smartsheep community. | ||||
|         </Typography> | ||||
|       </> | ||||
|     ) | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||
|  | ||||
|       <Card variant="outlined"> | ||||
|         <Collapse in={loading}> | ||||
|           <LinearProgress /> | ||||
|         </Collapse> | ||||
|  | ||||
|         <CardContent | ||||
|           style={{ padding: "40px 48px 36px" }} | ||||
|           sx={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center" | ||||
|           }} | ||||
|         > | ||||
|           {!done ? elements[0] : elements[1]} | ||||
|         </CardContent> | ||||
|       </Card> | ||||
|  | ||||
|       <Grid container justifyContent="center" sx={{ mt: 2 }}> | ||||
|         <Grid item> | ||||
|           <Link component={RouterLink} to="/auth/sign-in" variant="body2"> | ||||
|             Already have an account? Sign in! | ||||
|           </Link> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,29 +0,0 @@ | ||||
| import { useEffect } from "react"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
| import { Outlet, useLocation, useNavigate } from "react-router-dom"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| export default function AuthGuard() { | ||||
|   const { userinfo } = useUserinfo(); | ||||
|  | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     console.log(userinfo) | ||||
|     if (userinfo?.isReady) { | ||||
|       if (!userinfo?.isLoggedIn) { | ||||
|         const callback = location.pathname + location.search; | ||||
|         navigate({ pathname: "/auth/sign-in", search: `redirect_uri=${callback}` }); | ||||
|       } | ||||
|     } | ||||
|   }, [userinfo]); | ||||
|  | ||||
|   return !userinfo?.isReady ? ( | ||||
|     <Box sx={{ pt: 32, display: "flex", justifyContent: "center", alignItems: "center" }}> | ||||
|       <Box> | ||||
|         <CircularProgress /> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ) : <Outlet />; | ||||
| } | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { Button, Container, Grid, Typography } from "@mui/material"; | ||||
| import { Link as RouterLink } from "react-router-dom"; | ||||
|  | ||||
| export function Component() { | ||||
|   return ( | ||||
|     <Container sx={{ height: "calc(100vh - 64px)", display: "flex", alignItems: "center", textAlign: "center" }}> | ||||
|       <Grid padding={5} spacing={8} container> | ||||
|         <Grid item xs={12} md={6}> | ||||
|           <Typography variant="h3">All Goatworks<sup>®</sup> Services</Typography> | ||||
|           <Typography variant="h3">In a single account</Typography> | ||||
|  | ||||
|           <Typography variant="body2" sx={{ mt: 8 }}>That's</Typography> | ||||
|           <Typography variant="h1">Goatpass</Typography> | ||||
|           <Button component={RouterLink} to="/auth/sign-up" variant="contained" sx={{ mt: 2 }}>Getting Start</Button> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={6} sx={{ order: { xs: -100, md: 0 } }}> | ||||
|           <img src="/favicon.svg" alt="Logo" width={256} height={256} className="block mx-auto" /> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
| @@ -1,35 +0,0 @@ | ||||
| import { Alert, Box, Card, CardContent, Container, Typography } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| export function Component() { | ||||
|   const { userinfo } = useUserinfo(); | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||
|       <Box sx={{ px: 3 }}> | ||||
|         <Typography variant="h5">Welcome, {userinfo?.displayName}</Typography> | ||||
|         <Typography variant="body2">What can I help you today?</Typography> | ||||
|       </Box> | ||||
|  | ||||
|       { | ||||
|         !userinfo?.data?.confirmed_at && | ||||
|         <Alert severity="warning" sx={{ mt: 3, mx: 1 }}> | ||||
|           Your account haven't confirmed yet. Go to your linked email | ||||
|           inbox and check out our registration confirm email. | ||||
|         </Alert> | ||||
|       } | ||||
|  | ||||
|       <Box sx={{ px: 1, mt: 3 }}> | ||||
|         <Typography variant="h6" sx={{ px: 2 }}>Frequently Asked Questions</Typography> | ||||
|  | ||||
|         <Card variant="outlined" sx={{ mt: 1 }}> | ||||
|           <CardContent style={{ padding: "40px" }}> | ||||
|             <Typography>没有人有问题。没有人敢有问题。鲁迅曾经说过:</Typography> | ||||
|             <Typography sx={{ pl: 4 }} fontWeight="bold">解决不了问题,就解决提问题的人。 —— 鲁迅</Typography> | ||||
|             <Typography>所以,我们的客诉率是 0% 哦~</Typography> | ||||
|           </CardContent> | ||||
|         </Card> | ||||
|       </Box> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
| @@ -1,65 +0,0 @@ | ||||
| import { Outlet, useLocation, useNavigate } from "react-router-dom"; | ||||
| import { Box, Tab, Tabs, useMediaQuery } from "@mui/material"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { theme } from "@/theme.ts"; | ||||
| import DashboardIcon from "@mui/icons-material/Dashboard"; | ||||
| import InboxIcon from "@mui/icons-material/Inbox"; | ||||
| import DrawIcon from "@mui/icons-material/Draw"; | ||||
| import SecurityIcon from "@mui/icons-material/Security"; | ||||
|  | ||||
| export default function UserLayout() { | ||||
|   const [focus, setFocus] = useState(0); | ||||
|  | ||||
|   const isMobile = useMediaQuery(theme.breakpoints.down("md")); | ||||
|  | ||||
|   const locations = ["/users", "/users/notifications", "/users/personalize", "/users/security"]; | ||||
|   const tabs = [ | ||||
|     { icon: <DashboardIcon />, label: "Dashboard" }, | ||||
|     { icon: <InboxIcon />, label: "Notifications" }, | ||||
|     { icon: <DrawIcon />, label: "Personalize" }, | ||||
|     { icon: <SecurityIcon />, label: "Security" } | ||||
|   ]; | ||||
|  | ||||
|   const location = useLocation(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const idx = locations.indexOf(location.pathname); | ||||
|     setFocus(idx); | ||||
|   }, []); | ||||
|  | ||||
|   function swap(idx: number) { | ||||
|     navigate(locations[idx]); | ||||
|     setFocus(idx); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", height: "calc(100vh - 64px)" }}> | ||||
|       <Box sx={{ width: isMobile ? "100%" : 280 }}> | ||||
|         <Tabs | ||||
|           orientation={isMobile ? "horizontal" : "vertical"} | ||||
|           variant="scrollable" | ||||
|           value={focus} | ||||
|           onChange={(_, val) => swap(val)} | ||||
|           sx={{ | ||||
|             borderRight: isMobile ? 0 : 1, | ||||
|             borderBottom: isMobile ? 1 : 0, | ||||
|             borderColor: "divider", | ||||
|             height: isMobile ? "fit-content" : "100%", | ||||
|             py: isMobile ? 0 : 1, | ||||
|             px: isMobile ? 1 : 0 | ||||
|           }} | ||||
|         > | ||||
|           {tabs.map((tab, idx) => ( | ||||
|             <Tab key={idx} icon={tab.icon} iconPosition={isMobile ? "top" : "start"} label={tab.label} | ||||
|                  sx={{ px: 5, justifyContent: isMobile ? "center" : "left" }} /> | ||||
|           ))} | ||||
|         </Tabs> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ flexGrow: 1 }}> | ||||
|         <Outlet /> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,87 +0,0 @@ | ||||
| import { Alert, Box, Collapse, IconButton, LinearProgress, List, ListItem, ListItemText } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { TransitionGroup } from "react-transition-group"; | ||||
| import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead"; | ||||
|  | ||||
| export function Component() { | ||||
|   const { userinfo, readProfiles, getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState<null | string>(null); | ||||
|  | ||||
|   const [notifications, setNotifications] = useState<any[]>([]); | ||||
|  | ||||
|   async function readNotifications() { | ||||
|     const res = await request(`/api/notifications?take=100`, { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setNotifications(data["data"]); | ||||
|       setError(null); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function markNotifications(item: any) { | ||||
|     setLoading(true); | ||||
|     const res = await request(`/api/notifications/${item.id}/read`, { | ||||
|       method: "PUT", | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       readNotifications().then(() => readProfiles()); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readNotifications().then(() => setLoading(false)); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Collapse in={loading}> | ||||
|         <LinearProgress color="info" /> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Collapse in={error != null}> | ||||
|         <Alert severity="error" variant="filled" square>{error}</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Collapse in={userinfo?.data?.notifications?.length <= 0}> | ||||
|         <Alert severity="success" variant="filled" square>You are done! There's no unread notifications for you.</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <List sx={{ width: "100%", bgcolor: "background.paper" }}> | ||||
|         <TransitionGroup> | ||||
|           {notifications.map((item, idx) => ( | ||||
|             <Collapse key={idx} sx={{ px: 5 }}> | ||||
|               <ListItem alignItems="flex-start" secondaryAction={ | ||||
|                 <IconButton | ||||
|                   edge="end" | ||||
|                   aria-label="delete" | ||||
|                   title="Delete" | ||||
|                   onClick={() => markNotifications(item)} | ||||
|                 > | ||||
|                   <MarkEmailReadIcon /> | ||||
|                 </IconButton> | ||||
|               }> | ||||
|                 <ListItemText | ||||
|                   primary={item.subject} | ||||
|                   secondary={item.content} | ||||
|                 /> | ||||
|               </ListItem> | ||||
|             </Collapse> | ||||
|           ))} | ||||
|         </TransitionGroup> | ||||
|       </List> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,250 +0,0 @@ | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   CircularProgress, | ||||
|   Collapse, | ||||
|   Container, | ||||
|   Divider, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Snackbar, | ||||
|   styled, | ||||
|   TextField, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { ChangeEvent, FormEvent, useState } from "react"; | ||||
| import { DatePicker } from "@mui/x-date-pickers"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import SaveIcon from "@mui/icons-material/Save"; | ||||
| import PublishIcon from "@mui/icons-material/Publish"; | ||||
| import NoAccountsIcon from "@mui/icons-material/NoAccounts"; | ||||
| import dayjs from "dayjs"; | ||||
|  | ||||
| const VisuallyHiddenInput = styled("input")({ | ||||
|   clip: "rect(0 0 0 0)", | ||||
|   clipPath: "inset(50%)", | ||||
|   height: 1, | ||||
|   overflow: "hidden", | ||||
|   position: "absolute", | ||||
|   bottom: 0, | ||||
|   left: 0, | ||||
|   whiteSpace: "nowrap", | ||||
|   width: 1 | ||||
| }); | ||||
|  | ||||
| export function Component() { | ||||
|   const { userinfo, readProfiles, getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [done, setDone] = useState(false); | ||||
|   const [error, setError] = useState<any>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   async function submit(evt: FormEvent<HTMLFormElement>) { | ||||
|     evt.preventDefault(); | ||||
|  | ||||
|     const data: any = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||
|     if (data.birthday) data.birthday = new Date(data.birthday); | ||||
|  | ||||
|     setLoading(true); | ||||
|     const res = await request("/api/users/me", { | ||||
|       method: "PUT", | ||||
|       headers: { "Content-Type": "application/json", "Authorization": `Bearer ${getAtk()}` }, | ||||
|       body: JSON.stringify(data) | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readProfiles(); | ||||
|       setDone(true); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   async function changeAvatar(evt: ChangeEvent<HTMLInputElement>) { | ||||
|     if (!evt.target.files) return; | ||||
|  | ||||
|     const file = evt.target.files[0]; | ||||
|     const payload = new FormData(); | ||||
|     payload.set("avatar", file); | ||||
|  | ||||
|     setLoading(true); | ||||
|     const res = await request("/api/avatar", { | ||||
|       method: "PUT", | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` }, | ||||
|       body: payload | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readProfiles(); | ||||
|       setDone(true); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   function getBirthday() { | ||||
|     return userinfo?.data?.profile?.birthday ? dayjs(userinfo?.data?.profile?.birthday) : undefined; | ||||
|   } | ||||
|  | ||||
|   const basisForm = ( | ||||
|     <Box component="form" onSubmit={submit} sx={{ mt: 3 }}> | ||||
|       <Grid container spacing={2}> | ||||
|         <Grid item xs={6}> | ||||
|           <TextField | ||||
|             name="name" | ||||
|             required | ||||
|             disabled | ||||
|             fullWidth | ||||
|             label="Username" | ||||
|             autoComplete="username" | ||||
|             defaultValue={userinfo?.data?.name} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={6}> | ||||
|           <TextField | ||||
|             name="nick" | ||||
|             required | ||||
|             fullWidth | ||||
|             label="Nickname" | ||||
|             autoComplete="nickname" | ||||
|             defaultValue={userinfo?.data?.nick} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={12}> | ||||
|           <TextField | ||||
|             name="description" | ||||
|             multiline | ||||
|             fullWidth | ||||
|             label="Description" | ||||
|             autoComplete="bio" | ||||
|             defaultValue={userinfo?.data?.description} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={6} md={4}> | ||||
|           <TextField | ||||
|             name="first_name" | ||||
|             fullWidth | ||||
|             label="First Name" | ||||
|             autoComplete="given_name" | ||||
|             defaultValue={userinfo?.data?.profile?.first_name} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={6} md={4}> | ||||
|           <TextField | ||||
|             name="last_name" | ||||
|             fullWidth | ||||
|             label="Last Name" | ||||
|             autoComplete="famliy_name" | ||||
|             defaultValue={userinfo?.data?.profile?.last_name} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <DatePicker | ||||
|             name="birthday" | ||||
|             label="Birthday" | ||||
|             defaultValue={getBirthday()} | ||||
|             sx={{ width: "100%" }} | ||||
|           /> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|  | ||||
|       <Button | ||||
|         type="submit" | ||||
|         variant="contained" | ||||
|         disabled={loading} | ||||
|         startIcon={<SaveIcon />} | ||||
|         sx={{ mt: 2, width: "180px" }} | ||||
|       > | ||||
|         Save changes | ||||
|       </Button> | ||||
|  | ||||
|       <Divider sx={{ my: 2, mx: -3 }} /> | ||||
|  | ||||
|       <Box sx={{ mt: 2.5, display: "flex", gap: 1, alignItems: "center" }}> | ||||
|         <Box> | ||||
|           <Avatar | ||||
|             sx={{ width: 32, height: 32 }} | ||||
|             alt={userinfo?.displayName} | ||||
|             src={`/api/avatar/${userinfo?.data?.avatar}`} | ||||
|           > | ||||
|             <NoAccountsIcon /> | ||||
|           </Avatar> | ||||
|         </Box> | ||||
|         <Box> | ||||
|           {/* @ts-ignore */} | ||||
|           <Button | ||||
|             type="button" | ||||
|             color="info" | ||||
|             component="label" | ||||
|             tabIndex={-1} | ||||
|             disabled={loading} | ||||
|             startIcon={<PublishIcon />} | ||||
|             sx={{ width: "180px" }} | ||||
|           > | ||||
|             Change avatar | ||||
|             <VisuallyHiddenInput type="file" accept="image/*" onChange={changeAvatar} /> | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||
|       <Box sx={{ px: 3 }}> | ||||
|         <Typography variant="h5">Personalize</Typography> | ||||
|         <Typography variant="body2"> | ||||
|           Customize your appearance and name card across all Goatworks information. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
|       <Collapse in={error}> | ||||
|         <Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Box sx={{ mt: 2 }}> | ||||
|         <Card variant="outlined"> | ||||
|           <Collapse in={loading}> | ||||
|             <LinearProgress /> | ||||
|           </Collapse> | ||||
|  | ||||
|           <CardContent style={{ padding: "20px 24px" }}> | ||||
|             <Box sx={{ px: 1, my: 1 }}> | ||||
|               <Typography variant="h6">Information</Typography> | ||||
|               <Typography variant="subtitle2"> | ||||
|                 The information for public. Let us and others better to know who you are. | ||||
|               </Typography> | ||||
|             </Box> | ||||
|  | ||||
|             { | ||||
|               userinfo?.data != null ? basisForm : | ||||
|                 <Box sx={{ pt: 1, px: 1 }}> | ||||
|                   <CircularProgress /> | ||||
|                 </Box> | ||||
|             } | ||||
|  | ||||
|           </CardContent> | ||||
|         </Card> | ||||
|       </Box> | ||||
|  | ||||
|       <Snackbar | ||||
|         open={done} | ||||
|         autoHideDuration={1000 * 10} | ||||
|         onClose={() => setDone(false)} | ||||
|         message="Your profile has been updated. Some settings maybe need sometime to apply across site." | ||||
|       /> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
| @@ -1,267 +0,0 @@ | ||||
| import { | ||||
|   Alert, | ||||
|   Box, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Collapse, | ||||
|   Container, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Tab, | ||||
|   Tabs, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { TabContext, TabPanel } from "@mui/lab"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { DataGrid, GridActionsCellItem, GridColDef, GridRowParams, GridValueGetterParams } from "@mui/x-data-grid"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import ExitToAppIcon from "@mui/icons-material/ExitToApp"; | ||||
|  | ||||
|  | ||||
| export function Component() { | ||||
|   const dataDefinitions: { [id: string]: GridColDef[] } = { | ||||
|     challenges: [ | ||||
|       { field: "id", headerName: "ID", width: 64 }, | ||||
|       { field: "ip_address", headerName: "IP Address", minWidth: 128 }, | ||||
|       { field: "user_agent", headerName: "User Agent", minWidth: 320 }, | ||||
|       { | ||||
|         field: "created_at", | ||||
|         headerName: "Issued At", | ||||
|         minWidth: 160, | ||||
|         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||
|       } | ||||
|     ], | ||||
|     sessions: [ | ||||
|       { field: "id", headerName: "ID", width: 64 }, | ||||
|       { | ||||
|         field: "audiences", | ||||
|         headerName: "Audiences", | ||||
|         minWidth: 128, | ||||
|         valueGetter: (params: GridValueGetterParams) => params.row.audiences.join(", ") | ||||
|       }, | ||||
|       { | ||||
|         field: "claims", | ||||
|         headerName: "Claims", | ||||
|         minWidth: 224, | ||||
|         valueGetter: (params: GridValueGetterParams) => params.row.claims.join(", ") | ||||
|       }, | ||||
|       { | ||||
|         field: "created_at", | ||||
|         headerName: "Issued At", | ||||
|         minWidth: 160, | ||||
|         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||
|       }, | ||||
|       { | ||||
|         field: "actions", | ||||
|         type: "actions", | ||||
|         getActions: (params: GridRowParams) => [ | ||||
|           <GridActionsCellItem | ||||
|             icon={<ExitToAppIcon />} | ||||
|             onClick={() => killSession(params.row)} | ||||
|             disabled={loading} | ||||
|             label="Sign Out" | ||||
|           /> | ||||
|         ] | ||||
|       } | ||||
|     ], | ||||
|     events: [ | ||||
|       { field: "id", headerName: "ID", width: 64 }, | ||||
|       { field: "type", headerName: "Type", minWidth: 128 }, | ||||
|       { field: "target", headerName: "Affected Object", minWidth: 128 }, | ||||
|       { field: "ip_address", headerName: "IP Address", minWidth: 128 }, | ||||
|       { field: "user_agent", headerName: "User Agent", minWidth: 128 }, | ||||
|       { | ||||
|         field: "created_at", | ||||
|         headerName: "Performed At", | ||||
|         minWidth: 160, | ||||
|         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||
|       } | ||||
|     ] | ||||
|   }; | ||||
|  | ||||
|   const { getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [challenges, setChallenges] = useState<any[]>([]); | ||||
|   const [challengeCount, setChallengeCount] = useState(0); | ||||
|   const [sessions, setSessions] = useState<any[]>([]); | ||||
|   const [sessionCount, setSessionCount] = useState(0); | ||||
|   const [events, setEvents] = useState<any[]>([]); | ||||
|   const [eventCount, setEventCount] = useState(0); | ||||
|  | ||||
|   const [pagination, setPagination] = useState({ | ||||
|     challenges: { page: 0, pageSize: 5 }, | ||||
|     sessions: { page: 0, pageSize: 5 }, | ||||
|     events: { page: 0, pageSize: 5 } | ||||
|   }); | ||||
|  | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [reverting] = useState({ | ||||
|     challenges: true, | ||||
|     sessions: true, | ||||
|     events: true | ||||
|   }); | ||||
|  | ||||
|   const [dataPane, setDataPane] = useState("challenges"); | ||||
|  | ||||
|   async function readChallenges() { | ||||
|     reverting.challenges = true; | ||||
|     const res = await request("/api/users/me/challenges?" + new URLSearchParams({ | ||||
|       take: pagination.challenges.pageSize.toString(), | ||||
|       offset: (pagination.challenges.page * pagination.challenges.pageSize).toString() | ||||
|     }), { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setChallenges(data["data"]); | ||||
|       setChallengeCount(data["count"]); | ||||
|     } | ||||
|     reverting.challenges = false; | ||||
|   } | ||||
|  | ||||
|   async function readSessions() { | ||||
|     reverting.sessions = true; | ||||
|     const res = await request("/api/users/me/sessions?" + new URLSearchParams({ | ||||
|       take: pagination.sessions.pageSize.toString(), | ||||
|       offset: (pagination.sessions.page * pagination.sessions.pageSize).toString() | ||||
|     }), { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setSessions(data["data"]); | ||||
|       setSessionCount(data["count"]); | ||||
|     } | ||||
|     reverting.sessions = false; | ||||
|   } | ||||
|  | ||||
|   async function readEvents() { | ||||
|     reverting.events = true; | ||||
|     const res = await request("/api/users/me/events?" + new URLSearchParams({ | ||||
|       take: pagination.events.pageSize.toString(), | ||||
|       offset: (pagination.events.page * pagination.events.pageSize).toString() | ||||
|     }), { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setEvents(data["data"]); | ||||
|       setEventCount(data["count"]); | ||||
|     } | ||||
|     reverting.events = false; | ||||
|   } | ||||
|  | ||||
|   async function killSession(item: any) { | ||||
|     setLoading(true); | ||||
|     const res = await request(`/api/users/me/sessions/${item.id}`, { | ||||
|       method: "DELETE", | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readSessions(); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readChallenges().then(() => console.log("Refreshed challenges list.")); | ||||
|   }, [pagination.challenges]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readSessions().then(() => console.log("Refreshed sessions list.")); | ||||
|   }, [pagination.sessions]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readEvents().then(() => console.log("Refreshed events list.")); | ||||
|   }, [pagination.events]); | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||
|       <Box sx={{ px: 3 }}> | ||||
|         <Typography variant="h5">Security</Typography> | ||||
|         <Typography variant="body2"> | ||||
|           Overview and control all security details in your account. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
|       <Collapse in={error != null}> | ||||
|         <Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Grid container> | ||||
|         <Grid item xs={12}> | ||||
|           <Box sx={{ mt: 2 }}> | ||||
|             <Card variant="outlined"> | ||||
|               <Collapse in={loading}> | ||||
|                 <LinearProgress /> | ||||
|               </Collapse> | ||||
|  | ||||
|               <TabContext value={dataPane}> | ||||
|                 <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | ||||
|                   <Tabs centered value={dataPane} onChange={(_, val) => setDataPane(val)}> | ||||
|                     <Tab label="Challenges" value="challenges" /> | ||||
|                     <Tab label="Sessions" value="sessions" /> | ||||
|                     <Tab label="Events" value="events" /> | ||||
|                   </Tabs> | ||||
|                 </Box> | ||||
|  | ||||
|                 <CardContent style={{ padding: "20px 24px" }}> | ||||
|                   <TabPanel value={"challenges"}> | ||||
|                     <DataGrid | ||||
|                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||
|                       paginationMode="server" | ||||
|                       loading={reverting.challenges} | ||||
|                       rows={challenges} | ||||
|                       rowCount={challengeCount} | ||||
|                       columns={dataDefinitions.challenges} | ||||
|                       paginationModel={pagination.challenges} | ||||
|                       onPaginationModelChange={(val) => setPagination({ ...pagination, challenges: val })} | ||||
|                       checkboxSelection | ||||
|                     /> | ||||
|                   </TabPanel> | ||||
|                   <TabPanel value={"sessions"}> | ||||
|                     <DataGrid | ||||
|                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||
|                       paginationMode="server" | ||||
|                       loading={reverting.sessions} | ||||
|                       rows={sessions} | ||||
|                       rowCount={sessionCount} | ||||
|                       columns={dataDefinitions.sessions} | ||||
|                       paginationModel={pagination.sessions} | ||||
|                       onPaginationModelChange={(val) => setPagination({ ...pagination, sessions: val })} | ||||
|                       checkboxSelection | ||||
|                     /> | ||||
|                   </TabPanel> | ||||
|                   <TabPanel value={"events"}> | ||||
|                     <DataGrid | ||||
|                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||
|                       paginationMode="server" | ||||
|                       loading={reverting.events} | ||||
|                       rows={events} | ||||
|                       rowCount={eventCount} | ||||
|                       columns={dataDefinitions.events} | ||||
|                       paginationModel={pagination.events} | ||||
|                       onPaginationModelChange={(val) => setPagination({ ...pagination, events: val })} | ||||
|                       checkboxSelection | ||||
|                     /> | ||||
|                   </TabPanel> | ||||
|                 </CardContent> | ||||
|               </TabContext> | ||||
|             </Card> | ||||
|           </Box> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										15
									
								
								pkg/views/src/router/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								pkg/views/src/router/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { createRouter, createWebHistory } from "vue-router" | ||||
| import MasterLayout from "@/layouts/master.vue" | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(import.meta.env.BASE_URL), | ||||
|   routes: [ | ||||
|     { | ||||
|       path: "/", | ||||
|       component: MasterLayout, | ||||
|       children: [{ path: "/", name: "dashboard", component: () => import("@/views/dashboard.vue") }], | ||||
|     }, | ||||
|   ], | ||||
| }) | ||||
|  | ||||
| export default router | ||||
| @@ -1,4 +1,10 @@ | ||||
| declare global { | ||||
|   interface Window { | ||||
|     __LAUNCHPAD_TARGET__?: string | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function request(input: string, init?: RequestInit) { | ||||
|   const prefix = window.__LAUNCHPAD_TARGET__ ?? ""; | ||||
|   const prefix = window.__LAUNCHPAD_TARGET__ ?? "" | ||||
|   return await fetch(prefix + input, init) | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										56
									
								
								pkg/views/src/stores/userinfo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								pkg/views/src/stores/userinfo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| import Cookie from "universal-cookie" | ||||
| import { defineStore } from "pinia" | ||||
| import { ref } from "vue" | ||||
| import { request } from "@/scripts/request" | ||||
|  | ||||
| export interface Userinfo { | ||||
|   isReady: boolean | ||||
|   isLoggedIn: boolean | ||||
|   displayName: string | ||||
|   data: any | ||||
| } | ||||
|  | ||||
| const defaultUserinfo: Userinfo = { | ||||
|   isReady: false, | ||||
|   isLoggedIn: false, | ||||
|   displayName: "Citizen", | ||||
|   data: null | ||||
| } | ||||
|  | ||||
| export function getAtk(): string { | ||||
|   return new Cookie().get("identity_auth_key") | ||||
| } | ||||
|  | ||||
| export function checkLoggedIn(): boolean { | ||||
|   return new Cookie().get("identity_auth_key") | ||||
| } | ||||
|  | ||||
| export const useUserinfo = defineStore("userinfo", () => { | ||||
|   const userinfo = ref(defaultUserinfo) | ||||
|   const isReady = ref(false) | ||||
|  | ||||
|   async function readProfiles() { | ||||
|     if (!checkLoggedIn()) { | ||||
|         isReady.value = true; | ||||
|       } | ||||
|    | ||||
|       const res = await request("/api/users/me", { | ||||
|         headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|       }); | ||||
|    | ||||
|       if (res.status !== 200) { | ||||
|         return; | ||||
|       } | ||||
|    | ||||
|       const data = await res.json(); | ||||
|    | ||||
|       userinfo.value = { | ||||
|         isReady: true, | ||||
|         isLoggedIn: true, | ||||
|         displayName: data["nick"], | ||||
|         data: data | ||||
|       }; | ||||
|   } | ||||
|  | ||||
|   return { userinfo, isReady, readProfiles } | ||||
| }) | ||||
| @@ -1,79 +0,0 @@ | ||||
| import Cookie from "universal-cookie"; | ||||
| import { request } from "../scripts/request.ts"; | ||||
| import { createContext, useContext, useState } from "react"; | ||||
|  | ||||
| export interface Userinfo { | ||||
|   isReady: boolean, | ||||
|   isLoggedIn: boolean, | ||||
|   displayName: string, | ||||
|   data: any, | ||||
| } | ||||
|  | ||||
| const defaultUserinfo: Userinfo = { | ||||
|   isReady: false, | ||||
|   isLoggedIn: false, | ||||
|   displayName: "Citizen", | ||||
|   data: null | ||||
| }; | ||||
|  | ||||
| const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo }); | ||||
|  | ||||
| export function UserinfoProvider(props: any) { | ||||
|   const [userinfo, setUserinfo] = useState<Userinfo>(structuredClone(defaultUserinfo)); | ||||
|  | ||||
|   function getAtk(): string { | ||||
|     return new Cookie().get("identity_auth_key"); | ||||
|   } | ||||
|  | ||||
|   function checkLoggedIn(): boolean { | ||||
|     return new Cookie().get("identity_auth_key"); | ||||
|   } | ||||
|  | ||||
|   async function readProfiles() { | ||||
|     if (!checkLoggedIn()) { | ||||
|       setUserinfo((data) => { | ||||
|         data.isReady = true; | ||||
|         return data; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     const res = await request("/api/users/me", { | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|  | ||||
|     if (res.status !== 200) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const data = await res.json(); | ||||
|  | ||||
|     setUserinfo({ | ||||
|       isReady: true, | ||||
|       isLoggedIn: true, | ||||
|       displayName: data["nick"], | ||||
|       data: data | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function clearUserinfo() { | ||||
|     const cookies = document.cookie.split(";"); | ||||
|     for (let i = 0; i < cookies.length; i++) { | ||||
|       const cookie = cookies[i]; | ||||
|       const eqPos = cookie.indexOf("="); | ||||
|       const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie; | ||||
|       document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; | ||||
|     } | ||||
|  | ||||
|     setUserinfo(defaultUserinfo); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <UserinfoContext.Provider value={{ userinfo, readProfiles, checkLoggedIn, getAtk, clearUserinfo }}> | ||||
|       {props.children} | ||||
|     </UserinfoContext.Provider> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useUserinfo() { | ||||
|   return useContext(UserinfoContext); | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| import { createContext, useContext, useState } from "react"; | ||||
| import { request } from "../scripts/request.ts"; | ||||
|  | ||||
| const WellKnownContext = createContext<any>(null); | ||||
|  | ||||
| export function WellKnownProvider(props: any) { | ||||
|   const [wellKnown, setWellKnown] = useState<any>(null); | ||||
|  | ||||
|   async function readWellKnown() { | ||||
|     const res = await request("/.well-known"); | ||||
|     setWellKnown(await res.json()); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <WellKnownContext.Provider value={{ wellKnown, readWellKnown }}> | ||||
|       {props.children} | ||||
|     </WellKnownContext.Provider> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useWellKnown() { | ||||
|   return useContext(WellKnownContext); | ||||
| } | ||||
| @@ -1,20 +0,0 @@ | ||||
| import { createTheme } from "@mui/material/styles"; | ||||
|  | ||||
| export const theme = createTheme({ | ||||
|   palette: { | ||||
|     primary: { | ||||
|       main: "#49509e", | ||||
|     }, | ||||
|     secondary: { | ||||
|       main: "#d43630", | ||||
|     }, | ||||
|   }, | ||||
|   typography: { | ||||
|     h1: { fontSize: "2.5rem" }, | ||||
|     h2: { fontSize: "2rem" }, | ||||
|     h3: { fontSize: "1.75rem" }, | ||||
|     h4: { fontSize: "1.5rem" }, | ||||
|     h5: { fontSize: "1.25rem" }, | ||||
|     h6: { fontSize: "1.15rem" }, | ||||
|   }, | ||||
| }); | ||||
							
								
								
									
										3
									
								
								pkg/views/src/views/dashboard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								pkg/views/src/views/dashboard.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <template> | ||||
|   <v-container>Hello, world!</v-container> | ||||
| </template> | ||||
							
								
								
									
										14
									
								
								pkg/views/tsconfig.app.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								pkg/views/tsconfig.app.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| { | ||||
|   "extends": "@vue/tsconfig/tsconfig.dom.json", | ||||
|   "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], | ||||
|   "exclude": ["src/**/__tests__/*"], | ||||
|   "compilerOptions": { | ||||
|     "composite": true, | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||
|  | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|       "@/*": ["./src/*"] | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,30 +1,11 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "resolveJsonModule": true, | ||||
|     "isolatedModules": true, | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|  | ||||
|     "baseUrl": "./src", | ||||
|     "paths": { | ||||
|       "@/*": ["./*"] | ||||
|   "files": [], | ||||
|   "references": [ | ||||
|     { | ||||
|       "path": "./tsconfig.node.json" | ||||
|     }, | ||||
|     { | ||||
|       "path": "./tsconfig.app.json" | ||||
|     } | ||||
|   }, | ||||
|   "include": ["src"], | ||||
|   "references": [{ "path": "./tsconfig.node.json" }] | ||||
|   ] | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| { | ||||
|   "extends": "@tsconfig/node20/tsconfig.json", | ||||
|   "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"], | ||||
|   "compilerOptions": { | ||||
|     "composite": true, | ||||
|     "skipLibCheck": true, | ||||
|     "noEmit": true, | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | ||||
|  | ||||
|     "module": "ESNext", | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "strict": true | ||||
|   }, | ||||
|   "include": ["vite.config.ts"] | ||||
|     "moduleResolution": "Bundler", | ||||
|     "types": ["node"] | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { defineConfig, presetUno } from "unocss"; | ||||
| import { defineConfig, presetAttributify, presetTypography, presetUno } from "unocss" | ||||
|  | ||||
| export default defineConfig({ | ||||
|   presets: [presetUno({ preflight: false })] | ||||
| }); | ||||
|   presets: [presetAttributify(), presetTypography(), presetUno({ preflight: false })], | ||||
| }) | ||||
|   | ||||
| @@ -1,19 +1,20 @@ | ||||
| import { defineConfig } from 'vite' | ||||
| import path from "path"; | ||||
| import react from '@vitejs/plugin-react-swc' | ||||
| import { fileURLToPath, URL } from "node:url" | ||||
|  | ||||
| import { defineConfig } from "vite" | ||||
| import vue from "@vitejs/plugin-vue" | ||||
| import vueJsx from "@vitejs/plugin-vue-jsx" | ||||
| import unocss from "unocss/vite" | ||||
|  | ||||
| // https://vitejs.dev/config/ | ||||
| export default defineConfig({ | ||||
|   plugins: [react(), unocss()], | ||||
|   plugins: [vue(), vueJsx(), unocss()], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       "@": path.resolve(__dirname, "./src"), | ||||
|       "@": fileURLToPath(new URL("./src", import.meta.url)), | ||||
|     }, | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       "/.well-known": "http://localhost:8444", | ||||
|       "/api": "http://localhost:8444" | ||||
|     } | ||||
|   } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user