Compare commits

...
39 Commits
Author SHA1 Message Date
Gani Georgiev 44bf55097a updated changelogs 2026-04-27 09:30:32 +03:00
Gani Georgiev 338d672bee updated ui/dist 2026-04-27 09:04:38 +03:00
Gani Georgiev 5bd9d87bad reorder editor buttons to avoid dropdowns text wrapping 2026-04-27 08:47:14 +03:00
Gani Georgiev 6ba78d5218 updated gitlab userinfo doc reference 2026-04-27 08:03:20 +03:00
Gani Georgiev 260bd59c5b updated jstypes 2026-04-27 07:57:43 +03:00
Gani Georgiev 006566478a added explicit gitlab confirmed_at check 2026-04-27 07:55:51 +03:00
Gani Georgiev 419f335f5b various minor ui fixes 2026-04-27 01:13:08 +03:00
Gani Georgiev 326f150db2 added more tests for internal record hooks 2026-04-26 20:47:47 +03:00
Gani Georgiev 1c86addc4c [#7665] added BaseURL to the ghupdate plugin configuration 2026-04-26 20:47:02 +03:00
Gani Georgiev 494f47efb8 bumped go deps 2026-04-26 16:50:24 +03:00
Gani Georgiev 555a4f1a1e lowered the default mfa duration and reorganized internal record pre/post handling 2026-04-26 16:46:16 +03:00
Gani Georgiev 37b258810a updated gitea displayName 2026-04-26 14:23:23 +03:00
Gani Georgiev ca7cf1162f added App.DeleteAllExternalAuthsByRecord 2026-04-26 11:40:09 +03:00
Gani Georgiev dddb0a029f updated bitbucket,github and gitea oauth2 providers 2026-04-25 17:51:28 +03:00
Gani Georgiev 5d55fc18ee added dummy bcrypt check 2026-04-25 16:16:23 +03:00
Gani Georgiev 449e5af590 adjust dark text color 2026-04-25 11:41:09 +03:00
Gani Georgiev 1e460d3f96 updated changelog and rebuild ui/dist 2026-04-24 22:27:26 +03:00
Gani Georgiev b5030ddfa1 [#7664] fixed codeEditor not firing the change and input events on autocomplete selection 2026-04-24 22:08:58 +03:00
Gani Georgiev fbeb09c40b [#7660] added missing type:button attribute and replaced form tag with div to minimize future regressions 2026-04-24 15:42:47 +03:00
Gani Georgiev 69cdda4bf3 [#7659] fixed SMTP IPv6 format 2026-04-23 21:25:58 +03:00
Gani Georgiev e708f39e1b updated erd styles 2026-04-23 21:18:32 +03:00
Gani Georgiev 52eccb3aac fade non-focused erd tables 2026-04-23 17:43:15 +03:00
Gani Georgiev 1d593476b0 updated view autocomplete keywords 2026-04-23 17:32:07 +03:00
Gani Georgiev 8a04904de1 fixed godoc example typo 2026-04-23 16:57:26 +03:00
Gani Georgiev a3ac674f36 removed title loader to minimize layout jumps 2026-04-23 14:12:59 +03:00
Gani Georgiev ae7041a889 preload the record preview to minimize content jumps 2026-04-23 00:37:05 +03:00
Gani Georgiev 257f03e1fa removed lazy tinymce mount since the relation are now preloaded 2026-04-23 00:01:43 +03:00
Gani Georgiev 3566ba3729 exclude expand from the record draft 2026-04-22 23:15:46 +03:00
Gani Georgiev a6002c4622 optimized record upsert panel loading to minimize layout jumps 2026-04-22 23:02:56 +03:00
Gani Georgiev 2ddf161314 renamed list-group to list-content for consistency with the others 2026-04-22 17:59:11 +03:00
Gani Georgiev b15f358fc9 [#7655] added backups list scroll container 2026-04-22 17:22:22 +03:00
Gani Georgiev 83e44a7cfb added view query sample loading indicator 2026-04-22 16:04:57 +03:00
Gani Georgiev 866b8b8029 added missing name attribute, fixed initial collections page load routing params persistence and removed unnecessery sub label required mark 2026-04-22 15:42:17 +03:00
Gani Georgiev 857214e10d reordered number settings for consistency with the other fields 2026-04-22 14:46:19 +03:00
Gani Georgiev 592b13913f added min-height to the page tables and adjusted light surface colors 2026-04-22 07:00:36 +03:00
Gani Georgiev 84b50c4869 minor color and styles improvements 2026-04-21 17:40:11 +03:00
Gani Georgiev 3c33868ea8 [#7653] updated tinymce options to fix its dialogs position 2026-04-21 14:16:23 +03:00
Gani Georgiev 223ac7a64a enabled text wrapping for the API rule fileds 2026-04-21 11:52:45 +03:00
Gani Georgiev 0cee0662f6 fixed 0 total count on page back/forward navigation 2026-04-21 10:26:37 +03:00
74 changed files with 4410 additions and 3396 deletions
+35
View File
@@ -1,3 +1,38 @@
## v0.37.4
- Added backups list scroll container ([#7655](https://github.com/pocketbase/pocketbase/issues/7655)).
- Optimized record upsert and preview modals data loading to minimize layout jumps.
- Fixed SMTP IPv6 network address format ([#7659](https://github.com/pocketbase/pocketbase/issues/7659)).
- Fixed autocomplete selection not properly updating the underlying input value ([#7664](https://github.com/pocketbase/pocketbase/issues/7664)).
- Added `ghupdate.BaseURL` config option ([#7665](https://github.com/pocketbase/pocketbase/issues/7665)).
- Added dummy bcrypt password check for the failure auth path to minimize enumeration timing attacks when registrations are disabled.
- Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
- ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662); thanks @Alardiians for reporting it privately).
- Bumped Go and npm dependencies.
## v0.37.3
- Fixed total count load on page back/forward navigation.
- Fixed `editor` floating dialogs position when scrolling ([#7653](https://github.com/pocketbase/pocketbase/issues/7653)).
- Enabled text wrapping for the API rule fields.
- Added view query sample loading indicator.
- Other minor light UI contrast and styles improvements.
## v0.37.2
- Fixed autoexpandable input in Firefox ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
+8
View File
@@ -2,6 +2,14 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.42
- (_Backported from v0.37.4_) Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
- (_Backported from v0.37.4_) ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662)).
## v0.22.41
- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
+2 -2
View File
@@ -85,8 +85,8 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/norate", 0, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 700, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 800, false, 200},
{"/rate/a", 800, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 500, false, 200},
{"/rate/a", 800, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
+40 -1
View File
@@ -111,12 +111,51 @@ func TestRecordConfirmEmailChange(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
"OnRecordValidate": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified before the confirmation")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByEmail("users", "change@example.com")
user, err := app.FindAuthRecordByEmail("users", "change@example.com")
if err != nil {
t.Fatalf("Expected to find user with email %q, got error: %v", "change@example.com", err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
@@ -114,11 +114,18 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnModelValidate": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
"OnModelValidate": 1,
"OnRecordValidate": 1,
// ---
"OnModelDelete": 2, // pre-existing OAuth2 links
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
@@ -151,6 +158,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
if !user.ValidatePassword("1234567!") {
t.Fatal("Password wasn't changed")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
@@ -221,6 +237,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
if !user.ValidatePassword("1234567!") {
t.Fatal("Password wasn't changed")
}
// ensure that all pre-existing OAuth2 were NOT deleted
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) != 2 {
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
}
},
},
{
@@ -251,11 +276,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
oldTokenKey := user.TokenKey()
// ensure that the user is already verified
user.SetVerified(true)
if err := app.Save(user); err != nil {
t.Fatalf("Failed to update user verified state")
}
// resave with the old token key since the verified change above
// would refresh it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByToken(
@@ -105,6 +105,51 @@ func TestRecordConfirmVerification(t *testing.T) {
"OnRecordValidate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified before the confirmation")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+23 -6
View File
@@ -338,26 +338,43 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
e.Auth.Id == e.Record.Id &&
e.Auth.Collection().Id == e.Record.Collection().Id
// set random password for users with unverified email
// (this is in case a malicious actor has registered previously with the user email)
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
e.Record.SetRandomPassword()
// prevent pre-hijacking with password auth
//
// reset the unverified user password in case the record was precreated by a malicious actor
if !isLoggedAuthRecord && !e.Record.Verified() {
needUpdate = true
e.Record.SetRandomPassword()
}
// prevent pre-hijacking with different OAuth2 provider
//
// delete all other previous OAuth2 record links for the cases
// when the user was precreated by malicious OAuth2 auth with custom payload data
//
// while this would be also done automatically on unverified -> verified upgrade,
// doing it manually here ensures that a single unverified record could have
// max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
if !e.Record.Verified() {
err := txApp.DeleteAllExternalAuthsByRecord(e.Record)
if err != nil {
return err
}
optExternalAuth = nil // clear to allow recreate below
}
// update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if e.Record.Email() == "" && e.OAuth2User.Email != "" {
e.Record.SetEmail(e.OAuth2User.Email)
needUpdate = true
e.Record.SetEmail(e.OAuth2User.Email)
}
// update the existing auth record verified state
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
if !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
e.Record.SetVerified(true)
needUpdate = true
e.Record.SetVerified(true)
}
if needUpdate {
+233 -13
View File
@@ -178,6 +178,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatal(err)
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that it was deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Id != ea.Id {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
@@ -213,12 +227,12 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnModelCreate": 2, // user + recreated external auth
"OnModelCreateExecute": 2,
"OnModelAfterCreateSuccess": 2,
"OnRecordCreate": 2,
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
@@ -227,8 +241,15 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelValidate": 2, // create + update
"OnRecordValidate": 2,
"OnModelDelete": 3, // pre-existing external auths
"OnModelDeleteExecute": 3,
"OnModelAfterDeleteSuccess": 3,
"OnRecordDelete": 3,
"OnRecordDeleteExecute": 3,
"OnRecordAfterDeleteSuccess": 3,
// ---
"OnModelValidate": 3, // user create/update + recreated external auth
"OnRecordValidate": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
@@ -248,6 +269,24 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
// ensure that other linked external auths have been deleted
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) != 1 {
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
}
if provider := externalAuths[0].Provider(); provider != "test" {
t.Fatalf("Expected %q external auth, got %q", "test", provider)
}
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
}
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
}
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
}
},
},
{
@@ -343,7 +382,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected old password %q to be valid", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -353,7 +392,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "link by email",
Name: "link by email (unverified user)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -376,6 +415,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that it was deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Provider() != "test" {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
@@ -432,6 +485,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -449,6 +509,145 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
// ensure that other linked external auths have been deleted
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) != 1 {
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
}
if provider := externalAuths[0].Provider(); provider != "test" {
t.Fatalf("Expected %q external auth, got %q", "test", provider)
}
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
}
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
}
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
}
},
},
{
Name: "link by email (verified user)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected user %q to be verified", user.Email())
}
// ensure that the old password works
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id", Email: "test3@example.com"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that they are not deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Provider() != "test" {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// add the test provider in the collection
user.Collection().MFA.Enabled = false
user.Collection().OAuth2.Enabled = true
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test3@example.com"`,
`"id":"bgs820n361vj1qd"`,
`"id":"test_id"`,
`"verified":true`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelCreate": 2, // authOrigins + externalAuths
"OnModelCreateExecute": 2,
"OnModelAfterCreateSuccess": 2,
"OnRecordCreate": 2,
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelValidate": 2, // authOrigins + externalAuths
"OnRecordValidate": 2,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
var hasTestEA = false
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) <= 1 {
t.Fatalf("Expected to have 2+ ExternalAuth records, got %d (%v)", len(externalAuths), err)
}
for _, rel := range externalAuths {
if rel.Provider() == "test" {
hasTestEA = true
break
}
}
if !hasTestEA {
t.Fatal("Expected test external auth to be linked")
}
},
},
{
@@ -531,6 +730,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 2,
"OnRecordValidate": 2,
},
@@ -541,7 +747,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -652,6 +858,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -662,7 +875,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -758,6 +971,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -768,7 +988,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
+20 -10
View File
@@ -65,28 +65,38 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
// ---
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
otpId := e.OTP.Id
otpSentTo := e.OTP.SentTo()
// eagerly delete the OTP to avoid unnecessery double delete model hook calls
// triggered by the password change below
err := e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
// update the user email verified state in case the OTP originate from an email address matching the current record one
//
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
otpSentTo := e.OTP.SentTo()
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
e.Record.SetVerified(true)
err = e.App.Save(e.Record)
if err != nil {
// this is technically not required but we enforce password
// reset on verified upgrades in case the OTP is used on its own
// since this makes it less error prone to pre-hijacking attacks
if !e.Record.Collection().MFA.Enabled {
e.Record.SetRandomPassword()
}
if err := e.App.Save(e.Record); err != nil {
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
"error", err,
"otpId", e.OTP.Id,
"otpId", otpId,
"recordId", e.Record.Id,
)
}
}
// try to delete the used otp
err = e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
})
}
+43 -7
View File
@@ -327,6 +327,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
if user.Verified() {
t.Fatal("Expected the user to remain unverified because sentTo != email")
}
// ensure that all pre-existing OAuth2 were NOT deleted
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) != 2 {
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
}
},
},
{
@@ -364,6 +373,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
// verify that there are at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auth")
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -388,10 +406,10 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
// OTP delete
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteSuccess": 1,
// record OTP + 2 ExternalAuths delete
"OnModelDelete": 3,
"OnModelDeleteExecute": 3,
"OnModelAfterDeleteSuccess": 3,
// user verified update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
@@ -401,9 +419,9 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
"OnRecordDelete": 3,
"OnRecordDeleteExecute": 3,
"OnRecordAfterDeleteSuccess": 3,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
@@ -417,6 +435,24 @@ func TestRecordAuthWithOTP(t *testing.T) {
if !user.Verified() {
t.Fatal("Expected the user to be marked as verified")
}
// ensure that all pre-existing OTPs are cleared
otps, err := app.FindAllOTPsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(otps) > 0 {
t.Fatalf("Expected all OTPs to be cleared, found %d", len(otps))
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+20
View File
@@ -85,6 +85,11 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
return e.App.OnRecordAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordRequestEvent) error {
if e.Record == nil || !e.Record.ValidatePassword(e.Password) {
// dummy password check to minimize enumeration side-channel attacks
if e.Record == nil {
dummyPasswordCheck(e.App, e.Collection)
}
return e.BadRequestError("Failed to authenticate.", errors.New("invalid login credentials"))
}
@@ -115,6 +120,21 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
)
}
// dummy password check to minimize side-channel attacks
// (performed with the collection configured field cost)
func dummyPasswordCheck(app core.App, collection *core.Collection) {
record := &core.Record{}
// find any random existing record
err := app.RecordQuery(collection).Limit(1).One(record)
if err != nil {
return
}
// the value and result doesn't matter, we just need a constant-time check
_ = record.ValidatePassword("")
}
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
return nil, errors.New("invalid identity field " + field)
+5
View File
@@ -502,6 +502,11 @@ type App interface {
// ExternalAuth model that satisfies the non-nil expression.
FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalAuth, error)
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
//
// Returns a combined error with the failed deletes.
DeleteAllExternalAuthsByRecord(authRecord *Record) error
// ---------------------------------------------------------------
// FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
+1 -1
View File
@@ -60,7 +60,7 @@ func (m *Collection) setDefaultAuthOptions() {
},
MFA: MFAConfig{
Enabled: false,
Duration: 1800, // 30min
Duration: 600, // 10min
},
OTP: OTPConfig{
Enabled: false,
+38
View File
@@ -137,4 +137,42 @@ func (app *BaseApp) registerExternalAuthHooks() {
},
Priority: 99,
})
// delete all pre-existing external auths on verified upgrade
app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
Func: func(e *RecordEvent) error {
if !e.Record.Collection().IsAuth() {
return e.Next()
}
hasUpgradedVerified := !e.Record.Original().IsNew() && !e.Record.Original().Verified() && e.Record.Verified()
if !hasUpgradedVerified {
return e.Next()
}
originalApp := e.App
return e.App.RunInTransaction(func(txApp App) error {
e.App = txApp
defer func() { e.App = originalApp }()
externalAuths, err := txApp.FindAllExternalAuthsByRecord(e.Record)
if err != nil {
return err
}
if len(externalAuths) > 0 {
// delete all pre-existing external auths
if err := txApp.DeleteAllExternalAuthsByRecord(e.Record); err != nil {
return err
}
// force refresh tokens reset (if not already)
e.Record.RefreshTokenKey()
}
return e.Next()
})
},
Priority: 99,
})
}
+101
View File
@@ -308,3 +308,104 @@ func TestExternalAuthValidateHook(t *testing.T) {
})
}
}
func TestExternalAuthClearOnVerfiedUpgrade(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
t.Run("unverified->no changes", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected user to be unverified")
}
beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(beforeAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey != user.TokenKey() {
t.Fatal("Expected tokenKey to remain unchanged")
}
afterAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(afterAuths) != len(beforeAuths) {
t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
}
})
t.Run("unverified->verified", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected user to be unverified")
}
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(externalAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
user.SetVerified(true)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey == user.TokenKey() {
t.Fatal("Expected tokenKey to be renewed")
}
externalAuths, err = app.FindAllExternalAuthsByRecord(user)
if err != nil || len(externalAuths) != 0 {
t.Fatalf("Expected all user external auths to be deleted, found %d (%v)", len(externalAuths), err)
}
})
t.Run("verified->no changes", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatal("Expected user to be verified")
}
beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(beforeAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey != user.TokenKey() {
t.Fatal("Expected tokenKey to remain unchanged")
}
afterAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(afterAuths) != len(beforeAuths) {
t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
}
})
}
+24
View File
@@ -1,6 +1,8 @@
package core
import (
"errors"
"github.com/pocketbase/dbx"
)
@@ -59,3 +61,25 @@ func (app *BaseApp) FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalA
return model, nil
}
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
//
// Returns a combined error with the failed deletes.
func (app *BaseApp) DeleteAllExternalAuthsByRecord(authRecord *Record) error {
models, err := app.FindAllExternalAuthsByRecord(authRecord)
if err != nil {
return err
}
var errs []error
for _, m := range models {
if err := app.Delete(m); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
+66
View File
@@ -2,6 +2,7 @@ package core_test
import (
"fmt"
"slices"
"testing"
"github.com/pocketbase/dbx"
@@ -174,3 +175,68 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
})
}
}
func TestDeleteAllExternalAuthsByRecord(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client1, err := testApp.FindAuthRecordByEmail("clients", "test@example.com")
if err != nil {
t.Fatal(err)
}
client2, err := testApp.FindAuthRecordByEmail("clients", "test2@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
record *core.Record
deletedIds []string
}{
{demo1, nil}, // non-auth record
{user1, []string{"dlmflokuq1xl342", "clmflokuq1xl341"}},
{client1, []string{"f1z5b3843pzc964"}},
{client2, nil},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
deletedIds := []string{}
app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
deletedIds = append(deletedIds, e.Record.Id)
return e.Next()
})
err := app.DeleteAllExternalAuthsByRecord(s.record)
if err != nil {
t.Fatal(err)
}
if len(deletedIds) != len(s.deletedIds) {
t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
}
for _, id := range s.deletedIds {
if !slices.Contains(deletedIds, id) {
t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
}
}
})
}
}
+6 -5
View File
@@ -3,6 +3,7 @@ package core
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -141,11 +142,11 @@ func (app *BaseApp) registerMFAHooks() {
if old != new {
err = e.App.DeleteAllMFAsByRecord(e.Record)
if err != nil {
e.App.Logger().Warn(
"Failed to delete all previous mfas",
"error", err,
"recordId", e.Record.Id,
"collectionId", e.Record.Collection().Id,
return fmt.Errorf(
"[%s] failed to delete all previos MFAs for record %q: %w",
e.Record.Collection().Name,
e.Record.Id,
err,
)
}
}
+61
View File
@@ -300,3 +300,64 @@ func TestMFAValidateHook(t *testing.T) {
})
}
}
func TestMFAClearOnPasswordChange(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
mfasToCreate := map[*core.Record]int{
user1: 3,
user2: 2,
}
for user, total := range mfasToCreate {
for range total {
mfa := core.NewMFA(app)
mfa.SetCollectionRef(user.Collection().Id)
mfa.SetRecordRef(user.Id)
mfa.SetMethod(core.MFAMethodPassword)
if err := app.Save(mfa); err != nil {
t.Fatal(err)
}
}
}
// update both users
err = app.Save(user1)
if err != nil {
t.Fatal(err)
}
user2.SetRandomPassword()
err = app.Save(user2)
if err != nil {
t.Fatal(err)
}
expectedMFAs := map[*core.Record]int{
user1: 3,
user2: 0,
}
for user, expected := range expectedMFAs {
mfas, err := app.FindAllMFAsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(mfas) != expected {
t.Fatalf("Expected %d MFAs, got %d", expected, len(mfas))
}
}
}
+27
View File
@@ -3,8 +3,10 @@ package core
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -124,4 +126,29 @@ func (app *BaseApp) registerOTPHooks() {
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
}
})
// delete all record OTPs on tokenKey change to minimize the risk of hijacking attacks
app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
Func: func(e *RecordEvent) error {
err := e.Next()
if err != nil || !e.Record.Collection().IsAuth() {
return err
}
if !e.Record.Original().IsNew() && e.Record.Original().TokenKey() != e.Record.TokenKey() {
err := e.App.DeleteAllOTPsByRecord(e.Record)
if err != nil {
return fmt.Errorf(
"[%s] failed to delete all previos OTPs for record %q: %w",
e.Record.Collection().Name,
e.Record.Id,
err,
)
}
}
return nil
},
Priority: 99,
})
}
+61
View File
@@ -300,3 +300,64 @@ func TestOTPValidateHook(t *testing.T) {
})
}
}
func TestOTPClearOnTokenKeyChange(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
otpsToCreate := map[*core.Record]int{
user1: 3,
user2: 2,
}
for user, total := range otpsToCreate {
for range total {
otp := core.NewOTP(app)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
}
}
// update both users
err = app.Save(user1)
if err != nil {
t.Fatal(err)
}
user2.RefreshTokenKey()
err = app.Save(user2)
if err != nil {
t.Fatal(err)
}
expectedOTPs := map[*core.Record]int{
user1: 3,
user2: 0,
}
for user, expected := range expectedOTPs {
otps, err := app.FindAllOTPsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(otps) != expected {
t.Fatalf("Expected %d OTPs, got %d", expected, len(otps))
}
}
}
+3 -2
View File
@@ -1428,13 +1428,13 @@ func onRecordValidate(e *RecordEvent) error {
func onRecordSaveExecute(e *RecordEvent) error {
if e.Record.Collection().IsAuth() {
// ensure that the token key is regenerated on password change or email change
if !e.Record.IsNew() {
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
if err != nil {
return err
}
// ensure that the token key is regenerated on password change or email change
if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
(lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
lastSavedRecord.Email() != e.Record.Email()) {
@@ -1442,7 +1442,8 @@ func onRecordSaveExecute(e *RecordEvent) error {
}
}
// cross-check that the auth record id is unique across all auth collections.
// loosely cross-check that the auth record id is unique across all auth collections
// to minimize impact of mistakes in API rules when multiple auth collections are used
authCollections, err := e.App.FindAllCollections(CollectionTypeAuth)
if err != nil {
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
+1 -1
View File
@@ -22,7 +22,7 @@ require (
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
modernc.org/sqlite v1.49.1
modernc.org/sqlite v1.50.0
)
require (
+2 -2
View File
@@ -134,8 +134,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
const (
expectedDriverVersion = "v1.49.1"
expectedDriverVersion = "v1.50.0"
expectedLibcVersion = "v1.72.0"
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
+16 -10
View File
@@ -46,6 +46,12 @@ type Config struct {
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
// BaseURL is the base URL of the GitHub API (or similar compatible)
// used to fetch the latest releases information.
//
// Defaults to "https://api.github.com".
BaseURL string
// Optional context to use when fetching and downloading the latest release.
Context context.Context
@@ -82,6 +88,12 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p.config.ArchiveExecutable = "pocketbase"
}
if p.config.BaseURL == "" {
p.config.BaseURL = "https://api.github.com"
} else {
p.config.BaseURL = strings.TrimRight(p.config.BaseURL, "/")
}
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
@@ -145,12 +157,9 @@ func (p *plugin) updateCmd() *cobra.Command {
func (p *plugin) update(withBackup bool) error {
color.Yellow("Fetching release information...")
latest, err := fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
p.config.Owner,
p.config.Repo,
)
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", p.config.BaseURL, p.config.Owner, p.config.Repo)
latest, err := fetchLatestRelease(p.config.Context, p.config.HttpClient, url)
if err != nil {
return err
}
@@ -260,11 +269,8 @@ func (p *plugin) update(withBackup bool) error {
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
owner string,
repo string,
url string,
) (*release, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -137,7 +137,7 @@ migrate((app) => {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -319,7 +319,7 @@ func init() {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -590,7 +590,7 @@ migrate((app) => {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -775,7 +775,7 @@ func init() {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
+4 -3
View File
@@ -120,8 +120,9 @@ func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
expected := struct {
Values []struct {
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
IsConfirmed bool `json:"is_confirmed"`
} `json:"values"`
}{}
if err := json.Unmarshal(data, &expected); err != nil {
@@ -129,7 +130,7 @@ func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
}
for _, v := range expected.Values {
if v.IsPrimary {
if v.IsPrimary && v.IsConfirmed {
return v.Email, nil
}
}
+65 -7
View File
@@ -3,6 +3,9 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
@@ -15,10 +18,10 @@ func init() {
var _ Provider = (*Gitea)(nil)
// NameGitea is the unique name of the Gitea provider.
// NameGitea is the unique name of the Gitea/Forgejo provider.
const NameGitea string = "gitea"
// Gitea allows authentication via Gitea OAuth2.
// Gitea allows authentication via Gitea/Forgejo OAuth2.
type Gitea struct {
BaseProvider
}
@@ -29,7 +32,7 @@ func NewGiteaProvider() *Gitea {
ctx: context.Background(),
order: 11,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640"><path d="m396 484-127-61c-12-6-18-21-12-34l61-127c6-12 21-17 34-11l27 13V154h17v118s57 24 83 40q7 3 13 14 3 10-1 19l-61 127c-6 13-22 18-34 12" style="fill:#fff"/><path d="M623 150c-4-4-10-4-10-4l-178 8-39 1v117l-17-8V155l-89-3-157-8q-15-2-39 1c-9 2-34 8-54 27C-5 212 7 276 8 286c2 12 7 44 32 72 46 56 144 55 144 55s12 29 31 56c25 33 50 59 75 62h189s12 0 29-11c14-8 26-23 26-23s13-14 31-45l14-28s55-118 55-232c-1-34-9-40-11-42M126 354c-26-9-37-19-37-19s-19-13-29-40c-16-44-1-71-1-71s8-22 38-30c14-4 31-3 31-3s7 59 16 94c7 30 25 78 25 78s-26-3-43-9m300 108s-6 14-20 15l-10-1-5-2-113-55s-11-6-13-16c-2-8 3-18 3-18l54-112s5-10 12-13l5-1c8-3 18 2 18 2l110 54s13 6 16 16q1 12-2 17c-6 16-55 114-55 114" style="fill:#609926"/><path d="M327 380q-14 1-17 14-2 13 9 20c7 4 17 2 22-5q8-13-1-24l24-49h6l7-4 29 16 5 5c2 6-2 15-2 15-2 7-18 40-18 40q-13 1-18 13-4 14 9 21a18 18 0 0 0 21-28l6-11 13-30c1-2 6-11 3-22-2-11-13-16-13-16-12-8-29-16-29-16l-1-7-4-6 14-29-12-6-14 29q-11 0-16 10t1 20z" style="fill:#609926"/></svg>`,
displayName: "Gitea",
displayName: "Gitea/Forgejo",
pkce: true,
scopes: []string{"read:user", "user:email"},
authURL: "https://gitea.com/login/oauth/authorize",
@@ -38,9 +41,9 @@ func NewGiteaProvider() *Gitea {
}}
}
// FetchAuthUser returns an AuthUser instance based on Gitea's user api.
// FetchAuthUser returns an AuthUser instance based on Gitea/Forgejo's user api.
//
// API reference: https://try.gitea.io/api/swagger#/user/userGetCurrent
// API reference: https://codeberg.org/api/swagger#/user/userGetCurrent
func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -55,26 +58,81 @@ func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
Active bool `json:"active"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if !extracted.Active {
return nil, errors.New("user account is not active")
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
email, err := p.fetchVerifiedPrimaryEmail(token)
if err != nil {
return nil, fmt.Errorf("failed to fetch primary email: %w", err)
}
user.Email = email
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://codeberg.org/api/swagger#/user/userListEmails
func (p *Gitea) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
// (the email field is optional, aka. return the auth user without it)
if response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode == 404 {
return "", nil
}
content, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
emails := []struct {
Email string
Verified bool
Primary bool
}{}
if err := json.Unmarshal(content, &emails); err != nil {
return "", err
}
// extract the verified primary email
for _, email := range emails {
if email.Verified && email.Primary {
return email.Email, nil
}
}
return "", nil
}
+12 -15
View File
@@ -42,7 +42,7 @@ func NewGithubProvider() *Github {
// FetchAuthUser returns an AuthUser instance based the Github's user api.
//
// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
// API reference: https://docs.github.com/en/rest/users/users?apiVersion=2026-03-10#get-the-authenticated-user
func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -55,9 +55,8 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
@@ -69,7 +68,6 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
@@ -78,27 +76,26 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
// in case user has set "Keep my email address private", send an
// **optional** API request to retrieve the verified primary email
if user.Email == "" {
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
// always send a primary email request even though the email is
// returned in the userinfo endpoint since the API may change and
// enterprise setups may have configuration that could allow unverified emails
email, err := p.fetchVerifiedPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the verified
// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28
func (p *Github) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
func (p *Github) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
+13 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
@@ -40,7 +41,7 @@ func NewGitlabProvider() *Gitlab {
// FetchAuthUser returns an AuthUser instance based the Gitlab's user api.
//
// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin
// API reference: https://docs.gitlab.com/api/users/#retrieve-the-current-user
func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -53,11 +54,12 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
ConfirmedAt string `json:"confirmed_at"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
@@ -67,7 +69,6 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
@@ -76,5 +77,10 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
confirmedAt, err := time.Parse(time.RFC3339, extracted.ConfirmedAt)
if err == nil && !confirmedAt.IsZero() {
user.Email = extracted.Email
}
return user, nil
}
+12 -7
View File
@@ -3,7 +3,9 @@ package mailer
import (
"errors"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"github.com/domodwyer/mailyak/v3"
@@ -70,16 +72,18 @@ func (c *SMTPClient) send(m *Message) error {
}
}
hostWithPort := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
// create mail instance
var yak *mailyak.MailYak
if c.TLS {
var tlsErr error
yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth, nil)
yak, tlsErr = mailyak.NewWithTLS(hostWithPort, smtpAuth, nil)
if tlsErr != nil {
return tlsErr
}
} else {
yak = mailyak.New(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth)
yak = mailyak.New(hostWithPort, smtpAuth)
}
if c.LocalName != "" {
@@ -133,15 +137,16 @@ func (c *SMTPClient) send(m *Message) error {
}
// add custom headers (if any)
var hasMessageId bool
var hasMessageIdHeader bool
for k, v := range m.Headers {
if strings.EqualFold(k, "Message-ID") {
hasMessageId = true
if !hasMessageIdHeader && strings.EqualFold(k, "Message-ID") {
hasMessageIdHeader = true
}
yak.AddHeader(k, v)
}
if !hasMessageId {
// add a default message id if missing
// add a default message id if missing
if !hasMessageIdHeader {
fromParts := strings.Split(m.From.Address, "@")
if len(fromParts) == 2 {
yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>",
+1 -1
View File
@@ -349,7 +349,7 @@ const DefaultMaxMemory = 16 << 20 // 16mb
//
// Title string `json:"title" form:"title"`
// Total int `json:"total" form:"total"`
// }
// }{}
// err := e.BindBody(&data)
func (e *Event) BindBody(dst any) error {
if e.Request.ContentLength == 0 {
+1 -1
View File
@@ -11,4 +11,4 @@ PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.37.2"
PB_VERSION = "v0.37.4"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
<!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-Un_20Bdf.js"></script>
<script type="module" crossorigin src="./assets/index-BB3JTWj3.js"></script>
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
<link rel="stylesheet" crossorigin href="./assets/index-Cdd8TSHO.css">
<link rel="stylesheet" crossorigin href="./assets/index-ouas71Vg.css">
</head>
<body>
</body>
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -224,9 +224,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -937,9 +937,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
File diff suppressed because one or more lines are too long
+2
View File
@@ -59,6 +59,7 @@ export function appHeader() {
colorSchemeButton(),
t.button(
{
type: "button",
className: "header-link logged-user txt-normal",
"html-popovertarget": "logged-user-dropdown",
},
@@ -108,6 +109,7 @@ function colorSchemeButton() {
return [
t.button(
{
type: "button",
className: "header-link color-scheme-picker",
"html-popovertarget": "color-scheme-dropdown",
title: "Color scheme",
+18 -15
View File
@@ -41,9 +41,9 @@ window.app.components.codeEditor = function(propsArg = {}) {
// ]
autocomplete: undefined, // Array<string|Object> | function(word): Array<string|Object>,
// ---
oninput: function(val, e) {},
onfocus: function(val, e) {},
onblur: function(val, e) {},
oninput: function(val) {},
onfocus: function(val) {},
onblur: function(val) {},
});
const extendWatchers = app.utils.extendStore(props, propsArg, "autocomplete");
@@ -121,6 +121,12 @@ window.app.components.codeEditor = function(propsArg = {}) {
isFieldVisible = true;
}
function updateValue(newVal) {
props.value = newVal;
props.oninput?.(newVal);
editorContent.dispatchEvent(new CustomEvent("change", { detail: newVal }));
}
let isCtrlOrCmdKey = false;
let valueWatcher;
@@ -155,8 +161,8 @@ window.app.components.codeEditor = function(propsArg = {}) {
valueWatcher?.unwatch();
closeAutocompleteDropdown();
},
onfocus: (e) => {
props.onfocus?.(props.value, e);
onfocus: () => {
props.onfocus?.(props.value);
},
onblur: (e) => {
// not blurred because of dropdown click
@@ -164,16 +170,12 @@ window.app.components.codeEditor = function(propsArg = {}) {
closeAutocompleteDropdown();
}
props.onblur?.(props.value, e);
props.onblur?.(props.value);
},
oninput: (e) => {
closeAutocompleteDropdown();
props.value = editorContent.textContent;
props.oninput?.(props.value, e);
editorContent.dispatchEvent(new CustomEvent("change", { detail: props.value }));
updateValue(editorContent.textContent);
if (!props.value?.length) {
editorContent.textContent = ""; // ensure that no comments, br, etc. tags are left
@@ -235,7 +237,8 @@ window.app.components.codeEditor = function(propsArg = {}) {
editorContent.textContent = editorContent.textContent.substring(0, match.start)
+ word
+ editorContent.textContent.substring(match.end + 1);
props.value = editorContent.textContent;
updateValue(editorContent.textContent);
try {
window
@@ -316,13 +319,13 @@ window.app.components.codeEditor = function(propsArg = {}) {
selection.modify("extend", "backward", "character");
if (selection.toString()[0] == "\t") {
selection.deleteFromDocument();
props.value = editorContent.textContent;
updateValue(editorContent.textContent);
} else {
// check ahead and restore
selection.modify("extend", "forward", "character");
if (selection.toString()[0] == "\t") {
selection.deleteFromDocument();
props.value = editorContent.textContent;
updateValue(editorContent.textContent);
}
}
@@ -335,7 +338,7 @@ window.app.components.codeEditor = function(propsArg = {}) {
range.deleteContents();
range.insertNode(document.createTextNode("\t"));
range.collapse();
props.value = editorContent.textContent;
updateValue(editorContent.textContent);
}
return;
+1
View File
@@ -247,6 +247,7 @@ function initSearch(selectFunc = null) {
if (data.searchTerm.length) {
return t.button(
{
type: "button",
className: "link-hint",
title: "Clear search",
onclick: () => reset(),
+1 -1
View File
@@ -249,7 +249,7 @@ window.app.components.select = function(propsArg = {}) {
tabIndex: -1,
role: "button",
className: "ri-close-line link-hint btn-option-unset",
ariaLabel: app.attrs.tooltip("Unset", "left"),
ariaLabel: app.attrs.tooltip("Unset"),
onclick: () => {
toggle(opt);
return false;
+5 -4
View File
@@ -124,10 +124,10 @@ window.app.components.tinymce = function(propsArg = {}) {
clearTimeout(changeTimeoutId);
// workaround for https://github.com/tinymce/tinymce/issues/9377
editorRef.dom?.unbind(document);
catchError(() => {
// workaround for https://github.com/tinymce/tinymce/issues/9377
editorRef.dom?.unbind(document);
window.tinymce?.remove(editorRef);
});
editorRef = null;
@@ -162,6 +162,7 @@ window.app.components.tinymce = function(propsArg = {}) {
autoresize_bottom_margin: 30,
media_poster: false,
media_alt_source: false,
ui_mode: "split",
codesample_languages: [
{ text: "HTML/XML", value: "markup" },
{ text: "CSS", value: "css" },
@@ -202,7 +203,7 @@ window.app.components.tinymce = function(propsArg = {}) {
"wordcount",
],
toolbar:
"styles | alignleft aligncenter alignright | bold italic forecolor backcolor | bullist numlist | link table media_picker codesample | direction code",
"styles | alignleft aligncenter alignright | bold italic forecolor backcolor | bullist numlist | link media_picker table codesample | direction code",
paste_postprocess: (editor, args) => {
cleanupPastedNode(args.node);
},
+1
View File
@@ -133,6 +133,7 @@ function addToast(textOrElem, options = {}) {
textOrElem,
t.button(
{
type: "button",
className: "m-l-auto btn circle sm transparent secondary toast-remove",
title: "Clear",
onclick: () => removeToast(toastRef),
+8 -3
View File
@@ -6,8 +6,9 @@ export function collectionViewQueryTab(upsertData) {
// dprint-ignore
const autocomplete = [
"SELECT", "FROM", "WHERE", "LEFT JOIN", "INNER JOIN", "ON",
"GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET", "AS",
"WITH", "NOT", "IN", "EXISTS", "LIKE", "CAST",
"AS", "GROUP BY", "HAVING", "ORDER BY", "ASC", "DESC", "LIMIT",
"OFFSET", "WITH", "NOT", "IN", "AND", "OR", "EXISTS", "LIKE",
"CAST", "REAL", "DECIMAL", "NUMERIC", "INT", "TEXT", "BOOL",
];
const local = store({
@@ -171,9 +172,13 @@ export function collectionViewQueryTab(upsertData) {
),
t.div(
{ className: "col-12" },
t.p({ className: "txt-sm txt-bold" }, "Sample output:"),
t.p(
{ className: "txt-sm txt-bold" },
"Sample output:",
),
t.div(
{ className: "view-query-sample-wrapper" },
t.span({ hidden: () => !local.isTesting, className: "loader sm" }),
app.components.codeBlock({
language: () => local.testError ? "plain" : "js",
className: () => `view-query-sample ${local.testError ? "txt-danger" : ""}`,
+1 -1
View File
@@ -6,7 +6,7 @@ export function mfaAccordion(collection) {
if (!collection.mfa) {
collection.mfa = {
enabled: false,
duration: 900,
duration: 600,
rule: "",
};
}
+8 -9
View File
@@ -5,9 +5,10 @@ const FILTER_QUERY_KEY = "filter";
const COLLECTION_QUERY_KEY = "collection";
const RECORD_QUERY_KEY = "record";
const LAST_ACTIVE_STORAGE_KEY = "pbLastActiveCollection";
const TOTAL_COUNT_REQUEST_KEY = "recordsTotalCountRequest";
export function pageCollections(route) {
const uniqueId = "page_collections_" + app.utils.randomString();
app.store.activeCollection = route.query[COLLECTION_QUERY_KEY]?.[0]
|| window.localStorage.getItem(LAST_ACTIVE_STORAGE_KEY);
@@ -34,7 +35,8 @@ export function pageCollections(route) {
);
const result = await app.pb.collection(app.store.activeCollection.name).getList(1, 1, {
requestKey: TOTAL_COUNT_REQUEST_KEY,
// use a per page unique id and not a global constant to prevent race issues with the async unmount
requestKey: uniqueId,
filter: normalizedFilter,
fields: "id",
});
@@ -60,13 +62,10 @@ export function pageCollections(route) {
(newVal, oldVal) => {
app.store.title = app.store.activeCollection?.name || "Collections";
// skip unnecessery initial params replacement
if (!oldVal) {
return;
}
const hasChanged = oldVal && oldVal != newVal;
// reset filter and sort params on collection change
if (oldVal != newVal) {
if (hasChanged) {
pageData.filter = "";
pageData.sort = "";
}
@@ -75,7 +74,7 @@ export function pageCollections(route) {
[COLLECTION_QUERY_KEY]: app.store.activeCollection?.name,
[FILTER_QUERY_KEY]: pageData.filter || null,
[SORT_QUERY_KEY]: pageData.sort || null,
}, newVal != oldVal ? true : null);
}, hasChanged ? true : null);
if (app.store.activeCollection?.id) {
window.localStorage.setItem(LAST_ACTIVE_STORAGE_KEY, app.store.activeCollection.id);
@@ -184,7 +183,7 @@ export function pageCollections(route) {
}
},
onunmount: () => {
app.pb.cancelRequest(TOTAL_COUNT_REQUEST_KEY);
app.pb.cancelRequest(uniqueId);
watchers.forEach((w) => w?.unwatch());
+4 -4
View File
@@ -894,14 +894,14 @@ hr {
background: var(--surfaceAlt1Color);
background: linear-gradient(
90deg,
var(--surfaceAlt1Color) 8%,
var(--surfaceAlt2Color) 18%,
var(--surfaceAlt1Color) 33%
var(--surfaceAlt1Color) 10%,
var(--surfaceAlt2Color) 25%,
var(--surfaceAlt1Color) 60%
);
background-size: 200% 100%;
animation:
fadeIn var(--animationSpeed),
shine 0.8s linear infinite;
shine 1s linear infinite;
&.sm {
height: 8px;
}
+10
View File
@@ -88,7 +88,17 @@
padding: var(--smSpacing);
}
.view-query-sample-wrapper {
position: relative;
.loader {
position: absolute;
z-index: 1;
right: 10px;
top: 10px;
}
}
.view-query-sample code {
z-index: 0;
max-height: 300px;
background: none;
border: 1px solid var(--surfaceAlt2Color);
+7 -2
View File
@@ -138,7 +138,6 @@
width: 2000px; /* something large enough to fit the lines */
height: 8000px;
pointer-events: none;
transition: opacity var(--animationSpeed);
}
.paths-group {
fill: none;
@@ -146,7 +145,7 @@
stroke: var(--pathColor);
}
.erd-paths.front {
--pathColor: var(--surfaceAlt5Color);
--pathColor: var(--surfaceTxtDisabledColor);
z-index: 2;
.active-from {
@@ -176,6 +175,12 @@
cursor: grabbing;
}
&.active {
.erd-table {
opacity: 0.7;
&.active {
opacity: 1;
}
}
.erd-paths.back {
opacity: 0.3;
}
+5 -3
View File
@@ -585,6 +585,7 @@ button {
&.required label,
label.required,
.required > label,
label:has(~ .autoexpand-wrapper textarea[required]),
label:has(~ [required]),
label:has(~ .input.required) {
&::after {
@@ -599,6 +600,7 @@ button {
&.disabled label,
label.disabled,
.disabled > label,
label:has(~ .autoexpand-wrapper textarea[disabled]),
label:has(~ [disabled]),
label:has(~ .input.disabled) {
color: var(--surfaceTxtDisabledColor);
@@ -672,15 +674,15 @@ button {
}
.field-list {
label {
padding-bottom: 9px;
}
.field-list-content {
overflow: auto;
max-height: 500px;
scrollbar-width: thin;
color: var(--surfaceTxtColor);
}
label + .field-list-content {
margin-top: 8px;
}
.field-list-item {
display: flex;
width: 100%;
+8
View File
@@ -15,6 +15,14 @@
display: none;
}
.list-content {
display: block;
width: 100%;
max-height: 425px;
overflow: auto;
scrollbar-width: thin;
}
.list-item {
word-break: break-word;
position: relative;
+5 -6
View File
@@ -196,12 +196,6 @@
}
}
.record-field-input {
.record-summary {
animation: fadeIn var(--animationSpeed);
}
}
.field-type-bool {
&.record-field-view .label {
min-width: 48px;
@@ -229,6 +223,11 @@
&.record-field-view {
white-space: nowrap;
}
/* disable multiple unnecessery required marks */
&.record-field-input .field-list-item label::after {
content: none;
display: none;
}
}
.field-type-select {
+14 -6
View File
@@ -3,11 +3,14 @@
.code-editor {
z-index: 0;
min-height: 41px;
.editor-content:empty::before {
font-family: var(--baseFontFamily);
}
.highlight-overlay {
color: var(--surfaceTxtColor);
}
.editor-content:empty::before {
font-family: var(--baseFontFamily);
.editor-content, .highlight-overlay {
white-space: pre-wrap;
}
}
.superuser-toggle,
@@ -32,8 +35,10 @@
font-size: var(--smFontSize);
padding: 8px 10px;
color: var(--surfaceTxtHintColor);
background: var(--surfaceAlt4Color);
box-shadow: inset 1px -1px 2px rgba(0,0,0,0.03);
background: var(--inputFocusColor);
border: 1px solid var(--surfaceAlt5Color);
border-top: 0;
border-right: 0;
transition:
color var(--animationSpeed),
background var(--animationSpeed);
@@ -41,14 +46,17 @@
&:active,
&:focus-visible {
color: var(--successColor);
background: var(--surfaceAlt5Color);
background: var(--inputBorderColor);
}
&[disabled] {
cursor: not-allowed;
color: var(--surfaceTxtDisabledColor);
background: var(--surfaceAlt4Color);
background: var(--surfaceAlt3Color);
}
}
.input:focus-within ~ .superuser-toggle {
background: var(--inputBorderColor);
}
label {
position: relative;
z-index: 2;
+1
View File
@@ -298,6 +298,7 @@ table {
.page-table-wrapper {
display: block;
width: auto;
min-height: 130px;
overflow: auto;
margin-left: calc(-1 * var(--spacing));
margin-right: calc(-1 * var(--spacing));
+15 -14
View File
@@ -12,9 +12,9 @@
--primaryTxtHintColor: color-mix(in srgb, var(--primaryTxtColor), transparent 35%);
--primaryTxtDisabledColor: color-mix(in srgb, var(--primaryTxtColor), transparent 50%);
--secondaryColor: #e8eaee;
--secondaryAlt1Color: #dcdfe5;
--secondaryAlt2Color: #c4cbd4;
--secondaryColor: #e4e8ec;
--secondaryAlt1Color: #dce0e5;
--secondaryAlt2Color: #d3d9df;
--secondaryTxtColor: var(--primaryColor);
--secondaryTxtHintColor: color-mix(in srgb, var(--secondaryTxtColor), transparent 35%);
--secondaryTxtDisabledColor: color-mix(in srgb, var(--secondaryTxtColor), transparent 50%);
@@ -55,20 +55,20 @@
--accentTxtDisabledColor: color-mix(in srgb, var(--accentTxtColor), transparent 50%);
--surfaceColor: #fff;
--surfaceAlt1Color: #f6f7f9;
--surfaceAlt2Color: #e8eaee;
--surfaceAlt3Color: #dfe2e7;
--surfaceAlt4Color: #d9dde2;
--surfaceAlt5Color: #d1d6dc;
--surfaceAlt1Color: #f8f9fa;
--surfaceAlt2Color: #e4e8ec;
--surfaceAlt3Color: #dce0e5;
--surfaceAlt4Color: #d3d9df;
--surfaceAlt5Color: #ccd4db;
--surfaceTxtColor: #25272d;
--surfaceTxtHintColor: #6b747b;
--surfaceTxtDisabledColor: #abaeba;
--surfaceTxtHintColor: #687278;
--surfaceTxtDisabledColor: #a2a9ae;
--surfaceAccentColor: color-mix(in srgb, var(--accentColor), white 80%);
--surfaceInfoColor: color-mix(in srgb, var(--infoColor), white 80%);
--surfaceSuccessColor: color-mix(in srgb, var(--successColor), white 80%);
--surfaceWarningColor: color-mix(in srgb, var(--warningColor), white 80%);
--surfaceDangerColor: color-mix(in srgb, var(--dangerColor), white 80%);
--selectionColor: rgba(34, 36, 42, 0.22);
--selectionColor: rgba(70, 90, 115, 0.25);
--inputColor: var(--surfaceAlt2Color);
--inputFocusColor: var(--surfaceAlt3Color);
--inputBorderColor: var(--surfaceAlt4Color);
@@ -85,7 +85,7 @@
/* ---------------- */
--modalAnimationSpeed: 200ms;
--modalOverlayColor: rgba(34, 36, 42, 0.22);
--modalOverlayColor: rgba(70, 90, 115, 0.25);
--tooltipTxtColor: #fff;
--tooltipSurfaceColor: rgba(34, 36, 36, 0.9);
@@ -139,13 +139,13 @@
[data-color-scheme="dark"],
[data-color-scheme="dark"] .dropdown {
--surfaceColor: #1f1f1f;
--surfaceAlt1Color: color-mix(in srgb, var(--surfaceColor), white 3%);
--surfaceAlt1Color: color-mix(in srgb, var(--surfaceColor), white 2%);
--surfaceAlt2Color: color-mix(in srgb, var(--surfaceColor), white 7%);
--surfaceAlt3Color: color-mix(in srgb, var(--surfaceColor), white 12%);
--surfaceAlt4Color: color-mix(in srgb, var(--surfaceColor), white 14%);
--surfaceAlt5Color: color-mix(in srgb, var(--surfaceColor), white 16%);
--surfaceTxtColor: #dedede;
--surfaceTxtColor: #e3e3e3;
--tooltipTxtColor: var(--surfaceTxtColor);
--surfaceTxtHintColor: color-mix(in srgb, var(--surfaceTxtColor), transparent 45%);
--surfaceTxtDisabledColor: color-mix(in srgb, var(--surfaceTxtColor), transparent 65%);
@@ -193,6 +193,7 @@
scrollbar-color: var(--surfaceAlt5Color) transparent;
--modalOverlayColor: rgb(0,0,0, 0.45);
--tooltipSurfaceColor: rgb(22,22,22, 0.9);
--boxShadow: 0px 8px 5px -5px rgba(0,0,0, 0.3);
--leftBoxShadow: -1px 0px 5px 0 rgba(0,0,0, 0.3);
+12 -7
View File
@@ -7,26 +7,31 @@
export function input(props) {
const uniqueId = "editor_" + app.utils.randomString();
const local = store({
lazyEditor: null,
const data = store({
lazyEditor: "",
});
let lazyEditorTimeoutId;
return t.div(
{
className: "record-field-input field-type-editor large-modal",
onmount: () => {
requestAnimationFrame(() => {
local.lazyEditor = app.components.tinymce({
lazyEditorTimeoutId = setTimeout(() => {
data.lazyEditor = app.components.tinymce({
id: uniqueId,
name: () => props.field.name,
required: () => props.field.required,
convertURLs: () => props.field.convertURLs,
name: () => props.field.name,
value: () => props.record[props.field.name] || "",
onchange: (val) => {
props.record[props.field.name] = val;
},
});
});
}, 0);
},
onunmount: () => {
clearTimeout(lazyEditorTimeoutId);
},
},
t.div(
@@ -36,7 +41,7 @@ export function input(props) {
t.i({ className: app.fieldTypes.editor.icon, ariaHidden: true }),
t.span({ className: "txt" }, () => props.field.name),
),
() => local.lazyEditor,
() => data.lazyEditor,
),
() => {
if (props.field.help) {
+1 -1
View File
@@ -240,7 +240,7 @@ export function input(props) {
},
},
t.div(
{ className: () => `field ${props.field.required ? "required" : ""}` },
{ className: () => `field-list ${props.field.required ? "required" : ""}` },
t.label(
{ htmlFor: uniqueId },
t.i({ className: app.fieldTypes.file.icon, ariaHidden: true }),
+1 -1
View File
@@ -304,7 +304,7 @@ export function settings(data) {
t.span({ className: "txt" }, "Protected"),
t.small(
{ className: "txt-hint" },
"Files will require View API rule permissions and file token (",
"File download requests will need to satisfy the View API rule (",
t.a({
href: import.meta.env.PB_PROTECTED_FILE_DOCS,
target: "_blank",
+4 -4
View File
@@ -33,8 +33,8 @@ export function input(data) {
id: uniqueId + ".lon",
type: "number",
step: "any",
min: "-180",
max: "180",
min: -180,
max: 180,
placeholder: 0,
name: () => data.field.name,
required: () => data.field.required,
@@ -53,8 +53,8 @@ export function input(data) {
id: uniqueId + ".lat",
type: "number",
step: "any",
min: "-90",
max: "90",
min: -90,
max: 90,
placeholder: 0,
name: () => data.field.name,
required: () => data.field.required,
+19 -19
View File
@@ -69,25 +69,6 @@ export function settings(data) {
),
),
footer: () => [
t.div(
{ className: "field" },
t.input({
className: "sm",
type: "checkbox",
id: uniqueId + ".onlyInt",
name: () => `fields.${data.fieldIndex}.onlyInt`,
checked: () => !!data.field.onlyInt,
onchange: (e) => (data.field.onlyInt = e.target.checked),
}),
t.label(
{ htmlFor: uniqueId + ".onlyInt" },
t.span({ className: "txt" }, "No decimals"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip("Existing decimal numbers will not be affected."),
}),
),
),
t.div(
{ className: "field" },
t.input({
@@ -108,6 +89,25 @@ export function settings(data) {
}),
),
),
t.div(
{ className: "field" },
t.input({
className: "sm",
type: "checkbox",
id: uniqueId + ".onlyInt",
name: () => `fields.${data.fieldIndex}.onlyInt`,
checked: () => !!data.field.onlyInt,
onchange: (e) => (data.field.onlyInt = e.target.checked),
}),
t.label(
{ htmlFor: uniqueId + ".onlyInt" },
t.span({ className: "txt" }, "No decimals"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip("Existing decimal numbers will not be affected."),
}),
),
),
],
});
}
+40 -22
View File
@@ -36,34 +36,51 @@ export function input(props) {
return;
}
try {
const fieldCollection = app.store.collections.find((c) => c.id == props.field.collectionId);
const resultRecords = [];
const idsToLoad = [];
// eagerly expand first level presentable relations (if any and the collections are loaded)
const relExpands = [];
const presentableRelationFields = fieldCollection?.fields?.filter(
(f) => !f.hidden && f.presentable && f.type == "relation",
) || [];
for (const field of presentableRelationFields) {
relExpands.push(field.name);
// check for preloaded expand
const expanded = app.utils.toArray(props.record.expand?.[props.field.name]);
for (const id of ids) {
const found = expanded.find((r) => r.id == id);
if (found) {
resultRecords.push(found);
} else {
idsToLoad.push(id);
}
}
const records = await app.pb.collection(props.field.collectionId).getFullList({
requestKey: null,
filter: ids.map((id) => app.pb.filter("id={:id}", { id })).join("||"),
expand: relExpands.join(",") || undefined,
});
try {
if (idsToLoad.length) {
const fieldCollection = app.store.collections.find((c) => c.id == props.field.collectionId);
// preserve the original order
const orderedRecords = [];
for (let id of ids) {
const record = records.find((r) => r.id == id);
if (record) {
orderedRecords.push(record);
// eagerly expand first level presentable relations (if any and the collections are loaded)
const relExpands = [];
const presentableRelationFields = fieldCollection?.fields?.filter(
(f) => !f.hidden && f.presentable && f.type == "relation",
) || [];
for (const field of presentableRelationFields) {
relExpands.push(field.name);
}
const records = await app.pb.collection(props.field.collectionId).getFullList({
requestKey: null,
filter: idsToLoad.map((id) => app.pb.filter("id={:id}", { id })).join("||"),
expand: relExpands.join(",") || undefined,
});
// preserve the original order
for (const id of idsToLoad) {
const found = records.find((r) => r.id == id);
if (found) {
resultRecords.push(found);
} else {
console.warn("missing relation id:", id);
}
}
}
local.selected = orderedRecords;
local.selected = resultRecords;
local.isLoading = false;
} catch (err) {
if (!err.isAbort) {
@@ -135,6 +152,7 @@ export function input(props) {
{ className: "actions" },
t.button(
{
type: "button",
className: "btn sm secondary transparent circle",
ariaLabel: app.attrs.tooltip("Remove"),
onclick: () => remove(record.id),
@@ -180,7 +198,7 @@ export function input(props) {
},
},
t.div(
{ className: () => `field ${props.field.required ? "required" : ""}` },
{ className: () => `field-list ${props.field.required ? "required" : ""}` },
t.label(
{ htmlFor: uniqueId },
t.i({ className: app.fieldTypes.relation.icon, ariaHidden: true }),
+12 -11
View File
@@ -4,7 +4,7 @@
// record: undefined,
// field: undefined,
// }
export function input(data) {
export function input(props) {
const uniqueId = "select_" + app.utils.randomString();
return t.div(
@@ -14,33 +14,34 @@ export function input(data) {
t.label(
{ htmlFor: uniqueId },
t.i({ className: app.fieldTypes.select.icon, ariaHidden: true }),
t.span({ className: "txt" }, () => data.field.name),
t.span({ className: "txt" }, () => props.field.name),
),
app.components.select({
id: uniqueId,
max: () => data.field.maxSelect || 1,
required: () => data.field.required,
name: () => props.field.name,
max: () => props.field.maxSelect || 1,
required: () => props.field.required,
options: () => {
return data.field.values.map((v) => {
return props.field.values.map((v) => {
return { value: v };
});
},
value: () => {
return app.utils.toArray(data.record[data.field.name]);
return app.utils.toArray(props.record[props.field.name]);
},
onchange: (opts) => {
if (data.field.maxSelect <= 1) {
data.record[data.field.name] = opts?.[0]?.value || "";
if (props.field.maxSelect <= 1) {
props.record[props.field.name] = opts?.[0]?.value || "";
return;
}
data.record[data.field.name] = opts.map((o) => o.value);
props.record[props.field.name] = opts.map((o) => o.value);
},
}),
),
() => {
if (data.field.help) {
return t.div({ className: "field-help" }, data.field.help);
if (props.field.help) {
return t.div({ className: "field-help" }, props.field.help);
}
},
);
+46 -58
View File
@@ -54,6 +54,18 @@ function copyJSON(record) {
}
function recordPreviewModal(rawRecord, modalSettings) {
if (!rawRecord?.id) {
app.toasts.error("Failed to load record.");
console.warn("[recordPreviewModal] missing required record id field:", rawRecord);
return;
}
if (!rawRecord.collectionId && !rawRecord.collectionName) {
app.toasts.error("Failed to load record.");
console.warn("[recordPreviewModal] missing required collectionId or collectionName field:", rawRecord);
return;
}
let modal;
const uniqueId = app.utils.randomString();
@@ -69,39 +81,31 @@ function recordPreviewModal(rawRecord, modalSettings) {
});
async function loadRecord() {
if (!rawRecord?.id) {
app.toasts.error("Failed to load record.");
setTimeout(() => app.modals.close(modal), 0);
console.warn("[recordPreviewModal] missing required record id field:", rawRecord);
return;
}
if (!rawRecord.collectionId && !rawRecord.collectionName) {
app.toasts.error("Failed to load record.");
setTimeout(() => app.modals.close(modal), 0);
console.warn("[recordPreviewModal] missing required collectionId or collectionName field:", rawRecord);
return;
}
data.isLoading = true;
try {
// eagerly expand first level presentable relations (if any and the collections are loaded)
// preload to minimize content jumps
data.record = JSON.parse(JSON.stringify(rawRecord));
// eagerly expand first level relations (if any and the collections are loaded)
let relExpands = [];
const presentableRelationFields = data.collection?.fields?.filter(
(f) => !f.hidden && f.presentable && f.type == "relation",
(f) => !f.hidden && f.type == "relation",
) || [];
for (let field of presentableRelationFields) {
relExpands.push(field.name);
}
data.record = await app.pb
.collection(rawRecord.collectionId || rawRecord.collectionName)
const record = await app.pb
.collection(rawRecord.collectionName || rawRecord.collectionId)
.getOne(rawRecord.id, {
requestKey: "record_preview_" + rawRecord.id,
expand: relExpands.join(",") || undefined,
});
// populate with an up-to-date fields
Object.assign(data.record, record);
data.isLoading = false;
} catch (err) {
if (!err?.isAbort) {
@@ -130,10 +134,6 @@ function recordPreviewModal(rawRecord, modalSettings) {
modalSettings.onafterclose?.(el);
el?.remove();
},
onmount: (el) => {
},
onunmount: (el) => {
},
},
t.header(
{ className: "modal-header" },
@@ -145,45 +145,32 @@ function recordPreviewModal(rawRecord, modalSettings) {
t.button(
{
title: "More options",
className: "btn sm circle transparent m-l-auto",
className: () => `btn sm circle transparent m-l-auto ${data.isLoading ? "loading" : ""}`,
disabled: () => data.isLoading,
"html-popovertarget": uniqueId + "preview-dropdown",
},
t.i({ className: "ri-more-line", ariaHidden: true }),
),
t.div({ id: uniqueId + "preview-dropdown", className: "dropdown", popover: "auto" }, (el) => {
return t.button(
{
className: "dropdown-item",
onclick: () => {
copyJSON(data.record);
el.hidePopover();
t.div(
{ id: uniqueId + "preview-dropdown", className: "dropdown", popover: "auto" },
(el) => {
return t.button(
{
className: "dropdown-item",
onclick: () => {
copyJSON(data.record);
el.hidePopover();
},
},
},
t.i({ className: "ri-braces-line", ariaHidden: true }),
t.span({ className: "txt" }, "Copy JSON"),
);
}),
t.i({ className: "ri-braces-line", ariaHidden: true }),
t.span({ className: "txt" }, "Copy JSON"),
);
},
),
),
t.div({ className: "modal-content" }, () => {
// loader
if (data.isLoading || !data.record?.id || !data.collection?.id) {
return t.table(
null,
t.tbody(null, () => {
const totalRows = data.collection?.fields?.filter((f) => f.type != "password").length || 1;
const rows = [];
for (let i = 0; i < totalRows; i++) {
rows.push(t.tr(null, t.td(null, t.span({ className: "skeleton-loader" }))));
}
return rows;
}),
);
}
// attrs
return t.table(
t.div(
{ className: "modal-content" },
t.table(
{
pbEvent: "recordPreviewTable",
className: "record-preview-table responsive-table",
@@ -219,8 +206,8 @@ function recordPreviewModal(rawRecord, modalSettings) {
);
});
}),
);
}),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
@@ -234,7 +221,8 @@ function recordPreviewModal(rawRecord, modalSettings) {
t.button(
{
type: "button",
className: "btn",
className: () => `btn ${data.isLoading ? "loading" : ""}`,
disabled: () => data.isLoading,
onclick: () => downloadJSON(data.record),
},
t.i({ className: "ri-download-line", ariaHidden: true }),
+92 -79
View File
@@ -43,28 +43,27 @@ window.app.modals.openRecordUpsert = function(collection, record = null, modalSe
app.modals.open(modal);
};
const defaultRedactFields = ["expand"];
function redacted(record, redactFields = defaultRedactFields) {
// create redacted clone only if necessery
if (redactFields.find((f) => typeof record[f] !== "undefined")) {
record = Object.assign({}, record);
for (let f of redactFields) {
delete record[f];
}
// redact common sensitive fields
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter
function redactedReplacer(key, val) {
switch (key) {
case "expand":
case "password":
case "passwordConfirm":
case "tokenKey":
return undefined;
}
return record;
return val;
}
function downloadJSON(record) {
record = redacted(record);
app.utils.downloadJSON(record, record.collectionName + "_" + record.id + ".json");
const pojo = JSON.parse(JSON.stringify(record, redactedReplacer));
app.utils.downloadJSON(pojo, record.collectionName + "_" + record.id + ".json");
}
function copyJSON(record) {
record = redacted(record);
app.utils.copyToClipboard(JSON.stringify(record, null, 2));
app.utils.copyToClipboard(JSON.stringify(record, redactedReplacer, 2));
app.toasts.success("Record copied to clipboard!");
}
@@ -73,7 +72,7 @@ function serializeRecord(record) {
return "";
}
return JSON.stringify(record);
return JSON.stringify(record, redactedReplacer);
}
const TAB_MAIN = "main";
@@ -193,36 +192,71 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
data.record = draftClone;
}
let draftWatcher;
function initDraftWatcher() {
data.initialDraft = getDraft();
draftWatcher?.unwatch();
draftWatcher = watch(() => data.recordHash, (newHash, oldHash) => {
if (typeof oldHash == "undefined") {
return;
}
if (data.hasChanges) {
saveDraft(newHash);
} else {
deleteDraft();
}
});
}
async function initRecord(rawRecord) {
data.isLoading = true;
const recordId = typeof rawRecord == "string" ? rawRecord : rawRecord?.id;
draftWatcher?.unwatch();
// normalize rawRecord (could be plain id string)
rawRecord = app.utils.isObject(rawRecord) ? rawRecord : { id: rawRecord || "" };
// new record
if (!recordId) {
const record = app.utils.isObject(rawRecord) ? JSON.parse(JSON.stringify(rawRecord)) : {};
data.originalRecord = app.utils.emptyClone(record, ["collectionId", "collectionName"]);
data.initialDraft = getDraft();
data.record = record;
if (!rawRecord.id) {
data.originalRecord = JSON.parse(JSON.stringify(rawRecord));
data.record = JSON.parse(JSON.stringify(rawRecord));
data.isLoading = false;
data.isLocked = false;
initDraftWatcher();
return;
}
data.isLocked = !!app.store.settings?.meta?.hideControls;
try {
// eagerly load to allow elements to show their "update" UI and minimize flickering
data.originalRecord = { id: recordId };
data.isLocked = !!app.store.settings?.meta?.hideControls;
const record = await app.pb.collection(collection.name).getOne(recordId, {
requestKey: "upsert_load_" + recordId,
// preload to minimize content jumps
data.originalRecord = JSON.parse(JSON.stringify(rawRecord));
data.record = JSON.parse(JSON.stringify(rawRecord));
// fetch to ensure that the main record fields are up-to-date
let record = await app.pb.collection(collection.name).getOne(rawRecord.id, {
requestKey: "upsert_load_" + rawRecord.id,
});
data.originalRecord = record;
data.initialDraft = getDraft();
data.record = JSON.parse(JSON.stringify(record));
// preload existing expands (if any)
if (rawRecord.expand) {
record.expand = JSON.parse(JSON.stringify(rawRecord.expand));
}
// extend, not overwrite, to prevent reseting the reference passed down to the inputs
Object.assign(data.originalRecord, JSON.parse(JSON.stringify(record)));
Object.assign(data.record, JSON.parse(JSON.stringify(record)));
data.isLoading = false;
// schedule a macro task to allow fields to populate their reactive values
setTimeout(() => {
initDraftWatcher();
}, 0);
} catch (err) {
if (!err?.isAbort) {
app.checkApiError(err);
@@ -232,24 +266,32 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
}
}
function deleteInternalKeys(record) {
for (let key in record) {
if (key.startsWith("@@")) {
delete record[key];
}
}
}
async function exportPayload() {
const payload = {};
// shallow copy of the record fields
for (const prop in data.record) {
for (const key in data.record) {
// skip expand and internal dynamic enumerable props
if (prop == "expand" || prop.startsWith("@@")) {
if (key == "expand" || key.startsWith("@@")) {
continue;
}
let val = data.record[prop]?.__raw || data.record[prop];
let val = data.record[key]?.__raw || data.record[key];
// normalize undefined values
if (typeof val == "undefined") {
val = null;
}
payload[prop] = val;
payload[key] = val;
}
// apply fields save normalization funcs
@@ -297,12 +339,14 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
data.originalRecord = structuredClone(record);
data.record = structuredClone(record);
} else {
// don't overwrite to prevent loosing the reference passed down to the inputs
// extend, not overwrite, to prevent reseting the reference passed down to the inputs
Object.assign(data.originalRecord, structuredClone(record));
Object.assign(data.record, structuredClone(record));
deleteInternalKeys(data.originalRecord);
deleteInternalKeys(data.record);
}
modalSettings.onsave?.(structuredClone(record), isNew);
modalSettings.onsave?.(record, isNew);
// reset all errors
app.store.errors = null;
@@ -360,21 +404,15 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
initRecord(clone);
}
const watchers = [];
function mainTab() {
return [
t.div(
{ className: "modal-content" },
t.form(
t.div(
{
id: uniqueId + "form",
className: "grid",
inert: () => data.isLoading || data.isSaving,
onsubmit: (e) => {
e.preventDefault();
// save(); // don't allow to prevent accidental save on input enter
},
onmount: (el) => {
el._quickSaveHandler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS") {
@@ -605,31 +643,12 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
onbeforeopen: () => {
initRecord(rawRecord);
watchers.push(
watch(() => data.recordHash, (newHash, oldHash) => {
if (!oldHash || !newHash || newHash == "{}" || oldHash == "{}") {
return;
}
saveDraft(newHash);
}),
);
return modalSettings.onbeforeopen?.(el);
},
onafteropen: (el) => {
modalSettings.onafteropen?.(el);
},
onbeforeclose: (el, forceClosed) => {
if (
// there are no unsaved changes
!data.hasChanges
// the form has been edited
&& data.initialDraftHash != getDraftHash()
) {
deleteDraft();
}
if (forceClosed) {
return modalSettings.onbeforeclose?.(el);
}
@@ -656,11 +675,10 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
},
onafterclose: (el) => {
modalSettings.onafterclose?.(el);
watchers.forEach((w) => w?.unwatch());
el?.remove();
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
draftWatcher?.unwatch();
},
},
t.header(
@@ -669,20 +687,15 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
{ className: "grid" },
t.div(
{ className: "col-12 flex" },
t.h6({ className: "modal-title" }, () => {
if (data.isLoading) {
return t.span({ className: "loader sm" });
}
return [
t.span(null, () => (data.isNew ? "Create " : "Edit ")),
t.strong(
{ className: "txt-ellipsis collection-name", style: "max-width: 220px" },
() => collection.name,
),
t.span(null, " record"),
];
}),
t.h6(
{ className: "modal-title" },
t.span(null, () => (data.isNew ? "Create " : "Edit ")),
t.strong(
{ className: "txt-ellipsis collection-name", style: "max-width: 220px" },
() => collection.name,
),
t.span(null, " record"),
),
t.div({ className: "flex-fill" }),
() => {
if (app.utils.isEmpty(data.originalRecord?.id)) {
@@ -693,8 +706,8 @@ function recordUpsertModal(collection, rawRecord, modalSettings) {
t.button(
{
type: "button",
className: "btn sm circle transparent",
title: "More options",
className: () => `btn sm circle transparent ${data.isLoading ? "loading" : ""}`,
disabled: () => data.isLoading,
"html-popovertarget": uniqueId + "modal-header-dropdown",
},
+2 -2
View File
@@ -94,9 +94,9 @@ window.app.components.recordsList = function(propsArg = {}) {
// eagerly expand first level relations
// (to prevent too many relation queries)
const relExpands = [];
const relationFields = props.collection.fields.filter(
const relationFields = props.collection.fields?.filter(
(f) => !f.hidden && f.type == "relation",
);
) || [];
for (const field of relationFields) {
relExpands.push(field.name);
}
+80 -76
View File
@@ -100,7 +100,8 @@ export function backupsList(propsArg = {}) {
return t.div(
{
className: "list",
pbEvent: "backupsList",
className: "list backups-list",
onmount: (el) => {
watchers.push(watch(() => props.reset, () => {
loadBackups();
@@ -116,83 +117,86 @@ export function backupsList(propsArg = {}) {
},
},
t.div(
{
hidden: () => !data.isLoading || data.backups.length,
className: "list-item",
{ className: "list-content" },
t.div(
{
hidden: () => !data.isLoading || data.backups.length,
className: "list-item",
},
t.div({ className: "skeleton-loader" }),
),
t.div(
{
hidden: () => data.isLoading || data.backups.length,
className: () => "list-item",
},
t.div({ className: "content block txt-hint" }, "No backups found."),
),
() => {
return data.backups.map((backup) => {
return t.div(
{ className: () => `list-item ${data.isLoading ? "faded" : ""}` },
t.i({ className: "ri-folder-zip-line", ariaHidden: true }),
t.div(
{ className: "content" },
t.span({
className: "backup-name txt-ellipsis",
title: () => backup.key,
textContent: () => backup.key,
}),
t.small(
{ className: "backup-size txt-hint txt-nowrap" },
"(",
() => app.utils.formattedFileSize(backup.size),
")",
),
),
t.nav(
{
hidden: () => data.isLoading,
className: "actions autohide",
},
t.button(
{
type: "button",
ariaLabel: app.attrs.tooltip("Download"),
className: () =>
`btn sm circle secondary transparent ${
data.isDownloading[backup.key] ? "loading" : ""
}`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => downloadBackup(backup.key),
},
t.i({ className: "ri-download-line", ariaHidden: true }),
),
t.button(
{
type: "button",
ariaLabel: app.attrs.tooltip("Restore"),
className: () => `btn sm circle secondary transparent`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => openBackupRestoreModal(backup.key),
},
t.i({ className: "ri-restart-line", ariaHidden: true }),
),
t.button(
{
type: "button",
ariaLabel: app.attrs.tooltip("Delete"),
className: () =>
`btn sm circle secondary transparent ${
data.isDeleting[backup.key] ? "loading" : ""
}`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => confirmBackupDelete(backup.key),
},
t.i({ className: "ri-delete-bin-7-line", ariaHidden: true }),
),
),
);
});
},
t.div({ className: "skeleton-loader" }),
),
t.div(
{
hidden: () => data.isLoading || data.backups.length,
className: () => "list-item",
},
t.div({ className: "content block txt-hint" }, "No backups found."),
),
() => {
return data.backups.map((backup) => {
return t.div(
{ className: () => `list-item ${data.isLoading ? "faded" : ""}` },
t.i({ className: "ri-folder-zip-line", ariaHidden: true }),
t.div(
{ className: "content" },
t.span({
className: "backup-name txt-ellipsis",
title: () => backup.key,
textContent: () => backup.key,
}),
t.small(
{ className: "backup-size txt-hint txt-nowrap" },
"(",
() => app.utils.formattedFileSize(backup.size),
")",
),
),
t.nav(
{
hidden: () => data.isLoading,
className: "actions autohide",
},
t.button(
{
type: "button",
ariaLabel: app.attrs.tooltip("Download"),
className: () =>
`btn sm circle secondary transparent ${
data.isDownloading[backup.key] ? "loading" : ""
}`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => downloadBackup(backup.key),
},
t.i({ className: "ri-download-line", ariaHidden: true }),
),
t.button(
{
type: "button",
ariaLabel: app.attrs.tooltip("Restore"),
className: () => `btn sm circle secondary transparent`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => openBackupRestoreModal(backup.key),
},
t.i({ className: "ri-restart-line", ariaHidden: true }),
),
t.button(
{
type: "button",
ariaLabel: app.attrs.tooltip("Delete"),
className: () =>
`btn sm circle secondary transparent ${
data.isDeleting[backup.key] ? "loading" : ""
}`,
disabled: () => data.isDeleting[backup.key] || data.isDownloading[backup.key],
onclick: () => confirmBackupDelete(backup.key),
},
t.i({ className: "ri-delete-bin-7-line", ariaHidden: true }),
),
),
);
});
},
t.div(
{ className: "list-item" },
t.button(
+3 -2
View File
@@ -357,7 +357,7 @@ function removeErrorState(input, container) {
watch(
() => JSON.stringify(app.store.errors) && app.store.errors,
(errs) => {
// search for input or other elements wiht "name" attribute
// search for input or other elements with "name" attribute
const inputs = document.querySelectorAll(`[name]`);
for (let input of inputs) {
@@ -365,7 +365,8 @@ watch(
continue;
}
const container = input.closest(".fields") || input.closest(".field");
// find the top-most wrapper field element
const container = input.closest(".field-list") || input.closest(".fields") || input.closest(".field");
if (!container) {
continue;
}
+2
View File
@@ -348,6 +348,8 @@ const utils = {
clone[prop] = "";
} else if (typeof clone[prop] == "number") {
clone[prop] = 0;
} else if (typeof clone[prop] == "boolean") {
clone[prop] = false;
} else if (Array.isArray(clone[prop])) {
clone[prop] = [];
} else if (app.utils.isObject(clone[prop])) {