️ 重现旧 UI #4

Merged
LittleSheep merged 8 commits from refactor/v2 into master 2024-06-26 08:06:28 +00:00
81 changed files with 2359 additions and 1460 deletions

View 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>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@ -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>

View File

@ -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[{
&quot;keyToString&quot;: { "keyToString": {
&quot;DefaultGoTemplateProperty&quot;: &quot;Go File&quot;, "DefaultGoTemplateProperty": "Go File",
&quot;Go Build.Backend.executor&quot;: &quot;Run&quot;, "Go Build.Backend.executor": "Run",
&quot;Go 构建.Backend.executor&quot;: &quot;Run&quot;, "Go 构建.Backend.executor": "Run",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.go.formatter.settings.were.checked&quot;: &quot;true&quot;, "RunOnceActivity.go.formatter.settings.were.checked": "true",
&quot;RunOnceActivity.go.migrated.go.modules.settings&quot;: &quot;true&quot;, "RunOnceActivity.go.migrated.go.modules.settings": "true",
&quot;RunOnceActivity.go.modules.automatic.dependencies.download&quot;: &quot;true&quot;, "RunOnceActivity.go.modules.automatic.dependencies.download": "true",
&quot;RunOnceActivity.go.modules.go.list.on.any.changes.was.set&quot;: &quot;true&quot;, "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true",
&quot;git-widget-placeholder&quot;: &quot;master&quot;, "git-widget-placeholder": "refactor/v2",
&quot;go.import.settings.migrated&quot;: &quot;true&quot;, "go.import.settings.migrated": "true",
&quot;go.sdk.automatically.set&quot;: &quot;true&quot;, "go.sdk.automatically.set": "true",
&quot;last_opened_file_path&quot;: &quot;/Users/littlesheep&quot;, "last_opened_file_path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/pkg/internal/server/api",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;run.code.analysis.last.selected.profile&quot;: &quot;pProject Default&quot;, "run.code.analysis.last.selected.profile": "pProject Default",
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;, "settings.editor.selected.configurable": "preferences.pluginManager",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "ts.external.directory.path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
}, },
&quot;keyToStringList&quot;: { "keyToStringList": {
&quot;DatabaseDriversLRU&quot;: [ "DatabaseDriversLRU": [
&quot;postgresql&quot; "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
View File

@ -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
View File

@ -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=

View File

@ -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{},

View File

@ -1,6 +0,0 @@
package pkg
import "embed"
//go:embed all:views/*
var FS embed.FS

View File

@ -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)
} }

View File

@ -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"`

View File

@ -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"`
}

View File

@ -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"`
} }

View File

@ -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
} }

View File

@ -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,
}) })
} }

View File

@ -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)

View File

@ -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
})
} }
} }

View 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)
} }
} }

View File

@ -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)
}

View File

@ -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...")
} }
} }

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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")))
}

View File

@ -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")))
}

View File

@ -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")))
}
}

View File

@ -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...")
}
}
}
}

View File

@ -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,
} }

View File

@ -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
} }

View File

@ -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

View File

@ -1,10 +0,0 @@
<!doctype html>
<html lang="en">
{{template "views/partials/header"}}
<body>
{{embed}}
</body>
</html>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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()

View File

@ -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
View 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
View 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
View 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
View 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

Binary file not shown.

1
web/env.d.ts vendored Executable file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
web/index.html Executable file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

14
web/src/assets/utils.css Executable file
View 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;
}

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@ -0,0 +1,5 @@
<template>
<v-app>
<router-view />
</v-app>
</template>

22
web/src/layouts/master.vue Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

13
web/tsconfig.node.json Executable file
View 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
View 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
View 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"
}
}
});