💄 全新设计重构 #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"
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user