From f5ff7193a94a3a04942b7fa8e4a87f8fb9e443c8 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Wed, 31 Aug 2022 13:38:31 +0300 Subject: [PATCH] [#276] added support for linking external auths by provider id --- apis/user.go | 67 ++++++ apis/user_test.go | 191 +++++++++++++++++- core/app.go | 21 +- core/base.go | 60 +++--- core/base_test.go | 12 +- core/events.go | 15 +- daos/external_auth.go | 99 +++++++++ daos/external_auth_test.go | 189 +++++++++++++++++ daos/user.go | 3 +- daos/user_test.go | 1 + forms/admin_login.go | 12 +- forms/admin_password_reset_confirm.go | 16 +- forms/admin_password_reset_request.go | 12 +- forms/admin_upsert.go | 14 +- forms/collection_upsert.go | 20 +- forms/collections_import.go | 16 +- forms/record_upsert.go | 16 +- forms/settings_upsert.go | 20 +- forms/user_email_change_confirm.go | 16 +- forms/user_email_change_request.go | 12 +- forms/user_email_login.go | 12 +- forms/user_oauth2_login.go | 114 +++++++---- forms/user_password_reset_confirm.go | 16 +- forms/user_password_reset_request.go | 12 +- forms/user_upsert.go | 14 +- forms/user_verification_confirm.go | 16 +- forms/user_verification_request.go | 12 +- .../1661586591_add_externalAuths_table.go | 76 +++++++ models/external_auth.go | 15 ++ models/external_auth_test.go | 14 ++ tests/app.go | 25 ++- tests/data/data.db | Bin 159744 -> 172032 bytes ui/src/components/users/PageUsers.svelte | 9 +- 33 files changed, 924 insertions(+), 223 deletions(-) create mode 100644 daos/external_auth.go create mode 100644 daos/external_auth_test.go create mode 100644 migrations/1661586591_add_externalAuths_table.go create mode 100644 models/external_auth.go create mode 100644 models/external_auth_test.go diff --git a/apis/user.go b/apis/user.go index de48344f..af078a81 100644 --- a/apis/user.go +++ b/apis/user.go @@ -38,6 +38,8 @@ func BindUserApi(app core.App, rg *echo.Group) { subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id")) subGroup.PATCH("/:id", api.update, RequireAdminAuth()) 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 { @@ -450,3 +452,68 @@ func (api *userApi) delete(c echo.Context) error { 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 +} diff --git a/apis/user_test.go b/apis/user_test.go index 66532d0a..fcd937db 100644 --- a/apis/user_test.go +++ b/apis/user_test.go @@ -584,11 +584,12 @@ func TestUsersList(t *testing.T) { ExpectedContent: []string{ `"page":1`, `"perPage":30`, - `"totalItems":3`, + `"totalItems":4`, `"items":[{`, `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`, `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`, + `"id":"cx9u0dh2udo8xol"`, }, ExpectedEvents: map[string]int{"OnUsersListRequest": 1}, }, @@ -603,8 +604,9 @@ func TestUsersList(t *testing.T) { ExpectedContent: []string{ `"page":2`, `"perPage":2`, - `"totalItems":3`, + `"totalItems":4`, `"items":[{`, + `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`, `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, }, ExpectedEvents: map[string]int{"OnUsersListRequest": 1}, @@ -630,10 +632,11 @@ func TestUsersList(t *testing.T) { ExpectedContent: []string{ `"page":1`, `"perPage":30`, - `"totalItems":2`, + `"totalItems":3`, `"items":[{`, `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`, + `"id":"cx9u0dh2udo8xol"`, }, ExpectedEvents: map[string]int{"OnUsersListRequest": 1}, }, @@ -926,3 +929,185 @@ func TestUserUpdate(t *testing.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) + } +} diff --git a/core/app.go b/core/app.go index c19c06b6..f995fb7d 100644 --- a/core/app.go +++ b/core/app.go @@ -317,16 +317,21 @@ type App interface { // authenticated user data and token. OnUserAuthRequest() *hook.Hook[*UserAuthEvent] - // OnUserBeforeOauth2Register hook is triggered before each User OAuth2 - // authentication request (when the client config has enabled new users registration). + // OnUserListExternalAuths hook is triggered on each API user's external auhts list request. // - // Could be used to additionally validate or modify the new user - // before persisting in the DB. - OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] + // Could be used to validate or modify the response before returning it to the client. + OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] - // OnUserAfterOauth2Register hook is triggered after each successful User - // OAuth2 authentication sign-up request (right after the new user persistence). - OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] + // OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's + // external auth unlink request (after models load and before the actual relation deletion). + // + // 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 diff --git a/core/base.go b/core/base.go index d17d93fe..7c79db31 100644 --- a/core/base.go +++ b/core/base.go @@ -85,18 +85,19 @@ type BaseApp struct { onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent] onAdminAuthRequest *hook.Hook[*AdminAuthEvent] - // user api event hooks - onUsersListRequest *hook.Hook[*UsersListEvent] - onUserViewRequest *hook.Hook[*UserViewEvent] - onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent] - onUserAfterCreateRequest *hook.Hook[*UserCreateEvent] - onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent] - onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent] - onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent] - onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent] - onUserAuthRequest *hook.Hook[*UserAuthEvent] - onUserBeforeOauth2Register *hook.Hook[*UserOauth2RegisterEvent] - onUserAfterOauth2Register *hook.Hook[*UserOauth2RegisterEvent] + // user api event hooks + onUsersListRequest *hook.Hook[*UsersListEvent] + onUserViewRequest *hook.Hook[*UserViewEvent] + onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent] + onUserAfterCreateRequest *hook.Hook[*UserCreateEvent] + onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent] + onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent] + onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent] + onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent] + onUserAuthRequest *hook.Hook[*UserAuthEvent] + onUserListExternalAuths *hook.Hook[*UserListExternalAuthsEvent] + onUserBeforeUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent] + onUserAfterUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent] // record api event hooks onRecordsListRequest *hook.Hook[*RecordsListEvent] @@ -180,17 +181,18 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp { onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{}, // user API event hooks - onUsersListRequest: &hook.Hook[*UsersListEvent]{}, - onUserViewRequest: &hook.Hook[*UserViewEvent]{}, - onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{}, - onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{}, - onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{}, - onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{}, - onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{}, - onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{}, - onUserAuthRequest: &hook.Hook[*UserAuthEvent]{}, - onUserBeforeOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{}, - onUserAfterOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{}, + onUsersListRequest: &hook.Hook[*UsersListEvent]{}, + onUserViewRequest: &hook.Hook[*UserViewEvent]{}, + onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{}, + onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{}, + onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{}, + onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{}, + onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{}, + onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{}, + onUserAuthRequest: &hook.Hook[*UserAuthEvent]{}, + onUserListExternalAuths: &hook.Hook[*UserListExternalAuthsEvent]{}, + onUserBeforeUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{}, + onUserAfterUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{}, // record API event hooks onRecordsListRequest: &hook.Hook[*RecordsListEvent]{}, @@ -611,12 +613,16 @@ func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] { return app.onUserAuthRequest } -func (app *BaseApp) OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] { - return app.onUserBeforeOauth2Register +func (app *BaseApp) OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] { + return app.onUserListExternalAuths } -func (app *BaseApp) OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] { - return app.onUserAfterOauth2Register +func (app *BaseApp) OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] { + return app.onUserBeforeUnlinkExternalAuthRequest +} + +func (app *BaseApp) OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] { + return app.onUserAfterUnlinkExternalAuthRequest } // ------------------------------------------------------------------- diff --git a/core/base_test.go b/core/base_test.go index 0a589fda..c09f6638 100644 --- a/core/base_test.go +++ b/core/base_test.go @@ -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) } - if app.onUserBeforeOauth2Register != app.OnUserBeforeOauth2Register() || app.OnUserBeforeOauth2Register() == nil { - t.Fatalf("Getter app.OnUserBeforeOauth2Register does not match or nil (%v vs %v)", app.OnUserBeforeOauth2Register(), app.onUserBeforeOauth2Register) + if app.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil { + 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 { - t.Fatalf("Getter app.OnUserAfterOauth2Register does not match or nil (%v vs %v)", app.OnUserAfterOauth2Register(), app.onUserAfterOauth2Register) + if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil { + 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 { diff --git a/core/events.go b/core/events.go index 1d9bd927..11f88534 100644 --- a/core/events.go +++ b/core/events.go @@ -4,7 +4,6 @@ import ( "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/auth" "github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/subscriptions" @@ -180,10 +179,16 @@ type UserAuthEvent struct { Meta any } -type UserOauth2RegisterEvent struct { - HttpContext echo.Context - User *models.User - AuthData *auth.AuthUser +type UserListExternalAuthsEvent struct { + HttpContext echo.Context + User *models.User + ExternalAuths []*models.ExternalAuth +} + +type UserUnlinkExternalAuthEvent struct { + HttpContext echo.Context + User *models.User + ExternalAuth *models.ExternalAuth } // ------------------------------------------------------------------- diff --git a/daos/external_auth.go b/daos/external_auth.go new file mode 100644 index 00000000..e88ce606 --- /dev/null +++ b/daos/external_auth.go @@ -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) +} diff --git a/daos/external_auth_test.go b/daos/external_auth_test.go new file mode 100644 index 00000000..dece07f7 --- /dev/null +++ b/daos/external_auth_test.go @@ -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) + } +} diff --git a/daos/user.go b/daos/user.go index 9a13e85c..65f8c740 100644 --- a/daos/user.go +++ b/daos/user.go @@ -94,7 +94,7 @@ func (dao *Dao) FindUserById(id string) (*models.User, error) { 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 // into the found model. @@ -102,6 +102,7 @@ func (dao *Dao) FindUserByEmail(email string) (*models.User, error) { model := &models.User{} err := dao.UserQuery(). + AndWhere(dbx.Not(dbx.HashExp{"email": ""})). AndWhere(dbx.HashExp{"email": email}). Limit(1). One(model) diff --git a/daos/user_test.go b/daos/user_test.go index 8d818dd2..895328c9 100644 --- a/daos/user_test.go +++ b/daos/user_test.go @@ -110,6 +110,7 @@ func TestFindUserByEmail(t *testing.T) { email string expectError bool }{ + {"", true}, {"invalid", true}, {"missing@example.com", true}, {"test@example.com", false}, diff --git a/forms/admin_login.go b/forms/admin_login.go index 09cf4f97..d2e7b833 100644 --- a/forms/admin_login.go +++ b/forms/admin_login.go @@ -22,15 +22,15 @@ type AdminLogin struct { // // NB! App is a required struct member. type AdminLoginConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewAdminLogin creates a new [AdminLogin] form with initializer // config created from the provided [core.App] instance. // // 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 { return NewAdminLoginWithConfig(AdminLoginConfig{ App: app, @@ -46,8 +46,8 @@ func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin { panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -68,7 +68,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) { return nil, err } - admin, err := form.config.TxDao.FindAdminByEmail(form.Email) + admin, err := form.config.Dao.FindAdminByEmail(form.Email) if err != nil { return nil, err } diff --git a/forms/admin_password_reset_confirm.go b/forms/admin_password_reset_confirm.go index 3bc79a77..9898c078 100644 --- a/forms/admin_password_reset_confirm.go +++ b/forms/admin_password_reset_confirm.go @@ -21,15 +21,15 @@ type AdminPasswordResetConfirm struct { // // NB! App is required struct member. type AdminPasswordResetConfirmConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm] // form with initializer config created from the provided [core.App] instance. // // 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 { return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{ App: app, @@ -45,8 +45,8 @@ func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConf panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -67,7 +67,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error { return nil // nothing to check } - admin, err := form.config.TxDao.FindAdminByToken( + admin, err := form.config.Dao.FindAdminByToken( v, form.config.App.Settings().AdminPasswordResetToken.Secret, ) @@ -85,7 +85,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) { return nil, err } - admin, err := form.config.TxDao.FindAdminByToken( + admin, err := form.config.Dao.FindAdminByToken( form.Token, form.config.App.Settings().AdminPasswordResetToken.Secret, ) @@ -97,7 +97,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) { return nil, err } - if err := form.config.TxDao.SaveAdmin(admin); err != nil { + if err := form.config.Dao.SaveAdmin(admin); err != nil { return nil, err } diff --git a/forms/admin_password_reset_request.go b/forms/admin_password_reset_request.go index 2653c68c..663e38b2 100644 --- a/forms/admin_password_reset_request.go +++ b/forms/admin_password_reset_request.go @@ -24,7 +24,7 @@ type AdminPasswordResetRequest struct { // NB! App is required struct member. type AdminPasswordResetRequestConfig struct { App core.App - TxDao *daos.Dao + Dao *daos.Dao ResendThreshold float64 // in seconds } @@ -32,7 +32,7 @@ type AdminPasswordResetRequestConfig struct { // form with initializer config created from the provided [core.App] instance. // // 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 { return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{ App: app, @@ -49,8 +49,8 @@ func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConf panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -77,7 +77,7 @@ func (form *AdminPasswordResetRequest) Submit() error { return err } - admin, err := form.config.TxDao.FindAdminByEmail(form.Email) + admin, err := form.config.Dao.FindAdminByEmail(form.Email) if err != nil { return err } @@ -95,5 +95,5 @@ func (form *AdminPasswordResetRequest) Submit() error { // update last sent timestamp admin.LastResetSentAt = types.NowDateTime() - return form.config.TxDao.SaveAdmin(admin) + return form.config.Dao.SaveAdmin(admin) } diff --git a/forms/admin_upsert.go b/forms/admin_upsert.go index 4d648af1..ddde01bb 100644 --- a/forms/admin_upsert.go +++ b/forms/admin_upsert.go @@ -25,8 +25,8 @@ type AdminUpsert struct { // // NB! App is a required struct member. type AdminUpsertConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // 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{}`). // // 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 { return NewAdminUpsertWithConfig(AdminUpsertConfig{ App: app, @@ -54,8 +54,8 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad panic("Invalid initializer config or nil upsert model.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } // load defaults @@ -105,7 +105,7 @@ func (form *AdminUpsert) Validate() error { func (form *AdminUpsert) checkUniqueEmail(value any) error { v, _ := value.(string) - if form.config.TxDao.IsAdminEmailUnique(v, form.admin.Id) { + if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) { return nil } @@ -135,6 +135,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error { } return runInterceptors(func() error { - return form.config.TxDao.SaveAdmin(form.admin) + return form.config.Dao.SaveAdmin(form.admin) }, interceptors...) } diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go index 8f44fd36..387bca28 100644 --- a/forms/collection_upsert.go +++ b/forms/collection_upsert.go @@ -36,8 +36,8 @@ type CollectionUpsert struct { // // NB! App is a required struct member. type CollectionUpsertConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // 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{}`). // // 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 { return NewCollectionUpsertWithConfig(CollectionUpsertConfig{ App: app, @@ -65,8 +65,8 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo panic("Invalid initializer config or nil upsert model.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } // load defaults @@ -130,11 +130,11 @@ func (form *CollectionUpsert) Validate() error { func (form *CollectionUpsert) checkUniqueName(value any) error { 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).") } - 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.") } @@ -191,7 +191,7 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro 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( "validation_field_invalid_relation", "The relation collection doesn't exist.", @@ -228,7 +228,7 @@ func (form *CollectionUpsert) checkRule(value any) error { } 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) if err != nil { @@ -273,6 +273,6 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error { form.collection.DeleteRule = form.DeleteRule return runInterceptors(func() error { - return form.config.TxDao.SaveCollection(form.collection) + return form.config.Dao.SaveCollection(form.collection) }, interceptors...) } diff --git a/forms/collections_import.go b/forms/collections_import.go index 70314c73..8a1e8c50 100644 --- a/forms/collections_import.go +++ b/forms/collections_import.go @@ -24,15 +24,15 @@ type CollectionsImport struct { // // NB! App is a required struct member. type CollectionsImportConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewCollectionsImport creates a new [CollectionsImport] form with // initializer config created from the provided [core.App] instance. // // 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 { return NewCollectionsImportWithConfig(CollectionsImportConfig{ App: app, @@ -48,8 +48,8 @@ func NewCollectionsImportWithConfig(config CollectionsImportConfig) *Collections panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -79,7 +79,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) 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( form.Collections, form.DeleteMissing, @@ -122,8 +122,8 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map } upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{ - App: form.config.App, - TxDao: txDao, + App: form.config.App, + Dao: txDao, }, upsertModel) // load form fields with the refreshed collection state diff --git a/forms/record_upsert.go b/forms/record_upsert.go index 79f6aa14..c2c977f3 100644 --- a/forms/record_upsert.go +++ b/forms/record_upsert.go @@ -37,8 +37,8 @@ type RecordUpsert struct { // // NB! App is required struct member. type RecordUpsertConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // 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)`). // // 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 { return NewRecordUpsertWithConfig(RecordUpsertConfig{ App: app, @@ -68,8 +68,8 @@ func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) panic("Invalid initializer config or nil upsert model.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } form.Id = record.Id @@ -286,7 +286,7 @@ func (form *RecordUpsert) Validate() error { // record data validator dataValidator := validators.NewRecordDataValidator( - form.config.TxDao, + form.config.Dao, form.record, form.filesToUpload, ) @@ -316,7 +316,7 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error 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) if !ok { return errors.New("failed to get transaction db") @@ -366,7 +366,7 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) 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 if err := txDao.SaveRecord(form.record); err != nil { return err diff --git a/forms/settings_upsert.go b/forms/settings_upsert.go index dff96f7a..8d5eb562 100644 --- a/forms/settings_upsert.go +++ b/forms/settings_upsert.go @@ -20,16 +20,16 @@ type SettingsUpsert struct { // // NB! App is required struct member. type SettingsUpsertConfig struct { - App core.App - TxDao *daos.Dao - TxLogsDao *daos.Dao + App core.App + Dao *daos.Dao + LogsDao *daos.Dao } // NewSettingsUpsert creates a new [SettingsUpsert] form with initializer // config created from the provided [core.App] instance. // // 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 { return NewSettingsUpsertWithConfig(SettingsUpsertConfig{ App: app, @@ -45,12 +45,12 @@ func NewSettingsUpsertWithConfig(config SettingsUpsertConfig) *SettingsUpsert { panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } - if form.config.TxLogsDao == nil { - form.config.TxLogsDao = form.config.App.LogsDao() + if form.config.LogsDao == nil { + form.config.LogsDao = form.config.App.LogsDao() } // 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()) return runInterceptors(func() error { - saveErr := form.config.TxDao.SaveParam( + saveErr := form.config.Dao.SaveParam( models.ParamAppSettings, form.Settings, encryptionKey, @@ -88,7 +88,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error { } // explicitly trigger old logs deletion - form.config.TxLogsDao.DeleteOldRequests( + form.config.LogsDao.DeleteOldRequests( time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays), ) diff --git a/forms/user_email_change_confirm.go b/forms/user_email_change_confirm.go index a83bfa8a..3e9db0d5 100644 --- a/forms/user_email_change_confirm.go +++ b/forms/user_email_change_confirm.go @@ -20,8 +20,8 @@ type UserEmailChangeConfirm struct { // // NB! App is required struct member. type UserEmailChangeConfirmConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm] @@ -29,7 +29,7 @@ type UserEmailChangeConfirmConfig struct { // // This factory method is used primarily for convenience (and backward compatibility). // 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 { return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{ App: app, @@ -45,8 +45,8 @@ func NewUserEmailChangeConfirmWithConfig(config UserEmailChangeConfirmConfig) *U panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } 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 - 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) } // 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, form.config.App.Settings().UserEmailChangeToken.Secret, ) @@ -135,7 +135,7 @@ func (form *UserEmailChangeConfirm) Submit() (*models.User, error) { user.Verified = true 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 } diff --git a/forms/user_email_change_request.go b/forms/user_email_change_request.go index 1b050aa2..f85caa8d 100644 --- a/forms/user_email_change_request.go +++ b/forms/user_email_change_request.go @@ -21,15 +21,15 @@ type UserEmailChangeRequest struct { // // NB! App is required struct member. type UserEmailChangeRequestConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewUserEmailChangeRequest creates a new [UserEmailChangeRequest] // form with initializer config created from the provided [core.App] instance. // // 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 { return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{ App: app, @@ -48,8 +48,8 @@ func NewUserEmailChangeRequestWithConfig(config UserEmailChangeRequestConfig, us panic("Invalid initializer config or nil user model.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -71,7 +71,7 @@ func (form *UserEmailChangeRequest) Validate() error { func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error { 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.") } diff --git a/forms/user_email_login.go b/forms/user_email_login.go index 122f50c9..d3946add 100644 --- a/forms/user_email_login.go +++ b/forms/user_email_login.go @@ -20,8 +20,8 @@ type UserEmailLogin struct { // // NB! App is required struct member. type UserEmailLoginConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // 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). // 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 { return NewUserEmailLoginWithConfig(UserEmailLoginConfig{ App: app, @@ -45,8 +45,8 @@ func NewUserEmailLoginWithConfig(config UserEmailLoginConfig) *UserEmailLogin { panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -67,7 +67,7 @@ func (form *UserEmailLogin) Submit() (*models.User, error) { return nil, err } - user, err := form.config.TxDao.FindUserByEmail(form.Email) + user, err := form.config.Dao.FindUserByEmail(form.Email) if err != nil { return nil, err } diff --git a/forms/user_oauth2_login.go b/forms/user_oauth2_login.go index a4d35571..ae5b2a9d 100644 --- a/forms/user_oauth2_login.go +++ b/forms/user_oauth2_login.go @@ -35,15 +35,15 @@ type UserOauth2Login struct { // // NB! App is required struct member. type UserOauth2LoginConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewUserOauth2Login creates a new [UserOauth2Login] form with // initializer config created from the provided [core.App] instance. // // 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 { return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{ App: app, @@ -59,8 +59,8 @@ func NewUserOauth2LoginWithConfig(config UserOauth2LoginConfig) *UserOauth2Login panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -99,8 +99,11 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) { return nil, nil, err } + // load provider configuration 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) @@ -113,55 +116,78 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) { return nil, nil, err } - // fetch auth user + // fetch external auth user authData, err := provider.FetchAuthUser(token) if err != nil { return nil, nil, err } - // login/register the auth user - user, _ := form.config.TxDao.FindUserByEmail(authData.Email) - if user != nil { - // update the existing user's verified state - if !user.Verified { + var user *models.User + + // check for existing relation with the external auth user + rel, _ := form.config.Dao.FindExternalAuthByProvider(form.Provider, authData.Id) + 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 - if err := form.config.TxDao.SaveUser(user); err != nil { - return nil, authData, err + user.Email = authData.Email + 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 { - // registration of new users is not allowed via the Oauth2 provider - return nil, authData, errors.New("Cannot find user with the authorized email.") - } + // create ExternalAuth relation if missing + if rel == nil { + rel = &models.ExternalAuth{ + UserId: user.Id, + Provider: form.Provider, + ProviderId: authData.Id, + } + if err := txDao.SaveExternalAuth(rel); err != nil { + return err + } + } - // create new user - 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 + return nil + }) - event := &core.UserOauth2RegisterEvent{ - User: user, - 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 + if saveErr != nil { + return nil, authData, saveErr } return user, authData, nil diff --git a/forms/user_password_reset_confirm.go b/forms/user_password_reset_confirm.go index c2f88092..ab9680e0 100644 --- a/forms/user_password_reset_confirm.go +++ b/forms/user_password_reset_confirm.go @@ -22,15 +22,15 @@ type UserPasswordResetConfirm struct { // // NB! App is required struct member. type UserPasswordResetConfirmConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm] // form with initializer config created from the provided [core.App] instance. // // 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 { return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{ App: app, @@ -46,8 +46,8 @@ func NewUserPasswordResetConfirmWithConfig(config UserPasswordResetConfirmConfig panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -70,7 +70,7 @@ func (form *UserPasswordResetConfirm) checkToken(value any) error { return nil // nothing to check } - user, err := form.config.TxDao.FindUserByToken( + user, err := form.config.Dao.FindUserByToken( v, form.config.App.Settings().UserPasswordResetToken.Secret, ) @@ -88,7 +88,7 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) { return nil, err } - user, err := form.config.TxDao.FindUserByToken( + user, err := form.config.Dao.FindUserByToken( form.Token, form.config.App.Settings().UserPasswordResetToken.Secret, ) @@ -100,7 +100,7 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) { return nil, err } - if err := form.config.TxDao.SaveUser(user); err != nil { + if err := form.config.Dao.SaveUser(user); err != nil { return nil, err } diff --git a/forms/user_password_reset_request.go b/forms/user_password_reset_request.go index 9d7f939b..56ea9040 100644 --- a/forms/user_password_reset_request.go +++ b/forms/user_password_reset_request.go @@ -25,7 +25,7 @@ type UserPasswordResetRequest struct { // NB! App is required struct member. type UserPasswordResetRequestConfig struct { App core.App - TxDao *daos.Dao + Dao *daos.Dao ResendThreshold float64 // in seconds } @@ -33,7 +33,7 @@ type UserPasswordResetRequestConfig struct { // form with initializer config created from the provided [core.App] instance. // // 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 { return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{ App: app, @@ -50,8 +50,8 @@ func NewUserPasswordResetRequestWithConfig(config UserPasswordResetRequestConfig panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -78,7 +78,7 @@ func (form *UserPasswordResetRequest) Submit() error { return err } - user, err := form.config.TxDao.FindUserByEmail(form.Email) + user, err := form.config.Dao.FindUserByEmail(form.Email) if err != nil { return err } @@ -96,5 +96,5 @@ func (form *UserPasswordResetRequest) Submit() error { // update last sent timestamp user.LastResetSentAt = types.NowDateTime() - return form.config.TxDao.SaveUser(user) + return form.config.Dao.SaveUser(user) } diff --git a/forms/user_upsert.go b/forms/user_upsert.go index 37744549..89580723 100644 --- a/forms/user_upsert.go +++ b/forms/user_upsert.go @@ -28,8 +28,8 @@ type UserUpsert struct { // // NB! App is required struct member. type UserUpsertConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // 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{}`). // // 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 { return NewUserUpsertWithConfig(UserUpsertConfig{ App: app, @@ -57,8 +57,8 @@ func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUp panic("Invalid initializer config or nil upsert model.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } // load defaults @@ -103,7 +103,7 @@ func (form *UserUpsert) Validate() error { func (form *UserUpsert) checkUniqueEmail(value any) error { 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 } @@ -160,6 +160,6 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error { form.user.Email = form.Email return runInterceptors(func() error { - return form.config.TxDao.SaveUser(form.user) + return form.config.Dao.SaveUser(form.user) }, interceptors...) } diff --git a/forms/user_verification_confirm.go b/forms/user_verification_confirm.go index f3b83c64..ff0dcb92 100644 --- a/forms/user_verification_confirm.go +++ b/forms/user_verification_confirm.go @@ -19,15 +19,15 @@ type UserVerificationConfirm struct { // // NB! App is required struct member. type UserVerificationConfirmConfig struct { - App core.App - TxDao *daos.Dao + App core.App + Dao *daos.Dao } // NewUserVerificationConfirm creates a new [UserVerificationConfirm] // form with initializer config created from the provided [core.App] instance. // // 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 { return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{ App: app, @@ -43,8 +43,8 @@ func NewUserVerificationConfirmWithConfig(config UserVerificationConfirmConfig) panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -63,7 +63,7 @@ func (form *UserVerificationConfirm) checkToken(value any) error { return nil // nothing to check } - user, err := form.config.TxDao.FindUserByToken( + user, err := form.config.Dao.FindUserByToken( v, form.config.App.Settings().UserVerificationToken.Secret, ) @@ -81,7 +81,7 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) { return nil, err } - user, err := form.config.TxDao.FindUserByToken( + user, err := form.config.Dao.FindUserByToken( form.Token, form.config.App.Settings().UserVerificationToken.Secret, ) @@ -95,7 +95,7 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) { user.Verified = true - if err := form.config.TxDao.SaveUser(user); err != nil { + if err := form.config.Dao.SaveUser(user); err != nil { return nil, err } diff --git a/forms/user_verification_request.go b/forms/user_verification_request.go index 1303773e..047f494b 100644 --- a/forms/user_verification_request.go +++ b/forms/user_verification_request.go @@ -25,7 +25,7 @@ type UserVerificationRequest struct { // NB! App is required struct member. type UserVerificationRequestConfig struct { App core.App - TxDao *daos.Dao + Dao *daos.Dao ResendThreshold float64 // in seconds } @@ -33,7 +33,7 @@ type UserVerificationRequestConfig struct { // form with initializer config created from the provided [core.App] instance. // // 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 { return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{ App: app, @@ -50,8 +50,8 @@ func NewUserVerificationRequestWithConfig(config UserVerificationRequestConfig) panic("Missing required config.App instance.") } - if form.config.TxDao == nil { - form.config.TxDao = form.config.App.Dao() + if form.config.Dao == nil { + form.config.Dao = form.config.App.Dao() } return form @@ -78,7 +78,7 @@ func (form *UserVerificationRequest) Submit() error { return err } - user, err := form.config.TxDao.FindUserByEmail(form.Email) + user, err := form.config.Dao.FindUserByEmail(form.Email) if err != nil { return err } @@ -100,5 +100,5 @@ func (form *UserVerificationRequest) Submit() error { // update last sent timestamp user.LastVerificationSentAt = types.NowDateTime() - return form.config.TxDao.SaveUser(user) + return form.config.Dao.SaveUser(user) } diff --git a/migrations/1661586591_add_externalAuths_table.go b/migrations/1661586591_add_externalAuths_table.go new file mode 100644 index 00000000..71bde5d6 --- /dev/null +++ b/migrations/1661586591_add_externalAuths_table.go @@ -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 + }) +} diff --git a/models/external_auth.go b/models/external_auth.go new file mode 100644 index 00000000..74399faf --- /dev/null +++ b/models/external_auth.go @@ -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" +} diff --git a/models/external_auth_test.go b/models/external_auth_test.go new file mode 100644 index 00000000..26c53b06 --- /dev/null +++ b/models/external_auth_test.go @@ -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()) + } +} diff --git a/tests/app.go b/tests/app.go index 41df9cc9..40319e03 100644 --- a/tests/app.go +++ b/tests/app.go @@ -188,21 +188,26 @@ func NewTestApp() (*TestApp, error) { 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.EventCalls["OnUserAuthRequest"]++ 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.EventCalls["OnMailerBeforeAdminResetPasswordSend"]++ return nil diff --git a/tests/data/data.db b/tests/data/data.db index 4a7a7cdb591f30bf4506f6a1dd57ea270eae3837..f9da3d2d729ea7a3aeb602391eae97e44284167d 100644 GIT binary patch delta 2688 zcmb7GYfuwc6yCF0^4?q`#TYxvs(^%$mbvrjHTPrfvH8gMT{XqmQZ1sMG0;9moFYSf}HRKeU}`?XGFI!t-b$dC^?Hh{vB0+DUxY0zPwRs9pWY#h=aF zp{CFQ0{xDzp|8*ZOcFz8gQNwXiG$Kh9Kc`5%Z>lE#$1)H#F8XvN@Tl^rqC2-)6yjQ zYkbu{1yBS5*LlnE&-j9P$fMXXF}lTgWE?pXlOpQti&puDygj{GYkR1NpE_QWq^D2; zfi9s#XcsEL^ts4UCJ95wz+R_4T+_O{tJ~E)H00c}y|H3oXK%S{pp#p_sX5xTyUyFa zK45Eai!?+_+lE{YioZNrMu~Iz(69|ie%GW!BkCuz2}0D4?!~oRK1W-tL@D;!S|r%0 zSVV2JQ87i&Wy%)$@Bw&;+zLG8Sam&u)4XTWE;8}KVSAQ1xh($uq|Krh(@G*IfleYP zHZ4c7U8Kxt)aO)IdfVDLUq@$`e|LAFCunUSs_3_HUABHMR6Z06bo!$KZ=0X9*(^4j z$zsJPW3jADo@R^1vH-jb=4H3cu-8u0heDkJpP#cXz_S1>hE1Yd1o{Krio@;ikRwN8 zgp5eYMG~W8E@nwhyh5NW=r_!Pa5la{>{808hZ$+J11I(Pks|X^QZ%g@At?G3K|e-& zHCO3Vcv*S6#YHLTN+UkybV_@<$yUKw9qUR<*V(OR$68CUudi!Q$6i0z-!o9k1sAZ& zaG7|#43s1;stAE5(VOTdieP_jXo(ni1|wA@ae;7RIphoH*1&c=>7ZE7m(T<{h$oFA zVd5oFE_zBBmB9G6Eo-O<-mR|@=tXo<`nu+AO{=Du98}ynX zv{<-$4y=jur(r2b9kf(eUm)7(`AVfyxONokBwFHKUYmV55t@KkfYKyjLnq? zy2)kA9~JK@BJvxuhj3r^EqR;jMsBFi`jU)ivD7c=ILI@YPY{0i03xx`;zF6O)c~Vz ze~|MHWxN%8BcTC5=Zm=07qy(*&t+H@)ikly+gQfcP`lN|GPMm(b_erpM2tBUWIS_i zJxq}&?aoue%v{%U9)ocI1nj4%kBBN^@FbYCtwmBDn+HTvd!MmLs2Yb=!q_;->WW{N zuafBu2H0Ppe7DbaGWQNmN3gAW{bu$lJ;K+Az$h2fge5S%RiLKq(z|^y?IKF8z1JlHAV4K*68nzj`P3{CSd_NAA zX>4FzTb$y;)>JpwR6AJ_ak87)Cy2;wsLYq@8V$+qRyen(S?5NXt)3v(ihDr7`_vTc zD3AdV7+foGRsLwF>iLCwieaR3L#D8F@y~=oeBFf%=Y8!iFy5j zCtl?L;l^j z=mBmtH&BU&(&xZTS0rhCtLRL3vEIst?(A zt`A#%-H~wJ%UioPSKI5_ZDD8s;Kq6TPie&ha6E6{-RU-TGuen{wV{kSl5L1L?aSxS H9ccO&(Iy>% delta 717 zcmYL{Ur19?9LLY^+`G+Pckgdk2({ErQLHwnDCt9F1}ae@Lo|%UDe9AgIIBrgiIwzH zZJyw2`KS33lykE|2OpyO5-38EQa%RaL$Smjd@-Yrt?1zkhwqQ?_k7NeGn`O{KU;%G z^ddq?v5+n#3Hd|r3R4mBTt2bT^#oB<4}6>>xP>b?7uRd1kneBLr(0CyMhR|U6i4H_a}edDQZulU8->RF z3~khuKer_ae#8VOMe|E!>$lu2>B|zyR)Vd_a7FYrpj;{S18a{~qz-*I%Ym}ihn2Ij zGOoANP2|pYx{l0g_gbl_wF_Zt67s5=stprK-6QHAUe{JdV^ZaNxuEB zfj}&UivlBZu#9Dl2)r3ImfwJ#zdjAM-u$#eqS64tYFskk*6wMSwBxeh)?=%-=`>*7 zv_21w4l>=|Cev?_v#_59vh)RdSr+y2z!>}h`Hp6>@N17CCQ%Ukv5$bcN;PMmFEaWk zA)B346k}}y&Pn62E2iH;b}8meqi+)OZ56(&1Ks|CbeHv?Q=W1ZS6a$(Q5X}c2S7{A z#!eV=Ss9vm!!*1CcLr<|Ii{^@F11gsw?#}V^sFr-y@%#1@2yi-f7&O@E^J%gLJVT! z1vAFGr{EW>9g^i{QXUF>jO{Som9?Mrlo6N(503E$ECL0| VfGD&XJCDI_
- - {user.email} - + {#if user.email} + {user.email} + {:else} +
N/A
+ {/if} +