💄 全新设计重构 #2
@ -1,15 +1,16 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/database"
|
"code.smartsheep.studio/hydrogen/identity/pkg/database"
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/models"
|
"code.smartsheep.studio/hydrogen/identity/pkg/models"
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/services"
|
"code.smartsheep.studio/hydrogen/identity/pkg/services"
|
||||||
"fmt"
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getUserinfo(c *fiber.Ctx) error {
|
func getUserinfo(c *fiber.Ctx) error {
|
||||||
@ -20,7 +21,6 @@ func getUserinfo(c *fiber.Ctx) error {
|
|||||||
Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}).
|
Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}).
|
||||||
Preload("Profile").
|
Preload("Profile").
|
||||||
Preload("Contacts").
|
Preload("Contacts").
|
||||||
Preload("Notifications", "read_at IS NULL").
|
|
||||||
First(&data).Error; err != nil {
|
First(&data).Error; err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/database"
|
"code.smartsheep.studio/hydrogen/identity/pkg/database"
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/models"
|
"code.smartsheep.studio/hydrogen/identity/pkg/models"
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/services"
|
"code.smartsheep.studio/hydrogen/identity/pkg/services"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getNotifications(c *fiber.Ctx) error {
|
func getNotifications(c *fiber.Ctx) error {
|
||||||
@ -14,7 +15,7 @@ func getNotifications(c *fiber.Ctx) error {
|
|||||||
take := c.QueryInt("take", 0)
|
take := c.QueryInt("take", 0)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
|
|
||||||
only_unread := c.QueryBool("only_unread", true)
|
only_unread := !c.QueryBool("past", false)
|
||||||
|
|
||||||
tx := database.C.Where(&models.Notification{RecipientID: user.ID}).Model(&models.Notification{})
|
tx := database.C.Where(&models.Notification{RecipientID: user.ID}).Model(&models.Notification{})
|
||||||
if only_unread {
|
if only_unread {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/database"
|
"code.smartsheep.studio/hydrogen/identity/pkg/database"
|
||||||
"code.smartsheep.studio/hydrogen/identity/pkg/models"
|
"code.smartsheep.studio/hydrogen/identity/pkg/models"
|
||||||
"fmt"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@ -51,7 +52,7 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) {
|
|||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
factor.Secret = uuid.NewString()[:8]
|
factor.Secret = uuid.NewString()[:6]
|
||||||
if err := database.C.Save(&factor).Error; err != nil {
|
if err := database.C.Save(&factor).Error; err != nil {
|
||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
require("@rushstack/eslint-patch/modern-module-resolution")
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
"plugin:vue/vue3-essential",
|
||||||
'plugin:@typescript-eslint/recommended',
|
"eslint:recommended",
|
||||||
'plugin:react-hooks/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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
6
pkg/views/.eslintrc.js
Normal file
6
pkg/views/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ["plugin:vue/vue3-recommended"],
|
||||||
|
rules: {
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
},
|
||||||
|
}
|
8
pkg/views/.gitignore
vendored
8
pkg/views/.gitignore
vendored
@ -8,17 +8,23 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## 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
|
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||||
export default {
|
|
||||||
// other rules...
|
## Project Setup
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
```sh
|
||||||
sourceType: 'module',
|
npm install
|
||||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
### Compile and Hot-Reload for Development
|
||||||
- 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
|
```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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/xml+svg" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Goatpass</title>
|
<title>Solarpass</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,47 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "identity-web",
|
"name": "views",
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"preview": "vite preview",
|
||||||
"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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.3",
|
"@fontsource/roboto": "^5.0.12",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@mdi/font": "^7.4.47",
|
||||||
"@fontsource/roboto": "^5.0.8",
|
|
||||||
"@mui/icons-material": "^5.15.10",
|
|
||||||
"@mui/lab": "^5.0.0-alpha.166",
|
|
||||||
"@mui/material": "^5.15.10",
|
|
||||||
"@mui/x-data-grid": "^6.19.5",
|
|
||||||
"@mui/x-date-pickers": "^6.19.5",
|
|
||||||
"@unocss/reset": "^0.58.5",
|
"@unocss/reset": "^0.58.5",
|
||||||
"dayjs": "^1.11.10",
|
"pinia": "^2.1.7",
|
||||||
"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",
|
|
||||||
"universal-cookie": "^7.1.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.20",
|
"@rushstack/eslint-patch": "^1.3.3",
|
||||||
"@types/react": "^18.2.56",
|
"@tsconfig/node20": "^20.1.2",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/node": "^20.11.25",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@typescript-eslint/parser": "^7.0.2",
|
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vue/eslint-config-prettier": "^8.0.0",
|
||||||
"eslint": "^8.56.0",
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint": "^8.49.0",
|
||||||
"typescript": "^5.2.2",
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
"unocss": "^0.58.5",
|
"npm-run-all2": "^6.1.2",
|
||||||
"vite": "^5.1.4"
|
"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 After Width: | Height: | Size: 50 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)} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
6
pkg/views/src/components/Copyright.vue
Normal file
6
pkg/views/src/components/Copyright.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-xs text-center opacity-80">
|
||||||
|
<p>Copyright © {{ new Date().getFullYear() }} Solsynth</p>
|
||||||
|
<p>Powered by <a class="underline" href="#">Hydrogen.Identity</a></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
87
pkg/views/src/components/NotificationList.vue
Normal file
87
pkg/views/src/components/NotificationList.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu eager :close-on-content-click="false">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" stacked rounded="circle" size="small" variant="text" :loading="loading">
|
||||||
|
<v-badge v-if="pagination.total > 0" color="error" :content="pagination.total">
|
||||||
|
<v-icon icon="mdi-bell" />
|
||||||
|
</v-badge>
|
||||||
|
|
||||||
|
<v-icon v-else icon="mdi-bell" />
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list v-if="notifications.length <= 0" class="w-[380px]" density="compact">
|
||||||
|
<v-list-item>
|
||||||
|
<v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for you.</v-alert>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-list v-else class="w-[380px]" density="compact" lines="three">
|
||||||
|
<v-list-item v-for="item in notifications">
|
||||||
|
<template #title>{{ item.subject }}</template>
|
||||||
|
<template #subtitle>{{ item.content }}</template>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex text-xs gap-1">
|
||||||
|
<a v-for="link in item.links" class="mt-1 underline" target="_blank" :href="link.url">{{ link.label }}</a>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
|
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { getAtk } from "@/stores/userinfo"
|
||||||
|
import { reactive, ref } from "vue"
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const notifications = ref<any[]>([])
|
||||||
|
const pagination = reactive({ page: 1, pageSize: 25, total: 0 })
|
||||||
|
|
||||||
|
async function readNotifications() {
|
||||||
|
loading.value = true
|
||||||
|
const res = await request(
|
||||||
|
"/api/notifications?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
take: pagination.pageSize.toString(),
|
||||||
|
offset: ((pagination.page - 1) * pagination.pageSize).toString(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (res.status === 200) {
|
||||||
|
const data = await res.json()
|
||||||
|
notifications.value = data["data"]
|
||||||
|
pagination.total = data["count"]
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
readNotifications()
|
||||||
|
|
||||||
|
async function markAsRead(item: any) {
|
||||||
|
loading.value = true
|
||||||
|
const res = await request(`/api/notifications/${item.id}/read`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
await readNotifications()
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
43
pkg/views/src/components/UserMenu.vue
Normal file
43
pkg/views/src/components/UserMenu.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn flat exact v-bind="props" icon>
|
||||||
|
<v-avatar color="transparent" icon="mdi-account-circle" :image="'/api/avatar/' + id.userinfo.data?.avatar" />
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list density="compact" v-if="!id.userinfo.isLoggedIn">
|
||||||
|
<v-list-item title="Sign in" prepend-icon="mdi-login-variant" :to="{ name: 'auth.sign-in' }" />
|
||||||
|
<v-list-item title="Create account" prepend-icon="mdi-account-plus" :to="{ name: 'auth.sign-up' }" />
|
||||||
|
</v-list>
|
||||||
|
<v-list density="compact" v-else>
|
||||||
|
<v-list-item :title="nickname" :subtitle="username" />
|
||||||
|
|
||||||
|
<v-divider class="border-opacity-50 my-2" />
|
||||||
|
|
||||||
|
<v-list-item title="User Center" prepend-icon="mdi-account-supervisor" exact :to="{ name: 'dashboard' }" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
61
pkg/views/src/components/auth/AccountLocator.vue
Normal file
61
pkg/views/src/components/auth/AccountLocator.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<v-form class="flex-grow-1" @submit.prevent="submit">
|
||||||
|
<v-text-field label="Account ID" variant="solo" density="comfortable" :loading="props.loading" v-model="probe" />
|
||||||
|
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
|
||||||
|
Something went wrong... {{ error }}
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<v-btn type="button" variant="plain" color="grey-darken-3" :to="{ name: 'auth.sign-up' }">Sign up</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="justify-self-end"
|
||||||
|
append-icon="mdi-arrow-right"
|
||||||
|
:disabled="props.loading"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
|
||||||
|
const probe = ref("")
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{ loading?: boolean }>()
|
||||||
|
const emits = defineEmits(["swap", "update:loading", "update:factors", "update:challenge"])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!probe) return
|
||||||
|
|
||||||
|
emits("update:loading", true)
|
||||||
|
const res = await request("/api/auth", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: probe.value }),
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
emits("update:factors", data["factors"])
|
||||||
|
emits("update:challenge", data["challenge"])
|
||||||
|
emits("swap", "pick")
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
emits("update:loading", false)
|
||||||
|
}
|
||||||
|
</script>
|
16
pkg/views/src/components/auth/CallbackNotify.vue
Normal file
16
pkg/views/src/components/auth/CallbackNotify.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full max-w-[720px]">
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="route.query['redirect_uri']" variant="tonal" type="info" class="text-xs">
|
||||||
|
You need to sign in before access that page. After you signed in, we will redirect you to: <br />
|
||||||
|
<span class="font-mono">{{ route.query["redirect_uri"] }}</span>
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
</script>
|
120
pkg/views/src/components/auth/FactorApplicator.vue
Normal file
120
pkg/views/src/components/auth/FactorApplicator.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<v-form class="flex-grow-1" @submit.prevent="submit">
|
||||||
|
<div v-if="inputType === 'one-time-password'" class="text-center">
|
||||||
|
<p class="text-xs opacity-90">Check your inbox!</p>
|
||||||
|
<v-otp-input class="pt-0" variant="solo" density="compact" :length="6" v-model="password" :loading="loading" />
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-else
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
variant="solo"
|
||||||
|
density="comfortable"
|
||||||
|
:loading="loading"
|
||||||
|
v-model="password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
|
||||||
|
Something went wrong... {{ error }}
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="justify-self-end"
|
||||||
|
append-icon="mdi-arrow-right"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
|
const password = ref("")
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{ loading?: boolean; currentFactor?: any; challenge?: any }>()
|
||||||
|
const emits = defineEmits(["swap", "update:challenge"])
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { readProfiles } = useUserinfo()
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const res = await request(`/api/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_id: props.challenge?.id,
|
||||||
|
factor_id: props.currentFactor?.id,
|
||||||
|
secret: password.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data["is_finished"]) {
|
||||||
|
await getToken(data["session"]["grant_token"])
|
||||||
|
await readProfiles()
|
||||||
|
callback()
|
||||||
|
} else {
|
||||||
|
emits("swap", "pick")
|
||||||
|
emits("update:challenge", data["challenge"])
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken(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()
|
||||||
|
error.value = err
|
||||||
|
throw new Error(err)
|
||||||
|
} else {
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callback() {
|
||||||
|
if (route.query["closable"]) {
|
||||||
|
window.close()
|
||||||
|
} else if (route.query["redirect_uri"]) {
|
||||||
|
window.open((route.query["redirect_uri"] as string) ?? "/", "_self")
|
||||||
|
} else {
|
||||||
|
router.push({ name: "dashboard" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputType = computed(() => {
|
||||||
|
switch (props.currentFactor?.type) {
|
||||||
|
case 0:
|
||||||
|
return "text"
|
||||||
|
case 1:
|
||||||
|
return "one-time-password"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
74
pkg/views/src/components/auth/FactorPicker.vue
Normal file
74
pkg/views/src/components/auth/FactorPicker.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<v-card class="mb-3">
|
||||||
|
<v-list density="compact" color="primary">
|
||||||
|
<v-list-item
|
||||||
|
v-for="item in props.factors ?? []"
|
||||||
|
:prepend-icon="getFactorType(item)?.icon"
|
||||||
|
:title="getFactorType(item)?.label"
|
||||||
|
:active="focus === item.id"
|
||||||
|
:disabled="getFactorAvailable(item)"
|
||||||
|
@click="focus = item.id"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
|
||||||
|
Something went wrong... {{ error }}
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<v-btn variant="text" color="primary" class="justify-self-end" append-icon="mdi-arrow-right" @click="submit">
|
||||||
|
Next
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
|
||||||
|
const focus = ref<number | null>(null)
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{ factors?: any[]; challenge?: any }>()
|
||||||
|
const emits = defineEmits(["swap", "update:loading", "update:currentFactor"])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!focus) return
|
||||||
|
|
||||||
|
emits("update:loading", true)
|
||||||
|
const res = await request(`/api/auth/factors/${focus.value}`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
if (res.status !== 200 && res.status !== 204) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const item = props.factors?.find((item: any) => item.id === focus.value)
|
||||||
|
emits("update:currentFactor", item)
|
||||||
|
emits("swap", "applicator")
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
emits("update:loading", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFactorType(item: any) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 0:
|
||||||
|
return { icon: "mdi-form-textbox-password", label: "Password Validation" }
|
||||||
|
case 1:
|
||||||
|
return { icon: "mdi-email-fast", label: "Email One Time Password" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFactorAvailable(factor: any) {
|
||||||
|
const blacklist: number[] = props.challenge?.blacklist_factors ?? []
|
||||||
|
return blacklist.includes(factor.id)
|
||||||
|
}
|
||||||
|
</script>
|
@ -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>
|
46
pkg/views/src/layouts/master.vue
Normal file
46
pkg/views/src/layouts/master.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<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' }" class="flex gap-1">
|
||||||
|
<img src="/favicon.svg" width="24" height="24" class="icon-filter" />
|
||||||
|
<h2 class="ml-2 text-lg font-500">Solarpass</h2>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<div class="me-2">
|
||||||
|
<notification-list />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<user-menu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<router-view />
|
||||||
|
</v-main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import NotificationList from "@/components/NotificationList.vue"
|
||||||
|
import UserMenu from "@/components/UserMenu.vue"
|
||||||
|
|
||||||
|
const id = useUserinfo()
|
||||||
|
|
||||||
|
id.readProfiles()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-fab {
|
||||||
|
position: fixed !important;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-filter {
|
||||||
|
filter: invert(100%) sepia(100%) saturate(14%) hue-rotate(212deg) brightness(104%) contrast(104%);
|
||||||
|
}
|
||||||
|
</style>
|
19
pkg/views/src/layouts/user-center.vue
Normal file
19
pkg/views/src/layouts/user-center.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="pt-6 px-6">
|
||||||
|
<v-row>
|
||||||
|
<v-col :xs="12" :sm="12" :md="4" :lg="3">
|
||||||
|
<v-card title="Navigation">
|
||||||
|
<v-list density="comfortable">
|
||||||
|
<v-list-item title="Dashboard" prepend-icon="mdi-view-dashboard" :to="{ name: 'dashboard' }" exact />
|
||||||
|
<v-list-item title="Personalize" prepend-icon="mdi-card-bulleted-outline" :to="{ name: 'personalize' }" />
|
||||||
|
<v-list-item title="Security" prepend-icon="mdi-security" :to="{ name: 'security' }" />
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col :xs="12" :sm="12" :md="8" :lg="9">
|
||||||
|
<router-view />
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
83
pkg/views/src/router/index.ts
Normal file
83
pkg/views/src/router/index.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import MasterLayout from "@/layouts/master.vue"
|
||||||
|
import UserCenterLayout from "@/layouts/user-center.vue"
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: MasterLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: UserCenterLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "dashboard",
|
||||||
|
component: () => import("@/views/dashboard.vue"),
|
||||||
|
meta: { title: "Your account" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/me/personalize",
|
||||||
|
name: "personalize",
|
||||||
|
component: () => import("@/views/personalize.vue"),
|
||||||
|
meta: { title: "Your personality" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/me/security",
|
||||||
|
name: "security",
|
||||||
|
component: () => import("@/views/security.vue"),
|
||||||
|
meta: { title: "Your security" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/auth",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "sign-in",
|
||||||
|
name: "auth.sign-in",
|
||||||
|
component: () => import("@/views/auth/sign-in.vue"),
|
||||||
|
meta: { public: true, title: "Sign in" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "sign-up",
|
||||||
|
name: "auth.sign-up",
|
||||||
|
component: () => import("@/views/auth/sign-up.vue"),
|
||||||
|
meta: { public: true, title: "Sign up" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "o/connect",
|
||||||
|
name: "openid.connect",
|
||||||
|
component: () => import("@/views/auth/connect.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const id = useUserinfo()
|
||||||
|
if (!id.isReady) {
|
||||||
|
await id.readProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = `Solarpass | ${to.meta.title}`
|
||||||
|
} else {
|
||||||
|
document.title = "Solarpass"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to.meta.public && !id.userinfo.isLoggedIn) {
|
||||||
|
next({ name: "auth.sign-in", query: { redirect_uri: to.fullPath } })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
@ -1,4 +1,10 @@
|
|||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__LAUNCHPAD_TARGET__?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function request(input: string, init?: RequestInit) {
|
export async function request(input: string, init?: RequestInit) {
|
||||||
const prefix = window.__LAUNCHPAD_TARGET__ ?? "";
|
const prefix = window.__LAUNCHPAD_TARGET__ ?? ""
|
||||||
return await fetch(prefix + input, init)
|
return await fetch(prefix + input, init)
|
||||||
}
|
}
|
54
pkg/views/src/stores/userinfo.ts
Normal file
54
pkg/views/src/stores/userinfo.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import Cookie from "universal-cookie"
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
|
||||||
|
export interface Userinfo {
|
||||||
|
isLoggedIn: boolean
|
||||||
|
displayName: string
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUserinfo: Userinfo = {
|
||||||
|
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()
|
||||||
|
|
||||||
|
isReady.value = true
|
||||||
|
userinfo.value = {
|
||||||
|
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" },
|
|
||||||
},
|
|
||||||
});
|
|
13
pkg/views/src/views/auth/claims.ts
Normal file
13
pkg/views/src/views/auth/claims.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export interface ClaimType {
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claims: { [id: string]: ClaimType } = {
|
||||||
|
openid: {
|
||||||
|
icon: "mdi-identifier",
|
||||||
|
name: "Open Identity",
|
||||||
|
description: "Allow them to read your personal information.",
|
||||||
|
},
|
||||||
|
}
|
192
pkg/views/src/views/auth/connect.vue
Normal file
192
pkg/views/src/views/auth/connect.vue
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
|
||||||
|
<v-card class="w-full max-w-[720px]" :loading="loading">
|
||||||
|
<v-card-text class="card-grid pa-9">
|
||||||
|
<div>
|
||||||
|
<v-avatar color="accent" icon="mdi-connection" size="large" class="card-rounded mb-2" />
|
||||||
|
<h1 class="text-2xl">Connect to third-party</h1>
|
||||||
|
<p>One Solarpass, entire internet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-window :model-value="panel" class="pa-2 mx-[-0.5rem]">
|
||||||
|
<v-window-item value="confirm">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
|
||||||
|
<p>Something went wrong... {{ error }}</p>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<p class="font-bold">
|
||||||
|
It's usually not our fault. Try bringing this link to give feedback to the developer of the app you
|
||||||
|
came from.
|
||||||
|
</p>
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
|
||||||
|
<div v-if="!error">
|
||||||
|
<h1 class="font-bold text-xl">{{ metadata?.name ?? "Loading" }}</h1>
|
||||||
|
<p>{{ metadata?.description ?? "Hold on a second please!" }}</p>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="opacity-80 text-xs">Permissions they requested</p>
|
||||||
|
<v-card variant="tonal" class="mt-1 mx-[-4px]">
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="claim in requestedClaims" lines="two">
|
||||||
|
<template #title>
|
||||||
|
<span class="capitalize">{{ getClaimDescription(claim)?.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #subtitle>
|
||||||
|
<span>{{ getClaimDescription(claim)?.description }}</span>
|
||||||
|
</template>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon :icon="getClaimDescription(claim)?.icon" size="x-large" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-between">
|
||||||
|
<v-btn prepend-icon="mdi-close" variant="text" color="error" :disabled="loading" @click="decline">
|
||||||
|
Decline
|
||||||
|
</v-btn>
|
||||||
|
<v-btn append-icon="mdi-check" variant="tonal" color="success" :disabled="loading" @click="approve">
|
||||||
|
Approve
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 text-xs text-center opacity-75">
|
||||||
|
<p>After approve their request, you will be redirect to</p>
|
||||||
|
<p class="text-mono">{{ route.query["redirect_uri"] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
|
<v-window-item value="callback">
|
||||||
|
<div>
|
||||||
|
<v-icon icon="mdi-fire" size="32" color="grey-darken-3" class="mb-3" />
|
||||||
|
|
||||||
|
<h1 class="font-bold text-xl">Authoirzed</h1>
|
||||||
|
<p>You're done! We sucessfully established connection between you and {{ metadata?.name }}.</p>
|
||||||
|
|
||||||
|
<p class="mt-3">Now you can continue your their app, we will redirect you soon.</p>
|
||||||
|
|
||||||
|
<p class="mt-3">Teleporting you to...</p>
|
||||||
|
<p class="text-xs text-mono">{{ route.query["redirect_uri"] }}</p>
|
||||||
|
</div>
|
||||||
|
</v-window-item>
|
||||||
|
</v-window>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<copyright />
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { getAtk } from "@/stores/userinfo"
|
||||||
|
import { claims, type ClaimType } from "@/views/auth/claims"
|
||||||
|
import Copyright from "@/components/Copyright.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const metadata = ref<any>(null)
|
||||||
|
const requestedClaims = computed(() => {
|
||||||
|
const scope: string = (route.query["scope"] as string) ?? ""
|
||||||
|
return scope.split(" ")
|
||||||
|
})
|
||||||
|
|
||||||
|
const panel = ref("confirm")
|
||||||
|
|
||||||
|
async function preconnect() {
|
||||||
|
const res = await request(`/api/auth/o/connect${location.search}`, {
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data["session"]) {
|
||||||
|
panel.value = "callback"
|
||||||
|
callback(data["session"])
|
||||||
|
} else {
|
||||||
|
document.title = `Solarpass | Connect to ${data["client"]?.name}`
|
||||||
|
metadata.value = data["client"]
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preconnect()
|
||||||
|
|
||||||
|
function decline() {
|
||||||
|
if (window.history.length > 0) {
|
||||||
|
window.history.back()
|
||||||
|
} else {
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
loading.value = true
|
||||||
|
const res = await request(
|
||||||
|
"/api/auth/o/connect?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
client_id: route.query["client_id"] as string,
|
||||||
|
redirect_uri: encodeURIComponent(route.query["redirect_uri"] as string),
|
||||||
|
response_type: "code",
|
||||||
|
scope: route.query["scope"] as string,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
loading.value = false
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
panel.value = "callback"
|
||||||
|
setTimeout(() => callback(data["session"]), 1850)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callback(session: any) {
|
||||||
|
const url = `${route.query["redirect_uri"]}?code=${session["grant_token"]}&state=${route.query["state"]}`
|
||||||
|
window.open(url, "_self")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClaimDescription(key: string): ClaimType {
|
||||||
|
return claims.hasOwnProperty(key) ? claims[key] : { icon: "mdi-asterisk", name: key, description: "Unknown claim..." }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-rounded {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
81
pkg/views/src/views/auth/sign-in.vue
Normal file
81
pkg/views/src/views/auth/sign-in.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
|
||||||
|
<callback-notify />
|
||||||
|
|
||||||
|
<v-card class="w-full max-w-[720px]" :loading="loading">
|
||||||
|
<v-card-text class="card-grid pa-9">
|
||||||
|
<div>
|
||||||
|
<v-avatar color="accent" icon="mdi-login-variant" size="large" class="card-rounded mb-2" />
|
||||||
|
<h1 class="text-2xl">Sign in</h1>
|
||||||
|
<div v-if="challenge" class="flex items-center gap-4">
|
||||||
|
<v-tooltip>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-progress-circular v-bind="props" size="large" :model-value="challenge?.progress / challenge?.requirements" />
|
||||||
|
</template>
|
||||||
|
<p><b>Risk: </b> {{ challenge?.risk_level }}</p>
|
||||||
|
<p><b>Progress: </b> {{ challenge?.progress }}/{{ challenge?.requirements }}</p>
|
||||||
|
</v-tooltip>
|
||||||
|
<p>We need to verify that the person trying to access your account is you.</p>
|
||||||
|
</div>
|
||||||
|
<p v-else>Sign in via your Solar ID to access the entire Solar Network.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-window :model-value="panel" class="pa-2 mx-[-0.5rem]">
|
||||||
|
<v-window-item v-for="k in Object.keys(panels)" :value="k">
|
||||||
|
<component
|
||||||
|
:is="panels[k]"
|
||||||
|
@swap="(val: string) => (panel = val)"
|
||||||
|
v-model:loading="loading"
|
||||||
|
v-model:factors="factors"
|
||||||
|
v-model:currentFactor="currentFactor"
|
||||||
|
v-model:challenge="challenge"
|
||||||
|
/>
|
||||||
|
</v-window-item>
|
||||||
|
</v-window>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<copyright />
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, type Component } from "vue"
|
||||||
|
import Copyright from "@/components/Copyright.vue"
|
||||||
|
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
|
||||||
|
import AccountLocator from "@/components/auth/AccountLocator.vue"
|
||||||
|
import FactorPicker from "@/components/auth/FactorPicker.vue"
|
||||||
|
import FactorApplicator from "@/components/auth/FactorApplicator.vue"
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const factors = ref<any>(null)
|
||||||
|
const currentFactor = ref<any>(null)
|
||||||
|
const challenge = ref<any>(null)
|
||||||
|
|
||||||
|
const panel = ref("locate")
|
||||||
|
|
||||||
|
const panels: { [id: string]: Component } = {
|
||||||
|
locate: AccountLocator,
|
||||||
|
pick: FactorPicker,
|
||||||
|
applicator: FactorApplicator,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-rounded {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
162
pkg/views/src/views/auth/sign-up.vue
Normal file
162
pkg/views/src/views/auth/sign-up.vue
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
|
||||||
|
<callback-notify />
|
||||||
|
|
||||||
|
<v-card class="w-full max-w-[720px]" :loading="loading">
|
||||||
|
<v-card-text class="card-grid pa-9">
|
||||||
|
<div>
|
||||||
|
<v-avatar color="accent" icon="mdi-login-variant" size="large" class="card-rounded mb-2" />
|
||||||
|
<h1 class="text-2xl">Create an account</h1>
|
||||||
|
<p>Create an account on Solar Network. Then enjoy all our services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<v-form class="flex-grow-1" @submit.prevent="submit">
|
||||||
|
<v-row dense class="mb-3">
|
||||||
|
<v-col :cols="6">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Name"
|
||||||
|
autocomplete="username"
|
||||||
|
variant="solo"
|
||||||
|
density="comfortable"
|
||||||
|
v-model="data.name"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :cols="6">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Nick"
|
||||||
|
autocomplete="nickname"
|
||||||
|
variant="solo"
|
||||||
|
density="comfortable"
|
||||||
|
v-model="data.nick"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :cols="12">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
variant="solo"
|
||||||
|
density="comfortable"
|
||||||
|
v-model="data.email"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :cols="12">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
variant="solo"
|
||||||
|
density="comfortable"
|
||||||
|
v-model="data.password"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
|
||||||
|
Something went wrong... {{ error }}
|
||||||
|
</v-alert>
|
||||||
|
</v-expand-transition>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<v-btn type="button" variant="plain" color="grey-darken-3" :to="{ name: 'auth.sign-in' }">
|
||||||
|
Sign in
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn type="submit" variant="text" color="primary" append-icon="mdi-arrow-right" :disabled="loading">
|
||||||
|
Next
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="done" class="max-w-[560px]">
|
||||||
|
<v-card title="Congratulations">
|
||||||
|
<template #text>
|
||||||
|
You successfully created an account on Solar Network. Now sign in to your account and start exploring!
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex flex-grow-1 justify-end">
|
||||||
|
<v-btn @click="callback">Let's go</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<copyright />
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import Copyright from "@/components/Copyright.vue"
|
||||||
|
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const done = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const data = ref({
|
||||||
|
name: "",
|
||||||
|
nick: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const payload = data.value
|
||||||
|
if (!payload.name || !payload.nick || !payload.email || !payload.password) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const res = await request("/api/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
done.value = true
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function callback() {
|
||||||
|
if (route.params["closable"]) {
|
||||||
|
window.close()
|
||||||
|
} else {
|
||||||
|
router.push({ name: "auth.sign-in" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-rounded {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
31
pkg/views/src/views/dashboard.vue
Normal file
31
pkg/views/src/views/dashboard.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card class="px-1 mb-3">
|
||||||
|
<v-card-text class="flex gap-3.5">
|
||||||
|
<v-avatar
|
||||||
|
color="grey-lighten-2"
|
||||||
|
icon="mdi-account-circle"
|
||||||
|
class="rounded-card"
|
||||||
|
:size="54"
|
||||||
|
:image="'/api/avatar/' + id.userinfo.data.avatar"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">{{ id.userinfo.displayName }}</h1>
|
||||||
|
<p>What can I help you today?</p>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
|
||||||
|
const id = useUserinfo()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rounded-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
185
pkg/views/src/views/personalize.vue
Normal file
185
pkg/views/src/views/personalize.vue
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card class="mb-3" title="Information" prepend-icon="mdi-face-man-profile" :loading="loading">
|
||||||
|
<template #text>
|
||||||
|
<v-form class="mt-1" @submit.prevent="submit">
|
||||||
|
<v-row dense>
|
||||||
|
<v-col :xs="12" :md="6">
|
||||||
|
<v-text-field
|
||||||
|
readonly
|
||||||
|
hide-details
|
||||||
|
label="Username"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
v-model="data.name"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :xs="12" :md="6">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Nickname"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
v-model="data.nick"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :cols="12">
|
||||||
|
<v-textarea
|
||||||
|
hide-details
|
||||||
|
label="Description"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
v-model="data.description"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :xs="12" :md="6" :lg="4">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="First Name"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
v-model="data.first_name"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :xs="12" :md="6" :lg="4">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Last Name"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
v-model="data.last_name"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col :xs="12" :lg="4">
|
||||||
|
<v-text-field
|
||||||
|
hide-details
|
||||||
|
label="Birthday"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
type="datetime-local"
|
||||||
|
v-model="data.birthday"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading">
|
||||||
|
Apply Changes
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card>
|
||||||
|
<template #text>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<v-avatar
|
||||||
|
color="grey-lighten-2"
|
||||||
|
icon="mdi-account-circle"
|
||||||
|
class="rounded-card"
|
||||||
|
size="large"
|
||||||
|
:image="'/api/avatar/' + id.userinfo.data.avatar"
|
||||||
|
/>
|
||||||
|
<v-file-input
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
label="Upload another avatar"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
accept="image/*"
|
||||||
|
prepend-icon=""
|
||||||
|
append-icon="mdi-upload"
|
||||||
|
v-model="avatar"
|
||||||
|
@click:append="applyAvatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-snackbar v-model="done" :timeout="3000"> Your personal information has been updated. </v-snackbar>
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
|
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useUserinfo, getAtk } from "@/stores/userinfo"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
|
||||||
|
const id = useUserinfo()
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const done = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const data = ref<any>({})
|
||||||
|
const avatar = ref<any>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
id,
|
||||||
|
(val) => {
|
||||||
|
if (val.isReady) {
|
||||||
|
data.value.name = id.userinfo.data.name
|
||||||
|
data.value.nick = id.userinfo.data.nick
|
||||||
|
data.value.first_name = id.userinfo.data.profile.first_name
|
||||||
|
data.value.last_name = id.userinfo.data.profile.last_name
|
||||||
|
data.value.birthday = id.userinfo.data.profile.birthday
|
||||||
|
|
||||||
|
if (data.value.birthday) data.value.birthday = data.value.birthday.substring(0, 16)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const payload = data.value
|
||||||
|
if (payload.birthday) payload.birthday = new Date(payload.birthday).toISOString()
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const res = await request("/api/users/me", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
await id.readProfiles()
|
||||||
|
done.value = true
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAvatar() {
|
||||||
|
if (!avatar.value) return
|
||||||
|
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
const payload = new FormData()
|
||||||
|
payload.set("avatar", avatar.value[0])
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const res = await request("/api/avatar", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
await id.readProfiles()
|
||||||
|
done.value = true
|
||||||
|
error.value = null
|
||||||
|
avatar.value = null
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rounded-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
266
pkg/views/src/views/security.vue
Normal file
266
pkg/views/src/views/security.vue
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-expansion-panels>
|
||||||
|
<v-expansion-panel eager title="Challenges">
|
||||||
|
<template #text>
|
||||||
|
<v-card :loading="reverting.challenges" variant="outlined">
|
||||||
|
<v-data-table-server
|
||||||
|
density="compact"
|
||||||
|
:headers="dataDefinitions.challenges"
|
||||||
|
:items="challenges"
|
||||||
|
:items-length="pagination.challenges.total"
|
||||||
|
:loading="reverting.challenges"
|
||||||
|
v-model:items-per-page="pagination.challenges.pageSize"
|
||||||
|
@update:options="readChallenges"
|
||||||
|
item-value="id"
|
||||||
|
>
|
||||||
|
<template v-slot:item="{ item }: { item: any }">
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.id }}</td>
|
||||||
|
<td>{{ item.ip_address }}</td>
|
||||||
|
<td>
|
||||||
|
<v-tooltip :text="item.user_agent" location="top">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<div v-bind="props" class="text-ellipsis whitespace-nowrap overflow-hidden max-w-[280px]">
|
||||||
|
{{ item.user_agent }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</td>
|
||||||
|
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-expansion-panel>
|
||||||
|
|
||||||
|
<v-expansion-panel eager title="Sessions">
|
||||||
|
<template #text>
|
||||||
|
<v-card :loading="reverting.sessions" variant="outlined">
|
||||||
|
<v-data-table-server
|
||||||
|
density="compact"
|
||||||
|
:headers="dataDefinitions.sessions"
|
||||||
|
:items="sessions"
|
||||||
|
:items-length="pagination.sessions.total"
|
||||||
|
:loading="reverting.sessions"
|
||||||
|
v-model:items-per-page="pagination.sessions.pageSize"
|
||||||
|
@update:options="readSessions"
|
||||||
|
item-value="id"
|
||||||
|
>
|
||||||
|
<template v-slot:item="{ item }: { item: any }">
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.id }}</td>
|
||||||
|
<td>
|
||||||
|
<v-chip v-for="value in item.audiences" size="x-small" color="warning" class="capitalize">
|
||||||
|
{{ value }}
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-chip v-for="value in item.claims" size="x-small" color="info" class="font-mono">
|
||||||
|
{{ value }}
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
|
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||||
|
<td>
|
||||||
|
<v-tooltip text="Sign out">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
variant="text"
|
||||||
|
size="x-small"
|
||||||
|
color="error"
|
||||||
|
icon="mdi-logout-variant"
|
||||||
|
@click="killSession(item)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-expansion-panel>
|
||||||
|
|
||||||
|
<v-expansion-panel eager title="Events">
|
||||||
|
<template #text>
|
||||||
|
<v-card :loading="reverting.events" variant="outlined">
|
||||||
|
<v-data-table-server
|
||||||
|
density="compact"
|
||||||
|
:headers="dataDefinitions.events"
|
||||||
|
:items="events"
|
||||||
|
:items-length="pagination.events.total"
|
||||||
|
:loading="reverting.events"
|
||||||
|
v-model:items-per-page="pagination.events.pageSize"
|
||||||
|
@update:options="readEvents"
|
||||||
|
item-value="id"
|
||||||
|
>
|
||||||
|
<template v-slot:item="{ item }: { item: any }">
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.id }}</td>
|
||||||
|
<td>{{ item.type }}</td>
|
||||||
|
<td>{{ item.target }}</td>
|
||||||
|
<td>{{ item.ip_address }}</td>
|
||||||
|
<td>
|
||||||
|
<v-tooltip :text="item.user_agent" location="top">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<div v-bind="props" class="text-ellipsis whitespace-nowrap overflow-hidden max-w-[180px]">
|
||||||
|
{{ item.user_agent }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</td>
|
||||||
|
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||||
|
import { reactive, ref } from "vue"
|
||||||
|
|
||||||
|
const id = useUserinfo()
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const dataDefinitions: { [id: string]: any[] } = {
|
||||||
|
challenges: [
|
||||||
|
{ align: "start", key: "id", title: "ID" },
|
||||||
|
{ align: "start", key: "ip_address", title: "IP Address" },
|
||||||
|
{ align: "start", key: "user_agent", title: "User Agent" },
|
||||||
|
{ align: "start", key: "created_at", title: "Issued At" },
|
||||||
|
],
|
||||||
|
sessions: [
|
||||||
|
{ align: "start", key: "id", title: "ID" },
|
||||||
|
{ align: "start", key: "audiences", title: "Audiences" },
|
||||||
|
{ align: "start", key: "claims", title: "Claims" },
|
||||||
|
{ align: "start", key: "created_at", title: "Issued At" },
|
||||||
|
{ align: "start", key: "actions", title: "Actions", sortable: false },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ align: "start", key: "id", title: "ID" },
|
||||||
|
{ align: "start", key: "type", title: "Type" },
|
||||||
|
{ align: "start", key: "target", title: "Affected Object" },
|
||||||
|
{ align: "start", key: "ip_address", title: "IP Address" },
|
||||||
|
{ align: "start", key: "user_agent", title: "User Agent" },
|
||||||
|
{ align: "start", key: "created_at", title: "Performed At" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenges = ref<any>([])
|
||||||
|
const sessions = ref<any>([])
|
||||||
|
const events = ref<any>([])
|
||||||
|
|
||||||
|
const reverting = reactive({ challenges: false, sessions: false, events: false })
|
||||||
|
const pagination = reactive({
|
||||||
|
challenges: { page: 1, pageSize: 5, total: 0 },
|
||||||
|
sessions: { page: 1, pageSize: 5, total: 0 },
|
||||||
|
events: { page: 1, pageSize: 5, total: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function readChallenges({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||||
|
if (itemsPerPage) pagination.challenges.pageSize = itemsPerPage
|
||||||
|
if (page) pagination.challenges.page = page
|
||||||
|
|
||||||
|
reverting.challenges = true
|
||||||
|
const res = await request(
|
||||||
|
"/api/users/me/challenges?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
take: pagination.challenges.pageSize.toString(),
|
||||||
|
offset: ((pagination.challenges.page - 1) * pagination.challenges.pageSize).toString(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
challenges.value = data["data"]
|
||||||
|
pagination.challenges.total = data["count"]
|
||||||
|
}
|
||||||
|
reverting.challenges = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSessions({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||||
|
if (itemsPerPage) pagination.sessions.pageSize = itemsPerPage
|
||||||
|
if (page) pagination.sessions.page = page
|
||||||
|
|
||||||
|
reverting.sessions = true
|
||||||
|
const res = await request(
|
||||||
|
"/api/users/me/sessions?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
take: pagination.sessions.pageSize.toString(),
|
||||||
|
offset: ((pagination.sessions.page - 1) * pagination.sessions.pageSize).toString(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
sessions.value = data["data"]
|
||||||
|
pagination.sessions.total = data["count"]
|
||||||
|
}
|
||||||
|
reverting.sessions = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEvents({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||||
|
if (itemsPerPage) pagination.events.pageSize = itemsPerPage
|
||||||
|
if (page) pagination.events.page = page
|
||||||
|
|
||||||
|
reverting.events = true
|
||||||
|
const res = await request(
|
||||||
|
"/api/users/me/events?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
take: pagination.events.pageSize.toString(),
|
||||||
|
offset: ((pagination.events.page - 1) * pagination.events.pageSize).toString(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
events.value = data["data"]
|
||||||
|
pagination.events.total = data["count"]
|
||||||
|
}
|
||||||
|
reverting.events = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([readChallenges({}), readSessions({}), readEvents({})])
|
||||||
|
|
||||||
|
async function killSession(item: any) {
|
||||||
|
reverting.sessions = true
|
||||||
|
const res = await request(`/api/users/me/sessions/${item.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
await readSessions({})
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
reverting.sessions = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rounded-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
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": {
|
"files": [],
|
||||||
"target": "ES2020",
|
"references": [
|
||||||
"useDefineForClassFields": true,
|
{
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"path": "./tsconfig.node.json"
|
||||||
"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": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
{
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"skipLibCheck": true,
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "Bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"types": ["node"]
|
||||||
"strict": true
|
}
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { defineConfig, presetUno } from "unocss";
|
import { defineConfig, presetAttributify, presetTypography, presetUno } from "unocss"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
presets: [presetUno({ preflight: false })]
|
presets: [presetAttributify(), presetTypography(), presetUno({ preflight: false })],
|
||||||
});
|
})
|
||||||
|
@ -1,20 +1,22 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { fileURLToPath, URL } from "node:url"
|
||||||
import path from "path";
|
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import { defineConfig } from "vite"
|
||||||
|
import vue from "@vitejs/plugin-vue"
|
||||||
|
import vueJsx from "@vitejs/plugin-vue-jsx"
|
||||||
import unocss from "unocss/vite"
|
import unocss from "unocss/vite"
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), unocss()],
|
plugins: [vue(), vueJsx(), unocss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/.well-known": "http://localhost:8444",
|
"/api": "http://localhost:8444",
|
||||||
"/api": "http://localhost:8444"
|
"/.well-known": "http://localhost:8444"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user