mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bf55097a | ||
|
|
338d672bee | ||
|
|
5bd9d87bad | ||
|
|
6ba78d5218 | ||
|
|
260bd59c5b | ||
|
|
006566478a | ||
|
|
419f335f5b | ||
|
|
326f150db2 | ||
|
|
1c86addc4c | ||
|
|
494f47efb8 | ||
|
|
555a4f1a1e | ||
|
|
37b258810a | ||
|
|
ca7cf1162f | ||
|
|
dddb0a029f | ||
|
|
5d55fc18ee | ||
|
|
449e5af590 | ||
|
|
1e460d3f96 | ||
|
|
b5030ddfa1 | ||
|
|
fbeb09c40b | ||
|
|
69cdda4bf3 | ||
|
|
e708f39e1b | ||
|
|
52eccb3aac | ||
|
|
1d593476b0 | ||
|
|
8a04904de1 | ||
|
|
a3ac674f36 | ||
|
|
ae7041a889 | ||
|
|
257f03e1fa | ||
|
|
3566ba3729 | ||
|
|
a6002c4622 | ||
|
|
2ddf161314 | ||
|
|
b15f358fc9 | ||
|
|
83e44a7cfb | ||
|
|
866b8b8029 | ||
|
|
857214e10d | ||
|
|
592b13913f | ||
|
|
84b50c4869 | ||
|
|
3c33868ea8 | ||
|
|
223ac7a64a | ||
|
|
0cee0662f6 |
@@ -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)).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,7 +60,7 @@ func (m *Collection) setDefaultAuthOptions() {
|
||||
},
|
||||
MFA: MFAConfig{
|
||||
Enabled: false,
|
||||
Duration: 1800, // 30min
|
||||
Duration: 600, // 10min
|
||||
},
|
||||
OTP: OTPConfig{
|
||||
Enabled: false,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+2861
-2847
File diff suppressed because it is too large
Load Diff
@@ -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": ""
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+81
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-81
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Generated
+6
-6
@@ -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": [
|
||||
{
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" : ""}`,
|
||||
|
||||
@@ -6,7 +6,7 @@ export function mfaAccordion(collection) {
|
||||
if (!collection.mfa) {
|
||||
collection.mfa = {
|
||||
enabled: false,
|
||||
duration: 900,
|
||||
duration: 600,
|
||||
rule: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
Reference in New Issue
Block a user