⏪️ 重现旧 UI #4
							
								
								
									
										121
									
								
								.idea/workspace.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										121
									
								
								.idea/workspace.xml
									
									
									
										generated
									
									
									
								
							| @@ -4,9 +4,71 @@ | ||||
|     <option name="autoReloadType" value="ALL" /> | ||||
|   </component> | ||||
|   <component name="ChangeListManager"> | ||||
|     <list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":ambulance: Fix query services too much 429"> | ||||
|     <list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":ambulance: Fix nil map panic"> | ||||
|       <change afterPath="$PROJECT_DIR$/web/.eslintrc.cjs" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/.gitignore" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/.prettierrc.json" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/README.md" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/env.d.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/index.html" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/package.json" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/public/favicon.png" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/assets/utils.css" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/Copyright.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/NotificationList.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/UserMenu.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/auth/AccountLocator.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/auth/CallbackNotify.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/auth/FactorApplicator.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/components/auth/FactorPicker.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/index.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/layouts/master.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/layouts/user-center.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/main.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/router/index.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/scripts/request.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/stores/notifications.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/stores/userinfo.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/auth/claims.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/auth/connect.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/auth/sign-in.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/auth/sign-up.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/confirm.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/dashboard.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/personal-page.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/personalize.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/src/views/security.vue" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/tsconfig.app.json" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/tsconfig.json" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/tsconfig.node.json" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/uno.config.ts" afterDir="false" /> | ||||
|       <change afterPath="$PROJECT_DIR$/web/vite.config.ts" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/hyper/conn.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/hyper/conn.go" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/go.mod" beforeDir="false" afterPath="$PROJECT_DIR$/go.mod" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/go.sum" beforeDir="false" afterPath="$PROJECT_DIR$/go.sum" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/embed.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/grpc/server.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/grpc/server.go" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/api/index.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/api/index.go" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/server.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/server.go" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/ui/accounts.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/ui/index.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/ui/mfa.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/ui/oauth.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/ui/signin.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/server/ui/signup.go" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/authorize.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/favicon.png" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/index.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/layouts/auth.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/layouts/user-center.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/mfa-apply.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/mfa.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/partials/header.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/signin.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/signup.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/internal/views/users/me.gohtml" beforeDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/pkg/main.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/main.go" afterDir="false" /> | ||||
|       <change beforePath="$PROJECT_DIR$/settings.toml" beforeDir="false" afterPath="$PROJECT_DIR$/settings.toml" afterDir="false" /> | ||||
|     </list> | ||||
|     <option name="SHOW_DIALOG" value="false" /> | ||||
|     <option name="HIGHLIGHT_CONFLICTS" value="true" /> | ||||
| @@ -41,40 +103,41 @@ | ||||
|     <option name="hideEmptyMiddlePackages" value="true" /> | ||||
|     <option name="showLibraryContents" value="true" /> | ||||
|   </component> | ||||
|   <component name="PropertiesComponent">{ | ||||
|   "keyToString": { | ||||
|     "DefaultGoTemplateProperty": "Go File", | ||||
|     "Go Build.Backend.executor": "Run", | ||||
|     "Go 构建.Backend.executor": "Run", | ||||
|     "RunOnceActivity.ShowReadmeOnStart": "true", | ||||
|     "RunOnceActivity.go.formatter.settings.were.checked": "true", | ||||
|     "RunOnceActivity.go.migrated.go.modules.settings": "true", | ||||
|     "RunOnceActivity.go.modules.automatic.dependencies.download": "true", | ||||
|     "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", | ||||
|     "git-widget-placeholder": "master", | ||||
|     "go.import.settings.migrated": "true", | ||||
|     "go.sdk.automatically.set": "true", | ||||
|     "last_opened_file_path": "/Users/littlesheep", | ||||
|     "node.js.detected.package.eslint": "true", | ||||
|     "node.js.selected.package.eslint": "(autodetect)", | ||||
|     "nodejs_package_manager_path": "npm", | ||||
|     "run.code.analysis.last.selected.profile": "pProject Default", | ||||
|     "settings.editor.selected.configurable": "preferences.pluginManager", | ||||
|     "vue.rearranger.settings.migration": "true" | ||||
|   <component name="PropertiesComponent"><![CDATA[{ | ||||
|   "keyToString": { | ||||
|     "DefaultGoTemplateProperty": "Go File", | ||||
|     "Go Build.Backend.executor": "Run", | ||||
|     "Go 构建.Backend.executor": "Run", | ||||
|     "RunOnceActivity.ShowReadmeOnStart": "true", | ||||
|     "RunOnceActivity.go.formatter.settings.were.checked": "true", | ||||
|     "RunOnceActivity.go.migrated.go.modules.settings": "true", | ||||
|     "RunOnceActivity.go.modules.automatic.dependencies.download": "true", | ||||
|     "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", | ||||
|     "git-widget-placeholder": "refactor/v2", | ||||
|     "go.import.settings.migrated": "true", | ||||
|     "go.sdk.automatically.set": "true", | ||||
|     "last_opened_file_path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web", | ||||
|     "node.js.detected.package.eslint": "true", | ||||
|     "node.js.selected.package.eslint": "(autodetect)", | ||||
|     "nodejs_package_manager_path": "npm", | ||||
|     "run.code.analysis.last.selected.profile": "pProject Default", | ||||
|     "settings.editor.selected.configurable": "preferences.pluginManager", | ||||
|     "ts.external.directory.path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web/node_modules/typescript/lib", | ||||
|     "vue.rearranger.settings.migration": "true" | ||||
|   }, | ||||
|   "keyToStringList": { | ||||
|     "DatabaseDriversLRU": [ | ||||
|       "postgresql" | ||||
|   "keyToStringList": { | ||||
|     "DatabaseDriversLRU": [ | ||||
|       "postgresql" | ||||
|     ] | ||||
|   } | ||||
| }</component> | ||||
| }]]></component> | ||||
|   <component name="RecentsManager"> | ||||
|     <key name="CopyFile.RECENT_KEYS"> | ||||
|       <recent name="$PROJECT_DIR$/web" /> | ||||
|       <recent name="$PROJECT_DIR$/pkg/services" /> | ||||
|       <recent name="$PROJECT_DIR$/pkg/server/ui" /> | ||||
|       <recent name="$PROJECT_DIR$/pkg/views/users" /> | ||||
|       <recent name="$PROJECT_DIR$/pkg/views" /> | ||||
|       <recent name="$PROJECT_DIR$/pkg" /> | ||||
|     </key> | ||||
|     <key name="MoveFile.RECENT_KEYS"> | ||||
|       <recent name="$PROJECT_DIR$/pkg/internal/server/exts" /> | ||||
| @@ -143,7 +206,6 @@ | ||||
|     </option> | ||||
|   </component> | ||||
|   <component name="VcsManagerConfiguration"> | ||||
|     <MESSAGE value=":zap: In memory auth context cache" /> | ||||
|     <MESSAGE value=":sparkles: Bug fixes of permission check" /> | ||||
|     <MESSAGE value=":sparkles: Check permissions GRPC method" /> | ||||
|     <MESSAGE value=":recycle: Use paperclip to store avatar and more" /> | ||||
| @@ -168,7 +230,8 @@ | ||||
|     <MESSAGE value=":bug: Fix registration service issue" /> | ||||
|     <MESSAGE value=":bug: Fix avatar url missing endpoint prefix" /> | ||||
|     <MESSAGE value=":ambulance: Fix query services too much 429" /> | ||||
|     <option name="LAST_COMMIT_MESSAGE" value=":ambulance: Fix query services too much 429" /> | ||||
|     <MESSAGE value=":ambulance: Fix nil map panic" /> | ||||
|     <option name="LAST_COMMIT_MESSAGE" value=":ambulance: Fix nil map panic" /> | ||||
|   </component> | ||||
|   <component name="VgoProject"> | ||||
|     <settings-migrated>true</settings-migrated> | ||||
|   | ||||
							
								
								
									
										4
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.mod
									
									
									
									
									
								
							| @@ -10,9 +10,7 @@ require ( | ||||
| 	github.com/go-playground/validator/v10 v10.17.0 | ||||
| 	github.com/gofiber/contrib/websocket v1.3.0 | ||||
| 	github.com/gofiber/fiber/v2 v2.52.4 | ||||
| 	github.com/gofiber/template/html/v2 v2.1.1 | ||||
| 	github.com/golang-jwt/jwt/v5 v5.2.0 | ||||
| 	github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| 	github.com/hashicorp/consul/api v1.29.1 | ||||
| 	github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible | ||||
| @@ -57,8 +55,6 @@ require ( | ||||
| 	github.com/go-playground/locales v0.14.1 // indirect | ||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| 	github.com/go-sql-driver/mysql v1.7.1 // indirect | ||||
| 	github.com/gofiber/template v1.8.3 // indirect | ||||
| 	github.com/gofiber/utils v1.1.0 // indirect | ||||
| 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect | ||||
| 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect | ||||
| 	github.com/golang/protobuf v1.5.4 // indirect | ||||
|   | ||||
							
								
								
									
										8
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.sum
									
									
									
									
									
								
							| @@ -100,12 +100,6 @@ github.com/gofiber/contrib/websocket v1.3.0/go.mod h1:xguaOzn2ZZ759LavtosEP+rcxI | ||||
| github.com/gofiber/fiber/v2 v2.36.0/go.mod h1:tgCr+lierLwLoVHHO/jn3Niannv34WRkQETU8wiL9fQ= | ||||
| github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= | ||||
| github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= | ||||
| github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= | ||||
| github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= | ||||
| github.com/gofiber/template/html/v2 v2.1.1 h1:QEy3O3EBkvwDthy5bXVGUseOyO6ldJoiDxlF4+MJiV8= | ||||
| github.com/gofiber/template/html/v2 v2.1.1/go.mod h1:2G0GHHOUx70C1LDncoBpe4T6maQbNa4x1CVNFW0wju0= | ||||
| github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= | ||||
| github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= | ||||
| github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= | ||||
| github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= | ||||
| github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= | ||||
| @@ -135,8 +129,6 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS | ||||
| github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= | ||||
| github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= | ||||
| github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= | ||||
| github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 h1:yEt5djSYb4iNtmV9iJGVday+i4e9u6Mrn5iP64HH5QM= | ||||
| github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= | ||||
| github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= | ||||
| github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= | ||||
| github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= | ||||
|   | ||||
| @@ -1,6 +0,0 @@ | ||||
| package pkg | ||||
|  | ||||
| import "embed" | ||||
|  | ||||
| //go:embed all:views/* | ||||
| var FS embed.FS | ||||
| @@ -17,27 +17,31 @@ type Server struct { | ||||
| 	proto.UnimplementedFriendshipsServer | ||||
| 	proto.UnimplementedRealmsServer | ||||
| 	health.UnimplementedHealthServer | ||||
|  | ||||
| 	srv *grpc.Server | ||||
| } | ||||
|  | ||||
| var S *grpc.Server | ||||
| func NewServer() *Server { | ||||
| 	server := &Server{ | ||||
| 		srv: grpc.NewServer(), | ||||
| 	} | ||||
|  | ||||
| func NewGRPC() { | ||||
| 	S = grpc.NewServer() | ||||
| 	proto.RegisterAuthServer(server.srv, &Server{}) | ||||
| 	proto.RegisterNotifyServer(server.srv, &Server{}) | ||||
| 	proto.RegisterFriendshipsServer(server.srv, &Server{}) | ||||
| 	proto.RegisterRealmsServer(server.srv, &Server{}) | ||||
| 	health.RegisterHealthServer(server.srv, &Server{}) | ||||
|  | ||||
| 	proto.RegisterAuthServer(S, &Server{}) | ||||
| 	proto.RegisterNotifyServer(S, &Server{}) | ||||
| 	proto.RegisterFriendshipsServer(S, &Server{}) | ||||
| 	proto.RegisterRealmsServer(S, &Server{}) | ||||
| 	health.RegisterHealthServer(S, &Server{}) | ||||
| 	reflection.Register(server.srv) | ||||
|  | ||||
| 	reflection.Register(S) | ||||
| 	return server | ||||
| } | ||||
|  | ||||
| func ListenGRPC() error { | ||||
| func (v *Server) Listen() error { | ||||
| 	listener, err := net.Listen("tcp", viper.GetString("grpc_bind")) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return S.Serve(listener) | ||||
| 	return v.srv.Serve(listener) | ||||
| } | ||||
|   | ||||
| @@ -85,5 +85,9 @@ func MapAPIs(app *fiber.App) { | ||||
| 			} | ||||
| 			return c.Next() | ||||
| 		}).Get("/ws", websocket.New(listenWebsocket)) | ||||
|  | ||||
| 		api.All("/*", func(c *fiber.Ctx) error { | ||||
| 			return fiber.ErrNotFound | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,32 +1,31 @@ | ||||
| package server | ||||
|  | ||||
| import ( | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/admin" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/api" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/filesystem" | ||||
| 	"net/http" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
|  | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/i18n" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/admin" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/ui" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/cors" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/favicon" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/idempotency" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/logger" | ||||
| 	"github.com/gofiber/template/html/v2" | ||||
| 	jsoniter "github.com/json-iterator/go" | ||||
| 	"github.com/rs/zerolog/log" | ||||
| 	"github.com/spf13/viper" | ||||
| ) | ||||
|  | ||||
| var A *fiber.App | ||||
| type HTTPApp struct { | ||||
| 	app *fiber.App | ||||
| } | ||||
|  | ||||
| func NewServer() { | ||||
| 	templates := html.NewFileSystem(http.FS(pkg.FS), ".gohtml") | ||||
|  | ||||
| 	A = fiber.New(fiber.Config{ | ||||
| func NewServer() *HTTPApp { | ||||
| 	app := fiber.New(fiber.Config{ | ||||
| 		DisableStartupMessage: true, | ||||
| 		EnableIPValidation:    true, | ||||
| 		ServerHeader:          "Hydrogen.Passport", | ||||
| @@ -35,12 +34,10 @@ func NewServer() { | ||||
| 		JSONEncoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Marshal, | ||||
| 		JSONDecoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal, | ||||
| 		EnablePrintRoutes:     viper.GetBool("debug.print_routes"), | ||||
| 		Views:                 templates, | ||||
| 		ViewsLayout:           "views/index", | ||||
| 	}) | ||||
|  | ||||
| 	A.Use(idempotency.New()) | ||||
| 	A.Use(cors.New(cors.Config{ | ||||
| 	app.Use(idempotency.New()) | ||||
| 	app.Use(cors.New(cors.Config{ | ||||
| 		AllowCredentials: true, | ||||
| 		AllowMethods: strings.Join([]string{ | ||||
| 			fiber.MethodGet, | ||||
| @@ -56,27 +53,34 @@ func NewServer() { | ||||
| 		}, | ||||
| 	})) | ||||
|  | ||||
| 	A.Use(logger.New(logger.Config{ | ||||
| 	app.Use(logger.New(logger.Config{ | ||||
| 		Format: "${status} | ${latency} | ${method} ${path}\n", | ||||
| 		Output: log.Logger, | ||||
| 	})) | ||||
|  | ||||
| 	A.Use(exts.AuthMiddleware) | ||||
| 	A.Use(i18n.I18nMiddleware) | ||||
| 	app.Use(exts.AuthMiddleware) | ||||
| 	app.Use(i18n.I18nMiddleware) | ||||
|  | ||||
| 	A.Use(favicon.New(favicon.Config{ | ||||
| 		FileSystem: http.FS(pkg.FS), | ||||
| 		File:       "views/favicon.png", | ||||
| 		URL:        "/favicon.png", | ||||
| 	api.MapAPIs(app) | ||||
| 	admin.MapAdminEndpoints(app) | ||||
|  | ||||
| 	app.Use(filesystem.New(filesystem.Config{ | ||||
| 		Root:         http.Dir(viper.GetString("frontend_app")), | ||||
| 		Index:        "index.html", | ||||
| 		NotFoundFile: "index.html", | ||||
| 		MaxAge:       3600, | ||||
| 	})) | ||||
|  | ||||
| 	api.MapAPIs(A) | ||||
| 	admin.MapAdminEndpoints(A) | ||||
| 	ui.MapUserInterface(A) | ||||
| 	app.Use(favicon.New(favicon.Config{ | ||||
| 		File: filepath.Join(viper.GetString("frontend_app"), "favicon.png"), | ||||
| 		URL:  "/favicon.png", | ||||
| 	})) | ||||
|  | ||||
| 	return &HTTPApp{app} | ||||
| } | ||||
|  | ||||
| func Listen() { | ||||
| 	if err := A.Listen(viper.GetString("bind")); err != nil { | ||||
| func (v *HTTPApp) Listen() { | ||||
| 	if err := v.app.Listen(viper.GetString("bind")); err != nil { | ||||
| 		log.Fatal().Err(err).Msg("An error occurred when starting server...") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,55 +0,0 @@ | ||||
| package ui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" | ||||
| 	"html/template" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/database" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/models" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/gomarkdown/markdown" | ||||
| 	"github.com/gomarkdown/markdown/html" | ||||
| 	"github.com/gomarkdown/markdown/parser" | ||||
| 	"github.com/sujit-baniya/flash" | ||||
| ) | ||||
|  | ||||
| func selfUserinfoPage(c *fiber.Ctx) error { | ||||
| 	if err := exts.EnsureAuthenticated(c); err != nil { | ||||
| 		return DoAuthRedirect(c) | ||||
| 	} | ||||
| 	user := c.Locals("user").(models.Account) | ||||
|  | ||||
| 	var data models.Account | ||||
| 	if err := database.C. | ||||
| 		Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}). | ||||
| 		Preload("Profile"). | ||||
| 		Preload("PersonalPage"). | ||||
| 		Preload("Contacts"). | ||||
| 		First(&data).Error; err != nil { | ||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	birthday := "Unknown" | ||||
| 	if data.Profile.Birthday != nil { | ||||
| 		birthday = data.Profile.Birthday.Format(time.RFC822) | ||||
| 	} | ||||
|  | ||||
| 	doc := parser. | ||||
| 		NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock). | ||||
| 		Parse([]byte(data.PersonalPage.Content)) | ||||
|  | ||||
| 	renderer := html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.HrefTargetBlank}) | ||||
|  | ||||
| 	return c.Render("views/users/me", fiber.Map{ | ||||
| 		"info":          flash.Get(c)["message"], | ||||
| 		"uid":           fmt.Sprintf("%08d", data.ID), | ||||
| 		"joined_at":     data.CreatedAt.Format(time.RFC822), | ||||
| 		"birthday_at":   birthday, | ||||
| 		"personal_page": template.HTML(markdown.Render(doc, renderer)), | ||||
| 		"userinfo":      data, | ||||
| 		"avatar":        data.GetAvatar(), | ||||
| 		"banner":        data.GetBanner(), | ||||
| 	}, "views/layouts/user-center") | ||||
| } | ||||
| @@ -1,34 +0,0 @@ | ||||
| package ui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| ) | ||||
|  | ||||
| func DoAuthRedirect(c *fiber.Ctx) error { | ||||
| 	uri := c.Request().URI().FullURI() | ||||
| 	return c.Redirect(fmt.Sprintf("/sign-in?redirect_uri=%s", string(uri))) | ||||
| } | ||||
|  | ||||
| func MapUserInterface(app *fiber.App) { | ||||
| 	pages := app.Group("/").Name("Pages") | ||||
|  | ||||
| 	pages.Get("/", func(c *fiber.Ctx) error { | ||||
| 		return c.Redirect("/users/me") | ||||
| 	}) | ||||
|  | ||||
| 	pages.Get("/sign-up", signupPage) | ||||
| 	pages.Get("/sign-in", signinPage) | ||||
| 	pages.Get("/mfa", mfaRequestPage) | ||||
| 	pages.Get("/mfa/apply", mfaApplyPage) | ||||
| 	pages.Get("/authorize", authorizePage) | ||||
|  | ||||
| 	pages.Post("/sign-up", signupAction) | ||||
| 	pages.Post("/sign-in", signinAction) | ||||
| 	pages.Post("/mfa", mfaRequestAction) | ||||
| 	pages.Post("/mfa/apply", mfaApplyAction) | ||||
| 	pages.Post("/authorize", authorizeAction) | ||||
|  | ||||
| 	pages.Get("/users/me", selfUserinfoPage) | ||||
| } | ||||
| @@ -1,194 +0,0 @@ | ||||
| package ui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/models" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/services" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/nicksnyder/go-i18n/v2/i18n" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/sujit-baniya/flash" | ||||
| ) | ||||
|  | ||||
| func mfaRequestPage(c *fiber.Ctx) error { | ||||
| 	ticketId := c.QueryInt("ticket", 0) | ||||
|  | ||||
| 	ticket, err := services.GetTicket(uint(ticketId)) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "you must provide ticket id to perform multi-factor authenticate", | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
| 	user, err := services.GetAccount(ticket.AccountID) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "ticket related user just weirdly disappear", | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
| 	factors, err := services.ListUserFactor(user.ID) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("unable to get your factors: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	factors = lo.Filter(factors, func(item models.AuthFactor, index int) bool { | ||||
| 		return item.Type != models.PasswordAuthFactor | ||||
| 	}) | ||||
|  | ||||
| 	localizer := c.Locals("localizer").(*i18n.Localizer) | ||||
|  | ||||
| 	next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"}) | ||||
| 	title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaTitle"}) | ||||
| 	caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaCaption"}) | ||||
|  | ||||
| 	return c.Render("views/mfa", fiber.Map{ | ||||
| 		"info":         flash.Get(c)["message"], | ||||
| 		"redirect_uri": flash.Get(c)["redirect_uri"], | ||||
| 		"ticket_id":    ticket.ID, | ||||
| 		"factors": lo.Map(factors, func(item models.AuthFactor, index int) fiber.Map { | ||||
| 			return fiber.Map{ | ||||
| 				"name": services.GetFactorName(item.Type, localizer), | ||||
| 				"id":   item.ID, | ||||
| 			} | ||||
| 		}), | ||||
| 		"i18n": fiber.Map{ | ||||
| 			"next":    next, | ||||
| 			"title":   title, | ||||
| 			"caption": caption, | ||||
| 		}, | ||||
| 	}, "views/layouts/auth") | ||||
| } | ||||
|  | ||||
| func mfaRequestAction(c *fiber.Ctx) error { | ||||
| 	var data struct { | ||||
| 		TicketID uint `form:"ticket_id" validate:"required"` | ||||
| 		FactorID uint `form:"factor_id" validate:"required"` | ||||
| 	} | ||||
|  | ||||
| 	redirectBackUri := "/sign-in" | ||||
| 	err := exts.BindAndValidate(c, &data) | ||||
|  | ||||
| 	if data.TicketID > 0 { | ||||
| 		redirectBackUri = fmt.Sprintf("/mfa?ticket=%d", data.TicketID) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": err.Error(), | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
|  | ||||
| 	factor, err := services.GetFactor(data.FactorID) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("factor was not found: %v", err.Error()), | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
|  | ||||
| 	_, err = services.GetFactorCode(factor) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("unable to get factor code: %v", err.Error()), | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
|  | ||||
| 	return flash.WithData(c, fiber.Map{ | ||||
| 		"redirect_uri": exts.GetRedirectUri(c), | ||||
| 	}).Redirect(fmt.Sprintf("/mfa/apply?ticket=%d&factor=%d", data.TicketID, factor.ID)) | ||||
| } | ||||
|  | ||||
| func mfaApplyPage(c *fiber.Ctx) error { | ||||
| 	ticketId := c.QueryInt("ticket", 0) | ||||
| 	factorId := c.QueryInt("factor", 0) | ||||
|  | ||||
| 	ticket, err := services.GetTicket(uint(ticketId)) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("unable to find your ticket: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
| 	factor, err := services.GetFactor(uint(factorId)) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("unable to find your factors: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	localizer := c.Locals("localizer").(*i18n.Localizer) | ||||
|  | ||||
| 	next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"}) | ||||
| 	password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"}) | ||||
| 	title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaTitle"}) | ||||
| 	caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaCaption"}) | ||||
|  | ||||
| 	return c.Render("views/mfa-apply", fiber.Map{ | ||||
| 		"info":      flash.Get(c)["message"], | ||||
| 		"label":     services.GetFactorName(factor.Type, localizer), | ||||
| 		"ticket_id": ticket.ID, | ||||
| 		"factor_id": factor.ID, | ||||
| 		"i18n": fiber.Map{ | ||||
| 			"next":     next, | ||||
| 			"password": password, | ||||
| 			"title":    title, | ||||
| 			"caption":  caption, | ||||
| 		}, | ||||
| 	}, "views/layouts/auth") | ||||
| } | ||||
|  | ||||
| func mfaApplyAction(c *fiber.Ctx) error { | ||||
| 	var data struct { | ||||
| 		TicketID uint   `form:"ticket_id" validate:"required"` | ||||
| 		FactorID uint   `form:"factor_id" validate:"required"` | ||||
| 		Code     string `form:"code" validate:"required"` | ||||
| 	} | ||||
|  | ||||
| 	redirectBackUri := "/sign-in" | ||||
| 	err := exts.BindAndValidate(c, &data) | ||||
|  | ||||
| 	if data.TicketID > 0 { | ||||
| 		redirectBackUri = fmt.Sprintf("/mfa/apply?ticket=%d&factor=%d", data.TicketID, data.FactorID) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": err.Error(), | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
|  | ||||
| 	ticket, err := services.GetTicket(data.TicketID) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("unable to find your ticket: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
| 	factor, err := services.GetFactor(data.FactorID) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("factor was not found: %v", err.Error()), | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
|  | ||||
| 	ticket, err = services.ActiveTicketWithMFA(ticket, factor, data.Code) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("invalid multi-factor authenticate code: %v", err.Error()), | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} else if ticket.IsAvailable() != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "ticket weirdly still unavailable after multi-factor authenticate", | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	access, refresh, err := services.ExchangeToken(*ticket.GrantToken) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("failed to exchange token: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} else { | ||||
| 		exts.SetAuthCookies(c, access, refresh) | ||||
| 	} | ||||
|  | ||||
| 	return c.Redirect(lo.FromPtr(exts.GetRedirectUri(c, "/users/me"))) | ||||
| } | ||||
| @@ -1,166 +0,0 @@ | ||||
| package ui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/database" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/models" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/services" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/nicksnyder/go-i18n/v2/i18n" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/sujit-baniya/flash" | ||||
| 	"html/template" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func authorizePage(c *fiber.Ctx) error { | ||||
| 	localizer := c.Locals("localizer").(*i18n.Localizer) | ||||
|  | ||||
| 	if err := exts.EnsureAuthenticated(c); err != nil { | ||||
| 		return DoAuthRedirect(c) | ||||
| 	} | ||||
| 	user := c.Locals("user").(models.Account) | ||||
|  | ||||
| 	id := c.Query("client_id") | ||||
| 	redirect := c.Query("redirect_uri") | ||||
|  | ||||
| 	var message string | ||||
| 	if len(id) <= 0 || len(redirect) <= 0 { | ||||
| 		message = "invalid request, missing query parameters" | ||||
| 	} | ||||
|  | ||||
| 	var client models.ThirdClient | ||||
| 	if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil { | ||||
| 		message = fmt.Sprintf("unable to find client: %v", err) | ||||
| 	} else if !client.IsDraft && !lo.Contains(client.Callbacks, strings.Split(redirect, "?")[0]) { | ||||
| 		message = "invalid callback url" | ||||
| 	} | ||||
|  | ||||
| 	var ticket models.AuthTicket | ||||
| 	if err := database.C.Where(&models.AuthTicket{ | ||||
| 		AccountID: user.ID, | ||||
| 		ClientID:  &client.ID, | ||||
| 	}).Where("last_grant_at IS NULL").First(&ticket).Error; err == nil { | ||||
| 		if !(ticket.ExpiredAt != nil && ticket.ExpiredAt.Unix() < time.Now().Unix()) { | ||||
| 			ticket, err = services.RegenSession(ticket) | ||||
| 			if c.Query("response_type") == "code" { | ||||
| 				return c.Redirect(fmt.Sprintf( | ||||
| 					"%s?code=%s&state=%s", | ||||
| 					redirect, | ||||
| 					*ticket.GrantToken, | ||||
| 					c.Query("state"), | ||||
| 				)) | ||||
| 			} else if c.Query("response_type") == "token" { | ||||
| 				if access, refresh, err := services.GetToken(ticket); err == nil { | ||||
| 					return c.Redirect(fmt.Sprintf("%s?access_token=%s&refresh_token=%s&state=%s", | ||||
| 						redirect, | ||||
| 						access, | ||||
| 						refresh, c.Query("state"), | ||||
| 					)) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	decline, _ := localizer.LocalizeMessage(&i18n.Message{ID: "decline"}) | ||||
| 	approve, _ := localizer.LocalizeMessage(&i18n.Message{ID: "approve"}) | ||||
| 	title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "authorizeTitle"}) | ||||
| 	caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "authorizeCaption"}) | ||||
|  | ||||
| 	qs := "/authorize?" + string(c.Request().URI().QueryString()) | ||||
|  | ||||
| 	return c.Render("views/authorize", fiber.Map{ | ||||
| 		"info":       lo.Ternary[any](len(message) > 0, message, flash.Get(c)["message"]), | ||||
| 		"client":     client, | ||||
| 		"scopes":     strings.Split(c.Query("scope"), " "), | ||||
| 		"action_url": template.URL(qs), | ||||
| 		"i18n": fiber.Map{ | ||||
| 			"approve": approve, | ||||
| 			"decline": decline, | ||||
| 			"title":   title, | ||||
| 			"caption": caption, | ||||
| 		}, | ||||
| 	}, "views/layouts/auth") | ||||
| } | ||||
|  | ||||
| func authorizeAction(c *fiber.Ctx) error { | ||||
| 	if err := exts.EnsureAuthenticated(c); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	user := c.Locals("user").(models.Account) | ||||
| 	id := c.Query("client_id") | ||||
| 	response := c.Query("response_type") | ||||
| 	redirect := c.Query("redirect_uri") | ||||
| 	scope := c.Query("scope") | ||||
|  | ||||
| 	if err := exts.EnsureAuthenticated(c); err != nil { | ||||
| 		return DoAuthRedirect(c) | ||||
| 	} | ||||
|  | ||||
| 	redirectBackUri := "/authorize?" + string(c.Request().URI().QueryString()) | ||||
|  | ||||
| 	if len(scope) <= 0 { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "invalid request parameters", | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
|  | ||||
| 	var client models.ThirdClient | ||||
| 	if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil { | ||||
| 		return fiber.NewError(fiber.StatusNotFound, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	switch response { | ||||
| 	case "code": | ||||
| 		// OAuth Authorization Mode | ||||
| 		ticket, err := services.NewOauthTicket( | ||||
| 			user, | ||||
| 			client, | ||||
| 			strings.Split(scope, " "), | ||||
| 			[]string{"passport", client.Alias}, | ||||
| 			c.IP(), | ||||
| 			c.Get(fiber.HeaderUserAgent), | ||||
| 		) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 		} else { | ||||
| 			services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent)) | ||||
| 			return c.Redirect(fmt.Sprintf( | ||||
| 				"%s?code=%s&state=%s", | ||||
| 				redirect, | ||||
| 				*ticket.GrantToken, | ||||
| 				c.Query("state"), | ||||
| 			)) | ||||
| 		} | ||||
| 	case "token": | ||||
| 		// OAuth Implicit Mode | ||||
| 		ticket, err := services.NewOauthTicket( | ||||
| 			user, | ||||
| 			client, | ||||
| 			strings.Split(scope, " "), | ||||
| 			[]string{"passport", client.Alias}, | ||||
| 			c.IP(), | ||||
| 			c.Get(fiber.HeaderUserAgent), | ||||
| 		) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 		} else if access, refresh, err := services.GetToken(ticket); err != nil { | ||||
| 			return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 		} else { | ||||
| 			services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent)) | ||||
| 			return c.Redirect(fmt.Sprintf("%s?access_token=%s&refresh_token=%s&state=%s", | ||||
| 				redirect, | ||||
| 				access, | ||||
| 				refresh, c.Query("state"), | ||||
| 			)) | ||||
| 		} | ||||
| 	default: | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "unsupported response type", | ||||
| 		}).Redirect(redirectBackUri) | ||||
| 	} | ||||
| } | ||||
| @@ -1,93 +0,0 @@ | ||||
| package ui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/services" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/nicksnyder/go-i18n/v2/i18n" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/sujit-baniya/flash" | ||||
| ) | ||||
|  | ||||
| func signinPage(c *fiber.Ctx) error { | ||||
| 	localizer := c.Locals("localizer").(*i18n.Localizer) | ||||
|  | ||||
| 	next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"}) | ||||
| 	username, _ := localizer.LocalizeMessage(&i18n.Message{ID: "username"}) | ||||
| 	password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"}) | ||||
| 	signup, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupTitle"}) | ||||
| 	title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinTitle"}) | ||||
| 	caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinCaption"}) | ||||
| 	requiredNotify, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinRequired"}) | ||||
|  | ||||
| 	var info any | ||||
| 	if flash.Get(c)["message"] != nil { | ||||
| 		info = flash.Get(c)["message"] | ||||
| 	} else { | ||||
| 		info = requiredNotify | ||||
| 	} | ||||
|  | ||||
| 	return c.Render("views/signin", fiber.Map{ | ||||
| 		"info": info, | ||||
| 		"i18n": fiber.Map{ | ||||
| 			"next":     next, | ||||
| 			"username": username, | ||||
| 			"password": password, | ||||
| 			"signup":   signup, | ||||
| 			"title":    title, | ||||
| 			"caption":  caption, | ||||
| 		}, | ||||
| 	}, "views/layouts/auth") | ||||
| } | ||||
|  | ||||
| func signinAction(c *fiber.Ctx) error { | ||||
| 	var data struct { | ||||
| 		Username string `form:"username" validate:"required"` | ||||
| 		Password string `form:"password" validate:"required"` | ||||
| 	} | ||||
|  | ||||
| 	if err := exts.BindAndValidate(c, &data); err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": err.Error(), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	user, err := services.LookupAccount(data.Username) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("account was not found: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	ticket, err := services.NewTicket(user, c.IP(), c.Get(fiber.HeaderUserAgent)) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("unable setup ticket: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	ticket, err = services.ActiveTicketWithPassword(ticket, data.Password) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("invalid password: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} | ||||
|  | ||||
| 	if ticket.IsAvailable() != nil { | ||||
| 		return flash.WithData(c, fiber.Map{ | ||||
| 			"redirect_uri": exts.GetRedirectUri(c), | ||||
| 		}).Redirect(fmt.Sprintf("/mfa?ticket=%d", ticket.ID)) | ||||
| 	} | ||||
|  | ||||
| 	access, refresh, err := services.ExchangeToken(*ticket.GrantToken) | ||||
| 	if err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": fmt.Sprintf("failed to exchange token: %v", err.Error()), | ||||
| 		}).Redirect("/sign-in") | ||||
| 	} else { | ||||
| 		exts.SetAuthCookies(c, access, refresh) | ||||
| 	} | ||||
|  | ||||
| 	return c.Redirect(lo.FromPtr(exts.GetRedirectUri(c, "/users/me"))) | ||||
| } | ||||
| @@ -1,87 +0,0 @@ | ||||
| package ui | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/database" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/models" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts" | ||||
| 	"git.solsynth.dev/hydrogen/passport/pkg/internal/services" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/nicksnyder/go-i18n/v2/i18n" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/spf13/viper" | ||||
| 	"github.com/sujit-baniya/flash" | ||||
| ) | ||||
|  | ||||
| func signupPage(c *fiber.Ctx) error { | ||||
| 	localizer := c.Locals("localizer").(*i18n.Localizer) | ||||
|  | ||||
| 	next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"}) | ||||
| 	email, _ := localizer.LocalizeMessage(&i18n.Message{ID: "email"}) | ||||
| 	nickname, _ := localizer.LocalizeMessage(&i18n.Message{ID: "nickname"}) | ||||
| 	username, _ := localizer.LocalizeMessage(&i18n.Message{ID: "username"}) | ||||
| 	password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"}) | ||||
| 	magicToken, _ := localizer.LocalizeMessage(&i18n.Message{ID: "magicToken"}) | ||||
| 	signin, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinTitle"}) | ||||
| 	title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupTitle"}) | ||||
| 	caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupCaption"}) | ||||
|  | ||||
| 	return c.Render("views/signup", fiber.Map{ | ||||
| 		"info":            flash.Get(c)["message"], | ||||
| 		"use_magic_token": viper.GetBool("use_registration_magic_token"), | ||||
| 		"i18n": fiber.Map{ | ||||
| 			"next":        next, | ||||
| 			"email":       email, | ||||
| 			"username":    username, | ||||
| 			"nickname":    nickname, | ||||
| 			"password":    password, | ||||
| 			"magic_token": magicToken, | ||||
| 			"signin":      signin, | ||||
| 			"title":       title, | ||||
| 			"caption":     caption, | ||||
| 		}, | ||||
| 	}, "views/layouts/auth") | ||||
| } | ||||
|  | ||||
| func signupAction(c *fiber.Ctx) error { | ||||
| 	var data struct { | ||||
| 		Name       string `form:"name" validate:"required,lowercase,alphanum,min=4,max=16"` | ||||
| 		Nick       string `form:"nick" validate:"required,min=4,max=24"` | ||||
| 		Email      string `form:"email" validate:"required,email"` | ||||
| 		Password   string `form:"password" validate:"required,min=4,max=32"` | ||||
| 		MagicToken string `form:"magic_token"` | ||||
| 	} | ||||
|  | ||||
| 	if err := exts.BindAndValidate(c, &data); err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": err.Error(), | ||||
| 		}).Redirect("/sign-up") | ||||
| 	} else if viper.GetBool("use_registration_magic_token") && len(data.MagicToken) <= 0 { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "magic token was required", | ||||
| 		}).Redirect("/sign-up") | ||||
| 	} else if viper.GetBool("use_registration_magic_token") { | ||||
| 		if tk, err := services.ValidateMagicToken(data.MagicToken, models.RegistrationMagicToken); err != nil { | ||||
| 			return flash.WithInfo(c, fiber.Map{ | ||||
| 				"message": fmt.Sprintf("magic token was invalid: %v", err.Error()), | ||||
| 			}).Redirect("/sign-up") | ||||
| 		} else { | ||||
| 			database.C.Delete(&tk) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if _, err := services.CreateAccount( | ||||
| 		data.Name, | ||||
| 		data.Nick, | ||||
| 		data.Email, | ||||
| 		data.Password, | ||||
| 	); err != nil { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": err.Error(), | ||||
| 		}).Redirect("/sign-up") | ||||
| 	} else { | ||||
| 		return flash.WithInfo(c, fiber.Map{ | ||||
| 			"message": "account has been created. now you can sign in!", | ||||
| 		}).Redirect(lo.FromPtr(exts.GetRedirectUri(c, "/sign-in"))) | ||||
| 	} | ||||
| } | ||||
| @@ -1,56 +0,0 @@ | ||||
| <div class="left-part"> | ||||
|   <img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" /> | ||||
|  | ||||
|   <h1 class="title">{{.i18n.title}} {{.client.Name}}</h1> | ||||
|   <p class="caption">{{.i18n.caption}}</p> | ||||
| </div> | ||||
|  | ||||
| <div class="right-part"> | ||||
|   <div class="responsive-title-gap "></div> | ||||
|  | ||||
|   <form class="action-form" action="{{.action_url}}" method="POST"> | ||||
|     <div> | ||||
|       <div class="section-title">Description</div> | ||||
|       <div class="section-body">{{.client.Description}}</div> | ||||
|     </div> | ||||
|  | ||||
|     <div> | ||||
|       <div class="section-title">Requested scopes</div> | ||||
|       <ul class="section-scope list-group"> | ||||
|         {{range $_, $element := .scopes}} | ||||
|         <li class="monospace list-group-item"> | ||||
|           {{$element}} | ||||
|         </li> | ||||
|         {{end}} | ||||
|       </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div class="action-form-buttons"> | ||||
|       <button class="btn btn-secondary" type="button" id="decline-button">{{.i18n.decline}}</button> | ||||
|       <button class="btn btn-primary" type="submit">{{.i18n.approve}}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
|  | ||||
| <style> | ||||
|   .section-title { | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
|   .section-scope { | ||||
|     margin-top: 4px; | ||||
|     margin-left: -8px; | ||||
|     margin-right: -8px; | ||||
|   } | ||||
|  | ||||
|   .monospace { | ||||
|     font-family: "Roboto Mono", monospace; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| <script> | ||||
|   $("#decline-button").on("click", () => { | ||||
|     history.back() | ||||
|     window.close() | ||||
|   }) | ||||
| </script> | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 74 KiB | 
| @@ -1,10 +0,0 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|  | ||||
| {{template "views/partials/header"}} | ||||
|  | ||||
| <body> | ||||
| {{embed}} | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
| @@ -1,115 +0,0 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|  | ||||
| {{template "views/partials/header"}} | ||||
|  | ||||
| <body> | ||||
|   <div class="outer-container"> | ||||
|     <div class="inner-container"> | ||||
|       {{if ne .info nil}} | ||||
|       <div class="alert alert-primary" role="alert"> | ||||
|         <svg class="bi me-2" role="img" aria-label="Info:"> | ||||
|           <use xlink:href="#info-fill" /> | ||||
|         </svg> | ||||
|         <div class="content">{{.info}}</div> | ||||
|       </div> | ||||
|       {{end}} | ||||
|  | ||||
|       <div class="card card-container"> | ||||
|         {{embed}} | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </body> | ||||
|  | ||||
| <style> | ||||
|   .outer-container { | ||||
|     width: 100dvw; | ||||
|     height: 100dvh; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .inner-container { | ||||
|     width: 100%; | ||||
|     min-width: 0; | ||||
|     max-width: min(800px, 100dvw); | ||||
|  | ||||
|     margin: 1rem; | ||||
|  | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 1rem; | ||||
|   } | ||||
|  | ||||
|   .card-container { | ||||
|     transition: all .3s; | ||||
|     height: auto; | ||||
|     overflow: auto; | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr; | ||||
|     justify-content: center; | ||||
|     padding: 48px; | ||||
|     gap: 0 2rem; | ||||
|   } | ||||
|  | ||||
|   .logo { | ||||
|     margin-left: -8px; | ||||
|     margin-bottom: -8px; | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   .title { | ||||
|     margin-block-start: 0.33em; | ||||
|     margin-block-end: 0.33em; | ||||
|     font-size: 2.5rem; | ||||
|   } | ||||
|  | ||||
|   .caption { | ||||
|     font-size: 1rem; | ||||
|   } | ||||
|  | ||||
|   .action-form { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 0.8rem 0; | ||||
|   } | ||||
|  | ||||
|   .action-form-buttons { | ||||
|     display: flex; | ||||
|     gap: 4px; | ||||
|     margin-top: 10px; | ||||
|   } | ||||
|  | ||||
|   .action-form-buttons * { | ||||
|     flex: 1; | ||||
|   } | ||||
|  | ||||
|   .block-field { | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .responsive-hidden { | ||||
|     display: unset; | ||||
|   } | ||||
|  | ||||
|   .columns-two { | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     gap: 1rem; | ||||
|   } | ||||
|  | ||||
|   @media (min-width: 768px) { | ||||
|     .card-container { | ||||
|       grid-template-columns: 1fr 1fr; | ||||
|     } | ||||
|  | ||||
|     .responsive-title-gap { | ||||
|       height: calc(56px + 0.44rem); | ||||
|       display: block; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| </html> | ||||
| @@ -1,128 +0,0 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|  | ||||
| {{template "views/partials/header"}} | ||||
|  | ||||
| <body> | ||||
|   <div class="outer-container"> | ||||
|     <div class="inner-container"> | ||||
|       {{if ne .info nil}} | ||||
|       <div class="alert alert-primary" role="alert"> | ||||
|         <svg class="bi me-2" role="img" aria-label="Info:"> | ||||
|           <use xlink:href="#info-fill" /> | ||||
|         </svg> | ||||
|         <div class="content">{{.info}}</div> | ||||
|       </div> | ||||
|       {{end}} | ||||
|  | ||||
|       <div class="card card-container"> | ||||
|         {{embed}} | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </body> | ||||
|  | ||||
| <style> | ||||
|   body, | ||||
|   .outer-container { | ||||
|     scrollbar-width: none; | ||||
|     overflow-x: hidden; | ||||
|   } | ||||
|  | ||||
|   .outer-container { | ||||
|     width: 100dvw; | ||||
|     min-height: 100dvh; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .outer-container::-webkit-scrollbar, | ||||
|   body::-webkit-scrollbar { | ||||
|     display: none; | ||||
|     width: 0; | ||||
|   } | ||||
|  | ||||
|   .inner-container { | ||||
|     width: 100%; | ||||
|     min-width: 0; | ||||
|     max-width: min(800px, 100dvw); | ||||
|  | ||||
|     margin: 1rem; | ||||
|  | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 1rem; | ||||
|  | ||||
|     padding-top: 1rem; | ||||
|     padding-bottom: 1rem; | ||||
|   } | ||||
|  | ||||
|   .card-container { | ||||
|     transition: all .3s; | ||||
|     height: auto; | ||||
|     overflow: auto; | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr; | ||||
|     justify-content: center; | ||||
|     padding: 48px; | ||||
|     gap: 0 2rem; | ||||
|   } | ||||
|  | ||||
|  | ||||
|   .logo { | ||||
|     margin-left: -8px; | ||||
|     margin-bottom: -8px; | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   .title { | ||||
|     margin-block-start: 0.33em; | ||||
|     margin-block-end: 0.33em; | ||||
|     font-size: 2.5rem; | ||||
|   } | ||||
|  | ||||
|   .caption { | ||||
|     font-size: 1rem; | ||||
|   } | ||||
|  | ||||
|   .action-form { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 0.8rem 0; | ||||
|   } | ||||
|  | ||||
|   .action-form-buttons { | ||||
|     display: flex; | ||||
|     justify-content: end; | ||||
|     margin-top: 8px; | ||||
|     gap: 4px; | ||||
|   } | ||||
|  | ||||
|   .block-field { | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .responsive-hidden { | ||||
|     display: unset; | ||||
|   } | ||||
|  | ||||
|   .columns-two { | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     gap: 1rem; | ||||
|   } | ||||
|  | ||||
|   @media (min-width: 768px) { | ||||
|     .card-container { | ||||
|       grid-template-columns: 1fr 1fr; | ||||
|     } | ||||
|  | ||||
|     .responsive-title-gap { | ||||
|       height: calc(56px + 0.44rem); | ||||
|       display: block; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| </html> | ||||
| @@ -1,43 +0,0 @@ | ||||
| <div class="left-part"> | ||||
|   <img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" /> | ||||
|  | ||||
|   <h1 class="title">{{.i18n.title}}</h1> | ||||
|   <p class="caption">{{.i18n.caption}}</p> | ||||
| </div> | ||||
|  | ||||
| <div class="right-part"> | ||||
|   <div class="responsive-title-gap"></div> | ||||
|  | ||||
|   <form class="action-form" action="/mfa/apply" method="POST"> | ||||
|     <label> | ||||
|       <input name="ticket_id" value="{{.ticket_id}}" hidden> | ||||
|     </label> | ||||
|     <label> | ||||
|       <input name="factor_id" value="{{.factor_id}}" hidden> | ||||
|     </label> | ||||
|  | ||||
|     <div class="factor-label">{{.label}}</div> | ||||
|  | ||||
|     <div class="mb-1 block-field"> | ||||
|       <label for="code" class="form-label">{{.i18n.password}}</label> | ||||
|       <input type="password" class="form-control" id="code" name="password" autocomplete="off"> | ||||
|     </div> | ||||
|  | ||||
|     <div class="action-form-buttons"> | ||||
|       <button class="btn btn-primary" type="submit">{{.i18n.next}}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
|  | ||||
| <style> | ||||
|   .factor-label { | ||||
|     font-size: 14px; | ||||
|     text-align: left; | ||||
|   } | ||||
|  | ||||
|   @media (min-width: 768px) { | ||||
|     .factor-label { | ||||
|       text-align: center; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -1,59 +0,0 @@ | ||||
| <div class="left-part"> | ||||
|   <img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" /> | ||||
|  | ||||
|   <h1 class="title">{{.i18n.title}}</h1> | ||||
|   <p class="caption">{{.i18n.caption}}</p> | ||||
| </div> | ||||
|  | ||||
| <div class="right-part"> | ||||
|   <div class="responsive-title-gap"></div> | ||||
|  | ||||
|   <form class="action-form" action="/mfa" method="POST"> | ||||
|     <label> | ||||
|       <input name="ticket_id" value="{{.ticket_id}}" hidden> | ||||
|     </label> | ||||
|     {{if ne .redirect_uri nil}} | ||||
|     <label> | ||||
|       <input name="redirect_uri" value="{{.redirect_uri}}" hidden> | ||||
|     </label> | ||||
|     {{end}} | ||||
|  | ||||
|     <div class="block-field factor-list" role="radiogroup"> | ||||
|       {{range $_, $element := .factors}} | ||||
|       <div class="factor-label"> | ||||
|         <div class="form-check"> | ||||
|           <input class="form-check-input" type="radio" name="factor_id" id="factor-{{$element.id}}" | ||||
|             value="{{$element.id}}"> | ||||
|           <label class="form-check-label" for="factor-{{$element.id}}"> | ||||
|             {{$element.name}} | ||||
|           </label> | ||||
|         </div> | ||||
|       </div> | ||||
|       {{end}} | ||||
|     </div> | ||||
|  | ||||
|     <div class="action-form-buttons"> | ||||
|       <button class="btn btn-primary" type="submit">{{.i18n.next}}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
|  | ||||
| <style> | ||||
|   .factor-list { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|   } | ||||
|  | ||||
|   .factor-label { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .factor-label label { | ||||
|     display: inline-flex; | ||||
|     place-items: center; | ||||
|     gap: 8px; | ||||
|     font-family: Roboto, system-ui; | ||||
|     color: var(--md-sys-color-on-background); | ||||
|   } | ||||
| </style> | ||||
| @@ -1,56 +0,0 @@ | ||||
| <head> | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta name="viewport" | ||||
|     content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> | ||||
|   <meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||||
|  | ||||
|   <link rel="icon" type="image/png" href="/favicon.png"> | ||||
|  | ||||
|   <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" | ||||
|     integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> | ||||
|  | ||||
|   <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" | ||||
|     integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" | ||||
|     crossorigin="anonymous"></script> | ||||
|   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" | ||||
|     integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy" | ||||
|     crossorigin="anonymous"></script> | ||||
|   <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" | ||||
|     integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script> | ||||
|  | ||||
|  | ||||
|   <svg xmlns="http://www.w3.org/2000/svg" class="d-none"> | ||||
|     <symbol id="info-fill" viewBox="0 0 16 16"> | ||||
|       <path | ||||
|         d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" /> | ||||
|     </symbol> | ||||
|   </svg> | ||||
|  | ||||
|   <title>Solarpass</title> | ||||
|  | ||||
|   <style> | ||||
|     html, | ||||
|     body { | ||||
|       padding: 0; | ||||
|       margin: 0; | ||||
|     } | ||||
|  | ||||
|     .alert { | ||||
|       padding: 16px 48px; | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       gap: 8px; | ||||
|     } | ||||
|  | ||||
|     .alert .bi { | ||||
|       aspect-ratio: 1; | ||||
|       width: 16px; | ||||
|       fill: var(--bs-alert-color); | ||||
|     } | ||||
|  | ||||
|     .alert .content { | ||||
|       flex-grow: 1; | ||||
|       text-transform: capitalize; | ||||
|     } | ||||
|   </style> | ||||
| </head> | ||||
| @@ -1,27 +0,0 @@ | ||||
| <div class="left-part"> | ||||
|   <img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" /> | ||||
|  | ||||
|   <h1 class="title">{{.i18n.title}}</h1> | ||||
|   <p class="caption">{{.i18n.caption}}</p> | ||||
| </div> | ||||
|  | ||||
| <div class="right-part"> | ||||
|   <div class="responsive-title-gap"></div> | ||||
|  | ||||
|   <form class="action-form" action="/sign-in" method="POST"> | ||||
|     <div class="mb-1 block-field"> | ||||
|       <label for="username" class="form-label">{{.i18n.username}}</label> | ||||
|       <input type="text" class="form-control" id="username" name="username"> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-1 block-field"> | ||||
|       <label for="password" class="form-label">{{.i18n.password}}</label> | ||||
|       <input type="password" class="form-control" id="password" name="password"> | ||||
|     </div> | ||||
|  | ||||
|     <div class="action-form-buttons"> | ||||
|       <a class="btn btn-secondary" href="/sign-up">{{.i18n.signup}}</a> | ||||
|       <button class="btn btn-primary" type="submit">{{.i18n.next}}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
| @@ -1,47 +0,0 @@ | ||||
| <div class="left-part"> | ||||
|   <img class="logo" alt="Logo" src="/favicon.png" width="64" height="64" /> | ||||
|  | ||||
|   <h1 class="title">{{.i18n.title}}</h1> | ||||
|   <p class="caption">{{.i18n.caption}}</p> | ||||
| </div> | ||||
|  | ||||
| <div class="right-part"> | ||||
|   <div class="responsive-title-gap"></div> | ||||
|  | ||||
|   <form class="action-form" action="/sign-up" method="POST"> | ||||
|     <div class="columns-two"> | ||||
|       <div class="mb-1"> | ||||
|         <label for="name" class="form-label">{{.i18n.username}}</label> | ||||
|         <input type="text" class="form-control" id="name" name="name"> | ||||
|       </div> | ||||
|  | ||||
|       <div class="mb-1"> | ||||
|         <label for="nick" class="form-label">{{.i18n.nickname}}</label> | ||||
|         <input type="text" class="form-control" id="nick" name="nick"> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div class="mb-1 block-field"> | ||||
|       <label for="email" class="form-label">{{.i18n.email}}</label> | ||||
|       <input type="email" class="form-control" id="email" name="email"> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-1"> | ||||
|       <label for="password" class="form-label">{{.i18n.password}}</label> | ||||
|       <input type="password" class="form-control" id="password" name="password" autocomplete="new-password"> | ||||
|     </div> | ||||
|  | ||||
|     {{if eq .use_magic_token true}} | ||||
|     <div class="mb-1"> | ||||
|       <label for="token" class="form-label">{{.i18n.password}}</label> | ||||
|       <input type="password" class="form-control" id="token" name="magic_token" autocomplete="new-password"> | ||||
|     </div> | ||||
|     {{end}} | ||||
|  | ||||
|     <div class="action-form-buttons"> | ||||
|       <a class="btn btn-secondary" href="/sign-in">{{.i18n.signin}}</a> | ||||
|       <button class="btn btn-primary" type="submit">{{.i18n.next}}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
| @@ -1,153 +0,0 @@ | ||||
| <link rel="stylesheet" href="https://unpkg.com/tailwindcss@1.4.6/dist/base.min.css"> | ||||
| <link rel="stylesheet" href="https://unpkg.com/tailwindcss@1.4.6/dist/components.min.css"> | ||||
| <link rel="stylesheet" href="https://unpkg.com/@tailwindcss/typography@0.1.2/dist/typography.min.css"> | ||||
| <link rel="stylesheet" href="https://unpkg.com/tailwindcss@1.4.6/dist/utilities.min.css"> | ||||
|  | ||||
| <div class="banner-container"> | ||||
|   {{if ne .userinfo.Banner nil}} | ||||
|   <img src="{{.banner}}" alt="Banner" class="banner"> | ||||
|   {{end}} | ||||
| </div> | ||||
|  | ||||
| <div class="left-part name-card"> | ||||
|   {{if ne .userinfo.Avatar nil}} | ||||
|   <img src="{{.avatar}}" alt="Avatar" class="avatar"> | ||||
|   {{else}} | ||||
|   <div class="avatar empty"> | ||||
|     <span class="material-symbols-outlined">account_circle</span> | ||||
|   </div> | ||||
|   {{end}} | ||||
|  | ||||
|   <div class="name"> | ||||
|     <h2 class="username">{{.userinfo.Nick}}</h2> | ||||
|     <h6 class="nickname">@{{.userinfo.Name}}</h6> | ||||
|   </div> | ||||
|   {{if gt (len .userinfo.Description) 0}} | ||||
|   <div class="description">{{.userinfo.Description}}</div> | ||||
|   {{else}} | ||||
|   <div class="description empty">No description yet.</div> | ||||
|   {{end}} | ||||
|   <div class="uid">#{{.uid}}</div> | ||||
| </div> | ||||
|  | ||||
| <div class="right-part"> | ||||
|   <article class="personal-page prose"> | ||||
|     {{.personal_page}} | ||||
|   </article> | ||||
| </div> | ||||
|  | ||||
| <style> | ||||
|   .avatar { | ||||
|     display: block; | ||||
|     width: 64px; | ||||
|     height: 64px; | ||||
|     object-fit: cover; | ||||
|  | ||||
|     clip-path: circle(); | ||||
|   } | ||||
|  | ||||
|   .avatar.empty { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     background-color: var(--md-sys-color-secondary); | ||||
|     color: var(--md-sys-color-on-secondary); | ||||
|   } | ||||
|  | ||||
|   @media (min-width: 768px) { | ||||
|     .banner-container { | ||||
|       grid-column: span 2; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .banner { | ||||
|     display: block; | ||||
|     object-fit: cover; | ||||
|     border-radius: 28px; | ||||
|     aspect-ratio: 3 / 1; | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .name-card { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 1rem; | ||||
|   } | ||||
|  | ||||
|   .name-card .name { | ||||
|     display: flex; | ||||
|     align-items: baseline; | ||||
|     gap: 0.3rem; | ||||
|   } | ||||
|  | ||||
|   .name-card .username { | ||||
|     margin: 0; | ||||
|     font-size: 1.5rem; | ||||
|     font-weight: 600; | ||||
|   } | ||||
|  | ||||
|   .name-card .nickname { | ||||
|     margin: 0; | ||||
|     font-size: 0.75rem; | ||||
|     font-weight: 500; | ||||
|   } | ||||
|  | ||||
|   .name-card .uid { | ||||
|     margin-top: -0.8rem; | ||||
|     font-size: 0.7rem; | ||||
|     font-weight: 400; | ||||
|     font-family: Roboto Mono, monospace; | ||||
|   } | ||||
|  | ||||
|   .name-card .description { | ||||
|     margin-top: -1.25rem; | ||||
|   } | ||||
|  | ||||
|   .description.empty { | ||||
|     font-style: italic; | ||||
|   } | ||||
|  | ||||
|   .name-card .metadata { | ||||
|     font-size: 0.85rem; | ||||
|     font-weight: 500; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|   } | ||||
|  | ||||
|   .metadata>div { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.25rem; | ||||
|   } | ||||
|  | ||||
|   .metadata .material-symbols-outlined { | ||||
|     font-size: 1rem; | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   .actions { | ||||
|     display: flex; | ||||
|     gap: 0.5rem; | ||||
|     margin: 0 -0.5rem; | ||||
|   } | ||||
|  | ||||
|   @media (min-width: 768px) { | ||||
|     .actions { | ||||
|       flex-direction: column; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .actions .action { | ||||
|     width: fit-content; | ||||
|   } | ||||
|  | ||||
|   .actions .material-symbols-outlined { | ||||
|     font-size: 20px; | ||||
|     margin-bottom: 4px; | ||||
|   } | ||||
|  | ||||
|   .left-part .prose { | ||||
|     min-width: 0; | ||||
|     max-width: unset; | ||||
|   } | ||||
| </style> | ||||
| @@ -58,12 +58,10 @@ func main() { | ||||
| 	} | ||||
|  | ||||
| 	// Server | ||||
| 	server.NewServer() | ||||
| 	go server.Listen() | ||||
| 	go server.NewServer().Listen() | ||||
|  | ||||
| 	// Grpc Server | ||||
| 	grpc.NewGRPC() | ||||
| 	go grpc.ListenGRPC() | ||||
| 	go grpc.NewServer().Listen() | ||||
|  | ||||
| 	// Configure timed tasks | ||||
| 	quartz := cron.New(cron.WithLogger(cron.VerbosePrintfLogger(&log.Logger))) | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| id = "passport01" | ||||
|  | ||||
| frontend_app = "web/dist" | ||||
|  | ||||
| bind = "0.0.0.0:8444" | ||||
| grpc_bind = "0.0.0.0:7444" | ||||
| domain = "localhost" | ||||
|   | ||||
							
								
								
									
										18
									
								
								web/.eslintrc.cjs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										18
									
								
								web/.eslintrc.cjs
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| /* eslint-env node */ | ||||
| require("@rushstack/eslint-patch/modern-module-resolution") | ||||
|  | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   extends: [ | ||||
|     "plugin:vue/vue3-essential", | ||||
|     "eslint:recommended", | ||||
|     "@vue/eslint-config-typescript", | ||||
|     "@vue/eslint-config-prettier/skip-formatting", | ||||
|   ], | ||||
|   parserOptions: { | ||||
|     ecmaVersion: "latest", | ||||
|   }, | ||||
|   rules: { | ||||
|     "vue/multi-word-component-names": "off", | ||||
|   } | ||||
| } | ||||
							
								
								
									
										30
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										30
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
										Executable file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| node_modules | ||||
| .DS_Store | ||||
| dist | ||||
| dist-ssr | ||||
| coverage | ||||
| *.local | ||||
|  | ||||
| /cypress/videos/ | ||||
| /cypress/screenshots/ | ||||
|  | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
|  | ||||
| *.tsbuildinfo | ||||
							
								
								
									
										8
									
								
								web/.prettierrc.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										8
									
								
								web/.prettierrc.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/prettierrc", | ||||
|   "semi": false, | ||||
|   "tabWidth": 2, | ||||
|   "singleQuote": false, | ||||
|   "printWidth": 120, | ||||
|   "trailingComma": "all" | ||||
| } | ||||
							
								
								
									
										39
									
								
								web/README.md
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										39
									
								
								web/README.md
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| # views | ||||
|  | ||||
| This template should help get you started developing with Vue 3 in Vite. | ||||
|  | ||||
| ## Recommended IDE Setup | ||||
|  | ||||
| [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). | ||||
|  | ||||
| ## Type Support for `.vue` Imports in TS | ||||
|  | ||||
| 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. | ||||
|  | ||||
| ## Customize configuration | ||||
|  | ||||
| See [Vite Configuration Reference](https://vitejs.dev/config/). | ||||
|  | ||||
| ## Project Setup | ||||
|  | ||||
| ```sh | ||||
| npm install | ||||
| ``` | ||||
|  | ||||
| ### Compile and Hot-Reload for Development | ||||
|  | ||||
| ```sh | ||||
| npm run dev | ||||
| ``` | ||||
|  | ||||
| ### Type-Check, Compile and Minify for Production | ||||
|  | ||||
| ```sh | ||||
| npm run build | ||||
| ``` | ||||
|  | ||||
| ### Lint with [ESLint](https://eslint.org/) | ||||
|  | ||||
| ```sh | ||||
| npm run lint | ||||
| ``` | ||||
							
								
								
									
										
											BIN
										
									
								
								web/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										1
									
								
								web/env.d.ts
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										1
									
								
								web/env.d.ts
									
									
									
									
										vendored
									
									
										Executable file
									
								
							| @@ -0,0 +1 @@ | ||||
| /// <reference types="vite/client" /> | ||||
							
								
								
									
										13
									
								
								web/index.html
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								web/index.html
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/xml+svg" href="/favicon.png" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Solarpass</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/main.ts"></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										45
									
								
								web/package.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										45
									
								
								web/package.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| { | ||||
|   "name": "passport-web", | ||||
|   "version": "1.0.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "run-p type-check \"build-only {@}\" --", | ||||
|     "preview": "vite preview", | ||||
|     "build-only": "vite build", | ||||
|     "type-check": "vue-tsc --build --force", | ||||
|     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", | ||||
|     "format": "prettier --write src/" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fontsource/roboto": "^5.0.13", | ||||
|     "@mdi/font": "^7.4.47", | ||||
|     "@unocss/reset": "^0.58.9", | ||||
|     "dompurify": "^3.1.5", | ||||
|     "marked": "^12.0.2", | ||||
|     "pinia": "^2.1.7", | ||||
|     "universal-cookie": "^7.1.4", | ||||
|     "unocss": "^0.58.9", | ||||
|     "vue": "^3.4.30", | ||||
|     "vue-router": "^4.4.0", | ||||
|     "vuetify": "^3.6.10" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@rushstack/eslint-patch": "^1.10.3", | ||||
|     "@tsconfig/node20": "^20.1.4", | ||||
|     "@types/dompurify": "^3.0.5", | ||||
|     "@types/node": "^20.14.8", | ||||
|     "@vitejs/plugin-vue": "^5.0.5", | ||||
|     "@vitejs/plugin-vue-jsx": "^3.1.0", | ||||
|     "@vue/eslint-config-prettier": "^8.0.0", | ||||
|     "@vue/eslint-config-typescript": "^12.0.0", | ||||
|     "@vue/tsconfig": "^0.5.1", | ||||
|     "eslint": "^8.57.0", | ||||
|     "eslint-plugin-vue": "^9.26.0", | ||||
|     "npm-run-all2": "^6.2.0", | ||||
|     "prettier": "^3.3.2", | ||||
|     "typescript": "^5.4.5", | ||||
|     "vite": "^5.3.1", | ||||
|     "vue-tsc": "^2.0.22" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								web/public/favicon.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/public/favicon.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 75 KiB | 
							
								
								
									
										14
									
								
								web/src/assets/utils.css
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										14
									
								
								web/src/assets/utils.css
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| html, | ||||
| body, | ||||
| #app, | ||||
| .v-application { | ||||
|   font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| .no-scrollbar { | ||||
|   scrollbar-width: none; | ||||
| } | ||||
|  | ||||
| .no-scrollbar::-webkit-scrollbar { | ||||
|   width: 0; | ||||
| } | ||||
							
								
								
									
										6
									
								
								web/src/components/Copyright.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										6
									
								
								web/src/components/Copyright.vue
									
									
									
									
									
										Executable 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> | ||||
							
								
								
									
										70
									
								
								web/src/components/NotificationList.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										70
									
								
								web/src/components/NotificationList.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| <template> | ||||
|   <v-menu eager :close-on-content-click="false"> | ||||
|     <template #activator="{ props }"> | ||||
|       <v-btn v-bind="props" icon size="small" variant="text" :loading="loading"> | ||||
|         <v-badge v-if="notify.total > 0" color="error" :content="notify.total"> | ||||
|           <v-icon icon="mdi-bell" /> | ||||
|         </v-badge> | ||||
|  | ||||
|         <v-icon v-else icon="mdi-bell" /> | ||||
|       </v-btn> | ||||
|     </template> | ||||
|  | ||||
|     <v-list v-if="notify.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, idx) in notify.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, idx)" /> | ||||
|         </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 { computed, onMounted, onUnmounted, ref } from "vue"; | ||||
| import { useNotifications } from "@/stores/notifications"; | ||||
|  | ||||
| const notify = useNotifications() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const submitting = ref(false) | ||||
| const loading = computed(() => notify.loading || submitting.value) | ||||
|  | ||||
| async function markAsRead(item: any, idx: number) { | ||||
|   submitting.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 { | ||||
|     notify.remove(idx) | ||||
|     error.value = null | ||||
|   } | ||||
|   submitting.value = false | ||||
| } | ||||
|  | ||||
| notify.list() | ||||
|  | ||||
| onMounted(() => notify.connect()) | ||||
| onUnmounted(() => notify.disconnect()) | ||||
| </script> | ||||
							
								
								
									
										43
									
								
								web/src/components/UserMenu.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										43
									
								
								web/src/components/UserMenu.vue
									
									
									
									
									
										Executable 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
									
								
								web/src/components/auth/AccountLocator.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										61
									
								
								web/src/components/auth/AccountLocator.vue
									
									
									
									
									
										Executable 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" :disabled="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
									
								
								web/src/components/auth/CallbackNotify.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										16
									
								
								web/src/components/auth/CallbackNotify.vue
									
									
									
									
									
										Executable 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> | ||||
							
								
								
									
										129
									
								
								web/src/components/auth/FactorApplicator.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										129
									
								
								web/src/components/auth/FactorApplicator.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| <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" | ||||
|           type="text" | ||||
|           :length="6" | ||||
|           v-model="password" | ||||
|           :loading="loading" | ||||
|         /> | ||||
|       </div> | ||||
|       <v-text-field | ||||
|         v-else | ||||
|         label="Password" | ||||
|         type="password" | ||||
|         variant="solo" | ||||
|         density="comfortable" | ||||
|         :disabled="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 | ||||
|       password.value = "" | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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> | ||||
							
								
								
									
										75
									
								
								web/src/components/auth/FactorPicker.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										75
									
								
								web/src/components/auth/FactorPicker.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| <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 | ||||
|     focus.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> | ||||
							
								
								
									
										5
									
								
								web/src/index.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										5
									
								
								web/src/index.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| <template> | ||||
|   <v-app> | ||||
|     <router-view /> | ||||
|   </v-app> | ||||
| </template> | ||||
							
								
								
									
										46
									
								
								web/src/layouts/master.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										46
									
								
								web/src/layouts/master.vue
									
									
									
									
									
										Executable 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.png" width="27" 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> | ||||
							
								
								
									
										22
									
								
								web/src/layouts/user-center.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										22
									
								
								web/src/layouts/user-center.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| <template> | ||||
|   <v-container class="pt-6 px-6"> | ||||
|     <v-row> | ||||
|       <v-col :cols="12" :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="Personal Page" prepend-icon="mdi-sitemap" :to="{ name: 'personal-page' }" /> | ||||
|             <v-list-item title="Security" prepend-icon="mdi-security" :to="{ name: 'security' }" /> | ||||
|           </v-list> | ||||
|         </v-card> | ||||
|       </v-col> | ||||
|  | ||||
|       <v-col :cols="12" :xs="12" :sm="12" :md="8" :lg="9"> | ||||
|         <router-view /> | ||||
|       </v-col> | ||||
|     </v-row> | ||||
|   </v-container> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| </script> | ||||
							
								
								
									
										54
									
								
								web/src/main.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										54
									
								
								web/src/main.ts
									
									
									
									
									
										Executable 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") | ||||
							
								
								
									
										96
									
								
								web/src/router/index.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										96
									
								
								web/src/router/index.ts
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| 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/personal-page", | ||||
|               name: "personal-page", | ||||
|               component: () => import("@/views/personal-page.vue"), | ||||
|               meta: { title: "Your personal page" }, | ||||
|             }, | ||||
|             { | ||||
|               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"), | ||||
|         }, | ||||
|  | ||||
|         { | ||||
|           path: "/me/confirm", | ||||
|           name: "callback.confirm", | ||||
|           component: () => import("@/views/confirm.vue"), | ||||
|           meta: { public: true, title: "Confirm registration" }, | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|   ], | ||||
| }) | ||||
|  | ||||
| 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 | ||||
							
								
								
									
										3
									
								
								web/src/scripts/request.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										3
									
								
								web/src/scripts/request.ts
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| export async function request(input: string, init?: RequestInit) { | ||||
|   return await fetch(input, init) | ||||
| } | ||||
							
								
								
									
										64
									
								
								web/src/stores/notifications.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										64
									
								
								web/src/stores/notifications.ts
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import { defineStore } from "pinia"; | ||||
| import { ref } from "vue"; | ||||
| import { checkLoggedIn, getAtk } from "@/stores/userinfo"; | ||||
| import { request } from "@/scripts/request"; | ||||
|  | ||||
| export const useNotifications = defineStore("notifications", () => { | ||||
|   let socket: WebSocket; | ||||
|  | ||||
|   const loading = ref(false); | ||||
|  | ||||
|   const notifications = ref<any[]>([]); | ||||
|   const total = ref(0) | ||||
|  | ||||
|   async function list() { | ||||
|     loading.value = true; | ||||
|     const res = await request( | ||||
|       "/api/notifications?" + | ||||
|       new URLSearchParams({ | ||||
|         take: (25).toString(), | ||||
|         offset: (0).toString() | ||||
|       }), | ||||
|       { | ||||
|         headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|       } | ||||
|     ); | ||||
|     if (res.status === 200) { | ||||
|       const data = await res.json(); | ||||
|       notifications.value = data["data"]; | ||||
|       total.value = data["count"]; | ||||
|     } | ||||
|     loading.value = false; | ||||
|   } | ||||
|  | ||||
|   function remove(idx: number) { | ||||
|     notifications.value.splice(idx, 1) | ||||
|     total.value--; | ||||
|   } | ||||
|  | ||||
|   async function connect() { | ||||
|     if (!(checkLoggedIn())) return; | ||||
|  | ||||
|     const uri = `ws://${window.location.host}/api/notifications/listen`; | ||||
|  | ||||
|     socket = new WebSocket(uri + `?tk=${getAtk() as string}`); | ||||
|  | ||||
|     socket.addEventListener("open", (event) => { | ||||
|       console.log("[NOTIFICATIONS] The listen websocket has been established... ", event.type); | ||||
|     }); | ||||
|     socket.addEventListener("close", (event) => { | ||||
|       console.warn("[NOTIFICATIONS] The listen websocket is disconnected... ", event.reason, event.code); | ||||
|     }); | ||||
|     socket.addEventListener("message", (event) => { | ||||
|       const data = JSON.parse(event.data); | ||||
|       notifications.value.push(data); | ||||
|       total.value++; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function disconnect() { | ||||
|     socket.close(); | ||||
|   } | ||||
|  | ||||
|   return { loading, notifications, total, list, remove, connect, disconnect }; | ||||
| }); | ||||
							
								
								
									
										54
									
								
								web/src/stores/userinfo.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										54
									
								
								web/src/stores/userinfo.ts
									
									
									
									
									
										Executable 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("__hydrogen_atk") | ||||
| } | ||||
|  | ||||
| export function checkLoggedIn(): boolean { | ||||
|   return new Cookie().get("__hydrogen_rtk") | ||||
| } | ||||
|  | ||||
| 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 } | ||||
| }) | ||||
							
								
								
									
										13
									
								
								web/src/views/auth/claims.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								web/src/views/auth/claims.ts
									
									
									
									
									
										Executable 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
									
								
								web/src/views/auth/connect.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										192
									
								
								web/src/views/auth/connect.vue
									
									
									
									
									
										Executable 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] overflow-auto" :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 :touch="false" :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> | ||||
							
								
								
									
										76
									
								
								web/src/views/auth/sign-in.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										76
									
								
								web/src/views/auth/sign-in.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| <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] overflow-auto" :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) * 100" /> | ||||
|               </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 :touch="false" :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
									
								
								web/src/views/auth/sign-up.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										162
									
								
								web/src/views/auth/sign-up.vue
									
									
									
									
									
										Executable 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] overflow-auto" :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(payload), | ||||
|   }) | ||||
|   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> | ||||
							
								
								
									
										104
									
								
								web/src/views/confirm.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										104
									
								
								web/src/views/confirm.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| <template> | ||||
|   <v-container class="h-screen flex flex-col gap-3 items-center justify-center"> | ||||
|     <v-card class="w-full max-w-[720px] overflow-auto" :loading="loading"> | ||||
|       <v-card-text class="card-grid pa-9"> | ||||
|         <div> | ||||
|           <v-avatar color="accent" icon="mdi-check-decagram" size="large" class="card-rounded mb-2" /> | ||||
|           <h1 class="text-2xl">Confirm registration</h1> | ||||
|           <p>Confirm your account to keep your account longer than 48 hours.</p> | ||||
|         </div> | ||||
|  | ||||
|         <v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]"> | ||||
|           <v-window-item value="confirm"> | ||||
|             <div> | ||||
|               <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> | ||||
|  | ||||
|               <v-progress-circular v-if="!error" indeterminate size="32" color="grey-darken-3" class="mb-3" /> | ||||
|  | ||||
|               <h1 class="font-bold text-xl">Confirming</h1> | ||||
|               <p>We are confirming your account. Please stand by, this won't took a long time...</p> | ||||
|             </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">Confirmed</h1> | ||||
|               <p>You're done! We sucessfully confirmed your account.</p> | ||||
|  | ||||
|               <p class="mt-3">Now you can continue use Solarpass, we will redirect to dashboard you soon.</p> | ||||
|             </div> | ||||
|           </v-window-item> | ||||
|         </v-window> | ||||
|       </v-card-text> | ||||
|     </v-card> | ||||
|  | ||||
|     <copyright /> | ||||
|   </v-container> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref } from "vue" | ||||
| import { useRoute, useRouter } from "vue-router" | ||||
| import { request } from "@/scripts/request" | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import Copyright from "@/components/Copyright.vue" | ||||
|  | ||||
| const route = useRoute() | ||||
| const router = useRouter() | ||||
| const { readProfiles } = useUserinfo() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
|  | ||||
| const loading = ref(false) | ||||
|  | ||||
| const panel = ref("confirm") | ||||
|  | ||||
| async function confirm() { | ||||
|   if (!route.query["tk"]) { | ||||
|     error.value = "code was not exists" | ||||
|     return | ||||
|   } | ||||
|  | ||||
|   const res = await request("/api/users/me/confirm", { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       code: route.query["tk"], | ||||
|     }), | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     loading.value = true | ||||
|     panel.value = "callback" | ||||
|     await readProfiles() | ||||
|     router.push({ name: "dashboard" }) | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| confirm() | ||||
| </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> | ||||
							
								
								
									
										77
									
								
								web/src/views/dashboard.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										77
									
								
								web/src/views/dashboard.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <v-card> | ||||
|       <v-img cover class="bg-grey-lighten-2" :height="240" :src="'/api/avatar/' + id.userinfo.data.banner" /> | ||||
|  | ||||
|       <v-card-text class="flex gap-3.5 px-5 pb-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 cursor-pointer" @click="show.realname = !show.realname">{{ displayName }}</h1> | ||||
|           <p v-html="description"></p> | ||||
|  | ||||
|           <div class="mt-5"> | ||||
|             <p class="opacity-80 desc-line"> | ||||
|               <v-icon icon="mdi-calendar-blank" size="16" /> | ||||
|               <span>Joined at {{ new Date(id.userinfo.data?.created_at)?.toLocaleString() }}</span> | ||||
|             </p> | ||||
|             <p class="opacity-80 desc-line"> | ||||
|               <v-icon icon="mdi-cake-variant" size="16" /> | ||||
|               <span>Birthday is {{ new Date(id.userinfo.data?.profile.birthday)?.toLocaleString() }}</span> | ||||
|             </p> | ||||
|           </div> | ||||
|         </div> | ||||
|       </v-card-text> | ||||
|     </v-card> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import { computed } from "vue" | ||||
| import { reactive } from "vue" | ||||
| import { parse } from "marked" | ||||
| import dompurify from "dompurify" | ||||
|  | ||||
| const id = useUserinfo() | ||||
|  | ||||
| const displayName = computed(() => { | ||||
|   if (show.realname) { | ||||
|     return ( | ||||
|       (id.userinfo.data?.profile?.first_name ?? "Unknown") + " " + (id.userinfo.data?.profile?.last_name ?? "Unknown") | ||||
|     ) | ||||
|   } else { | ||||
|     return id.userinfo.displayName | ||||
|   } | ||||
| }) | ||||
| const description = computed(() => { | ||||
|   if (id.userinfo.data?.description) { | ||||
|     return dompurify().sanitize(parse(id.userinfo.data?.description) as string) | ||||
|   } else { | ||||
|     return "No description yet." | ||||
|   } | ||||
| }) | ||||
|  | ||||
| const show = reactive({ | ||||
|   realname: false, | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .desc-line { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 4px; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| <style> | ||||
| .rounded-card { | ||||
|   border-radius: 8px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										71
									
								
								web/src/views/personal-page.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										71
									
								
								web/src/views/personal-page.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <v-card class="mb-3" title="Design" prepend-icon="mdi-pencil-ruler" :loading="loading"> | ||||
|       <template #text> | ||||
|         <v-form class="mt-1" @submit.prevent="submit"> | ||||
|           <v-row dense> | ||||
|             <v-col :cols="12"> | ||||
|               <v-textarea hide-details label="Content" density="comfortable" variant="outlined" | ||||
|                           v-model="data.content" /> | ||||
|             </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-snackbar v-model="done" :timeout="3000"> Your personal page 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 } from "vue"; | ||||
| import { getAtk } from "@/stores/userinfo"; | ||||
| import { request } from "@/scripts/request"; | ||||
|  | ||||
| const error = ref<string | null>(null); | ||||
| const done = ref(false); | ||||
| const loading = ref(false); | ||||
|  | ||||
| const data = ref<any>({}); | ||||
|  | ||||
| async function read() { | ||||
|   loading.value = true; | ||||
|   const res = await request("/api/users/me/page", { | ||||
|     headers: { Authorization: `Bearer ${(getAtk())}` } | ||||
|   }); | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text(); | ||||
|   } else { | ||||
|     data.value = await res.json(); | ||||
|   } | ||||
|   loading.value = false; | ||||
| } | ||||
|  | ||||
| async function submit() { | ||||
|   const payload = data.value; | ||||
|  | ||||
|   loading.value = true; | ||||
|   const res = await request("/api/users/me/page", { | ||||
|     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 read(); | ||||
|     done.value = true; | ||||
|     error.value = null; | ||||
|   } | ||||
|   loading.value = false; | ||||
| } | ||||
|  | ||||
| read(); | ||||
| </script> | ||||
							
								
								
									
										170
									
								
								web/src/views/personalize.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										170
									
								
								web/src/views/personalize.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| <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> | ||||
|       <v-card-text 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" /> | ||||
|       </v-card-text> | ||||
|  | ||||
|       <v-img cover class="bg-grey-lighten-2" :height="320" :src="'/api/avatar/' + id.userinfo.data.banner" /> | ||||
|  | ||||
|       <v-card-text> | ||||
|         <v-file-input clearable hide-details label="Update your banner" variant="outlined" density="comfortable" | ||||
|           accept="image/*" prepend-icon="" append-icon="mdi-upload" v-model="banner" @click:append="applyBanner" /> | ||||
|       </v-card-text> | ||||
|     </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) | ||||
| const banner = 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.description = id.userinfo.data.description | ||||
|       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/users/me/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 | ||||
| } | ||||
|  | ||||
| async function applyBanner() { | ||||
|   if (!banner.value) return | ||||
|  | ||||
|   if (loading.value) return | ||||
|  | ||||
|   const payload = new FormData() | ||||
|   payload.set("banner", banner.value[0]) | ||||
|  | ||||
|   loading.value = true | ||||
|   const res = await request("/api/users/me/banner", { | ||||
|     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 | ||||
|     banner.value = null | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .rounded-card { | ||||
|   border-radius: 8px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										266
									
								
								web/src/views/security.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										266
									
								
								web/src/views/security.vue
									
									
									
									
									
										Executable 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
									
								
								web/tsconfig.app.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										14
									
								
								web/tsconfig.app.json
									
									
									
									
									
										Executable 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/*"] | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								web/tsconfig.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								web/tsconfig.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
|   "files": [], | ||||
|   "references": [ | ||||
|     { | ||||
|       "path": "./tsconfig.node.json" | ||||
|     }, | ||||
|     { | ||||
|       "path": "./tsconfig.app.json" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										13
									
								
								web/tsconfig.node.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								web/tsconfig.node.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| { | ||||
|   "extends": "@tsconfig/node20/tsconfig.json", | ||||
|   "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"], | ||||
|   "compilerOptions": { | ||||
|     "composite": true, | ||||
|     "noEmit": true, | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | ||||
|  | ||||
|     "module": "ESNext", | ||||
|     "moduleResolution": "Bundler", | ||||
|     "types": ["node"] | ||||
|   } | ||||
| } | ||||
							
								
								
									
										5
									
								
								web/uno.config.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										5
									
								
								web/uno.config.ts
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { defineConfig, presetAttributify, presetTypography, presetUno } from "unocss" | ||||
|  | ||||
| export default defineConfig({ | ||||
|   presets: [presetAttributify(), presetTypography(), presetUno({ preflight: false })], | ||||
| }) | ||||
							
								
								
									
										27
									
								
								web/vite.config.ts
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										27
									
								
								web/vite.config.ts
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { fileURLToPath, URL } from "node:url"; | ||||
|  | ||||
| import { defineConfig } from "vite"; | ||||
| import vue from "@vitejs/plugin-vue"; | ||||
| import vueJsx from "@vitejs/plugin-vue-jsx"; | ||||
| import unocss from "unocss/vite"; | ||||
|  | ||||
| // https://vitejs.dev/config/ | ||||
| export default defineConfig({ | ||||
|   plugins: [vue(), vueJsx(), unocss()], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       "@": fileURLToPath(new URL("./src", import.meta.url)) | ||||
|     } | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       "/api/ws": { | ||||
|         target: "ws://localhost:8444", | ||||
|         ws: true | ||||
|       }, | ||||
|  | ||||
|       "/api": "http://localhost:8444", | ||||
|       "/.well-known": "http://localhost:8444" | ||||
|     } | ||||
|   } | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user