[#276] added support for linking external auths by provider id
This commit is contained in:
parent
9fe94f5c7d
commit
f5ff7193a9
67
apis/user.go
67
apis/user.go
|
@ -38,6 +38,8 @@ func BindUserApi(app core.App, rg *echo.Group) {
|
||||||
subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
|
subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
|
||||||
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
|
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
|
||||||
subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
|
subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
|
||||||
|
subGroup.GET("/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
|
||||||
|
subGroup.DELETE("/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
|
||||||
}
|
}
|
||||||
|
|
||||||
type userApi struct {
|
type userApi struct {
|
||||||
|
@ -450,3 +452,68 @@ func (api *userApi) delete(c echo.Context) error {
|
||||||
|
|
||||||
return handlerErr
|
return handlerErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *userApi) listExternalAuths(c echo.Context) error {
|
||||||
|
id := c.PathParam("id")
|
||||||
|
if id == "" {
|
||||||
|
return rest.NewNotFoundError("", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := api.app.Dao().FindUserById(id)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return rest.NewNotFoundError("", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
externalAuths, err := api.app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return rest.NewBadRequestError("Failed to fetch the external auths for the specified user.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
event := &core.UserListExternalAuthsEvent{
|
||||||
|
HttpContext: c,
|
||||||
|
User: user,
|
||||||
|
ExternalAuths: externalAuths,
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.app.OnUserListExternalAuths().Trigger(event, func(e *core.UserListExternalAuthsEvent) error {
|
||||||
|
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *userApi) unlinkExternalAuth(c echo.Context) error {
|
||||||
|
id := c.PathParam("id")
|
||||||
|
provider := c.PathParam("provider")
|
||||||
|
if id == "" || provider == "" {
|
||||||
|
return rest.NewNotFoundError("", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := api.app.Dao().FindUserById(id)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return rest.NewNotFoundError("", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
externalAuth, err := api.app.Dao().FindExternalAuthByUserIdAndProvider(user.Id, provider)
|
||||||
|
if err != nil {
|
||||||
|
return rest.NewNotFoundError("Missing external auth provider relation.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
event := &core.UserUnlinkExternalAuthEvent{
|
||||||
|
HttpContext: c,
|
||||||
|
User: user,
|
||||||
|
ExternalAuth: externalAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerErr := api.app.OnUserBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.UserUnlinkExternalAuthEvent) error {
|
||||||
|
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||||
|
return rest.NewBadRequestError("Cannot unlink the external auth reference. Make sure that the user has other linked auth providers OR has an email address.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
if handlerErr == nil {
|
||||||
|
api.app.OnUserAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
|
@ -584,11 +584,12 @@ func TestUsersList(t *testing.T) {
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
`"page":1`,
|
`"page":1`,
|
||||||
`"perPage":30`,
|
`"perPage":30`,
|
||||||
`"totalItems":3`,
|
`"totalItems":4`,
|
||||||
`"items":[{`,
|
`"items":[{`,
|
||||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||||
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
||||||
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
||||||
|
`"id":"cx9u0dh2udo8xol"`,
|
||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
||||||
},
|
},
|
||||||
|
@ -603,8 +604,9 @@ func TestUsersList(t *testing.T) {
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
`"page":2`,
|
`"page":2`,
|
||||||
`"perPage":2`,
|
`"perPage":2`,
|
||||||
`"totalItems":3`,
|
`"totalItems":4`,
|
||||||
`"items":[{`,
|
`"items":[{`,
|
||||||
|
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
|
||||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
||||||
|
@ -630,10 +632,11 @@ func TestUsersList(t *testing.T) {
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
`"page":1`,
|
`"page":1`,
|
||||||
`"perPage":30`,
|
`"perPage":30`,
|
||||||
`"totalItems":2`,
|
`"totalItems":3`,
|
||||||
`"items":[{`,
|
`"items":[{`,
|
||||||
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
|
||||||
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
||||||
|
`"id":"cx9u0dh2udo8xol"`,
|
||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
|
||||||
},
|
},
|
||||||
|
@ -926,3 +929,185 @@ func TestUserUpdate(t *testing.T) {
|
||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserListExternalsAuths(t *testing.T) {
|
||||||
|
scenarios := []tests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "unauthorized",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as admin + nonexisting user id",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/000000000000000/external-auths",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as admin + existing user id and no external auths",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/97cc3d3d-6ba2-383f-b42a-7bc84d27410c/external-auths",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{
|
||||||
|
`[]`,
|
||||||
|
},
|
||||||
|
ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as admin + existing user id and 2 external auths",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{
|
||||||
|
`"id":"abcdefghijklmn1"`,
|
||||||
|
`"id":"abcdefghijklmn0"`,
|
||||||
|
`"userId":"cx9u0dh2udo8xol"`,
|
||||||
|
},
|
||||||
|
ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as user - trying to list another user external auths",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as user - owner without external auths",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c/external-auths",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{
|
||||||
|
`[]`,
|
||||||
|
},
|
||||||
|
ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as user - owner with 2 external auths",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImN4OXUwZGgydWRvOHhvbCIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.NgFYG2D7PftFW1tcfe5E2oDi_AVakDR9J6WI6VUZQfw",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{
|
||||||
|
`"id":"abcdefghijklmn1"`,
|
||||||
|
`"id":"abcdefghijklmn0"`,
|
||||||
|
`"userId":"cx9u0dh2udo8xol"`,
|
||||||
|
},
|
||||||
|
ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserUnlinkExternalsAuth(t *testing.T) {
|
||||||
|
scenarios := []tests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "unauthorized",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as admin - nonexisting user id",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Url: "/api/users/000000000000000/external-auths/google",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as admin - nonexisting provider",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths/facebook",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as admin - existing provider",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 204,
|
||||||
|
ExpectedContent: []string{},
|
||||||
|
ExpectedEvents: map[string]int{
|
||||||
|
"OnModelAfterDelete": 1,
|
||||||
|
"OnModelBeforeDelete": 1,
|
||||||
|
"OnUserAfterUnlinkExternalAuthRequest": 1,
|
||||||
|
"OnUserBeforeUnlinkExternalAuthRequest": 1,
|
||||||
|
},
|
||||||
|
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||||
|
auth, _ := app.Dao().FindExternalAuthByUserIdAndProvider("cx9u0dh2udo8xol", "google")
|
||||||
|
if auth != nil {
|
||||||
|
t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as user - trying to unlink another user external auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "authorized as user - owner with existing external auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImN4OXUwZGgydWRvOHhvbCIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.NgFYG2D7PftFW1tcfe5E2oDi_AVakDR9J6WI6VUZQfw",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 204,
|
||||||
|
ExpectedContent: []string{},
|
||||||
|
ExpectedEvents: map[string]int{
|
||||||
|
"OnModelAfterDelete": 1,
|
||||||
|
"OnModelBeforeDelete": 1,
|
||||||
|
"OnUserAfterUnlinkExternalAuthRequest": 1,
|
||||||
|
"OnUserBeforeUnlinkExternalAuthRequest": 1,
|
||||||
|
},
|
||||||
|
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||||
|
auth, _ := app.Dao().FindExternalAuthByUserIdAndProvider("cx9u0dh2udo8xol", "google")
|
||||||
|
if auth != nil {
|
||||||
|
t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
21
core/app.go
21
core/app.go
|
@ -317,16 +317,21 @@ type App interface {
|
||||||
// authenticated user data and token.
|
// authenticated user data and token.
|
||||||
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
|
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
|
||||||
|
|
||||||
// OnUserBeforeOauth2Register hook is triggered before each User OAuth2
|
// OnUserListExternalAuths hook is triggered on each API user's external auhts list request.
|
||||||
// authentication request (when the client config has enabled new users registration).
|
|
||||||
//
|
//
|
||||||
// Could be used to additionally validate or modify the new user
|
// Could be used to validate or modify the response before returning it to the client.
|
||||||
// before persisting in the DB.
|
OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]
|
||||||
OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent]
|
|
||||||
|
|
||||||
// OnUserAfterOauth2Register hook is triggered after each successful User
|
// OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's
|
||||||
// OAuth2 authentication sign-up request (right after the new user persistence).
|
// external auth unlink request (after models load and before the actual relation deletion).
|
||||||
OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent]
|
//
|
||||||
|
// Could be used to additionally validate the request data or implement
|
||||||
|
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||||
|
OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||||
|
|
||||||
|
// OnUserAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||||
|
// successful API user's external auth unlink request.
|
||||||
|
OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Record API event hooks
|
// Record API event hooks
|
||||||
|
|
60
core/base.go
60
core/base.go
|
@ -85,18 +85,19 @@ type BaseApp struct {
|
||||||
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||||
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
|
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
|
||||||
|
|
||||||
// user api event hooks
|
// user api event hooks
|
||||||
onUsersListRequest *hook.Hook[*UsersListEvent]
|
onUsersListRequest *hook.Hook[*UsersListEvent]
|
||||||
onUserViewRequest *hook.Hook[*UserViewEvent]
|
onUserViewRequest *hook.Hook[*UserViewEvent]
|
||||||
onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent]
|
onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent]
|
||||||
onUserAfterCreateRequest *hook.Hook[*UserCreateEvent]
|
onUserAfterCreateRequest *hook.Hook[*UserCreateEvent]
|
||||||
onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent]
|
onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent]
|
||||||
onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent]
|
onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent]
|
||||||
onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent]
|
onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent]
|
||||||
onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent]
|
onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent]
|
||||||
onUserAuthRequest *hook.Hook[*UserAuthEvent]
|
onUserAuthRequest *hook.Hook[*UserAuthEvent]
|
||||||
onUserBeforeOauth2Register *hook.Hook[*UserOauth2RegisterEvent]
|
onUserListExternalAuths *hook.Hook[*UserListExternalAuthsEvent]
|
||||||
onUserAfterOauth2Register *hook.Hook[*UserOauth2RegisterEvent]
|
onUserBeforeUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||||
|
onUserAfterUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||||
|
|
||||||
// record api event hooks
|
// record api event hooks
|
||||||
onRecordsListRequest *hook.Hook[*RecordsListEvent]
|
onRecordsListRequest *hook.Hook[*RecordsListEvent]
|
||||||
|
@ -180,17 +181,18 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||||
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
|
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
|
||||||
|
|
||||||
// user API event hooks
|
// user API event hooks
|
||||||
onUsersListRequest: &hook.Hook[*UsersListEvent]{},
|
onUsersListRequest: &hook.Hook[*UsersListEvent]{},
|
||||||
onUserViewRequest: &hook.Hook[*UserViewEvent]{},
|
onUserViewRequest: &hook.Hook[*UserViewEvent]{},
|
||||||
onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{},
|
onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{},
|
||||||
onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{},
|
onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{},
|
||||||
onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
|
onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
|
||||||
onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
|
onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
|
||||||
onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
|
onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
|
||||||
onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
|
onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
|
||||||
onUserAuthRequest: &hook.Hook[*UserAuthEvent]{},
|
onUserAuthRequest: &hook.Hook[*UserAuthEvent]{},
|
||||||
onUserBeforeOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{},
|
onUserListExternalAuths: &hook.Hook[*UserListExternalAuthsEvent]{},
|
||||||
onUserAfterOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{},
|
onUserBeforeUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
|
||||||
|
onUserAfterUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
|
||||||
|
|
||||||
// record API event hooks
|
// record API event hooks
|
||||||
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
|
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
|
||||||
|
@ -611,12 +613,16 @@ func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] {
|
||||||
return app.onUserAuthRequest
|
return app.onUserAuthRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BaseApp) OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] {
|
func (app *BaseApp) OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] {
|
||||||
return app.onUserBeforeOauth2Register
|
return app.onUserListExternalAuths
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BaseApp) OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] {
|
func (app *BaseApp) OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
|
||||||
return app.onUserAfterOauth2Register
|
return app.onUserBeforeUnlinkExternalAuthRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *BaseApp) OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
|
||||||
|
return app.onUserAfterUnlinkExternalAuthRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
@ -319,12 +319,16 @@ func TestBaseAppGetters(t *testing.T) {
|
||||||
t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
|
t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.onUserBeforeOauth2Register != app.OnUserBeforeOauth2Register() || app.OnUserBeforeOauth2Register() == nil {
|
if app.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil {
|
||||||
t.Fatalf("Getter app.OnUserBeforeOauth2Register does not match or nil (%v vs %v)", app.OnUserBeforeOauth2Register(), app.onUserBeforeOauth2Register)
|
t.Fatalf("Getter app.OnUserListExternalAuths does not match or nil (%v vs %v)", app.OnUserListExternalAuths(), app.onUserListExternalAuths)
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.onUserAfterOauth2Register != app.OnUserAfterOauth2Register() || app.OnUserAfterOauth2Register() == nil {
|
if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil {
|
||||||
t.Fatalf("Getter app.OnUserAfterOauth2Register does not match or nil (%v vs %v)", app.OnUserAfterOauth2Register(), app.onUserAfterOauth2Register)
|
t.Fatalf("Getter app.OnUserBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserBeforeUnlinkExternalAuthRequest(), app.onUserBeforeUnlinkExternalAuthRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.onUserAfterUnlinkExternalAuthRequest != app.OnUserAfterUnlinkExternalAuthRequest() || app.OnUserAfterUnlinkExternalAuthRequest() == nil {
|
||||||
|
t.Fatalf("Getter app.OnUserAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserAfterUnlinkExternalAuthRequest(), app.onUserAfterUnlinkExternalAuthRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
||||||
|
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/models/schema"
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
"github.com/pocketbase/pocketbase/tools/auth"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||||
|
@ -180,10 +179,16 @@ type UserAuthEvent struct {
|
||||||
Meta any
|
Meta any
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOauth2RegisterEvent struct {
|
type UserListExternalAuthsEvent struct {
|
||||||
HttpContext echo.Context
|
HttpContext echo.Context
|
||||||
User *models.User
|
User *models.User
|
||||||
AuthData *auth.AuthUser
|
ExternalAuths []*models.ExternalAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserUnlinkExternalAuthEvent struct {
|
||||||
|
HttpContext echo.Context
|
||||||
|
User *models.User
|
||||||
|
ExternalAuth *models.ExternalAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
package daos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExternalAuthQuery returns a new ExternalAuth select query.
|
||||||
|
func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
|
||||||
|
return dao.ModelQuery(&models.ExternalAuth{})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FindAllExternalAuthsByUserId returns all ExternalAuth models
|
||||||
|
/// linked to the provided userId.
|
||||||
|
func (dao *Dao) FindAllExternalAuthsByUserId(userId string) ([]*models.ExternalAuth, error) {
|
||||||
|
auths := []*models.ExternalAuth{}
|
||||||
|
|
||||||
|
err := dao.ExternalAuthQuery().
|
||||||
|
AndWhere(dbx.HashExp{"userId": userId}).
|
||||||
|
OrderBy("created ASC").
|
||||||
|
All(&auths)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return auths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindExternalAuthByProvider returns the first available
|
||||||
|
// ExternalAuth model for the specified provider and providerId.
|
||||||
|
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
|
||||||
|
model := &models.ExternalAuth{}
|
||||||
|
|
||||||
|
err := dao.ExternalAuthQuery().
|
||||||
|
AndWhere(dbx.HashExp{
|
||||||
|
"provider": provider,
|
||||||
|
"providerId": providerId,
|
||||||
|
}).
|
||||||
|
Limit(1).
|
||||||
|
One(model)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindExternalAuthByUserIdAndProvider returns the first available
|
||||||
|
// ExternalAuth model for the specified userId and provider.
|
||||||
|
func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*models.ExternalAuth, error) {
|
||||||
|
model := &models.ExternalAuth{}
|
||||||
|
|
||||||
|
err := dao.ExternalAuthQuery().
|
||||||
|
AndWhere(dbx.HashExp{
|
||||||
|
"userId": userId,
|
||||||
|
"provider": provider,
|
||||||
|
}).
|
||||||
|
Limit(1).
|
||||||
|
One(model)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveExternalAuth upserts the provided ExternalAuth model.
|
||||||
|
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||||
|
return dao.Save(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExternalAuth deletes the provided ExternalAuth model.
|
||||||
|
//
|
||||||
|
// The delete may fail if the linked user doesn't have an email and
|
||||||
|
// there are no other linked ExternalAuth models available.
|
||||||
|
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
|
||||||
|
user, err := dao.FindUserById(model.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Email == "" {
|
||||||
|
allExternalAuths, err := dao.FindAllExternalAuthsByUserId(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allExternalAuths) <= 1 {
|
||||||
|
return errors.New("You cannot delete the only available external auth relation because the user doesn't have an email set.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dao.Delete(model)
|
||||||
|
}
|
|
@ -0,0 +1,189 @@
|
||||||
|
package daos_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExternalAuthQuery(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
expected := "SELECT {{_externalAuths}}.* FROM `_externalAuths`"
|
||||||
|
|
||||||
|
sql := app.Dao().ExternalAuthQuery().Build().SQL()
|
||||||
|
if sql != expected {
|
||||||
|
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
userId string
|
||||||
|
expectedCount int
|
||||||
|
}{
|
||||||
|
{"", 0},
|
||||||
|
{"missing", 0},
|
||||||
|
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", 0},
|
||||||
|
{"cx9u0dh2udo8xol", 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range scenarios {
|
||||||
|
auths, err := app.Dao().FindAllExternalAuthsByUserId(s.userId)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("(%d) Unexpected error %v", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(auths) != s.expectedCount {
|
||||||
|
t.Errorf("(%d) Expected %d auths, got %d", i, s.expectedCount, len(auths))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, auth := range auths {
|
||||||
|
if auth.UserId != s.userId {
|
||||||
|
t.Errorf("(%d) Expected all auths to be linked to userId %s, got %v", i, s.userId, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExternalAuthByProvider(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
provider string
|
||||||
|
providerId string
|
||||||
|
expectedId string
|
||||||
|
}{
|
||||||
|
{"", "", ""},
|
||||||
|
{"github", "", ""},
|
||||||
|
{"github", "id1", ""},
|
||||||
|
{"github", "id2", ""},
|
||||||
|
{"google", "id1", "abcdefghijklmn0"},
|
||||||
|
{"gitlab", "id2", "abcdefghijklmn1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range scenarios {
|
||||||
|
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
|
||||||
|
|
||||||
|
hasErr := err != nil
|
||||||
|
expectErr := s.expectedId == ""
|
||||||
|
if hasErr != expectErr {
|
||||||
|
t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth != nil && auth.Id != s.expectedId {
|
||||||
|
t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
userId string
|
||||||
|
provider string
|
||||||
|
expectedId string
|
||||||
|
}{
|
||||||
|
{"", "", ""},
|
||||||
|
{"", "github", ""},
|
||||||
|
{"123456", "github", ""}, // missing user and provider record
|
||||||
|
{"123456", "google", ""}, // missing user but existing provider record
|
||||||
|
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "google", ""},
|
||||||
|
{"cx9u0dh2udo8xol", "google", "abcdefghijklmn0"},
|
||||||
|
{"cx9u0dh2udo8xol", "gitlab", "abcdefghijklmn1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range scenarios {
|
||||||
|
auth, err := app.Dao().FindExternalAuthByUserIdAndProvider(s.userId, s.provider)
|
||||||
|
|
||||||
|
hasErr := err != nil
|
||||||
|
expectErr := s.expectedId == ""
|
||||||
|
if hasErr != expectErr {
|
||||||
|
t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth != nil && auth.Id != s.expectedId {
|
||||||
|
t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveExternalAuth(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
auth := &models.ExternalAuth{
|
||||||
|
UserId: "97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
|
||||||
|
Provider: "test",
|
||||||
|
ProviderId: "test_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Dao().SaveExternalAuth(auth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if it was really saved
|
||||||
|
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.Id != foundAuth.Id {
|
||||||
|
t.Fatalf("Expected ExternalAuth with id %s, got \n%v", auth.Id, foundAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteExternalAuth(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
user, err := app.Dao().FindUserById("cx9u0dh2udo8xol")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Dao().DeleteExternalAuth(auths[0]); err != nil {
|
||||||
|
t.Fatalf("Failed to delete the first ExternalAuth relation, got \n%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Dao().DeleteExternalAuth(auths[1]); err == nil {
|
||||||
|
t.Fatal("Expected delete to fail, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the user model and try again
|
||||||
|
user.Email = "test_new@example.com"
|
||||||
|
if err := app.Dao().SaveUser(user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to delete auths[1] again
|
||||||
|
if err := app.Dao().DeleteExternalAuth(auths[1]); err != nil {
|
||||||
|
t.Fatalf("Failed to delete the last ExternalAuth relation, got \n%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the relations were really deleted
|
||||||
|
newAuths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newAuths) != 0 {
|
||||||
|
t.Fatalf("Expected all user %s ExternalAuth relations to be deleted, got \n%v", user.Id, newAuths)
|
||||||
|
}
|
||||||
|
}
|
|
@ -94,7 +94,7 @@ func (dao *Dao) FindUserById(id string) (*models.User, error) {
|
||||||
return model, nil
|
return model, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindUserByEmail finds a single User model by its email address.
|
// FindUserByEmail finds a single User model by its non-empty email address.
|
||||||
//
|
//
|
||||||
// This method also auto loads the related user profile record
|
// This method also auto loads the related user profile record
|
||||||
// into the found model.
|
// into the found model.
|
||||||
|
@ -102,6 +102,7 @@ func (dao *Dao) FindUserByEmail(email string) (*models.User, error) {
|
||||||
model := &models.User{}
|
model := &models.User{}
|
||||||
|
|
||||||
err := dao.UserQuery().
|
err := dao.UserQuery().
|
||||||
|
AndWhere(dbx.Not(dbx.HashExp{"email": ""})).
|
||||||
AndWhere(dbx.HashExp{"email": email}).
|
AndWhere(dbx.HashExp{"email": email}).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
One(model)
|
One(model)
|
||||||
|
|
|
@ -110,6 +110,7 @@ func TestFindUserByEmail(t *testing.T) {
|
||||||
email string
|
email string
|
||||||
expectError bool
|
expectError bool
|
||||||
}{
|
}{
|
||||||
|
{"", true},
|
||||||
{"invalid", true},
|
{"invalid", true},
|
||||||
{"missing@example.com", true},
|
{"missing@example.com", true},
|
||||||
{"test@example.com", false},
|
{"test@example.com", false},
|
||||||
|
|
|
@ -22,15 +22,15 @@ type AdminLogin struct {
|
||||||
//
|
//
|
||||||
// NB! App is a required struct member.
|
// NB! App is a required struct member.
|
||||||
type AdminLoginConfig struct {
|
type AdminLoginConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminLogin creates a new [AdminLogin] form with initializer
|
// NewAdminLogin creates a new [AdminLogin] form with initializer
|
||||||
// config created from the provided [core.App] instance.
|
// config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewAdminLoginWithConfig] with explicitly set TxDao.
|
// [NewAdminLoginWithConfig] with explicitly set Dao.
|
||||||
func NewAdminLogin(app core.App) *AdminLogin {
|
func NewAdminLogin(app core.App) *AdminLogin {
|
||||||
return NewAdminLoginWithConfig(AdminLoginConfig{
|
return NewAdminLoginWithConfig(AdminLoginConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -46,8 +46,8 @@ func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -68,7 +68,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
|
admin, err := form.config.Dao.FindAdminByEmail(form.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,15 +21,15 @@ type AdminPasswordResetConfirm struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type AdminPasswordResetConfirmConfig struct {
|
type AdminPasswordResetConfirmConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
|
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set TxDao.
|
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set Dao.
|
||||||
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
|
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
|
||||||
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
|
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -45,8 +45,8 @@ func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConf
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -67,7 +67,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||||
return nil // nothing to check
|
return nil // nothing to check
|
||||||
}
|
}
|
||||||
|
|
||||||
admin, err := form.config.TxDao.FindAdminByToken(
|
admin, err := form.config.Dao.FindAdminByToken(
|
||||||
v,
|
v,
|
||||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -85,7 +85,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
admin, err := form.config.TxDao.FindAdminByToken(
|
admin, err := form.config.Dao.FindAdminByToken(
|
||||||
form.Token,
|
form.Token,
|
||||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -97,7 +97,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := form.config.TxDao.SaveAdmin(admin); err != nil {
|
if err := form.config.Dao.SaveAdmin(admin); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ type AdminPasswordResetRequest struct {
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type AdminPasswordResetRequestConfig struct {
|
type AdminPasswordResetRequestConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
ResendThreshold float64 // in seconds
|
ResendThreshold float64 // in seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ type AdminPasswordResetRequestConfig struct {
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewAdminPasswordResetRequestWithConfig] with explicitly set TxDao.
|
// [NewAdminPasswordResetRequestWithConfig] with explicitly set Dao.
|
||||||
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
|
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
|
||||||
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
|
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -49,8 +49,8 @@ func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConf
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -77,7 +77,7 @@ func (form *AdminPasswordResetRequest) Submit() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
|
admin, err := form.config.Dao.FindAdminByEmail(form.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -95,5 +95,5 @@ func (form *AdminPasswordResetRequest) Submit() error {
|
||||||
// update last sent timestamp
|
// update last sent timestamp
|
||||||
admin.LastResetSentAt = types.NowDateTime()
|
admin.LastResetSentAt = types.NowDateTime()
|
||||||
|
|
||||||
return form.config.TxDao.SaveAdmin(admin)
|
return form.config.Dao.SaveAdmin(admin)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,8 +25,8 @@ type AdminUpsert struct {
|
||||||
//
|
//
|
||||||
// NB! App is a required struct member.
|
// NB! App is a required struct member.
|
||||||
type AdminUpsertConfig struct {
|
type AdminUpsertConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
|
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
|
||||||
|
@ -34,7 +34,7 @@ type AdminUpsertConfig struct {
|
||||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewAdminUpsertWithConfig] with explicitly set TxDao.
|
// [NewAdminUpsertWithConfig] with explicitly set Dao.
|
||||||
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
|
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
|
||||||
return NewAdminUpsertWithConfig(AdminUpsertConfig{
|
return NewAdminUpsertWithConfig(AdminUpsertConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -54,8 +54,8 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad
|
||||||
panic("Invalid initializer config or nil upsert model.")
|
panic("Invalid initializer config or nil upsert model.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
// load defaults
|
// load defaults
|
||||||
|
@ -105,7 +105,7 @@ func (form *AdminUpsert) Validate() error {
|
||||||
func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||||
v, _ := value.(string)
|
v, _ := value.(string)
|
||||||
|
|
||||||
if form.config.TxDao.IsAdminEmailUnique(v, form.admin.Id) {
|
if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,6 +135,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return runInterceptors(func() error {
|
return runInterceptors(func() error {
|
||||||
return form.config.TxDao.SaveAdmin(form.admin)
|
return form.config.Dao.SaveAdmin(form.admin)
|
||||||
}, interceptors...)
|
}, interceptors...)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,8 +36,8 @@ type CollectionUpsert struct {
|
||||||
//
|
//
|
||||||
// NB! App is a required struct member.
|
// NB! App is a required struct member.
|
||||||
type CollectionUpsertConfig struct {
|
type CollectionUpsertConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
||||||
|
@ -45,7 +45,7 @@ type CollectionUpsertConfig struct {
|
||||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewCollectionUpsertWithConfig] with explicitly set TxDao.
|
// [NewCollectionUpsertWithConfig] with explicitly set Dao.
|
||||||
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
|
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
|
||||||
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -65,8 +65,8 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||||
panic("Invalid initializer config or nil upsert model.")
|
panic("Invalid initializer config or nil upsert model.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
// load defaults
|
// load defaults
|
||||||
|
@ -130,11 +130,11 @@ func (form *CollectionUpsert) Validate() error {
|
||||||
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||||
v, _ := value.(string)
|
v, _ := value.(string)
|
||||||
|
|
||||||
if !form.config.TxDao.IsCollectionNameUnique(v, form.collection.Id) {
|
if !form.config.Dao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||||
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.TxDao.HasTable(v) {
|
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.Dao.HasTable(v) {
|
||||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,7 +191,7 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := form.config.TxDao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
if _, err := form.config.Dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||||
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
||||||
"validation_field_invalid_relation",
|
"validation_field_invalid_relation",
|
||||||
"The relation collection doesn't exist.",
|
"The relation collection doesn't exist.",
|
||||||
|
@ -228,7 +228,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
dummy := &models.Collection{Schema: form.Schema}
|
dummy := &models.Collection{Schema: form.Schema}
|
||||||
r := resolvers.NewRecordFieldResolver(form.config.TxDao, dummy, nil)
|
r := resolvers.NewRecordFieldResolver(form.config.Dao, dummy, nil)
|
||||||
|
|
||||||
_, err := search.FilterData(*v).BuildExpr(r)
|
_, err := search.FilterData(*v).BuildExpr(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -273,6 +273,6 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||||
form.collection.DeleteRule = form.DeleteRule
|
form.collection.DeleteRule = form.DeleteRule
|
||||||
|
|
||||||
return runInterceptors(func() error {
|
return runInterceptors(func() error {
|
||||||
return form.config.TxDao.SaveCollection(form.collection)
|
return form.config.Dao.SaveCollection(form.collection)
|
||||||
}, interceptors...)
|
}, interceptors...)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,15 +24,15 @@ type CollectionsImport struct {
|
||||||
//
|
//
|
||||||
// NB! App is a required struct member.
|
// NB! App is a required struct member.
|
||||||
type CollectionsImportConfig struct {
|
type CollectionsImportConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCollectionsImport creates a new [CollectionsImport] form with
|
// NewCollectionsImport creates a new [CollectionsImport] form with
|
||||||
// initializer config created from the provided [core.App] instance.
|
// initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewCollectionsImportWithConfig] with explicitly set TxDao.
|
// [NewCollectionsImportWithConfig] with explicitly set Dao.
|
||||||
func NewCollectionsImport(app core.App) *CollectionsImport {
|
func NewCollectionsImport(app core.App) *CollectionsImport {
|
||||||
return NewCollectionsImportWithConfig(CollectionsImportConfig{
|
return NewCollectionsImportWithConfig(CollectionsImportConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -48,8 +48,8 @@ func NewCollectionsImportWithConfig(config CollectionsImportConfig) *Collections
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -79,7 +79,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return runInterceptors(func() error {
|
return runInterceptors(func() error {
|
||||||
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
|
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||||
importErr := txDao.ImportCollections(
|
importErr := txDao.ImportCollections(
|
||||||
form.Collections,
|
form.Collections,
|
||||||
form.DeleteMissing,
|
form.DeleteMissing,
|
||||||
|
@ -122,8 +122,8 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||||
App: form.config.App,
|
App: form.config.App,
|
||||||
TxDao: txDao,
|
Dao: txDao,
|
||||||
}, upsertModel)
|
}, upsertModel)
|
||||||
|
|
||||||
// load form fields with the refreshed collection state
|
// load form fields with the refreshed collection state
|
||||||
|
|
|
@ -37,8 +37,8 @@ type RecordUpsert struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type RecordUpsertConfig struct {
|
type RecordUpsertConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
|
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
|
||||||
|
@ -46,7 +46,7 @@ type RecordUpsertConfig struct {
|
||||||
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
|
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewRecordUpsertWithConfig] with explicitly set TxDao.
|
// [NewRecordUpsertWithConfig] with explicitly set Dao.
|
||||||
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||||
return NewRecordUpsertWithConfig(RecordUpsertConfig{
|
return NewRecordUpsertWithConfig(RecordUpsertConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -68,8 +68,8 @@ func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record)
|
||||||
panic("Invalid initializer config or nil upsert model.")
|
panic("Invalid initializer config or nil upsert model.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
form.Id = record.Id
|
form.Id = record.Id
|
||||||
|
@ -286,7 +286,7 @@ func (form *RecordUpsert) Validate() error {
|
||||||
|
|
||||||
// record data validator
|
// record data validator
|
||||||
dataValidator := validators.NewRecordDataValidator(
|
dataValidator := validators.NewRecordDataValidator(
|
||||||
form.config.TxDao,
|
form.config.Dao,
|
||||||
form.record,
|
form.record,
|
||||||
form.filesToUpload,
|
form.filesToUpload,
|
||||||
)
|
)
|
||||||
|
@ -316,7 +316,7 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
|
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||||
tx, ok := txDao.DB().(*dbx.Tx)
|
tx, ok := txDao.DB().(*dbx.Tx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("failed to get transaction db")
|
return errors.New("failed to get transaction db")
|
||||||
|
@ -366,7 +366,7 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return runInterceptors(func() error {
|
return runInterceptors(func() error {
|
||||||
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
|
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||||
// persist record model
|
// persist record model
|
||||||
if err := txDao.SaveRecord(form.record); err != nil {
|
if err := txDao.SaveRecord(form.record); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -20,16 +20,16 @@ type SettingsUpsert struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type SettingsUpsertConfig struct {
|
type SettingsUpsertConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
TxLogsDao *daos.Dao
|
LogsDao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
|
// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
|
||||||
// config created from the provided [core.App] instance.
|
// config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewSettingsUpsertWithConfig] with explicitly set TxDao.
|
// [NewSettingsUpsertWithConfig] with explicitly set Dao.
|
||||||
func NewSettingsUpsert(app core.App) *SettingsUpsert {
|
func NewSettingsUpsert(app core.App) *SettingsUpsert {
|
||||||
return NewSettingsUpsertWithConfig(SettingsUpsertConfig{
|
return NewSettingsUpsertWithConfig(SettingsUpsertConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -45,12 +45,12 @@ func NewSettingsUpsertWithConfig(config SettingsUpsertConfig) *SettingsUpsert {
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxLogsDao == nil {
|
if form.config.LogsDao == nil {
|
||||||
form.config.TxLogsDao = form.config.App.LogsDao()
|
form.config.LogsDao = form.config.App.LogsDao()
|
||||||
}
|
}
|
||||||
|
|
||||||
// load the application settings into the form
|
// load the application settings into the form
|
||||||
|
@ -78,7 +78,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||||
encryptionKey := os.Getenv(form.config.App.EncryptionEnv())
|
encryptionKey := os.Getenv(form.config.App.EncryptionEnv())
|
||||||
|
|
||||||
return runInterceptors(func() error {
|
return runInterceptors(func() error {
|
||||||
saveErr := form.config.TxDao.SaveParam(
|
saveErr := form.config.Dao.SaveParam(
|
||||||
models.ParamAppSettings,
|
models.ParamAppSettings,
|
||||||
form.Settings,
|
form.Settings,
|
||||||
encryptionKey,
|
encryptionKey,
|
||||||
|
@ -88,7 +88,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// explicitly trigger old logs deletion
|
// explicitly trigger old logs deletion
|
||||||
form.config.TxLogsDao.DeleteOldRequests(
|
form.config.LogsDao.DeleteOldRequests(
|
||||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,8 @@ type UserEmailChangeConfirm struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserEmailChangeConfirmConfig struct {
|
type UserEmailChangeConfirmConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm]
|
// NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm]
|
||||||
|
@ -29,7 +29,7 @@ type UserEmailChangeConfirmConfig struct {
|
||||||
//
|
//
|
||||||
// This factory method is used primarily for convenience (and backward compatibility).
|
// This factory method is used primarily for convenience (and backward compatibility).
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
|
// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
|
||||||
func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
|
func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
|
||||||
return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{
|
return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -45,8 +45,8 @@ func NewUserEmailChangeConfirmWithConfig(config UserEmailChangeConfirmConfig) *U
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -103,12 +103,12 @@ func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensure that there aren't other users with the new email
|
// ensure that there aren't other users with the new email
|
||||||
if !form.config.TxDao.IsUserEmailUnique(newEmail, "") {
|
if !form.config.Dao.IsUserEmailUnique(newEmail, "") {
|
||||||
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// verify that the token is not expired and its signature is valid
|
// verify that the token is not expired and its signature is valid
|
||||||
user, err := form.config.TxDao.FindUserByToken(
|
user, err := form.config.Dao.FindUserByToken(
|
||||||
token,
|
token,
|
||||||
form.config.App.Settings().UserEmailChangeToken.Secret,
|
form.config.App.Settings().UserEmailChangeToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -135,7 +135,7 @@ func (form *UserEmailChangeConfirm) Submit() (*models.User, error) {
|
||||||
user.Verified = true
|
user.Verified = true
|
||||||
user.RefreshTokenKey() // invalidate old tokens
|
user.RefreshTokenKey() // invalidate old tokens
|
||||||
|
|
||||||
if err := form.config.TxDao.SaveUser(user); err != nil {
|
if err := form.config.Dao.SaveUser(user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,15 +21,15 @@ type UserEmailChangeRequest struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserEmailChangeRequestConfig struct {
|
type UserEmailChangeRequestConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserEmailChangeRequest creates a new [UserEmailChangeRequest]
|
// NewUserEmailChangeRequest creates a new [UserEmailChangeRequest]
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
|
// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
|
||||||
func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
|
func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
|
||||||
return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{
|
return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -48,8 +48,8 @@ func NewUserEmailChangeRequestWithConfig(config UserEmailChangeRequestConfig, us
|
||||||
panic("Invalid initializer config or nil user model.")
|
panic("Invalid initializer config or nil user model.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -71,7 +71,7 @@ func (form *UserEmailChangeRequest) Validate() error {
|
||||||
func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
|
func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||||
v, _ := value.(string)
|
v, _ := value.(string)
|
||||||
|
|
||||||
if !form.config.TxDao.IsUserEmailUnique(v, "") {
|
if !form.config.Dao.IsUserEmailUnique(v, "") {
|
||||||
return validation.NewError("validation_user_email_exists", "User email already exists.")
|
return validation.NewError("validation_user_email_exists", "User email already exists.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,8 @@ type UserEmailLogin struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserEmailLoginConfig struct {
|
type UserEmailLoginConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserEmailLogin creates a new [UserEmailLogin] form with
|
// NewUserEmailLogin creates a new [UserEmailLogin] form with
|
||||||
|
@ -29,7 +29,7 @@ type UserEmailLoginConfig struct {
|
||||||
//
|
//
|
||||||
// This factory method is used primarily for convenience (and backward compatibility).
|
// This factory method is used primarily for convenience (and backward compatibility).
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserEmailLoginWithConfig] with explicitly set TxDao.
|
// [NewUserEmailLoginWithConfig] with explicitly set Dao.
|
||||||
func NewUserEmailLogin(app core.App) *UserEmailLogin {
|
func NewUserEmailLogin(app core.App) *UserEmailLogin {
|
||||||
return NewUserEmailLoginWithConfig(UserEmailLoginConfig{
|
return NewUserEmailLoginWithConfig(UserEmailLoginConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -45,8 +45,8 @@ func NewUserEmailLoginWithConfig(config UserEmailLoginConfig) *UserEmailLogin {
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -67,7 +67,7 @@ func (form *UserEmailLogin) Submit() (*models.User, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByEmail(form.Email)
|
user, err := form.config.Dao.FindUserByEmail(form.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,15 +35,15 @@ type UserOauth2Login struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserOauth2LoginConfig struct {
|
type UserOauth2LoginConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserOauth2Login creates a new [UserOauth2Login] form with
|
// NewUserOauth2Login creates a new [UserOauth2Login] form with
|
||||||
// initializer config created from the provided [core.App] instance.
|
// initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserOauth2LoginWithConfig] with explicitly set TxDao.
|
// [NewUserOauth2LoginWithConfig] with explicitly set Dao.
|
||||||
func NewUserOauth2Login(app core.App) *UserOauth2Login {
|
func NewUserOauth2Login(app core.App) *UserOauth2Login {
|
||||||
return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{
|
return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -59,8 +59,8 @@ func NewUserOauth2LoginWithConfig(config UserOauth2LoginConfig) *UserOauth2Login
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -99,8 +99,11 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load provider configuration
|
||||||
config := form.config.App.Settings().NamedAuthProviderConfigs()[form.Provider]
|
config := form.config.App.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||||
config.SetupProvider(provider)
|
if err := config.SetupProvider(provider); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
provider.SetRedirectUrl(form.RedirectUrl)
|
provider.SetRedirectUrl(form.RedirectUrl)
|
||||||
|
|
||||||
|
@ -113,55 +116,78 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch auth user
|
// fetch external auth user
|
||||||
authData, err := provider.FetchAuthUser(token)
|
authData, err := provider.FetchAuthUser(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// login/register the auth user
|
var user *models.User
|
||||||
user, _ := form.config.TxDao.FindUserByEmail(authData.Email)
|
|
||||||
if user != nil {
|
// check for existing relation with the external auth user
|
||||||
// update the existing user's verified state
|
rel, _ := form.config.Dao.FindExternalAuthByProvider(form.Provider, authData.Id)
|
||||||
if !user.Verified {
|
if rel != nil {
|
||||||
|
user, err = form.config.Dao.FindUserById(rel.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, authData, err
|
||||||
|
}
|
||||||
|
} else if authData.Email != "" {
|
||||||
|
// look for an existing user by the external user's email
|
||||||
|
user, _ = form.config.Dao.FindUserByEmail(authData.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil && !config.AllowRegistrations {
|
||||||
|
return nil, authData, errors.New("New users registration is not allowed for the authorized provider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
saveErr := form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||||
|
if user == nil {
|
||||||
|
user = &models.User{}
|
||||||
user.Verified = true
|
user.Verified = true
|
||||||
if err := form.config.TxDao.SaveUser(user); err != nil {
|
user.Email = authData.Email
|
||||||
return nil, authData, err
|
user.SetPassword(security.RandomString(30))
|
||||||
|
|
||||||
|
// create the new user
|
||||||
|
if err := txDao.SaveUser(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// update the existing user verified state
|
||||||
|
if !user.Verified {
|
||||||
|
user.Verified = true
|
||||||
|
if err := txDao.SaveUser(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the existing user empty email if the authData has one
|
||||||
|
// (this in case previously the user was created with
|
||||||
|
// an OAuth2 provider that didn't return an email address)
|
||||||
|
if user.Email == "" && authData.Email != "" {
|
||||||
|
user.Email = authData.Email
|
||||||
|
if err := txDao.SaveUser(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return user, authData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !config.AllowRegistrations {
|
// create ExternalAuth relation if missing
|
||||||
// registration of new users is not allowed via the Oauth2 provider
|
if rel == nil {
|
||||||
return nil, authData, errors.New("Cannot find user with the authorized email.")
|
rel = &models.ExternalAuth{
|
||||||
}
|
UserId: user.Id,
|
||||||
|
Provider: form.Provider,
|
||||||
|
ProviderId: authData.Id,
|
||||||
|
}
|
||||||
|
if err := txDao.SaveExternalAuth(rel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// create new user
|
return nil
|
||||||
user = &models.User{Verified: true}
|
})
|
||||||
upsertForm := NewUserUpsertWithConfig(UserUpsertConfig{
|
|
||||||
App: form.config.App,
|
|
||||||
TxDao: form.config.TxDao,
|
|
||||||
}, user)
|
|
||||||
upsertForm.Email = authData.Email
|
|
||||||
upsertForm.Password = security.RandomString(30)
|
|
||||||
upsertForm.PasswordConfirm = upsertForm.Password
|
|
||||||
|
|
||||||
event := &core.UserOauth2RegisterEvent{
|
if saveErr != nil {
|
||||||
User: user,
|
return nil, authData, saveErr
|
||||||
AuthData: authData,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := form.config.App.OnUserBeforeOauth2Register().Trigger(event); err != nil {
|
|
||||||
return nil, authData, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := upsertForm.Submit(); err != nil {
|
|
||||||
return nil, authData, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := form.config.App.OnUserAfterOauth2Register().Trigger(event); err != nil {
|
|
||||||
return nil, authData, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, authData, nil
|
return user, authData, nil
|
||||||
|
|
|
@ -22,15 +22,15 @@ type UserPasswordResetConfirm struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserPasswordResetConfirmConfig struct {
|
type UserPasswordResetConfirmConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm]
|
// NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm]
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserPasswordResetConfirmWithConfig] with explicitly set TxDao.
|
// [NewUserPasswordResetConfirmWithConfig] with explicitly set Dao.
|
||||||
func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
|
func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
|
||||||
return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{
|
return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -46,8 +46,8 @@ func NewUserPasswordResetConfirmWithConfig(config UserPasswordResetConfirmConfig
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -70,7 +70,7 @@ func (form *UserPasswordResetConfirm) checkToken(value any) error {
|
||||||
return nil // nothing to check
|
return nil // nothing to check
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByToken(
|
user, err := form.config.Dao.FindUserByToken(
|
||||||
v,
|
v,
|
||||||
form.config.App.Settings().UserPasswordResetToken.Secret,
|
form.config.App.Settings().UserPasswordResetToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -88,7 +88,7 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByToken(
|
user, err := form.config.Dao.FindUserByToken(
|
||||||
form.Token,
|
form.Token,
|
||||||
form.config.App.Settings().UserPasswordResetToken.Secret,
|
form.config.App.Settings().UserPasswordResetToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -100,7 +100,7 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := form.config.TxDao.SaveUser(user); err != nil {
|
if err := form.config.Dao.SaveUser(user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ type UserPasswordResetRequest struct {
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserPasswordResetRequestConfig struct {
|
type UserPasswordResetRequestConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
ResendThreshold float64 // in seconds
|
ResendThreshold float64 // in seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ type UserPasswordResetRequestConfig struct {
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserPasswordResetRequestWithConfig] with explicitly set TxDao.
|
// [NewUserPasswordResetRequestWithConfig] with explicitly set Dao.
|
||||||
func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
|
func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
|
||||||
return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{
|
return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -50,8 +50,8 @@ func NewUserPasswordResetRequestWithConfig(config UserPasswordResetRequestConfig
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -78,7 +78,7 @@ func (form *UserPasswordResetRequest) Submit() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByEmail(form.Email)
|
user, err := form.config.Dao.FindUserByEmail(form.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -96,5 +96,5 @@ func (form *UserPasswordResetRequest) Submit() error {
|
||||||
// update last sent timestamp
|
// update last sent timestamp
|
||||||
user.LastResetSentAt = types.NowDateTime()
|
user.LastResetSentAt = types.NowDateTime()
|
||||||
|
|
||||||
return form.config.TxDao.SaveUser(user)
|
return form.config.Dao.SaveUser(user)
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,8 +28,8 @@ type UserUpsert struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserUpsertConfig struct {
|
type UserUpsertConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserUpsert creates a new [UserUpsert] form with initializer
|
// NewUserUpsert creates a new [UserUpsert] form with initializer
|
||||||
|
@ -37,7 +37,7 @@ type UserUpsertConfig struct {
|
||||||
// (for create you could pass a pointer to an empty User - `&models.User{}`).
|
// (for create you could pass a pointer to an empty User - `&models.User{}`).
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
|
// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
|
||||||
func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
|
func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
|
||||||
return NewUserUpsertWithConfig(UserUpsertConfig{
|
return NewUserUpsertWithConfig(UserUpsertConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -57,8 +57,8 @@ func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUp
|
||||||
panic("Invalid initializer config or nil upsert model.")
|
panic("Invalid initializer config or nil upsert model.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
// load defaults
|
// load defaults
|
||||||
|
@ -103,7 +103,7 @@ func (form *UserUpsert) Validate() error {
|
||||||
func (form *UserUpsert) checkUniqueEmail(value any) error {
|
func (form *UserUpsert) checkUniqueEmail(value any) error {
|
||||||
v, _ := value.(string)
|
v, _ := value.(string)
|
||||||
|
|
||||||
if v == "" || form.config.TxDao.IsUserEmailUnique(v, form.user.Id) {
|
if v == "" || form.config.Dao.IsUserEmailUnique(v, form.user.Id) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,6 +160,6 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||||
form.user.Email = form.Email
|
form.user.Email = form.Email
|
||||||
|
|
||||||
return runInterceptors(func() error {
|
return runInterceptors(func() error {
|
||||||
return form.config.TxDao.SaveUser(form.user)
|
return form.config.Dao.SaveUser(form.user)
|
||||||
}, interceptors...)
|
}, interceptors...)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,15 +19,15 @@ type UserVerificationConfirm struct {
|
||||||
//
|
//
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserVerificationConfirmConfig struct {
|
type UserVerificationConfirmConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserVerificationConfirm creates a new [UserVerificationConfirm]
|
// NewUserVerificationConfirm creates a new [UserVerificationConfirm]
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserVerificationConfirmWithConfig] with explicitly set TxDao.
|
// [NewUserVerificationConfirmWithConfig] with explicitly set Dao.
|
||||||
func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
|
func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
|
||||||
return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{
|
return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -43,8 +43,8 @@ func NewUserVerificationConfirmWithConfig(config UserVerificationConfirmConfig)
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -63,7 +63,7 @@ func (form *UserVerificationConfirm) checkToken(value any) error {
|
||||||
return nil // nothing to check
|
return nil // nothing to check
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByToken(
|
user, err := form.config.Dao.FindUserByToken(
|
||||||
v,
|
v,
|
||||||
form.config.App.Settings().UserVerificationToken.Secret,
|
form.config.App.Settings().UserVerificationToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -81,7 +81,7 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByToken(
|
user, err := form.config.Dao.FindUserByToken(
|
||||||
form.Token,
|
form.Token,
|
||||||
form.config.App.Settings().UserVerificationToken.Secret,
|
form.config.App.Settings().UserVerificationToken.Secret,
|
||||||
)
|
)
|
||||||
|
@ -95,7 +95,7 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) {
|
||||||
|
|
||||||
user.Verified = true
|
user.Verified = true
|
||||||
|
|
||||||
if err := form.config.TxDao.SaveUser(user); err != nil {
|
if err := form.config.Dao.SaveUser(user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ type UserVerificationRequest struct {
|
||||||
// NB! App is required struct member.
|
// NB! App is required struct member.
|
||||||
type UserVerificationRequestConfig struct {
|
type UserVerificationRequestConfig struct {
|
||||||
App core.App
|
App core.App
|
||||||
TxDao *daos.Dao
|
Dao *daos.Dao
|
||||||
ResendThreshold float64 // in seconds
|
ResendThreshold float64 // in seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ type UserVerificationRequestConfig struct {
|
||||||
// form with initializer config created from the provided [core.App] instance.
|
// form with initializer config created from the provided [core.App] instance.
|
||||||
//
|
//
|
||||||
// If you want to submit the form as part of another transaction, use
|
// If you want to submit the form as part of another transaction, use
|
||||||
// [NewUserVerificationRequestWithConfig] with explicitly set TxDao.
|
// [NewUserVerificationRequestWithConfig] with explicitly set Dao.
|
||||||
func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
|
func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
|
||||||
return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{
|
return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{
|
||||||
App: app,
|
App: app,
|
||||||
|
@ -50,8 +50,8 @@ func NewUserVerificationRequestWithConfig(config UserVerificationRequestConfig)
|
||||||
panic("Missing required config.App instance.")
|
panic("Missing required config.App instance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.config.TxDao == nil {
|
if form.config.Dao == nil {
|
||||||
form.config.TxDao = form.config.App.Dao()
|
form.config.Dao = form.config.App.Dao()
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
@ -78,7 +78,7 @@ func (form *UserVerificationRequest) Submit() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := form.config.TxDao.FindUserByEmail(form.Email)
|
user, err := form.config.Dao.FindUserByEmail(form.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -100,5 +100,5 @@ func (form *UserVerificationRequest) Submit() error {
|
||||||
// update last sent timestamp
|
// update last sent timestamp
|
||||||
user.LastVerificationSentAt = types.NowDateTime()
|
user.LastVerificationSentAt = types.NowDateTime()
|
||||||
|
|
||||||
return form.config.TxDao.SaveUser(user)
|
return form.config.Dao.SaveUser(user)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "github.com/pocketbase/dbx"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
AppMigrations.Register(func(db dbx.Builder) error {
|
||||||
|
_, createErr := db.NewQuery(`
|
||||||
|
CREATE TABLE {{_externalAuths}} (
|
||||||
|
[[id]] TEXT PRIMARY KEY,
|
||||||
|
[[userId]] TEXT NOT NULL,
|
||||||
|
[[provider]] TEXT NOT NULL,
|
||||||
|
[[providerId]] TEXT NOT NULL,
|
||||||
|
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||||
|
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||||
|
---
|
||||||
|
FOREIGN KEY ([[userId]]) REFERENCES {{_users}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX _externalAuths_userId_provider_idx on {{_externalAuths}} ([[userId]], [[provider]]);
|
||||||
|
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||||
|
`).Execute()
|
||||||
|
if createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the unique email index from the _users table and
|
||||||
|
// replace it with partial index
|
||||||
|
_, alterErr := db.NewQuery(`
|
||||||
|
-- crate new users table
|
||||||
|
CREATE TABLE {{_newUsers}} (
|
||||||
|
[[id]] TEXT PRIMARY KEY,
|
||||||
|
[[verified]] BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
[[email]] TEXT DEFAULT "" NOT NULL,
|
||||||
|
[[tokenKey]] TEXT NOT NULL,
|
||||||
|
[[passwordHash]] TEXT NOT NULL,
|
||||||
|
[[lastResetSentAt]] TEXT DEFAULT "" NOT NULL,
|
||||||
|
[[lastVerificationSentAt]] TEXT DEFAULT "" NOT NULL,
|
||||||
|
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||||
|
[[updated]] TEXT DEFAULT "" NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- copy all data from the old users table to the new one
|
||||||
|
INSERT INTO {{_newUsers}} SELECT * FROM {{_users}};
|
||||||
|
|
||||||
|
-- drop old table
|
||||||
|
DROP TABLE {{_users}};
|
||||||
|
|
||||||
|
-- rename new table
|
||||||
|
ALTER TABLE {{_newUsers}} RENAME TO {{_users}};
|
||||||
|
|
||||||
|
-- create named indexes
|
||||||
|
CREATE UNIQUE INDEX _users_email_idx ON {{_users}} ([[email]]) WHERE [[email]] != "";
|
||||||
|
CREATE UNIQUE INDEX _users_tokenKey_idx ON {{_users}} ([[tokenKey]]);
|
||||||
|
`).Execute()
|
||||||
|
if alterErr != nil {
|
||||||
|
return alterErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, func(db dbx.Builder) error {
|
||||||
|
if _, err := db.DropTable("_externalAuths").Execute(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop the partial email unique index and replace it with normal unique index
|
||||||
|
_, indexErr := db.NewQuery(`
|
||||||
|
DROP INDEX IF EXISTS _users_email_idx;
|
||||||
|
CREATE UNIQUE INDEX _users_email_idx on {{_users}} ([[email]]);
|
||||||
|
`).Execute()
|
||||||
|
if indexErr != nil {
|
||||||
|
return indexErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
var _ Model = (*ExternalAuth)(nil)
|
||||||
|
|
||||||
|
type ExternalAuth struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
UserId string `db:"userId" json:"userId"`
|
||||||
|
Provider string `db:"provider" json:"provider"`
|
||||||
|
ProviderId string `db:"providerId" json:"providerId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalAuth) TableName() string {
|
||||||
|
return "_externalAuths"
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package models_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExternalAuthTableName(t *testing.T) {
|
||||||
|
m := models.ExternalAuth{}
|
||||||
|
if m.TableName() != "_externalAuths" {
|
||||||
|
t.Fatalf("Unexpected table name, got %q", m.TableName())
|
||||||
|
}
|
||||||
|
}
|
25
tests/app.go
25
tests/app.go
|
@ -188,21 +188,26 @@ func NewTestApp() (*TestApp, error) {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
t.OnUserBeforeOauth2Register().Add(func(e *core.UserOauth2RegisterEvent) error {
|
|
||||||
t.EventCalls["OnUserBeforeOauth2Register"]++
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.OnUserAfterOauth2Register().Add(func(e *core.UserOauth2RegisterEvent) error {
|
|
||||||
t.EventCalls["OnUserAfterOauth2Register"]++
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.OnUserAuthRequest().Add(func(e *core.UserAuthEvent) error {
|
t.OnUserAuthRequest().Add(func(e *core.UserAuthEvent) error {
|
||||||
t.EventCalls["OnUserAuthRequest"]++
|
t.EventCalls["OnUserAuthRequest"]++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.OnUserListExternalAuths().Add(func(e *core.UserListExternalAuthsEvent) error {
|
||||||
|
t.EventCalls["OnUserListExternalAuths"]++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
t.OnUserBeforeUnlinkExternalAuthRequest().Add(func(e *core.UserUnlinkExternalAuthEvent) error {
|
||||||
|
t.EventCalls["OnUserBeforeUnlinkExternalAuthRequest"]++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
t.OnUserAfterUnlinkExternalAuthRequest().Add(func(e *core.UserUnlinkExternalAuthEvent) error {
|
||||||
|
t.EventCalls["OnUserAfterUnlinkExternalAuthRequest"]++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
t.OnMailerBeforeAdminResetPasswordSend().Add(func(e *core.MailerAdminEvent) error {
|
t.OnMailerBeforeAdminResetPasswordSend().Add(func(e *core.MailerAdminEvent) error {
|
||||||
t.EventCalls["OnMailerBeforeAdminResetPasswordSend"]++
|
t.EventCalls["OnMailerBeforeAdminResetPasswordSend"]++
|
||||||
return nil
|
return nil
|
||||||
|
|
Binary file not shown.
|
@ -197,9 +197,12 @@
|
||||||
|
|
||||||
<td class="col-type-email col-field-email">
|
<td class="col-type-email col-field-email">
|
||||||
<div class="inline-flex">
|
<div class="inline-flex">
|
||||||
<span class="txt" title={user.email}>
|
{#if user.email}
|
||||||
{user.email}
|
<span class="txt" title={user.email}>{user.email}</span>
|
||||||
</span>
|
{:else}
|
||||||
|
<div class="txt-hint">N/A</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="label"
|
class="label"
|
||||||
class:label-success={user.verified}
|
class:label-success={user.verified}
|
||||||
|
|
Loading…
Reference in New Issue