⏪️ 重现旧 UI #4
59
.idea/codeStyles/Project.xml
Normal file
59
.idea/codeStyles/Project.xml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<HTMLCodeStyleSettings>
|
||||||
|
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||||
|
</HTMLCodeStyleSettings>
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<VueCodeStyleSettings>
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||||
|
</VueCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="HTML">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Vue">
|
||||||
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
@ -5024,6 +5024,7 @@ true posixrules
|
|||||||
</table>
|
</table>
|
||||||
<table id="292" parent="267" name="passport_notifications">
|
<table id="292" parent="267" name="passport_notifications">
|
||||||
<ObjectId>16445</ObjectId>
|
<ObjectId>16445</ObjectId>
|
||||||
|
<Outdated>1</Outdated>
|
||||||
<StateNumber>25751</StateNumber>
|
<StateNumber>25751</StateNumber>
|
||||||
<AccessMethodId>2</AccessMethodId>
|
<AccessMethodId>2</AccessMethodId>
|
||||||
<OwnerName>postgres</OwnerName>
|
<OwnerName>postgres</OwnerName>
|
||||||
|
@ -4,9 +4,13 @@
|
|||||||
<option name="autoReloadType" value="ALL" />
|
<option name="autoReloadType" value="ALL" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<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=":arrow_up: Fix notification listen">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" 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$/pkg/internal/models/tokens.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/models/tokens.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/pkg/internal/server/api/auth_api.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/api/auth_api.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/pkg/internal/services/accounts.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/services/accounts.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/pkg/internal/services/tokens.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/services/tokens.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/pkg/main.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/main.go" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@ -41,40 +45,41 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"DefaultGoTemplateProperty": "Go File",
|
"DefaultGoTemplateProperty": "Go File",
|
||||||
"Go Build.Backend.executor": "Run",
|
"Go Build.Backend.executor": "Run",
|
||||||
"Go 构建.Backend.executor": "Run",
|
"Go 构建.Backend.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.go.formatter.settings.were.checked": "true",
|
"RunOnceActivity.go.formatter.settings.were.checked": "true",
|
||||||
"RunOnceActivity.go.migrated.go.modules.settings": "true",
|
"RunOnceActivity.go.migrated.go.modules.settings": "true",
|
||||||
"RunOnceActivity.go.modules.automatic.dependencies.download": "true",
|
"RunOnceActivity.go.modules.automatic.dependencies.download": "true",
|
||||||
"RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true",
|
"RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true",
|
||||||
"git-widget-placeholder": "master",
|
"git-widget-placeholder": "refactor/v2",
|
||||||
"go.import.settings.migrated": "true",
|
"go.import.settings.migrated": "true",
|
||||||
"go.sdk.automatically.set": "true",
|
"go.sdk.automatically.set": "true",
|
||||||
"last_opened_file_path": "/Users/littlesheep",
|
"last_opened_file_path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/pkg/internal/server/api",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"run.code.analysis.last.selected.profile": "pProject Default",
|
"run.code.analysis.last.selected.profile": "pProject Default",
|
||||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"ts.external.directory.path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web/node_modules/typescript/lib",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
},
|
},
|
||||||
"keyToStringList": {
|
"keyToStringList": {
|
||||||
"DatabaseDriversLRU": [
|
"DatabaseDriversLRU": [
|
||||||
"postgresql"
|
"postgresql"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}</component>
|
}]]></component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/pkg/internal/server/api" />
|
||||||
|
<recent name="$PROJECT_DIR$/web" />
|
||||||
<recent name="$PROJECT_DIR$/pkg/services" />
|
<recent name="$PROJECT_DIR$/pkg/services" />
|
||||||
<recent name="$PROJECT_DIR$/pkg/server/ui" />
|
<recent name="$PROJECT_DIR$/pkg/server/ui" />
|
||||||
<recent name="$PROJECT_DIR$/pkg/views/users" />
|
<recent name="$PROJECT_DIR$/pkg/views/users" />
|
||||||
<recent name="$PROJECT_DIR$/pkg/views" />
|
|
||||||
<recent name="$PROJECT_DIR$/pkg" />
|
|
||||||
</key>
|
</key>
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$/pkg/internal/server/exts" />
|
<recent name="$PROJECT_DIR$/pkg/internal/server/exts" />
|
||||||
@ -130,7 +135,7 @@
|
|||||||
<entry key="branch">
|
<entry key="branch">
|
||||||
<value>
|
<value>
|
||||||
<list>
|
<list>
|
||||||
<option value="master" />
|
<option value="refactor/v2" />
|
||||||
</list>
|
</list>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
@ -143,14 +148,7 @@
|
|||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsManagerConfiguration">
|
<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" />
|
|
||||||
<MESSAGE value=":bug: Bug fixes in update avatar" />
|
|
||||||
<MESSAGE value=":sparkles: Firebase is back" />
|
|
||||||
<MESSAGE value=":sparkles: Apple push notification services" />
|
<MESSAGE value=":sparkles: Apple push notification services" />
|
||||||
<MESSAGE value=":bug: Bug fixes" />
|
|
||||||
<MESSAGE value=":bug: Bug fix and fix" />
|
<MESSAGE value=":bug: Bug fix and fix" />
|
||||||
<MESSAGE value=":bug: Fix APNs non-production" />
|
<MESSAGE value=":bug: Fix APNs non-production" />
|
||||||
<MESSAGE value=":bug: Bug fixes on notification badges for APNs" />
|
<MESSAGE value=":bug: Bug fixes on notification badges for APNs" />
|
||||||
@ -168,7 +166,14 @@
|
|||||||
<MESSAGE value=":bug: Fix registration service issue" />
|
<MESSAGE value=":bug: Fix registration service issue" />
|
||||||
<MESSAGE value=":bug: Fix avatar url missing endpoint prefix" />
|
<MESSAGE value=":bug: Fix avatar url missing endpoint prefix" />
|
||||||
<MESSAGE value=":ambulance: Fix query services too much 429" />
|
<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" />
|
||||||
|
<MESSAGE value=":recycle: Update the sign in web page to the latest API" />
|
||||||
|
<MESSAGE value=":wastebasket: Remove the personal page" />
|
||||||
|
<MESSAGE value=":recycle: OAuth authenticate" />
|
||||||
|
<MESSAGE value=":sparkles: Recommend app component" />
|
||||||
|
<MESSAGE value=":bug: Bug fixes" />
|
||||||
|
<MESSAGE value=":arrow_up: Fix notification listen" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value=":arrow_up: Fix notification listen" />
|
||||||
</component>
|
</component>
|
||||||
<component name="VgoProject">
|
<component name="VgoProject">
|
||||||
<settings-migrated>true</settings-migrated>
|
<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/go-playground/validator/v10 v10.17.0
|
||||||
github.com/gofiber/contrib/websocket v1.3.0
|
github.com/gofiber/contrib/websocket v1.3.0
|
||||||
github.com/gofiber/fiber/v2 v2.52.4
|
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/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/google/uuid v1.6.0
|
||||||
github.com/hashicorp/consul/api v1.29.1
|
github.com/hashicorp/consul/api v1.29.1
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.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-jwt/jwt/v4 v4.5.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // 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.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 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
|
||||||
github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
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/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.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
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.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 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 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
|
||||||
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
||||||
|
@ -9,7 +9,6 @@ var AutoMaintainRange = []any{
|
|||||||
&models.Account{},
|
&models.Account{},
|
||||||
&models.AuthFactor{},
|
&models.AuthFactor{},
|
||||||
&models.AccountProfile{},
|
&models.AccountProfile{},
|
||||||
&models.AccountPage{},
|
|
||||||
&models.AccountContact{},
|
&models.AccountContact{},
|
||||||
&models.AccountFriendship{},
|
&models.AccountFriendship{},
|
||||||
&models.Badge{},
|
&models.Badge{},
|
||||||
|
@ -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.UnimplementedFriendshipsServer
|
||||||
proto.UnimplementedRealmsServer
|
proto.UnimplementedRealmsServer
|
||||||
health.UnimplementedHealthServer
|
health.UnimplementedHealthServer
|
||||||
|
|
||||||
|
srv *grpc.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
var S *grpc.Server
|
func NewServer() *Server {
|
||||||
|
server := &Server{
|
||||||
|
srv: grpc.NewServer(),
|
||||||
|
}
|
||||||
|
|
||||||
func NewGRPC() {
|
proto.RegisterAuthServer(server.srv, &Server{})
|
||||||
S = grpc.NewServer()
|
proto.RegisterNotifyServer(server.srv, &Server{})
|
||||||
|
proto.RegisterFriendshipsServer(server.srv, &Server{})
|
||||||
|
proto.RegisterRealmsServer(server.srv, &Server{})
|
||||||
|
health.RegisterHealthServer(server.srv, &Server{})
|
||||||
|
|
||||||
proto.RegisterAuthServer(S, &Server{})
|
reflection.Register(server.srv)
|
||||||
proto.RegisterNotifyServer(S, &Server{})
|
|
||||||
proto.RegisterFriendshipsServer(S, &Server{})
|
|
||||||
proto.RegisterRealmsServer(S, &Server{})
|
|
||||||
health.RegisterHealthServer(S, &Server{})
|
|
||||||
|
|
||||||
reflection.Register(S)
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListenGRPC() error {
|
func (v *Server) Listen() error {
|
||||||
listener, err := net.Listen("tcp", viper.GetString("grpc_bind"))
|
listener, err := net.Listen("tcp", viper.GetString("grpc_bind"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return S.Serve(listener)
|
return v.srv.Serve(listener)
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@ type Account struct {
|
|||||||
PermNodes datatypes.JSONMap `json:"perm_nodes"`
|
PermNodes datatypes.JSONMap `json:"perm_nodes"`
|
||||||
|
|
||||||
Profile AccountProfile `json:"profile"`
|
Profile AccountProfile `json:"profile"`
|
||||||
PersonalPage AccountPage `json:"personal_page"`
|
|
||||||
Badges []Badge `json:"badges"`
|
Badges []Badge `json:"badges"`
|
||||||
Contacts []AccountContact `json:"contacts"`
|
Contacts []AccountContact `json:"contacts"`
|
||||||
RealmIdentities []RealmMember `json:"realm_identities"`
|
RealmIdentities []RealmMember `json:"realm_identities"`
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gorm.io/datatypes"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,18 +13,3 @@ type AccountProfile struct {
|
|||||||
Birthday *time.Time `json:"birthday"`
|
Birthday *time.Time `json:"birthday"`
|
||||||
AccountID uint `json:"account_id"`
|
AccountID uint `json:"account_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountPage struct {
|
|
||||||
BaseModel
|
|
||||||
|
|
||||||
Content string `json:"content"`
|
|
||||||
Script string `json:"script"`
|
|
||||||
Style string `json:"style"`
|
|
||||||
Links datatypes.JSONSlice[AccountPageLinks] `json:"links"`
|
|
||||||
AccountID uint `json:"account_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountPageLinks struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
}
|
|
||||||
|
@ -14,6 +14,6 @@ type MagicToken struct {
|
|||||||
|
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Type int8 `json:"type"`
|
Type int8 `json:"type"`
|
||||||
AssignTo *uint `json:"assign_to"`
|
AccountID *uint `json:"account_id"`
|
||||||
ExpiredAt *time.Time `json:"expired_at"`
|
ExpiredAt *time.Time `json:"expired_at"`
|
||||||
}
|
}
|
||||||
|
@ -123,7 +123,7 @@ func editUserinfo(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusOK)
|
return c.SendStatus(fiber.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func killSession(c *fiber.Ctx) error {
|
func killTicket(c *fiber.Ctx) error {
|
||||||
if err := exts.EnsureAuthenticated(c); err != nil {
|
if err := exts.EnsureAuthenticated(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,8 @@ func doAuthenticate(c *fiber.Ctx) error {
|
|||||||
user, err := services.LookupAccount(data.Username)
|
user, err := services.LookupAccount(data.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("account was not found: %v", err.Error()))
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("account was not found: %v", err.Error()))
|
||||||
|
} else if user.ConfirmedAt == nil {
|
||||||
|
return fiber.NewError(fiber.StatusForbidden, "account was not confirmed")
|
||||||
}
|
}
|
||||||
|
|
||||||
ticket, err := services.NewTicket(user, c.IP(), c.Get(fiber.HeaderUserAgent))
|
ticket, err := services.NewTicket(user, c.IP(), c.Get(fiber.HeaderUserAgent))
|
||||||
@ -36,7 +38,7 @@ func doAuthenticate(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"is_finished": ticket.IsAvailable(),
|
"is_finished": ticket.IsAvailable() == nil,
|
||||||
"ticket": ticket,
|
"ticket": ticket,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -68,7 +70,7 @@ func doMultiFactorAuthenticate(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"is_finished": ticket.IsAvailable(),
|
"is_finished": ticket.IsAvailable() == nil,
|
||||||
"ticket": ticket,
|
"ticket": ticket,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,29 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
|
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func getAvailableFactors(c *fiber.Ctx) error {
|
||||||
|
ticketId := c.QueryInt("ticketId", 0)
|
||||||
|
if ticketId <= 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "must provide ticket id as a query parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, err := services.GetTicket(uint(ticketId))
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ticket was not found: %v", err))
|
||||||
|
}
|
||||||
|
factors, err := services.ListUserFactor(ticket.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(factors)
|
||||||
|
}
|
||||||
|
|
||||||
func requestFactorToken(c *fiber.Ctx) error {
|
func requestFactorToken(c *fiber.Ctx) error {
|
||||||
id, _ := c.ParamsInt("factorId", 0)
|
id, _ := c.ParamsInt("factorId", 0)
|
||||||
|
|
||||||
|
@ -26,12 +26,10 @@ func MapAPIs(app *fiber.App) {
|
|||||||
me.Put("/banner", setBanner)
|
me.Put("/banner", setBanner)
|
||||||
|
|
||||||
me.Get("/", getUserinfo)
|
me.Get("/", getUserinfo)
|
||||||
me.Get("/page", getOwnPersonalPage)
|
|
||||||
me.Put("/", editUserinfo)
|
me.Put("/", editUserinfo)
|
||||||
me.Put("/page", editPersonalPage)
|
|
||||||
me.Get("/events", getEvents)
|
me.Get("/events", getEvents)
|
||||||
me.Get("/tickets", getTickets)
|
me.Get("/tickets", getTickets)
|
||||||
me.Delete("/tickets/:ticketId", killSession)
|
me.Delete("/tickets/:ticketId", killTicket)
|
||||||
|
|
||||||
me.Post("/confirm", doRegisterConfirm)
|
me.Post("/confirm", doRegisterConfirm)
|
||||||
|
|
||||||
@ -49,14 +47,22 @@ func MapAPIs(app *fiber.App) {
|
|||||||
directory := api.Group("/users/:alias").Name("User Directory")
|
directory := api.Group("/users/:alias").Name("User Directory")
|
||||||
{
|
{
|
||||||
directory.Get("/", getOtherUserinfo)
|
directory.Get("/", getOtherUserinfo)
|
||||||
directory.Get("/page", getPersonalPage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
api.Post("/users", doRegister)
|
api.Post("/users", doRegister)
|
||||||
|
|
||||||
api.Post("/auth", doAuthenticate)
|
auth := api.Group("/auth").Name("Auth")
|
||||||
api.Post("/auth/token", getToken)
|
{
|
||||||
api.Post("/auth/factors/:factorId", requestFactorToken)
|
auth.Post("/", doAuthenticate)
|
||||||
|
auth.Post("/mfa", doMultiFactorAuthenticate)
|
||||||
|
auth.Post("/token", getToken)
|
||||||
|
|
||||||
|
auth.Get("/factors", getAvailableFactors)
|
||||||
|
auth.Post("/factors/:factorId", requestFactorToken)
|
||||||
|
|
||||||
|
auth.Get("/o/authorize", tryAuthorizeThirdClient)
|
||||||
|
auth.Post("/o/authorize", authorizeThirdClient)
|
||||||
|
}
|
||||||
|
|
||||||
realms := api.Group("/realms").Name("Realms API")
|
realms := api.Group("/realms").Name("Realms API")
|
||||||
{
|
{
|
||||||
@ -85,5 +91,9 @@ func MapAPIs(app *fiber.App) {
|
|||||||
}
|
}
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}).Get("/ws", websocket.New(listenWebsocket))
|
}).Get("/ws", websocket.New(listenWebsocket))
|
||||||
|
|
||||||
|
api.All("/*", func(c *fiber.Ctx) error {
|
||||||
|
return fiber.ErrNotFound
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
140
pkg/internal/server/ui/oauth.go → pkg/internal/server/api/oauth_api.go
Normal file → Executable file
140
pkg/internal/server/ui/oauth.go → pkg/internal/server/api/oauth_api.go
Normal file → Executable file
@ -1,111 +1,76 @@
|
|||||||
package ui
|
package api
|
||||||
|
|
||||||
import (
|
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/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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.solsynth.dev/hydrogen/passport/pkg/internal/database"
|
||||||
|
"git.solsynth.dev/hydrogen/passport/pkg/internal/models"
|
||||||
|
"git.solsynth.dev/hydrogen/passport/pkg/internal/services"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func authorizePage(c *fiber.Ctx) error {
|
func tryAuthorizeThirdClient(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")
|
id := c.Query("client_id")
|
||||||
redirect := c.Query("redirect_uri")
|
redirect := c.Query("redirect_uri")
|
||||||
|
|
||||||
var message string
|
|
||||||
if len(id) <= 0 || len(redirect) <= 0 {
|
if len(id) <= 0 || len(redirect) <= 0 {
|
||||||
message = "invalid request, missing query parameters"
|
return fiber.NewError(fiber.StatusBadRequest, "invalid request, missing query parameters")
|
||||||
}
|
}
|
||||||
|
|
||||||
var client models.ThirdClient
|
var client models.ThirdClient
|
||||||
if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil {
|
if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil {
|
||||||
message = fmt.Sprintf("unable to find client: %v", err)
|
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||||
} else if !client.IsDraft && !lo.Contains(client.Callbacks, strings.Split(redirect, "?")[0]) {
|
} else if !client.IsDraft && !lo.Contains(client.Callbacks, strings.Split(redirect, "?")[0]) {
|
||||||
message = "invalid callback url"
|
return fiber.NewError(fiber.StatusBadRequest, "invalid callback url")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := exts.EnsureAuthenticated(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user := c.Locals("user").(models.Account)
|
||||||
|
|
||||||
var ticket models.AuthTicket
|
var ticket models.AuthTicket
|
||||||
if err := database.C.Where(&models.AuthTicket{
|
if err := database.C.Where(&models.AuthTicket{
|
||||||
AccountID: user.ID,
|
AccountID: user.ID,
|
||||||
ClientID: &client.ID,
|
ClientID: &client.ID,
|
||||||
}).Where("last_grant_at IS NULL").First(&ticket).Error; err == nil {
|
}).Where("last_grant_at IS NULL").First(&ticket).Error; err == nil {
|
||||||
if !(ticket.ExpiredAt != nil && ticket.ExpiredAt.Unix() < time.Now().Unix()) {
|
if ticket.ExpiredAt != nil && ticket.ExpiredAt.Unix() < time.Now().Unix() {
|
||||||
ticket, err = services.RegenSession(ticket)
|
return c.JSON(fiber.Map{
|
||||||
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,
|
"client": client,
|
||||||
"scopes": strings.Split(c.Query("scope"), " "),
|
"ticket": nil,
|
||||||
"action_url": template.URL(qs),
|
})
|
||||||
"i18n": fiber.Map{
|
} else {
|
||||||
"approve": approve,
|
ticket, err = services.RegenSession(ticket)
|
||||||
"decline": decline,
|
}
|
||||||
"title": title,
|
|
||||||
"caption": caption,
|
return c.JSON(fiber.Map{
|
||||||
},
|
"client": client,
|
||||||
}, "views/layouts/auth")
|
"ticket": ticket,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"client": client,
|
||||||
|
"ticket": nil,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizeAction(c *fiber.Ctx) error {
|
func authorizeThirdClient(c *fiber.Ctx) error {
|
||||||
if err := exts.EnsureAuthenticated(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user := c.Locals("user").(models.Account)
|
|
||||||
id := c.Query("client_id")
|
id := c.Query("client_id")
|
||||||
response := c.Query("response_type")
|
response := c.Query("response_type")
|
||||||
redirect := c.Query("redirect_uri")
|
redirect := c.Query("redirect_uri")
|
||||||
scope := c.Query("scope")
|
scope := c.Query("scope")
|
||||||
|
if len(scope) <= 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "invalid request params")
|
||||||
|
}
|
||||||
|
|
||||||
if err := exts.EnsureAuthenticated(c); err != nil {
|
if err := exts.EnsureAuthenticated(c); err != nil {
|
||||||
return DoAuthRedirect(c)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
redirectBackUri := "/authorize?" + string(c.Request().URI().QueryString())
|
|
||||||
|
|
||||||
if len(scope) <= 0 {
|
|
||||||
return flash.WithInfo(c, fiber.Map{
|
|
||||||
"message": "invalid request parameters",
|
|
||||||
}).Redirect(redirectBackUri)
|
|
||||||
}
|
}
|
||||||
|
user := c.Locals("user").(models.Account)
|
||||||
|
|
||||||
var client models.ThirdClient
|
var client models.ThirdClient
|
||||||
if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil {
|
if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil {
|
||||||
@ -128,12 +93,10 @@ func authorizeAction(c *fiber.Ctx) error {
|
|||||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||||
} else {
|
} else {
|
||||||
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
|
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
|
||||||
return c.Redirect(fmt.Sprintf(
|
return c.JSON(fiber.Map{
|
||||||
"%s?code=%s&state=%s",
|
"ticket": ticket,
|
||||||
redirect,
|
"redirect_uri": redirect,
|
||||||
*ticket.GrantToken,
|
})
|
||||||
c.Query("state"),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
case "token":
|
case "token":
|
||||||
// OAuth Implicit Mode
|
// OAuth Implicit Mode
|
||||||
@ -152,15 +115,14 @@ func authorizeAction(c *fiber.Ctx) error {
|
|||||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||||
} else {
|
} else {
|
||||||
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
|
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",
|
return c.JSON(fiber.Map{
|
||||||
redirect,
|
"access_token": access,
|
||||||
access,
|
"refresh_token": refresh,
|
||||||
refresh, c.Query("state"),
|
"redirect_uri": redirect,
|
||||||
))
|
"ticket": ticket,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return flash.WithInfo(c, fiber.Map{
|
return fiber.NewError(fiber.StatusBadRequest, "unsupported response type")
|
||||||
"message": "unsupported response type",
|
|
||||||
}).Redirect(redirectBackUri)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,76 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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"
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getPersonalPage(c *fiber.Ctx) error {
|
|
||||||
alias := c.Params("alias")
|
|
||||||
|
|
||||||
var account models.Account
|
|
||||||
if err := database.C.
|
|
||||||
Where(&models.Account{Name: alias}).
|
|
||||||
First(&account).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
var page models.AccountPage
|
|
||||||
if err := database.C.
|
|
||||||
Where(&models.AccountPage{AccountID: account.ID}).
|
|
||||||
First(&page).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOwnPersonalPage(c *fiber.Ctx) error {
|
|
||||||
if err := exts.EnsureAuthenticated(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user := c.Locals("user").(models.Account)
|
|
||||||
|
|
||||||
var page models.AccountPage
|
|
||||||
if err := database.C.
|
|
||||||
Where(&models.AccountPage{AccountID: user.ID}).
|
|
||||||
FirstOrCreate(&page, &models.AccountPage{AccountID: user.ID}).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
func editPersonalPage(c *fiber.Ctx) error {
|
|
||||||
if err := exts.EnsureAuthenticated(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user := c.Locals("user").(models.Account)
|
|
||||||
|
|
||||||
var data struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Links []models.AccountPageLinks `json:"links"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := exts.BindAndValidate(c, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var page models.AccountPage
|
|
||||||
if err := database.C.
|
|
||||||
Where(&models.AccountPage{AccountID: user.ID}).
|
|
||||||
FirstOrInit(&page).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
page.Content = data.Content
|
|
||||||
page.Links = data.Links
|
|
||||||
|
|
||||||
if err := database.C.Save(&page).Error; err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
|
||||||
}
|
|
@ -1,32 +1,31 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
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/api"
|
||||||
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
|
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.solsynth.dev/hydrogen/passport/pkg/internal"
|
|
||||||
"git.solsynth.dev/hydrogen/passport/pkg/internal/i18n"
|
"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"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/favicon"
|
"github.com/gofiber/fiber/v2/middleware/favicon"
|
||||||
"github.com/gofiber/fiber/v2/middleware/idempotency"
|
"github.com/gofiber/fiber/v2/middleware/idempotency"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||||
"github.com/gofiber/template/html/v2"
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var A *fiber.App
|
type HTTPApp struct {
|
||||||
|
app *fiber.App
|
||||||
|
}
|
||||||
|
|
||||||
func NewServer() {
|
func NewServer() *HTTPApp {
|
||||||
templates := html.NewFileSystem(http.FS(pkg.FS), ".gohtml")
|
app := fiber.New(fiber.Config{
|
||||||
|
|
||||||
A = fiber.New(fiber.Config{
|
|
||||||
DisableStartupMessage: true,
|
DisableStartupMessage: true,
|
||||||
EnableIPValidation: true,
|
EnableIPValidation: true,
|
||||||
ServerHeader: "Hydrogen.Passport",
|
ServerHeader: "Hydrogen.Passport",
|
||||||
@ -35,12 +34,10 @@ func NewServer() {
|
|||||||
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
|
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
|
||||||
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
|
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
|
||||||
EnablePrintRoutes: viper.GetBool("debug.print_routes"),
|
EnablePrintRoutes: viper.GetBool("debug.print_routes"),
|
||||||
Views: templates,
|
|
||||||
ViewsLayout: "views/index",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
A.Use(idempotency.New())
|
app.Use(idempotency.New())
|
||||||
A.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
AllowMethods: strings.Join([]string{
|
AllowMethods: strings.Join([]string{
|
||||||
fiber.MethodGet,
|
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",
|
Format: "${status} | ${latency} | ${method} ${path}\n",
|
||||||
Output: log.Logger,
|
Output: log.Logger,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
A.Use(exts.AuthMiddleware)
|
app.Use(exts.AuthMiddleware)
|
||||||
A.Use(i18n.I18nMiddleware)
|
app.Use(i18n.I18nMiddleware)
|
||||||
|
|
||||||
A.Use(favicon.New(favicon.Config{
|
api.MapAPIs(app)
|
||||||
FileSystem: http.FS(pkg.FS),
|
admin.MapAdminEndpoints(app)
|
||||||
File: "views/favicon.png",
|
|
||||||
|
app.Use(filesystem.New(filesystem.Config{
|
||||||
|
Root: http.Dir(viper.GetString("frontend_app")),
|
||||||
|
Index: "index.html",
|
||||||
|
NotFoundFile: "index.html",
|
||||||
|
MaxAge: 3600,
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.Use(favicon.New(favicon.Config{
|
||||||
|
File: filepath.Join(viper.GetString("frontend_app"), "favicon.png"),
|
||||||
URL: "/favicon.png",
|
URL: "/favicon.png",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
api.MapAPIs(A)
|
return &HTTPApp{app}
|
||||||
admin.MapAdminEndpoints(A)
|
|
||||||
ui.MapUserInterface(A)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Listen() {
|
func (v *HTTPApp) Listen() {
|
||||||
if err := A.Listen(viper.GetString("bind")); err != nil {
|
if err := v.app.Listen(viper.GetString("bind")); err != nil {
|
||||||
log.Fatal().Err(err).Msg("An error occurred when starting server...")
|
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,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")))
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"time"
|
"time"
|
||||||
@ -93,7 +94,7 @@ func ConfirmAccount(code string) error {
|
|||||||
|
|
||||||
var user models.Account
|
var user models.Account
|
||||||
if err := database.C.Where(&models.Account{
|
if err := database.C.Where(&models.Account{
|
||||||
BaseModel: models.BaseModel{ID: *token.AssignTo},
|
BaseModel: models.BaseModel{ID: *token.AccountID},
|
||||||
}).First(&user).Error; err != nil {
|
}).First(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -121,3 +122,49 @@ func ConfirmAccount(code string) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeleteAccount(id uint) error {
|
||||||
|
tx := database.C.Begin()
|
||||||
|
|
||||||
|
for _, model := range []any{
|
||||||
|
&models.Badge{},
|
||||||
|
&models.RealmMember{},
|
||||||
|
&models.AccountContact{},
|
||||||
|
&models.AuthFactor{},
|
||||||
|
&models.AuthTicket{},
|
||||||
|
&models.MagicToken{},
|
||||||
|
&models.ThirdClient{},
|
||||||
|
&models.Notification{},
|
||||||
|
&models.NotificationSubscriber{},
|
||||||
|
&models.AccountFriendship{},
|
||||||
|
} {
|
||||||
|
if err := tx.Delete(model, "account_id = ?", id).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Delete(&models.Account{}, "id = ?", id).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecycleUnConfirmAccount() {
|
||||||
|
var hitList []models.Account
|
||||||
|
if err := database.C.Where("confirmed_at IS NULL").Find(&hitList).Error; err != nil {
|
||||||
|
log.Error().Err(err).Msg("An error occurred while recycling accounts...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hitList) > 0 {
|
||||||
|
log.Info().Int("count", len(hitList)).Msg("Going to recycle those un-confirmed accounts...")
|
||||||
|
for _, entry := range hitList {
|
||||||
|
if err := DeleteAccount(entry.ID); err != nil {
|
||||||
|
log.Error().Err(err).Msg("An error occurred while recycling accounts...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -12,22 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func DetectRisk(user models.Account, ip, ua string) bool {
|
func DetectRisk(user models.Account, ip, ua string) bool {
|
||||||
var availableFactor int64
|
var clue int64
|
||||||
if err := database.C.
|
|
||||||
Where(models.AuthFactor{AccountID: user.ID}).
|
|
||||||
Where("type != ?", models.PasswordAuthFactor).
|
|
||||||
Model(models.AuthFactor{}).
|
|
||||||
Where(&availableFactor); err != nil || availableFactor <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var secureFactor int64
|
|
||||||
if err := database.C.
|
if err := database.C.
|
||||||
Where(models.AuthTicket{AccountID: user.ID, IpAddress: ip}).
|
Where(models.AuthTicket{AccountID: user.ID, IpAddress: ip}).
|
||||||
Where("available_at IS NOT NULL").
|
Where("available_at IS NOT NULL").
|
||||||
Model(models.AuthTicket{}).
|
Model(models.AuthTicket{}).
|
||||||
Count(&secureFactor).Error; err == nil {
|
Count(&clue).Error; err == nil {
|
||||||
if secureFactor >= 1 {
|
if clue >= 1 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,7 +72,7 @@ func NewOauthTicket(
|
|||||||
AccessToken: lo.ToPtr(uuid.NewString()),
|
AccessToken: lo.ToPtr(uuid.NewString()),
|
||||||
RefreshToken: lo.ToPtr(uuid.NewString()),
|
RefreshToken: lo.ToPtr(uuid.NewString()),
|
||||||
AvailableAt: lo.ToPtr(time.Now()),
|
AvailableAt: lo.ToPtr(time.Now()),
|
||||||
ExpiredAt: lo.ToPtr(time.Now()),
|
ExpiredAt: lo.ToPtr(time.Now().Add(7 * 24 * time.Hour)),
|
||||||
ClientID: &client.ID,
|
ClientID: &client.ID,
|
||||||
AccountID: user.ID,
|
AccountID: user.ID,
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,7 @@ func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expired
|
|||||||
token := models.MagicToken{
|
token := models.MagicToken{
|
||||||
Code: strings.Replace(uuid.NewString(), "-", "", -1),
|
Code: strings.Replace(uuid.NewString(), "-", "", -1),
|
||||||
Type: mode,
|
Type: mode,
|
||||||
AssignTo: &uid,
|
AccountID: &uid,
|
||||||
ExpiredAt: expiredAt,
|
ExpiredAt: expiredAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,13 +59,13 @@ func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expired
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NotifyMagicToken(token models.MagicToken) error {
|
func NotifyMagicToken(token models.MagicToken) error {
|
||||||
if token.AssignTo == nil {
|
if token.AccountID == nil {
|
||||||
return fmt.Errorf("could notify a non-assign magic token")
|
return fmt.Errorf("could notify a non-assign magic token")
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.Account
|
var user models.Account
|
||||||
if err := database.C.Where(&models.Account{
|
if err := database.C.Where(&models.Account{
|
||||||
BaseModel: models.BaseModel{ID: *token.AssignTo},
|
BaseModel: models.BaseModel{ID: *token.AccountID},
|
||||||
}).Preload("Contacts").First(&user).Error; err != nil {
|
}).Preload("Contacts").First(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -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,18 +58,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
server.NewServer()
|
go server.NewServer().Listen()
|
||||||
go server.Listen()
|
|
||||||
|
|
||||||
// Grpc Server
|
// Grpc Server
|
||||||
grpc.NewGRPC()
|
go grpc.NewServer().Listen()
|
||||||
go grpc.ListenGRPC()
|
|
||||||
|
|
||||||
// Configure timed tasks
|
// Configure timed tasks
|
||||||
quartz := cron.New(cron.WithLogger(cron.VerbosePrintfLogger(&log.Logger)))
|
quartz := cron.New(cron.WithLogger(cron.VerbosePrintfLogger(&log.Logger)))
|
||||||
quartz.AddFunc("@every 60m", services.DoAutoSignoff)
|
quartz.AddFunc("@every 60m", services.DoAutoSignoff)
|
||||||
quartz.AddFunc("@every 60m", services.DoAutoDatabaseCleanup)
|
quartz.AddFunc("@every 60m", services.DoAutoDatabaseCleanup)
|
||||||
quartz.AddFunc("@every 60s", services.RecycleAuthContext)
|
quartz.AddFunc("@every 60s", services.RecycleAuthContext)
|
||||||
|
quartz.AddFunc("@every 60m", services.RecycleUnConfirmAccount)
|
||||||
quartz.AddFunc("@every 5m", services.KexCleanup)
|
quartz.AddFunc("@every 5m", services.KexCleanup)
|
||||||
quartz.Start()
|
quartz.Start()
|
||||||
|
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
id = "passport01"
|
id = "passport01"
|
||||||
|
name = "Solarpass"
|
||||||
|
|
||||||
|
frontend_app = "web/dist"
|
||||||
|
|
||||||
bind = "0.0.0.0:8444"
|
bind = "0.0.0.0:8444"
|
||||||
grpc_bind = "0.0.0.0:7444"
|
grpc_bind = "0.0.0.0:7444"
|
||||||
|
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 LLC</p>
|
||||||
|
<p>Powered by <a class="underline" href="https://git.solsynth.dev/Hydrogen/Passport">Hydrogen.Passport</a></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
19
web/src/components/GoUseSolian.vue
Normal file
19
web/src/components/GoUseSolian.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<v-card prepend-icon="mdi-cellphone-arrow-down-variant" title="Try the App">
|
||||||
|
<v-card-text>
|
||||||
|
<p>
|
||||||
|
Some features on Solarpass web was incomplete.
|
||||||
|
Go try out our brand-new all-in-one Solar Network application now!
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-btn prepend-icon="mdi-launch" href="https://lian.solsynth.dev" target="_blank">
|
||||||
|
Open in browser
|
||||||
|
</v-btn>
|
||||||
|
<v-btn prepend-icon="mdi-download" href="https://git.solsynth.dev/Hydrogen/Solian/releases" target="_blank"
|
||||||
|
color="teal">
|
||||||
|
Download now
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
68
web/src/components/NotificationList.vue
Executable file
68
web/src/components/NotificationList.vue
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<v-navigation-drawer :model-value="props.open" @update:model-value="val => emits('update:open', val)" location="right"
|
||||||
|
temporary order="0" width="400">
|
||||||
|
<v-list-item prepend-icon="mdi-bell" title="Notifications" class="py-3"></v-list-item>
|
||||||
|
|
||||||
|
<v-divider color="black" class="mb-1" />
|
||||||
|
|
||||||
|
<v-list v-if="notify.notifications.length <= 0" density="compact">
|
||||||
|
<v-list-item color="secondary" prepend-icon="mdi-check" title="All notifications read"
|
||||||
|
subtitle="There is no more new things for you..." />
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-list v-else class="w-[380px]" density="compact" lines="three">
|
||||||
|
<v-list-item v-for="(item, idx) in notify.notifications" :key="idx">
|
||||||
|
<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, idx) in item.links" :key="idx" class="mt-1 underline" target="_blank"
|
||||||
|
:href="link.url">{{ link.label }}</a>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<!-- @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 props = defineProps<{ open: boolean }>()
|
||||||
|
const emits = defineEmits(["update:open"])
|
||||||
|
|
||||||
|
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>
|
53
web/src/components/UserMenu.vue
Executable file
53
web/src/components/UserMenu.vue
Executable file
@ -0,0 +1,53 @@
|
|||||||
|
<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="Dashboard" prepend-icon="mdi-account-supervisor" exact :to="{ name: 'dashboard' }" />
|
||||||
|
<v-list-item title="Sign out" prepend-icon="mdi-logout" @click="signout"></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defaultUserinfo, useUserinfo } from "@/stores/userinfo"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import Cookie from "universal-cookie"
|
||||||
|
|
||||||
|
const id = useUserinfo()
|
||||||
|
|
||||||
|
const username = computed(() => {
|
||||||
|
if (id.userinfo.isLoggedIn) {
|
||||||
|
return "@" + id.userinfo.data?.name
|
||||||
|
} else {
|
||||||
|
return "@visitor"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const nickname = computed(() => {
|
||||||
|
if (id.userinfo.isLoggedIn) {
|
||||||
|
return id.userinfo.data?.nick
|
||||||
|
} else {
|
||||||
|
return "Anonymous"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function signout() {
|
||||||
|
const ck = new Cookie();
|
||||||
|
ck.remove("__hydrogen_atk");
|
||||||
|
ck.remove("__hydrogen_rtk")
|
||||||
|
id.userinfo = defaultUserinfo
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
</script>
|
65
web/src/components/auth/Authenticate.vue
Executable file
65
web/src/components/auth/Authenticate.vue
Executable file
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<v-form class="flex-grow-1" @submit.prevent="submit">
|
||||||
|
<v-text-field label="Username" variant="solo" density="comfortable" class="mb-3" :hide-details="true"
|
||||||
|
:disabled="props.loading" v-model="probe" />
|
||||||
|
<v-text-field label="Password" variant="solo" density="comfortable" type="password" :disabled="props.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-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 password = ref("")
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{ loading?: boolean }>()
|
||||||
|
const emits = defineEmits(["swap", "update:loading", "update:ticket"])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!probe.value || !password.value) return
|
||||||
|
|
||||||
|
emits("update:loading", true)
|
||||||
|
const res = await request("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: probe.value, password: password.value }),
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
emits("update:ticket", data["ticket"])
|
||||||
|
if (data.is_finished) emits("swap", "completed")
|
||||||
|
else emits("swap", "mfa")
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
emits("update:loading", false)
|
||||||
|
}
|
||||||
|
</script>
|
68
web/src/components/auth/AuthenticateCompleted.vue
Normal file
68
web/src/components/auth/AuthenticateCompleted.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-icon icon="mdi-lan-check" size="32" color="grey-darken-3" class="mb-3" />
|
||||||
|
|
||||||
|
<h1 class="font-bold text-xl">All Done!</h1>
|
||||||
|
<p>Welcome back! You just signed in right now! We're going to send you to jesus...</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const userinfo = useUserinfo()
|
||||||
|
|
||||||
|
const props = defineProps<{ loading?: boolean; currentFactor?: any; ticket?: any }>()
|
||||||
|
const emits = defineEmits(["update:loading"])
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
emits("update:loading", true)
|
||||||
|
await getToken(props.ticket.grant_token)
|
||||||
|
await userinfo.readProfiles()
|
||||||
|
emits("update:loading", false)
|
||||||
|
setTimeout(() => callback(), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load())
|
||||||
|
|
||||||
|
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["close"]) {
|
||||||
|
window.close()
|
||||||
|
} else if (route.query["redirect_uri"]) {
|
||||||
|
window.open((route.query["redirect_uri"] as string) ?? "/", "_self")
|
||||||
|
} else {
|
||||||
|
router.push({ name: "dashboard" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
93
web/src/components/auth/FactorApplicator.vue
Executable file
93
web/src/components/auth/FactorApplicator.vue
Executable file
@ -0,0 +1,93 @@
|
|||||||
|
<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 { computed, ref } from "vue"
|
||||||
|
|
||||||
|
const password = ref("")
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{ loading?: boolean; currentFactor?: any; ticket?: any }>()
|
||||||
|
const emits = defineEmits(["swap", "update:ticket", "update:loading"])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
emits("update:loading", true)
|
||||||
|
const res = await request(`/api/auth/mfa`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_id: props.ticket?.id,
|
||||||
|
factor_id: props.currentFactor?.id,
|
||||||
|
code: password.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
error.value = null
|
||||||
|
password.value = ""
|
||||||
|
emits("update:ticket", data["ticket"])
|
||||||
|
if (data["is_finished"]) emits("swap", "completed")
|
||||||
|
else emits("swap", "mfa")
|
||||||
|
}
|
||||||
|
emits("update:loading", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputType = computed(() => {
|
||||||
|
switch (props.currentFactor?.type) {
|
||||||
|
case 0:
|
||||||
|
return "text"
|
||||||
|
case 1:
|
||||||
|
return "one-time-password"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
88
web/src/components/auth/FactorPicker.vue
Executable file
88
web/src/components/auth/FactorPicker.vue
Executable file
@ -0,0 +1,88 @@
|
|||||||
|
<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, idx) in factors ?? []"
|
||||||
|
:key="idx"
|
||||||
|
: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 { onMounted, ref } from "vue"
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
|
||||||
|
const focus = ref<number | null>(null)
|
||||||
|
const factors = ref<any[]>([])
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{ ticket?: any }>()
|
||||||
|
const emits = defineEmits(["swap", "update:loading", "update:currentFactor"])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
emits("update:loading", true)
|
||||||
|
const res = await request(`/api/auth/factors?ticketId=${props.ticket.id}`)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
factors.value = (await res.json()).filter((e: any) => e.type != 0)
|
||||||
|
}
|
||||||
|
emits("update:loading", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load())
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!focus.value) 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 = factors.value.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 1:
|
||||||
|
return { icon: "mdi-email-fast", label: "Email Validation" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFactorAvailable(factor: any) {
|
||||||
|
const blacklist: number[] = props.ticket?.blacklist_factors ?? []
|
||||||
|
return blacklist.includes(factor.id)
|
||||||
|
}
|
||||||
|
</script>
|
49
web/src/components/navigation/AppBar.vue
Normal file
49
web/src/components/navigation/AppBar.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<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 ms-0.5">
|
||||||
|
<img src="/favicon.png" alt="logo" 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">
|
||||||
|
<v-btn icon size="small" variant="text" @click="openNotify = !openNotify">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<user-menu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #extension>
|
||||||
|
<slot name="extension" />
|
||||||
|
</template>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<NotificationList v-model:open="openNotify" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import NotificationList from "@/components/NotificationList.vue"
|
||||||
|
import UserMenu from "@/components/UserMenu.vue"
|
||||||
|
import { useNotifications } from "@/stores/notifications"
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
const notify = useNotifications()
|
||||||
|
|
||||||
|
const openNotify = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-filter {
|
||||||
|
filter: invert(100%) sepia(100%) saturate(14%) hue-rotate(212deg) brightness(104%) contrast(104%);
|
||||||
|
}
|
||||||
|
</style>
|
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>
|
22
web/src/layouts/master.vue
Executable file
22
web/src/layouts/master.vue
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<AppBar />
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<router-view />
|
||||||
|
</v-main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import AppBar from "@/components/navigation/AppBar.vue"
|
||||||
|
|
||||||
|
const id = useUserinfo()
|
||||||
|
|
||||||
|
id.readProfiles()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-filter {
|
||||||
|
filter: invert(100%) sepia(100%) saturate(14%) hue-rotate(212deg) brightness(104%) contrast(104%);
|
||||||
|
}
|
||||||
|
</style>
|
30
web/src/layouts/user-center.vue
Executable file
30
web/src/layouts/user-center.vue
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<AppBar>
|
||||||
|
<template #extension>
|
||||||
|
<v-tabs align-tabs="title" color="white">
|
||||||
|
<v-tab text="Dashboard" prepend-icon="mdi-view-dashboard" :to="{ name: 'dashboard' }" exact />
|
||||||
|
<v-tab text="Personalize" prepend-icon="mdi-card-bulleted-outline" :to="{ name: 'personalize' }" exact />
|
||||||
|
<v-tab text="Security" prepend-icon="mdi-security" :to="{ name: 'security' }" exact />
|
||||||
|
</v-tabs>
|
||||||
|
</template>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container class="pt-6 px-6 p-container">
|
||||||
|
<router-view />
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<Copyright />
|
||||||
|
</v-main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppBar from "@/components/navigation/AppBar.vue"
|
||||||
|
import Copyright from "@/components/Copyright.vue"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.p-container {
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
</style>
|
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")
|
87
web/src/router/index.ts
Executable file
87
web/src/router/index.ts
Executable file
@ -0,0 +1,87 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
|
import { useUserinfo } from "@/stores/userinfo"
|
||||||
|
import UserCenterLayout from "@/layouts/user-center.vue"
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
redirect: { name: "dashboard" },
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/users",
|
||||||
|
component: UserCenterLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/me",
|
||||||
|
name: "dashboard",
|
||||||
|
component: () => import("@/views/dashboard.vue"),
|
||||||
|
meta: { title: "Your account" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/me/personalize",
|
||||||
|
name: "personalize",
|
||||||
|
component: () => import("@/views/personalize.vue"),
|
||||||
|
meta: { title: "Your personality" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/me/security",
|
||||||
|
name: "security",
|
||||||
|
component: () => import("@/views/security.vue"),
|
||||||
|
meta: { title: "Your security" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
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: "authorize",
|
||||||
|
name: "oauth.authorize",
|
||||||
|
component: () => import("@/views/auth/authorize.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/users/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)
|
||||||
|
}
|
66
web/src/stores/notifications.ts
Executable file
66
web/src/stores/notifications.ts
Executable file
@ -0,0 +1,66 @@
|
|||||||
|
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/ws`
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (data["w"] == "notifications.new") {
|
||||||
|
notifications.value.push(data["p"])
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 }
|
||||||
|
})
|
196
web/src/views/auth/authorize.vue
Executable file
196
web/src/views/auth/authorize.vue
Executable file
@ -0,0 +1,196 @@
|
|||||||
|
<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, key) in requestedClaims" :key="key" 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 tryAuthorize() {
|
||||||
|
const res = await request(`/api/auth/o/authorize${location.search}`, {
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data["ticket"]) {
|
||||||
|
panel.value = "callback"
|
||||||
|
callback(data["ticket"])
|
||||||
|
} else {
|
||||||
|
document.title = `Solarpass | Connect to ${data["client"]?.name}`
|
||||||
|
metadata.value = data["client"]
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryAuthorize()
|
||||||
|
|
||||||
|
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/authorize?" +
|
||||||
|
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["ticket"]), 1850)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callback(ticket: any) {
|
||||||
|
const url = `${route.query["redirect_uri"]}?code=${ticket["grant_token"]}&state=${route.query["state"]}`
|
||||||
|
window.open(url, "_self")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClaimDescription(key: string): ClaimType {
|
||||||
|
return Object.prototype.hasOwnProperty.call(claims, 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>
|
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.",
|
||||||
|
},
|
||||||
|
}
|
67
web/src/views/auth/sign-in.vue
Executable file
67
web/src/views/auth/sign-in.vue
Executable file
@ -0,0 +1,67 @@
|
|||||||
|
<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>
|
||||||
|
<p v-if="ticket">We need to verify that the person trying to access your account is you.</p>
|
||||||
|
<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, idx) in Object.keys(panels)" :key="idx" :value="k">
|
||||||
|
<component :is="panels[k]" @swap="(val: string) => (panel = val)" v-model:loading="loading"
|
||||||
|
v-model:currentFactor="currentFactor" v-model:ticket="ticket" />
|
||||||
|
</v-window-item>
|
||||||
|
</v-window>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<copyright />
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type Component, ref } from "vue"
|
||||||
|
import Copyright from "@/components/Copyright.vue"
|
||||||
|
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
|
||||||
|
import FactorPicker from "@/components/auth/FactorPicker.vue"
|
||||||
|
import FactorApplicator from "@/components/auth/FactorApplicator.vue"
|
||||||
|
import AccountAuthenticate from "@/components/auth/Authenticate.vue"
|
||||||
|
import AuthenticateCompleted from "@/components/auth/AuthenticateCompleted.vue"
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const currentFactor = ref<any>(null)
|
||||||
|
const ticket = ref<any>(null)
|
||||||
|
|
||||||
|
const panel = ref("authenticate")
|
||||||
|
|
||||||
|
const panels: { [id: string]: Component } = {
|
||||||
|
authenticate: AccountAuthenticate,
|
||||||
|
mfa: FactorPicker,
|
||||||
|
applicator: FactorApplicator,
|
||||||
|
completed: AuthenticateCompleted,
|
||||||
|
}
|
||||||
|
</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 successfully 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>
|
157
web/src/views/personalize.vue
Executable file
157
web/src/views/personalize.vue
Executable file
@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<GoUseSolian class="mb-3" />
|
||||||
|
|
||||||
|
<v-card 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-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"
|
||||||
|
import GoUseSolian from "@/components/GoUseSolian.vue"
|
||||||
|
|
||||||
|
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>
|
197
web/src/views/security.vue
Executable file
197
web/src/views/security.vue
Executable file
@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-expansion-panels>
|
||||||
|
<v-expansion-panel eager title="Tickets">
|
||||||
|
<template #text>
|
||||||
|
<v-card :loading="reverting.tickets" variant="outlined">
|
||||||
|
<v-data-table-server
|
||||||
|
density="compact"
|
||||||
|
:headers="dataDefinitions.tickets"
|
||||||
|
:items="tickets"
|
||||||
|
:items-length="pagination.tickets.total"
|
||||||
|
:loading="reverting.tickets"
|
||||||
|
v-model:items-per-page="pagination.tickets.pageSize"
|
||||||
|
@update:options="readTickets"
|
||||||
|
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>
|
||||||
|
<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="killTicket(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 } from "@/stores/userinfo"
|
||||||
|
import { reactive, ref } from "vue"
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const dataDefinitions: { [id: string]: any[] } = {
|
||||||
|
tickets: [
|
||||||
|
{ 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" },
|
||||||
|
{ 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 tickets = ref<any>([])
|
||||||
|
const events = ref<any>([])
|
||||||
|
|
||||||
|
const reverting = reactive({ tickets: false, sessions: false, events: false })
|
||||||
|
const pagination = reactive({
|
||||||
|
tickets: { page: 1, pageSize: 5, total: 0 },
|
||||||
|
events: { page: 1, pageSize: 5, total: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function readTickets({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||||
|
if (itemsPerPage) pagination.tickets.pageSize = itemsPerPage
|
||||||
|
if (page) pagination.tickets.page = page
|
||||||
|
|
||||||
|
reverting.sessions = true
|
||||||
|
const res = await request(
|
||||||
|
"/api/users/me/tickets?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
take: pagination.tickets.pageSize.toString(),
|
||||||
|
offset: ((pagination.tickets.page - 1) * pagination.tickets.pageSize).toString(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
tickets.value = data["data"]
|
||||||
|
pagination.tickets.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([readTickets({}), readEvents({})])
|
||||||
|
|
||||||
|
async function killTicket(item: any) {
|
||||||
|
reverting.sessions = true
|
||||||
|
const res = await request(`/api/users/me/tickets/${item.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
await readTickets({})
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user